Compare commits

..

28 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
f283a568c3 Fix WSL distribution quoting and add distro dropdown in Settings UI
- Quote distro names with spaces in BuildWslArgs to prevent shell splitting
- Replace TextBox with ComboBox dropdown populated via 'wsl -l -q'
- Fix StyleCop SA1122 violation in _availableWslDistros initializer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 14:27:23 +08:00
Muyuan Li (from Dev Box)
873aa42d7f Fix ToWslPath to handle UNC WSL paths (\\\\wsl.localhost\\...)
When the WSL scripts folder is set to a UNC path like
\\\\wsl.localhost\\Ubuntu\\mnt\\pscripts, ToWslPath would throw
'Not an absolute Windows path' since it only handled C:\\ format.

Now detects \\\\wsl.localhost\\ and \\\\wsl\$\\ UNC prefixes and
strips the prefix to produce the native Linux path (e.g. /mnt/pscripts).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 15:21:39 +08:00
Muyuan Li (from Dev Box)
32f6b9e18c Fix migration overriding mode when legacy fields coexist in JSON
The migration was running unconditionally when IsEnabled had a value,
even if Mode was already set to 'wsl' by the new Settings UI. This
caused WSL mode to revert to Windows on every settings load.

Fix: Only migrate if Mode is still 'disabled' (default). Always clear
legacy fields after loading. Add JsonIgnore(WhenWritingNull) to legacy
properties so they're never re-serialized.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 13:39:19 +08:00
Muyuan Li (from Dev Box)
240bf6957d Rename Python scripts mode labels for clarity
Card header: 'Python runtime' (was 'Enable Python scripts')
Options: 'Off' / 'Windows Python' / 'WSL Python'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-17 14:21:03 +08:00
Muyuan Li (from Dev Box)
b3fe9f608e Replace enable/WSL toggles with mode dropdown (Disabled/Windows/WSL)
Each mode maintains independent settings:
- Windows mode: scriptsFolder + pythonExecutablePath
- WSL mode: scriptsFolder + distribution

Switching modes preserves previous settings so toggling back doesn't
lose configuration. Legacy settings (isEnabled/useWsl) are automatically
migrated on first load.

Settings model: Added PythonScriptWindowsSettings, PythonScriptWslSettings
as sub-objects. Main settings uses 'mode' field instead of booleans.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:38:33 +08:00
Muyuan Li (from Dev Box)
1af0dba08c Nest WSL distribution option under Use WSL toggle
Convert the Use WSL setting from a standalone SettingsCard to a
SettingsExpander. The WSL distribution textbox is now a child item
that only appears when the expander is open (bound to PythonUseWsl).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 14:41:36 +08:00
Muyuan Li (from Dev Box)
4d7575fcfb Make Python scripts list read-only in Settings page
Remove in-app editing of script name/description/enabled state for security.
The Settings page now shows a read-only list with:
- Script name and description (from header tags)
- Conversion summary (input → output from function name)
- Open in editor button to jump to the script file

Removed: PythonScriptEditDialog, EditPythonScript_Click,
ScriptEnabledToggle_Toggled, WriteScriptHeader, ApplySingleScriptChange,
ApplyPythonScriptChanges, GenerateHeaderText.

Added: InputType, OutputType, ConversionSummary properties to model,
function name parsing in CreateActionFromScript, OpenPythonScript_Click.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 16:42:10 +08:00
Muyuan Li (from Dev Box)
8c1cf09802 Add audio and video format support to Python extensions
- Update regex and ApInputTypeToFormat to recognize audio/video input types
- Add audio_path/video_path fields to input payload (Windows + WSL)
- Handle audio/video result types in ParseOutputAsync (Windows + WSL)
- Update _runner.py pattern, input_map, _apply_output_hint, _format_output
- Add 4 unit tests for audio/video metadata (input + output hints)
- Update documentation with audio/video in input/output tables

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 16:26:40 +08:00
Muyuan Li (from Dev Box)
796b232035 Fix empty space in Advanced Paste when filtering options
Changed grid row definitions from fixed-height/star to Auto so rows
collapse naturally when their content is filtered out during search.
Removed VerticalAlignment=Bottom on the standard formats list since
rows now auto-size to content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 14:25:15 +08:00
Muyuan Li (from Dev Box)
a48efdb6cb Enable PromptBox always for search/filter functionality
The input box in Advanced Paste is now always enabled, not just when
AI is configured. Typing in the box filters the options list by name.
The AI send button remains gated by IsCustomAIAvailable. The AI
disclaimer footer is hidden when AI is not configured.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 12:10:40 +08:00
Muyuan Li (from Dev Box)
78d05c9082 Python scripts: mandatory _to_ format, single function per script, WSL support
- Function naming: advanced_paste_from_<input>_to_<output> is now the only
  supported format. The old format without _to_ is removed.
- Each script must define exactly one advanced_paste_from_* function.
- Added WSL execution path (ExecuteScriptViaWslAsync) for users with Python
  only in WSL.
- New settings: UseWsl toggle and WslDistribution text field in Settings UI.
- Runner sets ADVANCED_PASTE_WORK_DIR env var so file-output scripts write
  to Windows-accessible paths under /mnt/c/ when running in WSL.
- Updated unit tests: replaced InferFormatsFromSignature tests with
  ReadMetadata tests covering the new naming convention.
- Updated documentation with WSL mode instructions and work_dir guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 10:32:18 +08:00
Muyuan Li (from Dev Box)
107c7c4cdc Fix image_path empty when clipboard bitmap not retrievable
- Use detectedFormat flags instead of clipboardData.Contains() for
  extraction decisions — avoids race condition where Contains() returns
  false after initial detection
- Wrap all clipboard data extraction in try-catch to gracefully handle
  stale clipboard state
- Remove format from array only when extraction actually fails
- Runner: improved fallback to prefer formats with available data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 10:37:04 +08:00
Muyuan Li (from Dev Box)
4394357013 fix(AdvancedPaste): improve runner format matching for multi-format clipboard
When clipboard has multiple formats (e.g. [image, text]) but data for one
format is missing (e.g. image_path empty), the runner now picks the best
format that has both a matching function AND available data, instead of
blindly using the first format in the array.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 10:24:00 +08:00
Muyuan Li (from Dev Box)
214d80415a feat(AdvancedPaste): rename ap_from_* to advanced_paste_from_* for clarity
- Renamed function convention from ap_from_text/html/image/files to
  advanced_paste_from_text/html/image/files for explicit discoverability
- Updated C# regex detection, Python runner, and documentation
- Added multi-format demo script (reverse_all.py)
- Added AI summarize demo script (ai_summarize.py) using OpenAI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 23:30:13 +08:00
Muyuan Li (from Dev Box)
7418da8806 feat(AdvancedPaste): named function interface (ap_from_*) with simplified settings UI
- Replaced V2 convert() with ap_from_text/html/image/files convention
- Auto-infer output type from return value (str, Path, list, dict)
- Simplified settings: only name, desc, disabled tag; DisplayName fallback to filename
- Placeholder text in edit dialog for empty name field
- Removed all V1/V2 legacy code and detection
- Scripts without ap_from_* are excluded from loading
- Updated documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 23:16:09 +08:00
Muyuan Li (from Dev Box)
8778af2e32 Add InferFormatsFromSignature unit tests and fix mock
- Add 11 unit tests for V2 format inference from convert() signature
- Fix IntegrationTestUserSettings mock to implement IsPythonScriptsEnabled
- All 33 PythonScriptServiceTests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 10:44:12 +08:00
Muyuan Li (from Dev Box)
aa3f1165b3 AdvancedPaste: V2 unified Python script interface with convert() function
Redesign the Python script extension interface to use a function-based
contract instead of platform-specific clipboard access:

- Scripts define a single convert() function that receives clipboard data
  as keyword arguments and returns the result
- Format support is inferred from the function's parameter names
  (text, html, image_path, file_paths, work_dir)
- Return value can be str, dict, Path, or list — runner normalizes it
- Platform tag eliminated — unified execution on Windows and WSL
- Only 'name' header remains required; desc, formats, platform all optional/inferred
- Ships _runner.py bootstrap that handles JSON stdin/stdout plumbing
- Full backward compat: scripts without convert() use legacy V1 paths

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 16:09:49 +08:00
Muyuan Li (from Dev Box)
df45e56511 WIP: AdvancedPaste ViewModel and test updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 15:57:44 +08:00
Muyuan Li (from Dev Box)
f8bca48db3 Settings UX: enable toggle, Load/Refresh button, greyed-out Apply
- Add master toggle to enable/disable Python scripts feature entirely
  (IsPythonScriptsEnabled in settings model, ViewModel, and runtime)
- Button shows 'Load scripts' initially, changes to 'Refresh scripts'
  after first load
- Edit dialog 'Apply changes' button is greyed out until a field changes
  (tracks initial snapshot vs current values for all fields)
- Runtime BuildPythonScriptFormats respects IsPythonScriptsEnabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:56:08 +08:00
Muyuan Li (from Dev Box)
5104d0846c Auto-apply on edit dialog and enable toggle, write-lock error fallback
- Remove Apply Changes button from parent expander
- Edit dialog now applies changes to file directly on confirm
- Enable toggle auto-applies changes immediately
- On write-lock (IOException), show error dialog with copyable header
  text and 'Copy header' button for manual paste fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:39:03 +08:00
Muyuan Li (from Dev Box)
8ca6c4d2ec Add open-folder button, fix duplicate header tags on apply
- Add button to open scripts folder in File Explorer
- Fix WriteScriptHeader: duplicate tag lines (e.g. multiple desc) are now
  deduplicated — only the first occurrence is updated, extras are removed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:13:54 +08:00
Muyuan Li (from Dev Box)
574aca841b Auto-refresh scripts, fix event handler leak, deep clone HotkeySettings, remove dead code
- Auto-refresh discovered scripts when ScriptsFolder changes
- ApplyPythonScriptChanges now refreshes after saving
- Fix event handler leak in EditPythonScript_Click (Toggled += lambda)
- Deep clone HotkeySettings in AdvancedPastePythonScriptAction.Clone()
- Remove unused WriteMetadata() and FormatFlagsToString() from PythonScriptService

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:48:55 +08:00
Muyuan Li (from Dev Box)
0833b68907 Advanced Paste: error details popup, enabled tag, and script settings page
- Error display: moved error UI from PromptBox to MainPage with Show Details
  button that opens a ContentDialog with the full error traceback
- Python scripts: added @advancedpaste:enabled header tag to control script
  visibility in the paste menu (default: true)
- Settings page: added Discovered Scripts section with Refresh/Apply buttons,
  per-script enable toggle, and edit dialog for name, description, platform,
  supported formats, and dependency detection mode (auto/manual)
- Model: extended AdvancedPastePythonScriptAction with IsEnabled, Platform,
  Formats, Requires, and RequiresAutoDetect properties
- ViewModel: added RefreshPythonScripts() to scan folder and read headers,
  ApplyPythonScriptChanges() to write metadata back to script files
- Build: added vswhere -all fallback in build-common.ps1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:49:02 +08:00
Muyuan Li
2051c13bf9 Improve Python script error handling and prevent freezes
- Extract script name, line, and column from Python tracebacks in error summaries
- Add ParsePipInstallError to show concise pip errors with full stderr in details tooltip
- Add timeouts to pip install (Windows/WSL) and import-check subprocesses to prevent UI freezes
- Add PythonPackageInstallTimeout resource string
- Add unit tests for all new parsing logic
2026-04-15 16:10:49 +08:00
Muyuan Li
4c7bf3df79 [AdvancedPaste] Python scripts: docs, custom folder, auto-import detection, better errors
1. Script header documentation (doc/devdocs/modules/advancedpaste-python-scripts.md)
   - Complete reference for all @advancedpaste: header tags
   - Windows and WSL/Linux execution mode protocols
   - Declaring dependencies, security trust model, error handling
   - Example scripts for both platforms

2. Custom scripts folder setting in Settings UI
   - Added ScriptsFolder property to AdvancedPasteViewModel
   - Added SettingsCard with TextBox + Browse folder dialog in XAML
   - Added localization strings for the new setting

3. Auto-detect missing Python modules from import statements
   - Scans script body for import/from-import statements
   - Filters Python stdlib modules (CPython 3.12 set)
   - Well-known import-to-pip mapping table (pywin32, Pillow, opencv-python, etc.)
   - Merges auto-detected imports with explicit @advancedpaste:requires entries
   - Explicit requires always take precedence

4. Better error messages for Python script failures
   - Parses stderr to extract the final Python exception line
   - User-friendly summaries for ModuleNotFoundError, SyntaxError, etc.
   - ModuleNotFoundError includes pip install hint from the mapping table
   - Full traceback available in Details section of the error UI

Added 12 unit tests for MergeWithAutoDetectedImports and ParsePythonError.
Fixed IntegrationTestUserSettings mock to implement IUserSettings Python members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 16:56:42 +08:00
Shawn Yuan
879163f48e add metadata analyse
Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
2026-03-04 12:54:51 +08:00
Shawn Yuan
4b84c00300 update 2026-03-02 11:46:23 +08:00
Shawn Yuan
6062bdc2f8 Make AP support Python extension
Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
2026-03-02 10:26:20 +08:00
242 changed files with 6577 additions and 15712 deletions

View File

@@ -67,7 +67,6 @@ ARPINSTALLLOCATION
ARPPRODUCTICON
ARRAYSIZE
ARROWKEYS
arrowshape
asf
AShortcut
ASingle
@@ -205,7 +204,6 @@ comdlg
comexp
cominterop
commandpalette
commoncontrols
compmgmt
COMPOSITIONFULL
CONFIGW
@@ -539,8 +537,6 @@ HIBYTE
hicon
HIDEWINDOW
Hif
highlightbackground
highlightthickness
HIMAGELIST
himl
hinst
@@ -631,7 +627,6 @@ inetcpl
Infobar
INFOEXAMPLE
Infotip
initialfile
INITDIALOG
INITGUID
INITTOLOGFONTSTRUCT
@@ -678,7 +673,6 @@ jpnime
Jsons
jsonval
jxr
kbmcontrols
keybd
KEYBDDATA
KEYBDINPUT
@@ -815,7 +809,6 @@ Metadatas
metafile
metapackage
mfc
mfalse
Mgmt
Microwaved
midl
@@ -874,7 +867,6 @@ msrc
msstore
msvcp
MTND
mtrue
MULTIPLEUSE
multizone
muxc
@@ -984,7 +976,6 @@ NTAPI
ntdll
NTSTATUS
NTSYSAPI
nullability
NULLCURSOR
nullonfailure
numberbox
@@ -1026,8 +1017,6 @@ OWNDC
OWNERDRAWFIXED
Packagemanager
PACL
padx
pady
PAINTSTRUCT
PALETTEWINDOW
PARENTNOTIFY
@@ -1459,7 +1448,6 @@ STYLECHANGING
subkeys
sublang
SUBMODULEUPDATE
sug
Superbar
sut
svchost
@@ -1532,7 +1520,6 @@ tlc
TPMLEFTALIGN
TPMRETURNCMD
TNP
Toggleable
Toolhelp
toolwindow
TOPDOWNDIB
@@ -2045,7 +2032,6 @@ metadatamatters
middleclickaction
MIIM
mikeclayton
mikehall
minimizebox
modelcontextprotocol
mousehighlighter
@@ -2166,7 +2152,6 @@ taskbar
TESTONLY
TEXTBOXNEWLINE
textextractor
textvariable
tgamma
THEMECHANGED
thickframe
@@ -2206,7 +2191,6 @@ wft
wikimedia
wikipedia
windowedge
WINDOWSAPPRUNTIME
windowsml
winexe
winforms

View File

@@ -289,6 +289,3 @@ St&yle
# Microsoft Store URLs and product IDs
ms-windows-store://\S+
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m

View File

@@ -33,4 +33,4 @@ These are auto-applied based on file location:
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)
- [Coding Style](../doc/devdocs/development/style.md)
- [Coding Style](../doc/devdocs/development/style.md)

View File

@@ -33,7 +33,7 @@ Generated Files/ReleaseNotes/
## Prerequisites
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
- GitHub CLI (`gh`) installed and authenticated
- MCP Server: github-mcp-server installed
- GitHub Copilot code review enabled for the org/repo
@@ -49,10 +49,6 @@ Generated Files/ReleaseNotes/
```
┌────────────────────────────────┐
│ 1.0 Verify gh auth + MemberList │
└────────────────────────────────┘
┌────────────────────────────────┐
│ 1.1 Collect PRs (stable range) │
└────────────────────────────────┘
@@ -89,7 +85,6 @@ Generated Files/ReleaseNotes/
| Step | Action | Details |
|------|--------|---------|
| 1.0 | Verify prerequisites | `gh auth status` must pass; generate MemberList.md |
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
| 2.12.4 | Label PRs | Auto-suggest + human label low-confidence |

View File

@@ -1,9 +1,9 @@
- Added mouse button actions so you can choose what left, right, or middle click does in [#1234](https://github.com/microsoft/PowerToys/pull/1234) by [@PesBandi](https://github.com/PesBandi)
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
- Aligned window styling with current Windows theme for a cleaner look in [#1235](https://github.com/microsoft/PowerToys/pull/1235) by [@sadirano](https://github.com/sadirano)
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
- Ensured screen readers are notified when the selected item in the list changes for better accessibility in [#1236](https://github.com/microsoft/PowerToys/pull/1236)
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours in [#1237](https://github.com/microsoft/PowerToys/pull/1237)
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
- Fixed Alt+Left Arrow navigation not working when search box contains text in [#1238](https://github.com/microsoft/PowerToys/pull/1238) by [@jiripolasek](https://github.com/jiripolasek)
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!

View File

@@ -1,7 +1,6 @@
# Step 1: Collection and Milestones
## 1.0 To-do
- 1.0.0 Verify GitHub CLI authentication (REQUIRED)
- 1.0.1 Generate MemberList.md (REQUIRED)
- 1.1 Collect PRs
- 1.2 Assign Milestones (REQUIRED)
@@ -21,34 +20,6 @@
---
## 1.0.0 Verify GitHub CLI Authentication (REQUIRED)
⚠️ **BLOCKING:** The collection script requires an authenticated `gh` CLI to fetch PR metadata and co-author information via GitHub's GraphQL API. Without authentication, PR data and `NeedThanks` attribution will be incomplete.
### Check authentication status
```powershell
gh auth status
```
**If authenticated:** You'll see `Logged in to github.com account <username>`. Proceed to 1.0.1.
**If NOT authenticated:** Run the login flow before continuing:
```powershell
# Interactive login (opens browser for OAuth)
gh auth login --hostname github.com --web
# Or use a personal access token
gh auth login --with-token <<< "YOUR_GITHUB_TOKEN"
```
**Required scopes:** `repo` (for reading PR data and assigning milestones)
After login, verify again with `gh auth status` and confirm exit code 0.
---
## 1.0.1 Generate MemberList.md (REQUIRED)
Create `Generated Files/ReleaseNotes/MemberList.md` from the **PowerToys core team** section in [COMMUNITY.md](../../../COMMUNITY.md).
@@ -109,8 +80,6 @@ The script detects both merge commits (`Merge pull request #12345`) and squash c
**Output** (in `Generated Files/ReleaseNotes/`):
- `milestone_prs.json` - raw PR data
- `sorted_prs.csv` - sorted PR list with columns: Id, Title, Labels, Author, Url, Body, CopilotSummary, NeedThanks
- `Author`: Comma-separated list of all contributors (PR opener + co-authors from commit trailers)
- `NeedThanks`: Comma-separated list of external contributors to thank (excludes core team members from MemberList.md). Empty string means no thanks needed.
---

View File

@@ -16,7 +16,7 @@ For each CSV in `Generated Files/ReleaseNotes/grouped_csv/`, create a markdown f
- Use the “Verb-ed + Scenario + Impact” sentence structure—make readers think, “Thats exactly what I need” or “Yes, thats an awesome fix.”; The "impact" can be end-user focused (written to convey user excitement) or technical (performance/stability) when user-facing impact is minimal.
- If nothing special on impact or unclear impact, mark as needing human summary
- Source from Title, Body, and CopilotSummary (prefer CopilotSummary when available)
- The `NeedThanks` column contains a comma-separated list of external contributor usernames who should be thanked (empty = no thanks needed, all authors are core team). For each non-empty `NeedThanks` value, append thanks for **every** listed contributor: `Thanks [@user1](https://github.com/user1)!` for a single contributor, or `Thanks [@user1](https://github.com/user1) and [@user2](https://github.com/user2)!` for two, or `Thanks [@user1](https://github.com/user1), [@user2](https://github.com/user2), and [@user3](https://github.com/user3)!` for three or more.
- If the column `NeedThanks` in CSV is `True`, append: `Thanks [@Author](https://github.com/Author)!`
- Do NOT include PR numbers in bullet lines
- Do NOT mention “security” or “privacy” issues, since these are not known and could be leveraged by attackers in earlier versions. Instead, describe the user-facing scenario, usage, or impact.
- If confidence < 70%, write: `Human Summary Needed: <PR full link>`
@@ -72,13 +72,13 @@ Some items in the Development section may overlap and should be moved to the Mod
## Advanced Paste
- Wrapped paste option lists in a single ScrollViewer in [#5678](https://github.com/microsoft/PowerToys/pull/5678)
- Added image input handling for AI-powered transformations in [#5679](https://github.com/microsoft/PowerToys/pull/5679)
- Wrapped paste option lists in a single ScrollViewer
- Added image input handling for AI-powered transformations
...
## Awake
- Fixed timed mode expiration in [#5680](https://github.com/microsoft/PowerToys/pull/5680) by [@daverayment](https://github.com/daverayment)
- Fixed timed mode expiration. Thanks [@daverayment](https://github.com/daverayment)!
...
---

View File

@@ -42,7 +42,30 @@ param(
[string]$OutputJson = "milestone_prs.json"
)
# (See top-level synopsis above for full documentation)
<#
.SYNOPSIS
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
.DESCRIPTION
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
queries GitHub (gh CLI) for details, then outputs a CSV.
PR merge commit messages in PowerToys generally contain patterns like:
Merge pull request #12345 from ...
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
.NOTES
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
CopilotSummary behavior:
- Attempts to locate the latest GitHub Copilot authored review (preferred).
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
#>
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
@@ -128,11 +151,11 @@ catch {
}
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` must be passed as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` must be passed as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Normalize list (filter out empty strings)
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
@@ -187,63 +210,6 @@ $prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Obj
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
# Build a map of PR number → list of commit SHAs (for co-author extraction)
$prToCommits = @{}
foreach ($mc in $mergeCommits) {
if (-not $prToCommits.ContainsKey($mc.Pr)) {
$prToCommits[$mc.Pr] = @()
}
$prToCommits[$mc.Pr] += $mc.Sha
}
<#
.SYNOPSIS
Get all authors (including co-authors) for a set of commits via GitHub GraphQL API.
.DESCRIPTION
Uses the Commit.authors field in GitHub's GraphQL API which natively includes
co-authors (from Co-authored-by trailers). Returns GitHub usernames (login)
without any email parsing — GitHub resolves the association for us.
NOTE: For squash merges this captures all co-authors correctly because GitHub
preserves Co-authored-by trailers in the squash commit. For traditional merge
commits, only the merger's author is returned — co-authors on individual PR
commits are not traversed. This is acceptable because PowerToys primarily uses
squash merging.
#>
function Get-CommitAuthors {
param(
[string[]]$CommitShas,
[string]$RepoFullName = "microsoft/PowerToys"
)
$parts = $RepoFullName -split '/'
$owner = $parts[0]
$repoName = $parts[1]
$allAuthors = @()
foreach ($sha in $CommitShas) {
try {
$query = "{ repository(owner: `"$owner`", name: `"$repoName`") { object(expression: `"$sha`") { ... on Commit { authors(first: 20) { nodes { user { login } name } } } } } }"
$result = gh api graphql -f query="$query" 2>$null | ConvertFrom-Json
$nodes = $result.data.repository.object.authors.nodes
if ($nodes) {
foreach ($node in $nodes) {
if ($node.user -and $node.user.login) {
$allAuthors += $node.user.login
} else {
# User without a GitHub account (rare) — use display name as fallback
Write-DebugMsg "Commit $sha has an author without GitHub account: $($node.name)"
}
}
}
}
catch {
Write-DebugMsg "GraphQL authors query failed for commit ${sha}: $_"
}
}
return $allAuthors | Select-Object -Unique
}
# Query GitHub for each PR
$prDetails = @()
function Get-CopilotSummaryFromPrJson {
@@ -341,45 +307,22 @@ foreach ($pr in $prNumbers) {
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
$bodyValue = $bodyValue -replace '\s+', ' '
# Collect all contributors: PR author + co-authors from commit messages
# Determine if author needs thanks (not in member list)
$authorLogin = $json.author.login
$allContributors = @($authorLogin)
# Extract all authors (including co-authors) from associated commits via GitHub GraphQL API
if ($prToCommits.ContainsKey([int]$pr)) {
$commitAuthors = Get-CommitAuthors -CommitShas $prToCommits[[int]$pr] -RepoFullName $Repo
if ($commitAuthors) {
$allContributors += $commitAuthors
}
$needThanks = $true
if ($memberList.Count -gt 0 -and $authorLogin) {
$needThanks = -not ($memberList -contains $authorLogin)
}
# Deduplicate contributors (case-insensitive)
$allContributors = $allContributors | Where-Object { $_ } | Sort-Object -Unique
# Filter to only external contributors (not in member list) for thanks
$externalContributors = @()
if ($memberList.Count -gt 0) {
$externalContributors = $allContributors | Where-Object { -not ($memberList -contains $_) }
} else {
$externalContributors = $allContributors
}
# Author column: all contributors (comma-separated)
$authorField = ($allContributors -join ', ')
# NeedThanks column: comma-separated list of external contributors who
# deserve thanks attribution. Empty string means no thanks needed.
$needThanksField = ($externalContributors -join ', ')
$prDetails += [PSCustomObject]@{
Id = $json.number
Title = $json.title
Labels = $labelNames
Author = $authorField
Author = $authorLogin
Url = $json.url
Body = $bodyValue
CopilotSummary = $copilot.Summary
NeedThanks = $needThanksField
NeedThanks = $needThanks
}
}
catch {

View File

@@ -106,12 +106,7 @@
"PowerToys.SvgThumbnailProvider.dll",
"PowerToys.SvgThumbnailProvider.exe",
"PowerToys.SvgThumbnailProviderCpp.dll",
"PowerToys.KeyboardManager.dll",
"KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe",
"KeyboardManagerEditorUI\\PowerToys.KeyboardManagerEditorUI.exe",
"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe",
"PowerToys.KeyboardManagerEditorLibraryWrapper.dll",
"WinUI3Apps\\PowerToys.HostsModuleInterface.dll",
"WinUI3Apps\\PowerToys.HostsUILib.dll",
"WinUI3Apps\\PowerToys.Hosts.dll",

View File

@@ -9,7 +9,7 @@ schedules:
always: false # only run if there's code changes!
pool:
vmImage: windows-latest
vmImage: windows-2019
resources:
repositories:

View File

@@ -17,10 +17,10 @@ $nonDirectoryAssetsItems = Get-ChildItem $targetAssetsDir -Attributes !Directory
$directoryAssetsItems = Get-ChildItem $targetAssetsDir -Attributes Directory
if ($directoryAssetsItems.Count -le 0) {
Write-Host -ForegroundColor Red "ERROR: No directories detected in " $nonDirectoryAssetsItems ". Are you sure this is the right path?`r`n"
Write-Host -ForegroundColor Red "No directories detected in " $nonDirectoryAssetsItems ". Are you sure this is the right path?`r`n"
$totalFailures++;
} elseif ($nonDirectoryAssetsItems.Count -gt 0) {
Write-Host -ForegroundColor Red "ERROR: Detected " $nonDirectoryAssetsItems " files in " $targetAssetsDir ". Each application should use a named subdirectory for assets.`r`n"
Write-Host -ForegroundColor Red "Detected " $nonDirectoryAssetsItems " files in " $targetAssetsDir "`r`n"
$totalFailures++;
} else {
Write-Host -ForegroundColor Green "Only directories detected in " $targetAssetsDir "`r`n"
@@ -29,7 +29,7 @@ if ($directoryAssetsItems.Count -le 0) {
# Make sure there's no resources.pri file. Each application should use a different name for their own resources file path.
$resourcesPriFiles = Get-ChildItem $targetDir -Filter resources.pri
if ($resourcesPriFiles.Count -gt 0) {
Write-Host -ForegroundColor Red "ERROR: Detected a resources.pri file in " $targetDir ". Each application should use a unique name for its resources file.`r`n"
Write-Host -ForegroundColor Red "Detected a resources.pri file in " $targetDir "`r`n"
$totalFailures++;
} else {
Write-Host -ForegroundColor Green "No resources.pri file detected in " $targetDir "`r`n"
@@ -38,7 +38,7 @@ if ($resourcesPriFiles.Count -gt 0) {
# Each application should have their XAML files in their own paths to avoid these conflicts.
$resourcesPriFiles = Get-ChildItem $targetDir -Filter *.xbf
if ($resourcesPriFiles.Count -gt 0) {
Write-Host -ForegroundColor Red "ERROR: Detected a .xbf file in " $targetDir ". Ensure all XAML files are placed in a subdirectory in each application.`r`n"
Write-Host -ForegroundColor Red "Detected a .xbf file in " $targetDir "`r`n"
$totalFailures++;
} else {
Write-Host -ForegroundColor Green "No .xbf files detected in " $targetDir "`r`n"

View File

@@ -20,7 +20,6 @@
<NuGetAuditMode>direct</NuGetAuditMode>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <!-- Don't add source revision hash to the product version of binaries. -->
<PlatformTarget>$(Platform)</PlatformTarget>
<RestoreEnablePackagePruning Condition=" '$(VisualStudioVersion)' == '17.0'">false </RestoreEnablePackagePruning>
<!-- Enable Microsoft.Testing.Platform -->
<EnableMSTestRunner>true</EnableMSTestRunner>

View File

@@ -26,7 +26,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
@@ -40,7 +40,7 @@
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
<PackageVersion Include="MessagePack" Version="3.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
<!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />

View File

@@ -497,31 +497,6 @@
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj" Id="ba661f5b-1d5a-4ffc-9bf1-fc39df280bdd" />
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj" Id="e496b7fc-1e99-4bab-849b-0e8367040b02" />
</Folder>
<Folder Name="/modules/keyboardmanager/MouseUtils/">
<Project Path="src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj" Id="48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5" />
<Project Path="src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj" Id="e94fd11c-0591-456f-899f-efc0ca548336" />
<Project Path="src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj" Id="782a61be-9d85-4081-b35c-1ccc9dcc1e88" />
<Project Path="src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MouseJump/MouseJump.vcxproj" Id="8a08d663-4995-40e3-b42c-3f910625f284" />
<Project Path="src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" />
</Folder>
<Folder Name="/modules/keyboardmanager/MouseUtils/Tests/">
<Project Path="src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/keyboardmanager/Tests/">
<Project Path="src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj" Id="62173d9a-6724-4c00-a1c8-fb646480a9ec" />
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj" Id="7f4b3a60-bc27-45a7-8000-68b0b6ea7466" />
@@ -745,6 +720,31 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/MouseUtils/">
<Project Path="src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj" Id="48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5" />
<Project Path="src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj" Id="e94fd11c-0591-456f-899f-efc0ca548336" />
<Project Path="src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj" Id="782a61be-9d85-4081-b35c-1ccc9dcc1e88" />
<Project Path="src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MouseJump/MouseJump.vcxproj" Id="8a08d663-4995-40e3-b42c-3f910625f284" />
<Project Path="src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" />
</Folder>
<Folder Name="/modules/MouseUtils/Tests/">
<Project Path="src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/MouseWithoutBorders/">
<Project Path="src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />

View File

@@ -0,0 +1,285 @@
# Advanced Paste Python Scripts
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
## Quick start
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
2. Drop a `.py` file into the folder.
3. Define one `advanced_paste_from_<input>_to_<output>` function (see [Writing a script](#writing-a-script)).
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
> **Important:** Each `.py` file must define exactly one `advanced_paste_from_<input>_to_<output>`
> function. Scripts with zero or multiple such functions are ignored.
## Writing a script
You write a single Python function whose **name** declares both what clipboard input it accepts
and what output type it produces.
No imports from PowerToys are needed — zero setup, zero dependencies on our side.
### Function naming convention
The function name follows the pattern:
```
advanced_paste_from_<input>_to_<output>(<param>)
```
**Input types** (what the function receives):
| Input | Parameter | When it runs |
|-------|-----------|--------------|
| `text` | `str` — clipboard text | Clipboard has text |
| `html` | `str` — clipboard HTML | Clipboard has HTML |
| `image` | `str` — path to temp image file | Clipboard has an image |
| `audio` | `str` — path to audio file | Clipboard has an audio file |
| `video` | `str` — path to video file | Clipboard has a video file |
| `files` | `list[str]` — file paths | Clipboard has files |
**Output types** (what the function produces — declared via `_to_` suffix):
| Output | Effect |
|--------|--------|
| `text` | Sets clipboard to text |
| `html` | Sets clipboard to HTML |
| `image` | Sets clipboard to image |
| `audio` | Sets clipboard to audio file |
| `video` | Sets clipboard to video file |
| `file` | Sets clipboard to a file |
| `files` | Sets clipboard to multiple files |
### Return value
The return value is interpreted according to the declared output type:
| Output type | Expected return value |
|-------------|---------------------|
| `text` | `str` (or any value — will be converted via `str()`) |
| `html` | `str` containing HTML |
| `image` | `str` or `pathlib.Path` pointing to an image file |
| `file` | `str` or `pathlib.Path` pointing to a file |
| `files` | `list` of `str`/`pathlib.Path` file paths |
Returning `None` produces an empty result (no-op).
## Examples
### Minimal — uppercase text
```python
def advanced_paste_from_text_to_text(text):
return text.upper()
```
That's it. No headers required, no imports from PowerToys.
### With optional metadata
```python
# @advancedpaste:name Reverse Text
# @advancedpaste:desc Reverses clipboard text character by character
def advanced_paste_from_text_to_text(text):
return text[::-1]
```
### Text to HTML
```python
# @advancedpaste:name Markdown Table to HTML
# @advancedpaste:desc Convert a markdown table to an HTML table
def advanced_paste_from_text_to_html(text):
headers = text.splitlines()[0].split("|")
return f"<table><tr>{''.join(f'<th>{h.strip()}</th>' for h in headers if h.strip())}</tr></table>"
```
### Image to text (OCR)
```python
# @advancedpaste:requires pytesseract
def advanced_paste_from_image_to_text(image_path):
import pytesseract
return pytesseract.image_to_string(image_path).strip()
```
### Save text as file
```python
import os
from pathlib import Path
import tempfile
def advanced_paste_from_text_to_file(text):
# Use ADVANCED_PASTE_WORK_DIR for WSL compatibility; falls back to temp dir on Windows.
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
out = Path(out_dir) / "clipboard.txt"
out.write_text(text, encoding="utf-8")
return out
```
### Image processing (image → image)
```python
import os
from PIL import Image
from pathlib import Path
import tempfile
def advanced_paste_from_image_to_image(image_path):
"""Convert image to grayscale."""
img = Image.open(image_path).convert("L")
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
out = Path(out_dir) / "gray.png"
img.save(out)
return out
```
### File listing (files → text)
```python
import os
def advanced_paste_from_files_to_text(file_paths):
lines = []
for p in file_paths:
size = os.path.getsize(p)
lines.append(f"{os.path.basename(p)} ({size} bytes)")
return "\n".join(lines)
```
## Header tags
All header tags are **optional**. Tags are placed in comment lines at the top of the script.
| Tag | Description |
|-----|-------------|
| `name` | Display name in the Advanced Paste UI. If omitted, the filename is used. |
| `desc` | Short description / tooltip. |
| `disabled` | Presence of this tag disables the script (it won't appear in the UI). |
| `requires` | Declare Python package dependencies (see [Dependencies](#declaring-dependencies)). |
### Example header
```python
# @advancedpaste:name My Formatter
# @advancedpaste:desc Formats clipboard text as markdown table
```
To disable a script without deleting it, add:
```python
# @advancedpaste:disabled
```
Remove the line to re-enable.
## Declaring dependencies
Use `requires` to declare Python packages the script needs:
```python
# @advancedpaste:requires PIL=Pillow
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
```
Each token is either:
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
- **`import_name=pip_package`** — when the import name differs from the pip package
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
### Automatic import detection
Advanced Paste also scans the script body for `import` and `from ... import` statements
and cross-references them against the Python standard library. Any non-stdlib import
that is not already installed triggers a prompt to install it automatically.
## Security — script trust
The first time a script is executed (or after it has been modified), Advanced Paste
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
Subsequent runs of the unchanged file skip the dialog.
## Error handling
When a script fails, Advanced Paste extracts the Python traceback from stderr and
displays a user-friendly summary in the UI:
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
- **SyntaxError** — shows the file and line number.
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
- **Other errors** — shows the last line of the traceback as a summary, with the full
traceback available in the expandable *Details* section.
## Settings
The Python scripts feature uses a **mode selector** (dropdown) with three options:
| Mode | Description |
|------|-------------|
| **Disabled** | Python scripts are not active. |
| **Windows** | Scripts run using a native Windows Python interpreter. |
| **WSL** | Scripts run inside Windows Subsystem for Linux. |
Each mode maintains its own independent settings (scripts folder, interpreter path, etc.),
so switching between Windows and WSL does not lose your previous configuration.
### Windows mode settings
| Setting | Description | Default |
|---------|-------------|---------|
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
### WSL mode settings
| Setting | Description | Default |
|---------|-------------|---------|
| Scripts folder | Folder to scan for `.py` scripts (Windows path — auto-translated to `/mnt/...`). | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
| WSL distribution | Which WSL distro to use (e.g. `Ubuntu`). Leave blank for the default distribution. | *(default)* |
### Scripts list
The Settings page shows a read-only list of discovered scripts. For each script you can see:
- **Name** — from `@advancedpaste:name` tag, or the filename if not set.
- **Description** — from `@advancedpaste:desc` tag.
- **Conversion** — the input → output types detected from the function name (e.g. "text → image").
The list is **not editable** from Settings. To change a script's name, description, enabled state,
or any other metadata, open the script file directly (click the "Open in editor" button) and edit
the `# @advancedpaste:...` header tags. After saving, click **Refresh** in Settings to reload.
### WSL mode details
When **WSL** mode is selected:
- Scripts are executed via `wsl.exe bash -l -c "python3 ..."` using the configured distribution.
- The scripts folder remains on the Windows filesystem; paths are automatically translated
to `/mnt/c/...` format for WSL access.
- Package installation uses `pip3 install` inside the WSL environment.
- Output files from scripts must be written under `/mnt/` (the Windows-mounted filesystem)
so they can be accessed from Windows. The runner sets the `ADVANCED_PASTE_WORK_DIR` environment
variable to a temp directory under `/mnt/c/...` — use it instead of `tempfile.gettempdir()`
when producing file output for cross-platform compatibility.
> **Tip:** If you have Python installed only in WSL (not on Windows), select WSL mode
> to use your existing WSL Python environment with all its packages.
## Tips
- Each `.py` file must contain exactly one `advanced_paste_from_<input>_to_<output>` function.
If you need to handle multiple input types, create separate script files for each.
- A `.py` file without any matching function is ignored — use this for helper modules
that other scripts can import.
- Scripts can be tested from the command line:
```
echo {"format":["text"],"text":"hello"} | python _runner.py my_script.py
```
- The script's directory is added to `sys.path` at runtime, so you can import sibling `.py`
files as helper modules.

View File

@@ -141,10 +141,3 @@ Note: The DllHost process loads the DLL only when the context menu is triggered
- A signature issue with the MSIX package
- For development and testing, using the Windows 10 handler can be easier since it doesn't require signing.
## Restoring Built-in Windows New context menu
If the Windows 11 built-in New context menu doesn't reappear on uninstalling PowerToys, some issue with settings etc. here's how to restore the built-in New context menu.
1. Open Registry Editor
1. Go to the key "Computer\HKEY_CURRENT_USER\Software\Classes\Directory\background\ShellEx\ContextMenuHandlers"
1. Delete the "New" subkey (i.e. fullpath "Computer\HKEY_CURRENT_USER\Software\Classes\Directory\background\ShellEx\ContextMenuHandlers\New")

View File

@@ -1119,35 +1119,6 @@ LExit:
return WcaFinalize(er);
}
UINT __stdcall RestoreBuiltInNewContextMenuCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
hr = WcaInitialize(hInstall, "RestoreBuiltInNewContextMenuCA");
constexpr wchar_t built_in_new_registry_path[] = LR"(Software\Classes\Directory\Background\ShellEx\ContextMenuHandlers\New)";
HKEY key{};
if (RegOpenKeyExW(HKEY_CURRENT_USER,
built_in_new_registry_path,
0,
KEY_ALL_ACCESS,
&key) != ERROR_SUCCESS)
{
return WcaFinalize(ERROR_SUCCESS);
}
if (RegDeleteValueW(key, nullptr) != ERROR_SUCCESS)
{
RegCloseKey(key);
return WcaFinalize(ERROR_SUCCESS);
}
RegCloseKey(key);
return WcaFinalize(ERROR_SUCCESS);
}
UINT __stdcall TelemetryLogInstallSuccessCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;

View File

@@ -7,7 +7,6 @@ EXPORTS
ApplyModulesRegistryChangeSetsCA
DetectPrevInstallPathCA
RemoveScheduledTasksCA
RestoreBuiltInNewContextMenuCA
TelemetryLogInstallSuccessCA
TelemetryLogInstallCancelCA
TelemetryLogInstallFailCA

View File

@@ -161,9 +161,6 @@
<!-- Clean Video Conference Mute registry keys that might be around from previous installations. We've deprecated this utility since then. -->
<Custom Action="CleanVideoConferenceRegistry" Before="InstallFinalize" Condition="NOT Installed" />
<!-- Restore built-in "New" context menu in case user disabled it via New+ -->
<Custom Action="RestoreBuiltInNewContextMenu" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
</InstallExecuteSequence>
<CustomAction Id="SetLaunchPowerToysParam" Property="LaunchPowerToys" Value="[INSTALLFOLDER]" />
@@ -265,8 +262,6 @@
<CustomAction Id="SetBundleInstallLocation" Return="ignore" Impersonate="no" Execute="deferred" DllEntry="SetBundleInstallLocationCA" BinaryRef="PTCustomActions" />
<CustomAction Id="RestoreBuiltInNewContextMenu" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="RestoreBuiltInNewContextMenuCA" BinaryRef="PTCustomActions" />
<!-- Close 'PowerToys.exe' before uninstall-->
<Property Id="MSIRESTARTMANAGERCONTROL" Value="DisableShutdown" />
<Property Id="MSIFASTINSTALL" Value="DisableShutdown" />

View File

@@ -1,11 +1,11 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls">
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
<Style BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="commoncontrols:KeyVisual" />
<Style BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="local:KeyVisual" />
<Style x:Key="DefaultKeyVisualStyle" TargetType="commoncontrols:KeyVisual">
<Style x:Key="DefaultKeyVisualStyle" TargetType="local:KeyVisual">
<Setter Property="MinWidth" Value="16" />
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
<Setter Property="IsTabStop" Value="False" />
@@ -25,7 +25,7 @@
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="commoncontrols:KeyVisual">
<ControlTemplate TargetType="local:KeyVisual">
<Grid
x:Name="KeyHolder"
MinWidth="{TemplateBinding MinWidth}"
@@ -40,7 +40,7 @@
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<commoncontrols:KeyCharPresenter
<local:KeyCharPresenter
x:Name="KeyPresenter"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
@@ -87,12 +87,12 @@
<Style
x:Key="SubtleKeyVisualStyle"
BasedOn="{StaticResource DefaultKeyVisualStyle}"
TargetType="commoncontrols:KeyVisual">
TargetType="local:KeyVisual">
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="commoncontrols:KeyVisual">
<ControlTemplate TargetType="local:KeyVisual">
<Grid
x:Name="KeyHolder"
MinWidth="{TemplateBinding MinWidth}"
@@ -106,7 +106,7 @@
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<commoncontrols:KeyCharPresenter
<local:KeyCharPresenter
x:Name="KeyPresenter"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
@@ -145,14 +145,14 @@
<Style
x:Key="AccentKeyVisualStyle"
BasedOn="{StaticResource DefaultKeyVisualStyle}"
TargetType="commoncontrols:KeyVisual">
TargetType="local:KeyVisual">
<Setter Property="Background" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Property="Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource AccentControlElevationBorderBrush}" />
<Setter Property="BackgroundSizing" Value="OuterBorderEdge" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="commoncontrols:KeyVisual">
<ControlTemplate TargetType="local:KeyVisual">
<Grid
x:Name="KeyHolder"
MinWidth="{TemplateBinding MinWidth}"
@@ -168,7 +168,7 @@
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<commoncontrols:KeyCharPresenter
<local:KeyCharPresenter
x:Name="KeyPresenter"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"

View File

@@ -292,8 +292,4 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredRunAtStartupValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusHideBuiltInNewContextMenuValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusHideBuiltInNewContextMenuValue());
}
}

View File

@@ -78,7 +78,6 @@ namespace winrt::PowerToys::GPOWrapper::implementation
static GpoRuleConfigured GetAllowDataDiagnosticsValue();
static GpoRuleConfigured GetConfiguredRunAtStartupValue();
static GpoRuleConfigured GetConfiguredNewPlusReplaceVariablesValue();
static GpoRuleConfigured GetConfiguredNewPlusHideBuiltInNewContextMenuValue();
};
}

View File

@@ -82,7 +82,6 @@ namespace PowerToys
static GpoRuleConfigured GetAllowDataDiagnosticsValue();
static GpoRuleConfigured GetConfiguredRunAtStartupValue();
static GpoRuleConfigured GetConfiguredNewPlusReplaceVariablesValue();
static GpoRuleConfigured GetConfiguredNewPlusHideBuiltInNewContextMenuValue();
}
}
}

View File

@@ -287,8 +287,4 @@ namespace winrt::PowerToys::Interop::implementation
{
return CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE;
}
hstring Constants::OpenNewKeyboardManagerEvent()
{
return CommonSharedConstants::OPEN_NEW_KEYBOARD_MANAGER_EVENT;
}
}

View File

@@ -75,7 +75,6 @@ namespace winrt::PowerToys::Interop::implementation
static hstring PowerDisplayToggleMessage();
static hstring PowerDisplayApplyProfileMessage();
static hstring PowerDisplayTerminateAppMessage();
static hstring OpenNewKeyboardManagerEvent();
};
}

View File

@@ -72,7 +72,6 @@ namespace PowerToys
static String PowerDisplayToggleMessage();
static String PowerDisplayApplyProfileMessage();
static String PowerDisplayTerminateAppMessage();
static String OpenNewKeyboardManagerEvent();
}
}
}

View File

@@ -170,9 +170,6 @@ namespace CommonSharedConstants
const wchar_t LIGHT_SWITCH_LIGHT_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca";
const wchar_t LIGHT_SWITCH_DARK_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368";
// Path to events used by Keyboard Manager
const wchar_t OPEN_NEW_KEYBOARD_MANAGER_EVENT[] = L"Local\\PowerToysOpenNewKeyboardManagerEvent-9c1d2e3f-4b5a-6c7d-8e9f-0a1b2c3d4e5f";
// used from quick access window
const wchar_t CMDPAL_SHOW_EVENT[] = L"Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a";
const wchar_t CMDPAL_EXIT_EVENT[] = L"Local\\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd";

View File

@@ -103,7 +103,6 @@ namespace powertoys_gpo
const std::wstring POLICY_MWB_POLICY_DEFINED_IP_MAPPING_RULES = L"MwbPolicyDefinedIpMappingRules";
const std::wstring POLICY_NEW_PLUS_HIDE_TEMPLATE_FILENAME_EXTENSION = L"NewPlusHideTemplateFilenameExtension";
const std::wstring POLICY_NEW_PLUS_REPLACE_VARIABLES = L"NewPlusReplaceVariablesInTemplateFilenames";
const std::wstring POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU = L"NewPlusHideBuiltInNewContextMenu";
// Methods used for reading the registry
#pragma region ReadRegistryMethods
@@ -701,10 +700,5 @@ namespace powertoys_gpo
return getConfiguredValue(POLICY_NEW_PLUS_REPLACE_VARIABLES);
}
inline gpo_rule_configured_t getConfiguredNewPlusHideBuiltInNewContextMenuValue()
{
return getConfiguredValue(POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU);
}
#pragma endregion IndividualModuleSettingPolicies
}

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) Microsoft Corporation.
Licensed under the MIT License. -->
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.20" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
<policyNamespaces>
<target prefix="powertoys" namespace="Microsoft.Policies.PowerToys" />
</policyNamespaces>
<resources minRequiredRevision="1.20"/><!-- Last changed with PowerToys v0.98.0 -->
<resources minRequiredRevision="1.19"/><!-- Last changed with PowerToys v0.97.0 -->
<supportedOn>
<definitions>
<definition name="SUPPORTED_POWERTOYS_0_64_0" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0)"/>
@@ -28,7 +28,6 @@
<definition name="SUPPORTED_POWERTOYS_0_90_0" displayName="$(string.SUPPORTED_POWERTOYS_0_90_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_96_0" displayName="$(string.SUPPORTED_POWERTOYS_0_96_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_97_0" displayName="$(string.SUPPORTED_POWERTOYS_0_97_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_98_0" displayName="$(string.SUPPORTED_POWERTOYS_0_98_0)"/>
<definition name="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1)"/>
</definitions>
</supportedOn>
@@ -827,15 +826,5 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="NewPlusHideBuiltInNewContextMenu" class="Both" displayName="$(string.NewPlusHideBuiltInNewContextMenu)" explainText="$(string.NewPlusHideBuiltInNewContextMenuDescription)" key="Software\Policies\PowerToys" valueName="NewPlusHideBuiltInNewContextMenu">
<parentCategory ref="NewPlus" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_98_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
</policies>
</policyDefinitions>

View File

@@ -35,7 +35,6 @@
<string id="SUPPORTED_POWERTOYS_0_90_0">PowerToys version 0.90.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_96_0">PowerToys version 0.96.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_97_0">PowerToys version 0.97.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_98_0">PowerToys version 0.98.0 or later</string>
<string id="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1">From PowerToys version 0.64.0 until PowerToys version 0.87.1</string>
<string id="ConfigureAllUtilityGlobalEnabledStateDescription">This policy configures the enabled state for all PowerToys utilities.
@@ -239,7 +238,7 @@ If you disable this policy, the setting is disabled and variables in filenames w
If you don't configure this policy, the user will be able to control the setting and can enable or disable it.
</string>
<string id="ConfigureAllUtilityGlobalEnabledState">Configure global utility enabled state</string>
<string id="ConfigureEnabledUtilityAdvancedPaste">Advanced Paste: Configure enabled state</string>
<string id="ConfigureEnabledUtilityAlwaysOnTop">Always On Top: Configure enabled state</string>
@@ -357,15 +356,6 @@ If you disable this policy, users will not be able to select or use Foundry Loca
<string id="AllowDiagnosticData">Allow sending diagnostic data</string>
<string id="ConfigureRunAtStartup">Configure the run at startup setting</string>
<string id="NewPlusReplaceVariablesInTemplateFilenames">Replace variables in template filenames</string>
<string id="NewPlusHideBuiltInNewContextMenu">Hide the built-in "New" context menu</string>
<string id="NewPlusHideBuiltInNewContextMenuDescription">This policy configures if Windows' built-in New context menu should be hidden on the context menu.
If you enable this policy, then the built-in New context menu will be hidden, and user can only create new files and folders using New+ and the explorer toolbar New button.
If you disable this policy, then the build-in New context menu will be displayed as normal in Windows.
If you don't configure this policy, the user will be able to control the setting and can enable or disable it.
</string>
</stringTable>
<presentationTable>
@@ -379,3 +369,4 @@ If you don't configure this policy, the user will be able to control the setting
</resources>
</policyDefinitionResources>

View File

@@ -57,6 +57,22 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public PasteAIConfiguration PasteAIConfiguration => _configuration;
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
public string PythonScriptsFolder => string.Empty;
public string PythonExecutablePath => string.Empty;
public bool PythonUseWsl => false;
public string PythonWslDistribution => string.Empty;
public int PythonScriptTimeoutSeconds => 30;
public bool IsPythonScriptsEnabled => true;
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
public event EventHandler Changed;
public Task SetActiveAIProviderAsync(string providerId)
@@ -65,4 +81,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
Changed?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
}
}

View File

@@ -0,0 +1,560 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AdvancedPaste.Helpers;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.UnitTests.Mocks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AdvancedPaste.UnitTests.ServicesTests;
[TestClass]
public sealed class PythonScriptServiceTests
{
private PythonScriptService _service;
[TestInitialize]
public void Setup()
{
_service = new PythonScriptService(new IntegrationTestUserSettings());
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
{
var lines = new[]
{
"# @advancedpaste:name test",
"import requests",
"import numpy",
"import os",
"import sys",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsFromImports()
{
var lines = new[]
{
"from PIL import Image",
"from markitdown import MarkItDown",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_WellKnownMappings()
{
var lines = new[]
{
"import cv2",
"import win32clipboard",
"import yaml",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
{
var lines = new[]
{
"import cv2",
"import requests",
};
var explicitReqs = new List<PythonRequirement>
{
new("cv2", "opencv-python-headless"),
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
Assert.AreEqual(2, result.Count);
// cv2 should use the explicit pip package name, not the well-known mapping
var cv2Req = result.First(r => r.ImportName == "cv2");
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
// requests should be auto-detected
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsStdlib()
{
var lines = new[]
{
"import os",
"import sys",
"import json",
"import io",
"import pathlib",
"import tempfile",
"import subprocess",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsComments()
{
var lines = new[]
{
"# import requests",
"# from PIL import Image",
"import json",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
{
var lines = new[]
{
"import requests, numpy, pandas",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
{
var lines = new[]
{
"import win32com.client",
"from llama_cpp import Llama",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
}
[TestMethod]
public void ParsePythonError_ModuleNotFoundError()
{
var stderr = """
Traceback (most recent call last):
File "C:\scripts\reverse.py", line 4, in <module>
import win32clipboard
ModuleNotFoundError: No module named 'win32clipboard'
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxError()
{
var stderr = """
File "test.py", line 5
def foo(
^
SyntaxError: unexpected EOF while parsing
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxErrorWithColumn()
{
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_GenericError()
{
var stderr = """
Traceback (most recent call last):
File "test.py", line 10, in <module>
result = 1 / 0
ZeroDivisionError: division by zero
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
{
var stderr = """
Traceback (most recent call last):
File "main.py", line 5, in <module>
helper()
File "helper.py", line 12, in helper
do_work()
File "worker.py", line 8, in do_work
raise RuntimeError("bad state")
RuntimeError: bad state
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
}
[TestMethod]
public void ParsePythonError_EmptyStderr()
{
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
Assert.IsTrue(!string.IsNullOrEmpty(summary));
Assert.AreEqual(string.Empty, details);
}
[TestMethod]
public void ParsePythonError_NoTraceback_PlainStderr()
{
var stderr = "Something went wrong in the script\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
// No File "..." reference, so no location — just the message
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
}
[TestMethod]
public void ExtractLastTracebackLocation_BasicTraceback()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"script.py\", line 10, in <module>",
" result = 1 / 0",
"ZeroDivisionError: division by zero",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("script.py", location.Value.FileName);
Assert.AreEqual(10, location.Value.Line);
Assert.IsNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_WithCaret()
{
var lines = new[]
{
" File \"test.py\", line 5",
" def foo(",
" ^",
"SyntaxError: unexpected EOF while parsing",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("test.py", location.Value.FileName);
Assert.AreEqual(5, location.Value.Line);
Assert.IsNotNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
" some_call()",
"ValueError: invalid value",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("my_script.py", location.Value.FileName);
Assert.AreEqual(42, location.Value.Line);
}
[TestMethod]
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
{
var lines = new[]
{
"Some random error output",
"No traceback here",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNull(location);
}
[TestMethod]
public void ParsePipInstallError_ExtractsErrorLine()
{
var stderr = """
Collecting some-package
Downloading some-package-1.0.tar.gz (15 kB)
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
ERROR: No matching distribution found for some-package
""";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
{
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_EmptyStderr()
{
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
Assert.AreEqual("unknown error", summary);
Assert.AreEqual(string.Empty, fullStderr);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Text()
{
// The new interface uses function names like advanced_paste_from_text_to_text(...)
// to determine supported formats, not parameter signatures.
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_text(text):\n return text.upper()\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Html()
{
var scriptPath = CreateTempScript("def advanced_paste_from_html_to_text(html: str) -> str:\n return html\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Html, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Image()
{
var scriptPath = CreateTempScript("def advanced_paste_from_image_to_text(image_path):\n return 'desc'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Image, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Files()
{
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_text(file_paths):\n return ''\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.File, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Image()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_image(text):\n return '/path/img.png'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("image", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_File()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_file(text):\n return '/path/out.txt'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("file", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Files()
{
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_files(file_paths):\n return file_paths\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("files", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsMultipleFunctions()
{
var scriptPath = CreateTempScript(
"def advanced_paste_from_text_to_text(text):\n return text\n\n" +
"def advanced_paste_from_html_to_text(html):\n return html\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsNoFunction()
{
var scriptPath = CreateTempScript("def some_other_function(text):\n return text\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsOldFormatWithoutTo()
{
// Old format (advanced_paste_from_text without _to_) should be rejected.
var scriptPath = CreateTempScript("def advanced_paste_from_text(text):\n return text\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Audio()
{
var scriptPath = CreateTempScript("def advanced_paste_from_audio_to_text(audio_path):\n return 'transcribed'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Audio, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Video()
{
var scriptPath = CreateTempScript("def advanced_paste_from_video_to_text(video_path):\n return 'description'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Video, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Audio()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_audio(text):\n return '/path/out.mp3'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("audio", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Video()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_video(text):\n return '/path/out.mp4'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("video", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
private static string CreateTempScript(string content)
{
var path = Path.Combine(Path.GetTempPath(), $"test_script_{Guid.NewGuid():N}.py");
File.WriteAllText(path, content);
return path;
}
}

View File

@@ -155,5 +155,9 @@
<Content Include="Assets\AdvancedPaste\SemanticKernel.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Services\PythonScripts\_runner.py">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -14,6 +14,7 @@ using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
@@ -83,6 +84,8 @@ namespace AdvancedPaste
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
services.AddSingleton<IPythonScriptService, PythonScriptService>();
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
services.AddSingleton<OptionsViewModel>();
}).Build();

View File

@@ -755,63 +755,7 @@
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</TextBlock>
<Grid
x:Name="ErrorMessageGrid"
x:Uid="ErrorMessageGrid"
Grid.Row="1"
Margin="8,8,0,0"
ColumnSpacing="8"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<ToolTipService.ToolTip>
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel
MinWidth="300"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<TextBox
x:Name="AIErrorMessage"
x:Uid="AIErrorMessage"
FontSize="12"
IsReadOnly="True"
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
TextWrapping="Wrap" />
</StackPanel>
</ToolTip>
</ToolTipService.ToolTip>
<FontIcon
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
</StackPanel>
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</Grid>
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="DefaultState" />
@@ -832,7 +776,6 @@
<VisualState.Setters>
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -43,7 +43,8 @@ namespace AdvancedPaste
double GetHeight(int maxCustomActionCount) =>
baseHeight +
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
MinHeight = GetHeight(1);
Height = GetHeight(5);
@@ -59,6 +60,7 @@ namespace AdvancedPaste
UpdateHeight();
}
};
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
this.ExtendsContentIntoTitleBar = true;
@@ -141,11 +143,7 @@ namespace AdvancedPaste
internal void FinishLoading(bool success)
{
MainPage.CustomFormatTextBox.IsLoading(false);
if (success)
{
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
}
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
}
}
}

View File

@@ -144,6 +144,7 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -250,10 +251,12 @@
Grid.Row="1"
Margin="20,0,20,0"
x:FieldModifier="public"
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
IsEnabled="True"
TabIndex="0">
<controls:PromptBox.Footer>
<StackPanel Orientation="Horizontal">
<StackPanel
Orientation="Horizontal"
Visibility="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
x:Uid="AIMistakeNote"
Margin="0,0,2,0"
@@ -299,19 +302,70 @@
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>
<ScrollViewer Grid.Row="2">
<Grid
x:Name="ErrorMessageGrid"
Grid.Row="2"
Margin="20,4,20,0"
ColumnSpacing="8"
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView
x:Name="PasteOptionsListView"
Grid.Row="0"
VerticalAlignment="Bottom"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
@@ -341,6 +395,27 @@
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="2" />
<Rectangle
Grid.Row="3"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
<ListView
x:Name="PythonScriptsListView"
Grid.Row="4"
VerticalAlignment="Top"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="3" />
</Grid>
</ScrollViewer>
</Grid>

View File

@@ -208,5 +208,43 @@ namespace AdvancedPaste.Pages
Clipboard.SetHistoryItemAsContent(item.Item);
}
}
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
{
var details = ViewModel.PasteActionError?.Details;
if (string.IsNullOrEmpty(details))
{
return;
}
var scrollViewer = new ScrollViewer
{
MaxHeight = 400,
MinWidth = 400,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var textBlock = new TextBlock
{
Text = details,
TextWrapping = TextWrapping.Wrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
IsTextSelectionEnabled = true,
};
scrollViewer.Content = textBlock;
var dialog = new ContentDialog
{
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
Content = scrollViewer,
XamlRoot = this.XamlRoot,
};
await dialog.ShowAsync();
}
}
}

View File

@@ -27,8 +27,26 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
public bool IsPythonScriptsEnabled { get; }
public string PythonScriptsFolder { get; }
public string PythonExecutablePath { get; }
public bool PythonUseWsl { get; }
public string PythonWslDistribution { get; }
public int PythonScriptTimeoutSeconds { get; }
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
public event EventHandler Changed;
Task SetActiveAIProviderAsync(string providerId);
void StoreTrustedScriptHash(string scriptPath, string hash);
}
}

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading;
@@ -25,6 +26,10 @@ namespace AdvancedPaste.Settings
private readonly Lock _loadingSettingsLock = new();
private readonly List<PasteFormats> _additionalActions;
private readonly List<AdvancedPasteCustomAction> _customActions;
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
private FileSystemWatcher _scriptFolderWatcher;
private CancellationTokenSource _scriptFolderDebounce;
private string _watchedScriptsFolder = string.Empty;
private const string AdvancedPasteModuleName = "AdvancedPaste";
private const int MaxNumberOfRetry = 5;
@@ -48,6 +53,22 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
public string PythonScriptsFolder { get; private set; }
public bool IsPythonScriptsEnabled { get; private set; }
public string PythonExecutablePath { get; private set; }
public bool PythonUseWsl { get; private set; }
public string PythonWslDistribution { get; private set; } = string.Empty;
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
public UserSettings(IFileSystem fileSystem)
{
_settingsUtils = new SettingsUtils(fileSystem);
@@ -57,8 +78,14 @@ namespace AdvancedPaste.Settings
CloseAfterLosingFocus = false;
EnableClipboardPreview = true;
PasteAIConfiguration = new PasteAIConfiguration();
PythonScriptsFolder = GetDefaultScriptsFolder();
PythonExecutablePath = string.Empty;
PythonUseWsl = false;
PythonWslDistribution = string.Empty;
PythonScriptTimeoutSeconds = 30;
_additionalActions = [];
_customActions = [];
_pythonScriptActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
LoadSettingsFromJson();
@@ -66,6 +93,14 @@ namespace AdvancedPaste.Settings
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
}
private static string GetDefaultScriptsFolder() =>
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"AdvancedPaste",
"Scripts");
private void OnSettingsFileChanged()
{
lock (_loadingSettingsLock)
@@ -131,6 +166,42 @@ namespace AdvancedPaste.Settings
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
pythonScripts.MigrateLegacyIfNeeded();
var mode = pythonScripts.Mode ?? "disabled";
IsPythonScriptsEnabled = !string.Equals(mode, "disabled", StringComparison.OrdinalIgnoreCase);
PythonUseWsl = string.Equals(mode, "wsl", StringComparison.OrdinalIgnoreCase);
if (PythonUseWsl)
{
var wslSettings = pythonScripts.WslSettings ?? new PythonScriptWslSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(wslSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: wslSettings.ScriptsFolder;
PythonExecutablePath = string.Empty;
PythonWslDistribution = wslSettings.Distribution ?? string.Empty;
}
else
{
var winSettings = pythonScripts.WindowsSettings ?? new PythonScriptWindowsSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(winSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: winSettings.ScriptsFolder;
PythonExecutablePath = winSettings.PythonExecutablePath ?? string.Empty;
PythonWslDistribution = string.Empty;
}
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
TrustedScriptHashes = new Dictionary<string, string>(
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
_pythonScriptActions.Clear();
_pythonScriptActions.AddRange(pythonScripts.Value.Where(a => a.IsShown));
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -295,6 +366,102 @@ namespace AdvancedPaste.Settings
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
}
private void UpdateScriptFolderWatcher(string folderPath)
{
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
_scriptFolderWatcher?.Dispose();
_scriptFolderWatcher = null;
_watchedScriptsFolder = folderPath;
if (string.IsNullOrWhiteSpace(folderPath))
{
return;
}
try
{
if (!System.IO.Directory.Exists(folderPath))
{
System.IO.Directory.CreateDirectory(folderPath);
}
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
IncludeSubdirectories = false,
};
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
_scriptFolderWatcher.Created += OnScriptFolderChanged;
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
}
catch (Exception ex)
{
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
}
}
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
{
lock (_loadingSettingsLock)
{
_scriptFolderDebounce?.Cancel();
_scriptFolderDebounce = new CancellationTokenSource();
Task.Delay(TimeSpan.FromMilliseconds(500))
.ContinueWith(
_ =>
{
Task.Factory
.StartNew(
() => Changed?.Invoke(this, EventArgs.Empty),
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler)
.Wait();
},
_scriptFolderDebounce.Token,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
}
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
lock (_loadingSettingsLock)
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
if (settings?.Properties?.PythonScripts is null)
{
return;
}
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
settings.Save(_settingsUtils);
// Update in-memory cache.
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
{
[scriptPath] = hash,
};
TrustedScriptHashes = updated;
}
catch (Exception ex)
{
Logger.LogError("Failed to store trusted script hash", ex);
}
}
}
public async Task SetActiveAIProviderAsync(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
@@ -387,6 +554,8 @@ namespace AdvancedPaste.Settings
if (disposing)
{
_cancellationTokenSource?.Dispose();
_scriptFolderDebounce?.Dispose();
_scriptFolderWatcher?.Dispose();
_watcher?.Dispose();
}

View File

@@ -40,6 +40,14 @@ public sealed class PasteFormat
IsSavedQuery = isSavedQuery,
};
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
{
Name = name,
Prompt = scriptPath,
IsSavedQuery = true,
};
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
public string IconGlyph => Metadata.IconGlyph;

View File

@@ -122,4 +122,13 @@ public enum PasteFormats
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
RequiresPrompt = true)]
CustomTextTransformation,
[PasteFormatMetadata(
IsCoreAction = false,
IconGlyph = "\uE943",
RequiresAIService = false,
CanPreview = true,
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File,
KernelFunctionDescription = "Runs a user-provided Python script on clipboard content.")]
PythonScript,
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -9,15 +9,23 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
public sealed class PasteFormatExecutor(
IKernelService kernelService,
ICustomActionTransformService customActionTransformService,
IPythonScriptService pythonScriptService,
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
{
private readonly IKernelService _kernelService = kernelService;
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
{
@@ -32,6 +40,15 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
var clipboardData = Clipboard.GetContent();
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
// to await it directly without wrapping in Task.Run.
if (format == PasteFormats.PythonScript)
{
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
}
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
return await Task.Run(async () =>
pasteFormat.Format switch
@@ -42,6 +59,92 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
});
}
private async Task<DataPackage> ExecutePythonScriptAsync(
string scriptPath,
DataPackageView clipboardData,
CancellationToken cancellationToken,
IProgress<double> progress)
{
// Security: ensure the script is trusted before executing.
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
{
var hash = _pythonScriptTrustService.ComputeHash(scriptPath);
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
if (!approved)
{
throw new OperationCanceledException("User declined to trust the Python script.");
}
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
}
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
// Pre-flight: check for missing packages and offer to install them.
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
if (missingPackages.Count > 0)
{
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
if (!approved)
{
throw new OperationCanceledException("User declined to install missing Python packages.");
}
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
}
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
// V2 interface: script defines advanced_paste_from_*_to_*() — use unified runner.
if (metadata.IsV2)
{
return await _pythonScriptService.ExecuteScriptAsync(scriptPath, metadata.Platform, clipboardData, detectedFormat, cancellationToken, progress);
}
// Legacy paths for backward compatibility.
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
{
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
}
else
{
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
// Re-read clipboard after script has run.
return Clipboard.GetContent() is { } updatedView
? await DataPackageFromViewAsync(updatedView)
: new DataPackage();
}
}
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
{
var pkg = new DataPackage();
if (view.Contains(StandardDataFormats.Text))
{
pkg.SetText(await view.GetTextAsync());
}
else if (view.Contains(StandardDataFormats.Html))
{
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
}
else if (view.Contains(StandardDataFormats.StorageItems))
{
var items = await view.GetStorageItemsAsync();
pkg.SetStorageItems(items);
}
else if (view.Contains(StandardDataFormats.Bitmap))
{
var bitmap = await view.GetBitmapAsync();
pkg.SetBitmap(bitmap);
}
return pkg;
}
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
{
switch (source)

View File

@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptService
{
/// <summary>
/// V2 unified execution: C# reads the clipboard, pipes data as JSON to the runner,
/// and receives a DataPackage from JSON stdout. Works on both Windows and WSL
/// depending on the specified platform.
/// </summary>
Task<DataPackage> ExecuteScriptAsync(string scriptPath, string platform, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Legacy Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
/// Kept for backward compatibility with scripts that use win32clipboard directly.
/// </summary>
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Legacy WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
/// Kept for backward compatibility with scripts that use json.load(sys.stdin) directly.
/// </summary>
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Parses the @advancedpaste: header comments from a Python script file.
/// </summary>
PythonScriptMetadata ReadMetadata(string scriptPath);
/// <summary>
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
/// </summary>
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
/// <summary>
/// Finds the Python executable to use. Returns null if none is found.
/// </summary>
string TryFindPythonExecutable(string overridePath = null);
/// <summary>
/// Returns true if wsl.exe is available on this machine.
/// </summary>
bool IsWslAvailable();
/// <summary>
/// Checks which of the declared requirements are not yet importable.
/// Returns an empty list if all packages are installed.
/// </summary>
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
PythonScriptMetadata metadata,
CancellationToken cancellationToken);
/// <summary>
/// Installs the given packages via pip / pip3.
/// </summary>
Task InstallRequirementsAsync(
IReadOnlyList<PythonRequirement> requirements,
string platform,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptTrustService
{
/// <summary>
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
/// </summary>
bool IsTrusted(string scriptPath);
/// <summary>
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
/// </summary>
Task<bool> RequestTrustAsync(string scriptPath, string hash);
/// <summary>
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
/// </summary>
void StoreTrust(string scriptPath, string hash);
/// <summary>
/// Computes the SHA-256 hash of the script file and returns the hex string.
/// </summary>
string ComputeHash(string scriptPath);
/// <summary>
/// Shows a confirmation dialog listing the missing packages and asking the user
/// whether to install them. Returns true if the user approved installation.
/// </summary>
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace AdvancedPaste.Services.PythonScripts;
/// <summary>
/// Represents a single Python package requirement declared via
/// <c># @advancedpaste:requires import_name=pip_package</c>.
/// </summary>
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
public sealed record PythonRequirement(string ImportName, string PipPackage);

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using AdvancedPaste.Models;
namespace AdvancedPaste.Services.PythonScripts;
public sealed record PythonScriptMetadata(
string ScriptPath,
string Name,
string Description,
ClipboardFormat SupportedFormats,
string Platform,
string Version,
bool IsEnabled,
IReadOnlyList<PythonRequirement> Requirements,
bool IsV2 = false,
string OutputTypeHint = null);

View File

@@ -0,0 +1,126 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Settings;
using ManagedCommon;
using Microsoft.UI.Xaml.Controls;
namespace AdvancedPaste.Services.PythonScripts;
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
{
private readonly IUserSettings _userSettings = userSettings;
public bool IsTrusted(string scriptPath)
{
var hashes = _userSettings.TrustedScriptHashes;
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
{
return false;
}
try
{
var currentHash = ComputeHash(scriptPath);
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
return false;
}
}
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonScriptTrustContent"),
scriptPath),
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
};
// XamlRoot must be set for ContentDialog to function.
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show trust dialog", ex);
return false;
}
}
public void StoreTrust(string scriptPath, string hash)
{
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
}
public string ComputeHash(string scriptPath)
{
using var stream = File.OpenRead(scriptPath);
var hashBytes = SHA256.HashData(stream);
return Convert.ToHexStringLower(hashBytes);
}
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var packageList = string.Join("\n", missingPackages.Select(r =>
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
? $" • {r.PipPackage}"
: $" • {r.PipPackage} (import: {r.ImportName})"));
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonPackageInstallContent"),
scriptName,
packageList),
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
};
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show package install dialog", ex);
return false;
}
}
}

View File

@@ -0,0 +1,247 @@
# Copyright (c) Microsoft Corporation
# The Microsoft Corporation licenses this file to you under the MIT license.
# See the LICENSE file in the project root for more information.
"""
Advanced Paste Script Runner (V3 Named Function Interface)
This runner is shipped with PowerToys and is NOT user-editable.
It loads a user script, discovers the single advanced_paste_from_<input>_to_<output>
function by name convention, calls it with the current clipboard data, and formats
the return value into JSON on stdout.
Each script must define exactly one function matching the pattern:
def advanced_paste_from_<input>_to_<output>(<param>)
Supported input types:
- text, html, image, files
Required output types (declared via _to_ suffix):
- text, html, image, file, files
Examples:
- advanced_paste_from_text_to_text(text: str) → output is text
- advanced_paste_from_text_to_image(text: str) → output is image
- advanced_paste_from_image_to_text(image_path) → output is text
- advanced_paste_from_files_to_text(file_paths) → output is text
Protocol:
- Input: JSON on stdin (clipboard data from C#)
- Output: JSON on stdout (result for C# to set on clipboard)
- Errors: stderr (displayed to user on failure)
"""
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
def _apply_output_hint(result, hint: str) -> dict:
"""
Force the output to the type specified by the _to_ suffix in the function name.
Converts the return value to match the hinted type.
"""
if result is None:
if hint == "text":
return {"result_type": "text", "text": ""}
elif hint == "html":
return {"result_type": "html", "html": ""}
elif hint == "image":
return {"result_type": "image", "image_path": ""}
elif hint == "audio":
return {"result_type": "audio", "audio_path": ""}
elif hint == "video":
return {"result_type": "video", "video_path": ""}
elif hint in ("file", "files"):
return {"result_type": hint, "file_paths": []}
if hint == "text":
return {"result_type": "text", "text": str(result) if not isinstance(result, str) else result}
elif hint == "html":
return {"result_type": "html", "html": str(result) if not isinstance(result, str) else result}
elif hint == "image":
path = str(result)
return {"result_type": "image", "image_path": path}
elif hint == "audio":
path = str(result)
return {"result_type": "audio", "audio_path": path}
elif hint == "video":
path = str(result)
return {"result_type": "video", "video_path": path}
elif hint == "file":
if isinstance(result, (list, tuple)):
paths = [str(p) for p in result]
else:
paths = [str(result)]
return {"result_type": "file", "file_paths": paths}
elif hint == "files":
if isinstance(result, (list, tuple)):
paths = [str(p) for p in result]
else:
paths = [str(result)]
return {"result_type": "files", "file_paths": paths}
# Fallback (shouldn't happen with valid hints)
return {"result_type": "text", "text": str(result)}
# Pattern matching advanced_paste_from_<input>_to_<output> function names.
_AP_FUNCTION_PATTERN = re.compile(
r"^advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)$"
)
def _load_user_module(script_path: str):
"""Dynamically load the user script as a Python module."""
spec = importlib.util.spec_from_file_location("_user_script", script_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load script: {script_path}")
module = importlib.util.module_from_spec(spec)
# Add the script's directory to sys.path so relative imports/helpers work.
script_dir = os.path.dirname(os.path.abspath(script_path))
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
spec.loader.exec_module(module)
return module
def _discover_ap_function(module) -> tuple:
"""
Discover the single advanced_paste_from_<input>_to_<output> function in the module.
Returns a tuple (input_type, output_type, function) or None if not found.
Exits with error if multiple functions are defined.
"""
matches = []
for name in dir(module):
match = _AP_FUNCTION_PATTERN.match(name)
if match:
fn = getattr(module, name)
if callable(fn):
input_type = match.group(1)
output_type = match.group(2)
matches.append((input_type, output_type, fn))
if len(matches) == 0:
return None
if len(matches) > 1:
names = [f"advanced_paste_from_{m[0]}_to_{m[1]}" for m in matches]
print(
f"Error: script defines multiple advanced_paste_from_*_to_* functions "
f"({', '.join(names)}). Only one is allowed per script.",
file=sys.stderr,
)
sys.exit(1)
return matches[0]
def _format_output(result, output_type: str) -> dict:
"""
Format the return value according to the declared output type from the function name.
The output_type comes from the _to_ suffix and is always provided.
"""
if result is None:
if output_type in ("file", "files"):
return {"result_type": output_type, "file_paths": []}
elif output_type == "image":
return {"result_type": "image", "image_path": ""}
elif output_type == "audio":
return {"result_type": "audio", "audio_path": ""}
elif output_type == "video":
return {"result_type": "video", "video_path": ""}
elif output_type == "html":
return {"result_type": "html", "html": ""}
return {"result_type": "text", "text": ""}
return _apply_output_hint(result, output_type)
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
print("Usage: _runner.py <script_path>", file=sys.stderr)
sys.exit(1)
script_path = sys.argv[1]
if not os.path.isfile(script_path):
print(f"Error: script not found: {script_path}", file=sys.stderr)
sys.exit(1)
# Read input payload from stdin.
try:
data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
# Load the user script.
module = _load_user_module(script_path)
# Discover the single advanced_paste_from_* function.
ap_result = _discover_ap_function(module)
if ap_result is None:
print(
f"Error: script '{os.path.basename(script_path)}' does not define an "
f"advanced_paste_from_<input>_to_<output> function.\n"
f"Example: def advanced_paste_from_text_to_text(text): return text.upper()",
file=sys.stderr,
)
sys.exit(1)
input_type, output_type, fn = ap_result
# Determine the input data key for this function's input type.
input_map = {
"text": "text",
"html": "html",
"image": "image_path",
"audio": "audio_path",
"video": "video_path",
"files": "file_paths",
}
key = input_map.get(input_type, input_type)
input_value = data.get(key)
# Expose work_dir as environment variable so scripts can write output files
# to a location accessible from both WSL and Windows (under /mnt/c/...).
work_dir = data.get("work_dir", "")
if work_dir:
os.environ["ADVANCED_PASTE_WORK_DIR"] = work_dir
# Check if the clipboard has matching data for this script's input type.
formats = data.get("format", ["text"])
if isinstance(formats, str):
formats = [formats]
if input_type not in formats:
print(
f"Error: script expects '{input_type}' input but clipboard has [{', '.join(formats)}].",
file=sys.stderr,
)
sys.exit(1)
if not input_value:
print(
f"Error: no data available for format '{input_type}' "
f"(expected '{key}' in input payload).",
file=sys.stderr,
)
sys.exit(1)
# Call the function.
result = fn(input_value)
output = _format_output(result, output_type)
# Output JSON result.
json.dump(output, sys.stdout, ensure_ascii=False)
if __name__ == "__main__":
main()

View File

@@ -212,10 +212,10 @@
<value>Delete</value>
</data>
<data name="CustomFormatTextBox.PlaceholderText" xml:space="preserve">
<value>Describe what format you want..</value>
<value>Search or describe what format you want..</value>
</data>
<data name="InputTxtBoxTooltip.Text" xml:space="preserve">
<value>Describe what format you want..</value>
<value>Search or describe what format you want..</value>
</data>
<data name="LearnMoreLink.Text" xml:space="preserve">
<value>Privacy</value>
@@ -372,4 +372,73 @@
<value>Unable to load Foundry Local model: {0}</value>
<comment>{0} is the model identifier. Do not translate {0}.</comment>
</data>
<data name="PythonNotFound" xml:space="preserve">
<value>Python was not found. Please install Python or configure the path in Settings.</value>
</data>
<data name="WslNotAvailable" xml:space="preserve">
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
</data>
<data name="PythonScriptFailed" xml:space="preserve">
<value>The Python script failed to execute.</value>
</data>
<data name="PythonScriptTimeout" xml:space="preserve">
<value>Script execution timed out ({0} seconds).</value>
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
</data>
<data name="PythonScriptNotFound" xml:space="preserve">
<value>Script file not found: {0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptInvalidJson" xml:space="preserve">
<value>The script output is not valid JSON.</value>
</data>
<data name="PythonScriptTrustTitle" xml:space="preserve">
<value>Run Python Script?</value>
</data>
<data name="PythonScriptTrustContent" xml:space="preserve">
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
{0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptTrustConfirm" xml:space="preserve">
<value>Run</value>
</data>
<data name="PythonScriptTrustCancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="PythonPackageInstallTitle" xml:space="preserve">
<value>Install Missing Packages?</value>
</data>
<data name="PythonPackageInstallContent" xml:space="preserve">
<value>The script "{0}" requires the following Python packages that are not installed:
{1}
Install them now?</value>
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallConfirm" xml:space="preserve">
<value>Install</value>
</data>
<data name="PythonPackageInstallCancel" xml:space="preserve">
<value>Skip</value>
</data>
<data name="PythonPackageInstallFailed" xml:space="preserve">
<value>Failed to install package(s) "{0}": {1}</value>
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallTimeout" xml:space="preserve">
<value>Package installation for "{0}" timed out ({1} seconds).</value>
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
</data>
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
<value>Show details</value>
</data>
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
<value>Error Details</value>
</data>
<data name="ErrorDetailsDialogClose" xml:space="preserve">
<value>Close</value>
</data>
</root>

View File

@@ -16,6 +16,7 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -41,6 +42,7 @@ namespace AdvancedPaste.ViewModels
private readonly IUserSettings _userSettings;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly IAICredentialsProvider _credentialsProvider;
private readonly IPythonScriptService _pythonScriptService;
private CancellationTokenSource _pasteActionCancellationTokenSource;
@@ -100,6 +102,8 @@ namespace AdvancedPaste.ViewModels
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
public bool IsCustomAIServiceEnabled
{
get
@@ -258,11 +262,12 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
{
_credentialsProvider = credentialsProvider;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
_pythonScriptService = pythonScriptService;
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
@@ -413,12 +418,51 @@ namespace AdvancedPaste.ViewModels
}
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
.Where(format => format != PasteFormats.PythonScript &&
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
.Select(CreateStandardPasteFormat));
UpdateFormats(
CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
UpdateFormats(
PythonScriptPasteFormats,
BuildPythonScriptFormats());
}
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
{
if (!_userSettings.IsPythonScriptsEnabled)
{
yield break;
}
var folder = _userSettings.PythonScriptsFolder;
if (string.IsNullOrWhiteSpace(folder))
{
yield break;
}
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
var scriptActions = _userSettings.PythonScriptActions;
// Use metadata from discovered scripts, but apply IsShown from saved settings.
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
StringComparer.OrdinalIgnoreCase);
foreach (var meta in discoveredScripts)
{
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
{
continue;
}
// Filter by intersection: only pass clipboard formats the script supports.
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
}
}
public void Dispose()
@@ -692,7 +736,10 @@ namespace AdvancedPaste.ViewModels
_pasteActionCancellationTokenSource = new();
TransformProgress = double.NaN;
PasteActionError = PasteActionError.None;
Query = pasteFormat.Query;
// For Python scripts the Prompt field holds the file path, not a user-visible query.
// Setting Query to the path would show it in the AI prompt box, which is misleading.
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
try
{
@@ -732,7 +779,7 @@ namespace AdvancedPaste.ViewModels
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
.Where(pasteFormat => pasteFormat.IsEnabled)
.ElementAtOrDefault(key - VirtualKey.Number1);

View File

@@ -13,11 +13,13 @@
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
@@ -110,8 +112,12 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="CursorWrapTests\WrapSimulator\test_new_algorithm.py" />
<None Include="COMPLETE_REWRITE_SUMMARY.md" />
<None Include="CRITICAL_BUG_ANALYSIS.md" />
<None Include="CURSOR_WRAP_FIX_ANALYSIS.md" />
<None Include="DEBUG_GUIDE.md" />
<None Include="packages.config" />
<None Include="VERTICAL_WRAP_BUG_FIX.md" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
@@ -124,4 +130,4 @@
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project>
</Project>

View File

@@ -163,39 +163,6 @@ void CursorWrapCore::UpdateMonitorInfo()
Logger::info(L"======= UPDATE MONITOR INFO END =======");
}
void CursorWrapCore::ResetWrapState()
{
m_hasPreviousPosition = false;
m_hasLastWrapDestination = false;
m_previousPosition = { LONG_MIN, LONG_MIN };
m_lastWrapDestination = { LONG_MIN, LONG_MIN };
}
CursorDirection CursorWrapCore::CalculateDirection(const POINT& currentPos) const
{
CursorDirection dir = { 0, 0 };
if (m_hasPreviousPosition)
{
dir.dx = currentPos.x - m_previousPosition.x;
dir.dy = currentPos.y - m_previousPosition.y;
}
return dir;
}
bool CursorWrapCore::IsWithinWrapThreshold(const POINT& currentPos) const
{
if (!m_hasLastWrapDestination)
{
return false;
}
int dx = currentPos.x - m_lastWrapDestination.x;
int dy = currentPos.y - m_lastWrapDestination.y;
int distanceSquared = dx * dx + dy * dy;
return distanceSquared <= (WRAP_DISTANCE_THRESHOLD * WRAP_DISTANCE_THRESHOLD);
}
POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor)
{
// Check if wrapping should be disabled on single monitor
@@ -209,8 +176,6 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
loggedOnce = true;
}
#endif
m_previousPosition = currentPos;
m_hasPreviousPosition = true;
return currentPos;
}
@@ -220,31 +185,9 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
#ifdef _DEBUG
OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n");
#endif
m_previousPosition = currentPos;
m_hasPreviousPosition = true;
return currentPos;
}
// Check distance threshold to prevent rapid oscillation
if (IsWithinWrapThreshold(currentPos))
{
#ifdef _DEBUG
OutputDebugStringW(L"[CursorWrap] [THRESHOLD] Cursor within wrap threshold - skipping wrap\n");
#endif
m_previousPosition = currentPos;
m_hasPreviousPosition = true;
return currentPos;
}
// Clear wrap destination threshold once cursor moves away
if (m_hasLastWrapDestination && !IsWithinWrapThreshold(currentPos))
{
m_hasLastWrapDestination = false;
}
// Calculate cursor movement direction
CursorDirection direction = CalculateDirection(currentPos);
// Convert int wrapMode to WrapMode enum
WrapMode mode = static_cast<WrapMode>(wrapMode);
@@ -252,7 +195,6 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
{
std::wostringstream oss;
oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")";
oss << L" direction=(" << direction.dx << L", " << direction.dy << L")";
// Get current monitor and identify which one
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
@@ -287,9 +229,9 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
// Get current monitor
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
// Check if cursor is on an outer edge (filtered by wrap mode and direction)
// Check if cursor is on an outer edge (filtered by wrap mode)
EdgeType edgeType;
if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode, &direction))
if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode))
{
#ifdef _DEBUG
static bool lastWasNotOuter = false;
@@ -299,8 +241,6 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
lastWasNotOuter = true;
}
#endif
m_previousPosition = currentPos;
m_hasPreviousPosition = true;
return currentPos; // Not on an outer edge
}
@@ -338,16 +278,5 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
}
#endif
// Update tracking state
m_previousPosition = currentPos;
m_hasPreviousPosition = true;
// Store wrap destination for threshold checking
if (newPos.x != currentPos.x || newPos.y != currentPos.y)
{
m_lastWrapDestination = newPos;
m_hasLastWrapDestination = true;
}
return newPos;
}

View File

@@ -8,24 +8,6 @@
#include <string>
#include "MonitorTopology.h"
// Distance threshold to prevent rapid back-and-forth wrapping (in pixels)
constexpr int WRAP_DISTANCE_THRESHOLD = 50;
// Cursor movement direction
struct CursorDirection
{
int dx; // Horizontal movement (positive = right, negative = left)
int dy; // Vertical movement (positive = down, negative = up)
bool IsMovingLeft() const { return dx < 0; }
bool IsMovingRight() const { return dx > 0; }
bool IsMovingUp() const { return dy < 0; }
bool IsMovingDown() const { return dy > 0; }
// Returns true if horizontal movement is dominant
bool IsPrimarilyHorizontal() const { return abs(dx) >= abs(dy); }
};
// Core cursor wrapping engine
class CursorWrapCore
{
@@ -43,28 +25,11 @@ public:
size_t GetMonitorCount() const { return m_monitors.size(); }
const MonitorTopology& GetTopology() const { return m_topology; }
// Reset wrap state (call when disabling/re-enabling)
void ResetWrapState();
private:
#ifdef _DEBUG
std::wstring GenerateTopologyJSON() const;
#endif
// Calculate movement direction from previous position
CursorDirection CalculateDirection(const POINT& currentPos) const;
// Check if cursor is within threshold distance of last wrap position
bool IsWithinWrapThreshold(const POINT& currentPos) const;
std::vector<MonitorInfo> m_monitors;
MonitorTopology m_topology;
// Movement tracking for direction-based edge priority
POINT m_previousPosition = { LONG_MIN, LONG_MIN };
bool m_hasPreviousPosition = false;
// Wrap stability: prevent rapid oscillation
POINT m_lastWrapDestination = { LONG_MIN, LONG_MIN };
bool m_hasLastWrapDestination = false;
};

View File

@@ -1,7 +0,0 @@
<Solution>
<Configurations>
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="CursorLog/CursorLog.vcxproj" Id="646f6684-9f11-42cd-8b35-b2954404f985" />
</Solution>

View File

@@ -1,196 +0,0 @@
// CursorLog.cpp : Monitors mouse position and logs to file with monitor/DPI info
//
#include <iostream>
#include <fstream>
#include <string>
#include <filesystem>
#include <Windows.h>
#include <ShellScalingApi.h>
#pragma comment(lib, "Shcore.lib")
// Global variables
std::ofstream g_outputFile;
HHOOK g_mouseHook = nullptr;
POINT g_lastPosition = { LONG_MIN, LONG_MIN };
DWORD g_mainThreadId = 0;
// Get monitor information for a given point
std::string GetMonitorInfo(POINT pt, UINT* dpiX, UINT* dpiY)
{
HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
if (!hMonitor)
return "Unknown";
MONITORINFOEX monitorInfo = {};
monitorInfo.cbSize = sizeof(MONITORINFOEX);
GetMonitorInfo(hMonitor, &monitorInfo);
// Get DPI for this monitor
if (SUCCEEDED(GetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, dpiX, dpiY)))
{
// DPI retrieved successfully
}
else
{
*dpiX = 96;
*dpiY = 96;
}
// Convert device name to string using proper wide-to-narrow conversion
std::wstring deviceName(monitorInfo.szDevice);
int sizeNeeded = WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast<int>(deviceName.length()), nullptr, 0, nullptr, nullptr);
std::string result(sizeNeeded, 0);
WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast<int>(deviceName.length()), &result[0], sizeNeeded, nullptr, nullptr);
return result;
}
// Calculate scale factor from DPI
constexpr double GetScaleFactor(UINT dpi)
{
return static_cast<double>(dpi) / 96.0;
}
// Low-level mouse hook callback
LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode == HC_ACTION && wParam == WM_MOUSEMOVE)
{
MSLLHOOKSTRUCT* mouseStruct = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam);
POINT pt = mouseStruct->pt;
// Only log if position changed
if (pt.x != g_lastPosition.x || pt.y != g_lastPosition.y)
{
g_lastPosition = pt;
UINT dpiX = 96, dpiY = 96;
std::string monitorName = GetMonitorInfo(pt, &dpiX, &dpiY);
double scale = GetScaleFactor(dpiX);
if (g_outputFile.is_open())
{
g_outputFile << monitorName
<< "," << pt.x
<< "," << pt.y
<< "," << dpiX
<< "," << static_cast<int>(scale * 100) << "%"
<< "\n";
g_outputFile.flush();
}
}
}
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
}
// Console control handler for clean shutdown
BOOL WINAPI ConsoleHandler(DWORD ctrlType)
{
if (ctrlType == CTRL_C_EVENT || ctrlType == CTRL_CLOSE_EVENT)
{
std::cout << "\nShutting down..." << std::endl;
if (g_mouseHook)
{
UnhookWindowsHookEx(g_mouseHook);
g_mouseHook = nullptr;
}
if (g_outputFile.is_open())
{
g_outputFile.close();
}
// Post quit message to the main thread to exit the message loop
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
return TRUE;
}
return FALSE;
}
int main(int argc, char* argv[])
{
// Set DPI awareness FIRST, before any other Windows API calls
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
// Store main thread ID for clean shutdown
g_mainThreadId = GetCurrentThreadId();
// Check command line arguments
if (argc != 2)
{
std::cerr << "Usage: CursorLog.exe <output_path_and_filename>" << std::endl;
return 1;
}
std::filesystem::path outputPath(argv[1]);
std::filesystem::path parentPath = outputPath.parent_path();
// Validate the directory exists
if (!parentPath.empty() && !std::filesystem::exists(parentPath))
{
std::cerr << "Error: The directory '" << parentPath.string() << "' does not exist." << std::endl;
return 1;
}
// Check if file exists and prompt for overwrite
if (std::filesystem::exists(outputPath))
{
std::cout << "File '" << outputPath.string() << "' already exists. Overwrite? (y/n): ";
char response;
std::cin >> response;
if (response != 'y' && response != 'Y')
{
std::cout << "Operation cancelled." << std::endl;
return 0;
}
}
// Open output file
g_outputFile.open(outputPath, std::ios::out | std::ios::trunc);
if (!g_outputFile.is_open())
{
std::cerr << "Error: Unable to create or open file '" << outputPath.string() << "'." << std::endl;
return 1;
}
std::cout << "Logging mouse position to: " << outputPath.string() << std::endl;
std::cout << "Press Ctrl+C to stop..." << std::endl;
// Set up console control handler
SetConsoleCtrlHandler(ConsoleHandler, TRUE);
// Install low-level mouse hook
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, nullptr, 0);
if (!g_mouseHook)
{
std::cerr << "Error: Failed to install mouse hook. Error code: " << GetLastError() << std::endl;
g_outputFile.close();
return 1;
}
// Message loop - required for low-level hooks
MSG msg;
while (GetMessage(&msg, nullptr, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// Cleanup
if (g_mouseHook)
{
UnhookWindowsHookEx(g_mouseHook);
}
if (g_outputFile.is_open())
{
g_outputFile.close();
}
return 0;
}

View File

@@ -1,135 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>18.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{646f6684-9f11-42cd-8b35-b2954404f985}</ProjectGuid>
<RootNamespace>CursorLog</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="CursorLog.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="CursorLog.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>

View File

@@ -1,287 +0,0 @@
# CursorWrap Simulator
A Python visualization tool that displays monitor layouts and shows which edges will wrap to other monitors using the exact same logic as the PowerToys CursorWrap implementation.
## Purpose
This tool helps you:
- Visualize your multi-monitor setup
- Identify which screen edges are "outer edges" (edges that don't connect to another monitor)
- See where cursor wrapping will occur when you move the cursor to an outer edge
- **Find problem areas** where edges have NO wrap destination (shown in red)
## Requirements
- Python 3.6+
- Tkinter (included with standard Python on Windows)
## Usage
### Command Line
```bash
python wrap_simulator.py <path_to_monitor_layout.json>
```
### Without Arguments
```bash
python wrap_simulator.py
```
This opens the application with no layout loaded. Use the "Load JSON" button to select a file.
## JSON File Format
The monitor layout JSON file should have this structure:
```json
{
"captured_at": "2026-02-16T08:50:34+00:00",
"computer_name": "MY-PC",
"user_name": "User",
"monitor_count": 3,
"monitors": [
{
"left": 0,
"top": 0,
"right": 2560,
"bottom": 1440,
"width": 2560,
"height": 1440,
"dpi": 96,
"scaling_percent": 100.0,
"primary": true,
"device_name": "DISPLAY1"
}
]
}
```
## Understanding the Visualization
### Monitor Display
- **Gray rectangles**: Individual monitors
- **Orange border**: Primary monitor
- **Labels**: Show monitor index, device name, and resolution
### Edge Bars (Outside Monitor Boundaries)
Colored bars are drawn outside each **outer edge** (edges not adjacent to another monitor):
| Color | Meaning |
|-------|---------|
| **Yellow** | Edge segment has a wrap destination ✓ |
| **Red with stripes** | NO wrap destination - Problem area! ⚠️ |
The bar outline color indicates the edge type:
- Red = Left edge
- Teal = Right edge
- Blue = Top edge
- Green = Bottom edge
### Interactive Features
1. **Hover over edge segments**:
- See wrap destination info in the status bar
- Green arrow shows where the cursor would wrap to
- Green dashed rectangle highlights the destination
2. **Click on edge segments**:
- Detailed information appears in the info panel
- Shows full problem analysis with reason codes
- Explains why wrapping does/doesn't occur
- Provides suggestions for fixing problems
3. **Wrap Mode Selection**:
- **Both**: Wrap in all directions (default)
- **Vertical Only**: Only top/bottom edges wrap
- **Horizontal Only**: Only left/right edges wrap
4. **Export Analysis**:
- Click "Export Analysis" to save detailed diagnostic data
- Exports to JSON format for use in algorithm development
- Includes all problem segments with reason codes and suggestions
5. **Edge Test Simulation** (NEW):
- Click "🧪 Test Edges" to start automated edge testing
- Visually animates cursor movement along ALL outer edges
- Shows wrap destination for each test point with colored lines:
- **Red circle**: Source position on outer edge
- **Green circle**: Wrap destination
- **Green dashed line**: Connection showing wrap path
- **Red X**: No wrap destination (problem area)
- Use "New Algorithm" checkbox to toggle between:
- **NEW**: Projection-based algorithm (eliminates dead zones)
- **OLD**: Direct overlap only (may have dead zones)
- Results summary shows per-edge coverage statistics
## Problem Analysis
When a segment has no wrap destination, the tool provides detailed analysis:
### Problem Reason Codes
| Code | Description |
|------|-------------|
| `WRAP_MODE_DISABLED` | Edge type disabled by current wrap mode setting |
| `NO_OPPOSITE_OUTER_EDGES` | No outer edges of the opposite type exist at all |
| `NO_OVERLAPPING_RANGE` | Opposite edges exist but don't cover this coordinate range |
| `SINGLE_MONITOR` | Only one monitor - nowhere to wrap to |
### Diagnostic Details
For `NO_OVERLAPPING_RANGE` problems, the tool shows:
- Distance to the nearest valid wrap destination
- List of available opposite edges sorted by distance
- Whether the gap is above/below or left/right of the segment
- Suggested fixes (extend monitors or adjust positions)
## Sample Files
Included sample layouts:
- `sample_layout.json` - 3 monitors in a row with one offset
- `sample_staggered.json` - 3 monitors with staggered vertical positions (shows problem areas)
- `sample_with_gap.json` - 2 monitors with a gap between them
## Exported Analysis Format
The "Export Analysis" button generates a JSON file with this structure:
```json
{
"export_timestamp": "2026-02-16T08:50:34+00:00",
"wrap_mode": "BOTH",
"monitor_count": 3,
"monitors": [...],
"outer_edges": [...],
"problem_segments": [
{
"source": {
"monitor_index": 0,
"monitor_name": "DISPLAY1",
"edge_type": "TOP",
"edge_position": 200,
"segment_range": {"start": 0, "end": 200},
"segment_length_px": 200
},
"analysis": {
"reason_code": "NO_OVERLAPPING_RANGE",
"description": "No BOTTOM outer edge overlaps...",
"suggestion": "To fix: Either extend...",
"details": {
"gap_to_nearest": 200,
"available_opposite_edges": [...]
}
}
}
],
"summary": {
"total_outer_edges": 8,
"total_problem_segments": 4,
"total_problem_pixels": 800,
"problems_by_reason": {"NO_OVERLAPPING_RANGE": 4},
"has_problems": true
}
}
```
## How CursorWrap Logic Works
### Original Algorithm (v1)
1. **Outer Edge Detection**: An edge is "outer" if no other monitor's opposite edge is within 50 pixels AND has sufficient vertical/horizontal overlap
2. **Wrap Destination**: When cursor reaches an outer edge:
- Find the opposite type outer edge (Left→Right, Top→Bottom, etc.)
- The destination must overlap with the cursor's perpendicular position
- Cursor warps to the furthest matching outer edge
3. **Problem Areas**: If no opposite outer edge overlaps with a portion of an outer edge, that segment has no wrap destination - the cursor will simply stop at that edge.
### Enhanced Algorithm (v2) - With Projection
The enhanced algorithm eliminates dead zones by projecting cursor positions to valid destinations:
1. **Direct Overlap**: If an opposite outer edge directly overlaps the cursor's perpendicular coordinate, use it (same as v1)
2. **Nearest Edge Projection**: If no direct overlap exists:
- Find the nearest opposite outer edge by coordinate distance
- Calculate a projected position using offset-from-boundary approach
- The projection preserves relative position similar to how Windows handles monitor transitions
3. **No Dead Zones**: Every point on every outer edge will have a valid wrap destination
### Testing the Algorithm
Use the included test script to validate both algorithms:
```bash
python test_new_algorithm.py [layout_file.json]
```
This compares the old algorithm (with dead zones) against the new algorithm (with projection) and reports coverage.
## Cursor Log Playback
The simulator can play back recorded cursor movement logs to visualize how the cursor moves across monitors.
### Loading a Cursor Log
1. Click "Load Log" to select a cursor movement log file
2. Use the playback controls:
- **▶ Play / ⏸ Pause**: Start or pause playback
- **⏹ Stop**: Stop and reset to beginning
- **⏮ Reset**: Reset to beginning without stopping
- **Speed slider**: Adjust playback speed (10-500ms between frames)
### Log File Format
The cursor log file is CSV format with the following columns:
```
display_name,x,y,dpi,scaling%
```
Example:
```csv
\\.\DISPLAY1,1234,567,96,100%
\\.\DISPLAY2,2560,720,144,150%
\\.\DISPLAY3,-500,800,96,100%
```
- **display_name**: Windows display name (e.g., `\\.\DISPLAY1`)
- **x, y**: Screen coordinates
- **dpi**: Display DPI
- **scaling%**: Display scaling percentage (with or without % sign)
Lines starting with `#` are treated as comments and ignored.
### Playback Visualization
- **Green cursor**: Normal movement within a monitor
- **Red cursor with burst effect**: Monitor transition detected
- **Blue trail**: Recent cursor movement path (fades over time)
- **Dashed red arrow**: Shows transition path between monitors
The playback automatically slows down when a monitor transition is detected, making it easier to observe wrap behavior.
### Sample Log File
A sample cursor log file `sample_cursor_log.csv` is included that demonstrates cursor movement across a three-monitor setup.
## Architecture
The Python implementation mirrors the C++ code structure:
- `MonitorTopology` class: Manages edge-based monitor layout
- `MonitorEdge` dataclass: Represents a single edge of a monitor
- `EdgeSegment` dataclass: A portion of an edge with wrap info
- `CursorLogEntry` dataclass: A single cursor movement log entry
- `WrapSimulatorApp`: Tkinter GUI application
## Integration with PowerToys
This tool is designed to validate and debug the CursorWrap feature. The JSON files can be generated by the debug build of CursorWrap or created manually for testing specific configurations.

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""
Test script to validate the new projection-based wrapping algorithm.
"""
import json
import sys
from wrap_simulator import MonitorTopology, MonitorInfo, WrapMode
def test_layout(layout_file: str):
"""Test a monitor layout with both old and new algorithms."""
# Load the layout
with open(layout_file, 'r') as f:
layout = json.load(f)
# Create monitor info objects
monitors = []
for i, m in enumerate(layout['monitors']):
monitors.append(MonitorInfo(
left=m['left'], top=m['top'], right=m['right'], bottom=m['bottom'],
width=m['width'], height=m['height'], dpi=m.get('dpi', 96),
scaling_percent=m.get('scaling_percent', 100), primary=m.get('primary', False),
device_name=m.get('device_name', f'DISPLAY{i+1}'), monitor_id=i
))
# Initialize topology
topology = MonitorTopology()
topology.initialize(monitors)
print(f"Layout: {layout_file}")
print(f"Monitors: {len(monitors)}")
print(f"Outer edges: {len(topology.outer_edges)}")
# Validate with OLD algorithm
print("\n--- OLD Algorithm (may have dead zones) ---")
old_problems = 0
old_problem_details = []
for edge in topology.outer_edges:
segments = topology.get_edge_segments_with_wrap_info(edge, WrapMode.BOTH)
for seg in segments:
if not seg.has_wrap_destination:
length = seg.end - seg.start
old_problems += length
detail = f"Mon {edge.monitor_index} {edge.edge_type.name} [{seg.start}-{seg.end}] ({length}px)"
old_problem_details.append(detail)
print(f" PROBLEM: {detail}")
print(f"Total problematic pixels: {old_problems}")
# Validate with NEW algorithm
print("\n--- NEW Algorithm (with projection) ---")
result = topology.validate_all_edges_have_destinations(WrapMode.BOTH)
print(f"Total edge length: {result['total_edge_length']}px")
print(f"Covered: {result['covered_length']}px ({result['coverage_percent']:.1f}%)")
print(f"Uncovered: {result['uncovered_length']}px")
print(f"Fully covered: {result['is_fully_covered']}")
if result['problem_areas']:
for prob in result['problem_areas']:
print(f" PROBLEM: {prob}")
# Summary
print("\n--- COMPARISON ---")
print(f"Old algorithm dead zones: {old_problems}px")
print(f"New algorithm dead zones: {result['uncovered_length']}px")
if old_problems > 0 and result['uncovered_length'] == 0:
print("SUCCESS: New algorithm eliminates all dead zones!")
elif result['uncovered_length'] > 0:
print("WARNING: New algorithm still has dead zones")
else:
print("Both algorithms have no dead zones for this layout")
return result['is_fully_covered']
def main():
layout_files = [
'mikehall_monitor_layout.json',
'sample_layout.json',
'sample_staggered.json',
]
# Allow specifying layout on command line
if len(sys.argv) > 1:
layout_files = sys.argv[1:]
all_passed = True
for layout_file in layout_files:
try:
print(f"\n{'='*60}")
passed = test_layout(layout_file)
if not passed:
all_passed = False
except FileNotFoundError:
print(f"File not found: {layout_file}")
except Exception as e:
print(f"Error testing {layout_file}: {e}")
all_passed = False
print(f"\n{'='*60}")
if all_passed:
print("ALL TESTS PASSED")
else:
print("SOME TESTS FAILED")
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -4,7 +4,6 @@
#include "pch.h"
#include "MonitorTopology.h"
#include "CursorWrapCore.h" // For CursorDirection struct
#include "../../../common/logger/logger.h"
#include <algorithm>
#include <cmath>
@@ -14,7 +13,6 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
Logger::info(L"======= TOPOLOGY INITIALIZATION START =======");
Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size());
m_monitors = monitors;
m_outerEdges.clear();
m_edgeMap.clear();
@@ -165,80 +163,10 @@ bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEd
int overlapStart = max(edge1.start, edge2.start);
int overlapEnd = min(edge1.end, edge2.end);
return overlapEnd > overlapStart + tolerance;
}
EdgeType MonitorTopology::PrioritizeEdgeByDirection(const std::vector<EdgeType>& candidates,
const CursorDirection* direction) const
{
if (candidates.empty())
{
return EdgeType::Left; // Should not happen, but return a default
}
if (candidates.size() == 1 || direction == nullptr)
{
return candidates[0];
}
// Prioritize based on movement direction
// If moving primarily horizontally, prefer horizontal edges (Left/Right)
// If moving primarily vertically, prefer vertical edges (Top/Bottom)
if (direction->IsPrimarilyHorizontal())
{
// Prefer Left if moving left, Right if moving right
if (direction->IsMovingLeft())
{
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Left) return edge;
}
}
else if (direction->IsMovingRight())
{
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Right) return edge;
}
}
// Fall back to any horizontal edge
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Left || edge == EdgeType::Right) return edge;
}
}
else
{
// Prefer Top if moving up, Bottom if moving down
if (direction->IsMovingUp())
{
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Top) return edge;
}
}
else if (direction->IsMovingDown())
{
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Bottom) return edge;
}
}
// Fall back to any vertical edge
for (EdgeType edge : candidates)
{
if (edge == EdgeType::Top || edge == EdgeType::Bottom) return edge;
}
}
// Default to first candidate
return candidates[0];
}
bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType,
WrapMode wrapMode, const CursorDirection* direction) const
bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const
{
RECT monitorRect;
if (!GetMonitorRect(monitor, monitorRect))
@@ -320,40 +248,13 @@ bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, Ed
return false;
}
// Prioritize candidates by movement direction at corners
EdgeType prioritizedEdge = PrioritizeEdgeByDirection(candidateEdges, direction);
// Get the source edge info
auto sourceIt = m_edgeMap.find({monitorIndex, prioritizedEdge});
if (sourceIt == m_edgeMap.end())
{
return false;
}
// Use the new FindNearestOppositeEdge which handles non-overlapping regions
int cursorCoord = (prioritizedEdge == EdgeType::Left || prioritizedEdge == EdgeType::Right)
? cursorPos.y : cursorPos.x;
OppositeEdgeResult result = FindNearestOppositeEdge(prioritizedEdge, cursorCoord, sourceIt->second);
if (result.found)
{
outEdgeType = prioritizedEdge;
return true;
}
// If prioritized edge didn't work, try other candidates
// Try each candidate edge and return first with valid wrap destination
for (EdgeType candidate : candidateEdges)
{
if (candidate == prioritizedEdge) continue;
auto it = m_edgeMap.find({monitorIndex, candidate});
if (it == m_edgeMap.end()) continue;
int coord = (candidate == EdgeType::Left || candidate == EdgeType::Right)
? cursorPos.y : cursorPos.x;
OppositeEdgeResult altResult = FindNearestOppositeEdge(candidate, coord, it->second);
if (altResult.found)
MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate,
(candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x);
if (oppositeEdge.monitorIndex >= 0)
{
outEdgeType = candidate;
return true;
@@ -379,14 +280,16 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur
}
const MonitorEdge& fromEdge = it->second;
// Get cursor coordinate perpendicular to the edge
int cursorCoord = (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x;
// Use the new FindNearestOppositeEdge which handles non-overlapping regions
OppositeEdgeResult oppositeResult = FindNearestOppositeEdge(edgeType, cursorCoord, fromEdge);
// Calculate relative position on current edge (0.0 to 1.0)
double relativePos = GetRelativePosition(fromEdge,
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
if (!oppositeResult.found)
// Find opposite outer edge
MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType,
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
if (oppositeEdge.monitorIndex < 0)
{
// No opposite edge found, wrap within same monitor
RECT monitorRect;
@@ -418,35 +321,15 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur
if (edgeType == EdgeType::Left || edgeType == EdgeType::Right)
{
// Horizontal wrapping (Left<->Right edges)
result.x = oppositeResult.edge.position;
if (oppositeResult.requiresProjection)
{
// Use the pre-calculated projected coordinate for non-overlapping regions
result.y = oppositeResult.projectedCoordinate;
}
else
{
// Overlapping region - preserve Y coordinate
result.y = cursorPos.y;
}
// Horizontal edge -> vertical movement
result.x = oppositeEdge.position;
result.y = GetAbsolutePosition(oppositeEdge, relativePos);
}
else
{
// Vertical wrapping (Top<->Bottom edges)
result.y = oppositeResult.edge.position;
if (oppositeResult.requiresProjection)
{
// Use the pre-calculated projected coordinate for non-overlapping regions
result.x = oppositeResult.projectedCoordinate;
}
else
{
// Overlapping region - preserve X coordinate
result.x = cursorPos.x;
}
// Vertical edge -> horizontal movement
result.y = oppositeEdge.position;
result.x = GetAbsolutePosition(oppositeEdge, relativePos);
}
return result;
@@ -504,170 +387,6 @@ MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relati
return result;
}
OppositeEdgeResult MonitorTopology::FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate,
const MonitorEdge& sourceEdge) const
{
OppositeEdgeResult result;
result.found = false;
result.requiresProjection = false;
result.projectedCoordinate = 0;
result.edge.monitorIndex = -1;
EdgeType targetType;
bool findMax; // true = find max position (furthest right/bottom), false = find min (furthest left/top)
switch (fromEdge)
{
case EdgeType::Left:
targetType = EdgeType::Right;
findMax = true;
break;
case EdgeType::Right:
targetType = EdgeType::Left;
findMax = false;
break;
case EdgeType::Top:
targetType = EdgeType::Bottom;
findMax = true;
break;
case EdgeType::Bottom:
targetType = EdgeType::Top;
findMax = false;
break;
default:
return result; // Invalid edge type
}
// First, try to find an edge that directly overlaps the cursor coordinate
MonitorEdge directMatch = FindOppositeOuterEdge(fromEdge, cursorCoordinate);
if (directMatch.monitorIndex >= 0)
{
result.found = true;
result.requiresProjection = false;
result.edge = directMatch;
result.projectedCoordinate = cursorCoordinate; // Not used, but set for completeness
return result;
}
// No direct overlap - find the nearest opposite edge by coordinate distance
// This handles the "dead zone" case where cursor is in a non-overlapping region
int bestDistance = INT_MAX;
MonitorEdge bestEdge = { .monitorIndex = -1 };
int bestProjectedCoord = 0;
for (const auto& edge : m_outerEdges)
{
if (edge.type != targetType)
{
continue;
}
// Calculate distance from cursor coordinate to this edge's range
int distance = 0;
int projectedCoord = 0;
if (cursorCoordinate < edge.start)
{
// Cursor is before the edge's start - project to edge start with offset
distance = edge.start - cursorCoordinate;
projectedCoord = edge.start; // Clamp to edge start
}
else if (cursorCoordinate > edge.end)
{
// Cursor is after the edge's end - project to edge end with offset
distance = cursorCoordinate - edge.end;
projectedCoord = edge.end; // Clamp to edge end
}
else
{
// Cursor overlaps - this shouldn't happen since we checked direct match
distance = 0;
projectedCoord = cursorCoordinate;
}
// Choose the best edge: prefer closer edges, and among equals prefer extreme position
bool isBetter = false;
if (distance < bestDistance)
{
isBetter = true;
}
else if (distance == bestDistance && bestEdge.monitorIndex >= 0)
{
// Same distance - prefer the extreme position (furthest in wrap direction)
if ((findMax && edge.position > bestEdge.position) ||
(!findMax && edge.position < bestEdge.position))
{
isBetter = true;
}
}
if (isBetter)
{
bestDistance = distance;
bestEdge = edge;
bestProjectedCoord = projectedCoord;
}
}
if (bestEdge.monitorIndex >= 0)
{
result.found = true;
result.requiresProjection = true;
result.edge = bestEdge;
// Calculate projected position using offset-from-boundary approach
result.projectedCoordinate = CalculateProjectedPosition(cursorCoordinate, sourceEdge, bestEdge);
Logger::trace(L"FindNearestOppositeEdge: Non-overlapping wrap from {} to Mon {} edge, cursor={}, projected={}",
static_cast<int>(fromEdge), bestEdge.monitorIndex, cursorCoordinate, result.projectedCoordinate);
}
return result;
}
int MonitorTopology::CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge,
const MonitorEdge& targetEdge) const
{
// Windows behavior for non-overlapping regions:
// When cursor is in a region that doesn't overlap with the target edge,
// clamp to the nearest boundary of the target edge.
// This matches observed Windows cursor transition behavior.
// Find the shared boundary region between source and target edges
int sharedStart = max(sourceEdge.start, targetEdge.start);
int sharedEnd = min(sourceEdge.end, targetEdge.end);
if (cursorCoordinate >= sharedStart && cursorCoordinate <= sharedEnd)
{
// Cursor is in shared region - preserve the coordinate exactly
return cursorCoordinate;
}
// For non-overlapping regions, clamp to the nearest boundary of the target edge
// This matches Windows behavior where the cursor is projected to the closest
// valid point on the destination edge
int projectedCoord;
if (cursorCoordinate < sharedStart)
{
// Cursor is BEFORE the shared region (e.g., above shared area)
// Clamp to the start of the target edge (with small offset to stay within bounds)
projectedCoord = targetEdge.start + 1;
}
else
{
// Cursor is AFTER the shared region (e.g., below shared area)
// Clamp to the end of the target edge (with small offset to stay within bounds)
projectedCoord = targetEdge.end - 1;
}
// Final bounds check
projectedCoord = max(targetEdge.start, min(projectedCoord, targetEdge.end - 1));
return projectedCoord;
}
double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const
{
if (edge.end == edge.start)
@@ -692,7 +411,6 @@ int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativ
return static_cast<int>(result);
}
std::vector<MonitorTopology::GapInfo> MonitorTopology::DetectMonitorGaps() const
{
std::vector<GapInfo> gaps;

View File

@@ -7,9 +7,6 @@
#include <vector>
#include <map>
// Forward declaration
struct CursorDirection;
// Monitor information structure
struct MonitorInfo
{
@@ -47,15 +44,6 @@ struct MonitorEdge
bool isOuter; // True if no adjacent monitor touches this edge
};
// Result of finding an opposite edge, including projection info for non-overlapping regions
struct OppositeEdgeResult
{
MonitorEdge edge;
bool found; // True if an opposite edge was found
bool requiresProjection; // True if cursor position needs to be projected (non-overlapping region)
int projectedCoordinate; // The calculated coordinate on the target edge
};
// Monitor topology helper - manages edge-based monitor layout
struct MonitorTopology
{
@@ -63,9 +51,7 @@ struct MonitorTopology
// Check if cursor is on an outer edge of the given monitor
// wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly)
// direction is used to prioritize edges at corners based on cursor movement
bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType,
WrapMode wrapMode, const CursorDirection* direction = nullptr) const;
bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const;
// Get the wrap destination point for a cursor on an outer edge
POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const;
@@ -109,26 +95,12 @@ private:
// Check if two edges are adjacent (within tolerance)
bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const;
// Find the opposite outer edge for wrapping (original method - for overlapping regions)
// Find the opposite outer edge for wrapping
MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const;
// Find the nearest opposite outer edge, including projection for non-overlapping regions
// This implements Windows-like behavior for cursor transitions
OppositeEdgeResult FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate,
const MonitorEdge& sourceEdge) const;
// Calculate projected position for cursor in non-overlapping region
// Returns the coordinate on the destination edge using offset-from-boundary approach
int CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge,
const MonitorEdge& targetEdge) const;
// Calculate relative position along an edge (0.0 to 1.0)
double GetRelativePosition(const MonitorEdge& edge, int coordinate) const;
// Convert relative position to absolute coordinate on target edge
int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const;
// Prioritize edge candidates based on cursor movement direction
EdgeType PrioritizeEdgeByDirection(const std::vector<EdgeType>& candidates,
const CursorDirection* direction) const;
};

View File

@@ -54,7 +54,6 @@ namespace
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag";
const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode";
const wchar_t JSON_KEY_ACTIVATION_MODE[] = L"activation_mode";
const wchar_t JSON_KEY_DISABLE_ON_SINGLE_MONITOR[] = L"disable_cursor_wrap_on_single_monitor";
}
@@ -84,7 +83,6 @@ private:
bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag
bool m_disableOnSingleMonitor = false; // Default to false
int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly
int m_activationMode = 0; // 0=Always (default), 1=HoldingCtrl (disables wrap), 2=HoldingShift (disables wrap)
// Mouse hook
HHOOK m_mouseHook = nullptr;
@@ -432,21 +430,6 @@ private:
Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)");
}
try
{
// Parse activation mode
auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
if (propertiesObject.HasKey(JSON_KEY_ACTIVATION_MODE))
{
auto activationModeObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_MODE);
m_activationMode = static_cast<int>(activationModeObject.GetNamedNumber(JSON_KEY_VALUE));
}
}
catch (...)
{
Logger::warn("Failed to initialize CursorWrap activation mode from settings. Will use default value (0=Always)");
}
try
{
// Parse disable on single monitor
@@ -689,26 +672,6 @@ private:
if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive)
{
// Check activation mode to determine if wrapping should be disabled
// 0=Always, 1=HoldingCtrl (disables wrap when Ctrl held), 2=HoldingShift (disables wrap when Shift held)
int activationMode = g_cursorWrapInstance->m_activationMode;
bool disableByKey = false;
if (activationMode == 1) // HoldingCtrl - disable wrap when Ctrl is held
{
disableByKey = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
}
else if (activationMode == 2) // HoldingShift - disable wrap when Shift is held
{
disableByKey = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
}
if (disableByKey)
{
// Key is held, do not wrap - let normal behavior happen
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove(
currentPos,
g_cursorWrapInstance->m_disableWrapDuringDrag,

View File

@@ -18,8 +18,6 @@ namespace newplus::constants::non_localizable
constexpr WCHAR settings_json_key_template_location[] = L"TemplateLocation";
constexpr WCHAR settings_json_key_hide_built_in_new[] = L"BuiltInNewHidePreference";
constexpr WCHAR context_menu_package_name[] = L"NewPlusContextMenu";
constexpr WCHAR msix_package_name[] = L"NewPlusPackage.msix";

View File

@@ -4,6 +4,22 @@
namespace newplus::helpers::filesystem
{
namespace constants::non_localizable
{
constexpr WCHAR desktop_ini_filename[] = L"desktop.ini";
}
inline bool is_hidden(const std::filesystem::path path)
{
const std::filesystem::path::string_type name = path.filename();
if (name == constants::non_localizable::desktop_ini_filename)
{
return true;
}
return false;
}
inline bool is_directory(const std::filesystem::path path)
{
const auto entry = std::filesystem::directory_entry(path);

View File

@@ -129,18 +129,6 @@ namespace newplus::helpers::variables
return result;
}
static bool exclude_item(const std::filesystem::path& path)
{
DWORD attrs = GetFileAttributesW(path.c_str());
if (attrs == INVALID_FILE_ATTRIBUTES)
{
return false;
}
// Exclude if hidden or system
return (attrs & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM)) != 0;
}
inline void resolve_variables_in_filename_and_rename_files(const std::filesystem::path& path, const bool do_rename = true)
{
// Depth first recursion, so that we start renaming the leaves, and avoid having to rescan
@@ -155,7 +143,7 @@ namespace newplus::helpers::variables
// Perform the actual rename
for (const auto& current : std::filesystem::directory_iterator(path))
{
if (!exclude_item(current))
if (!newplus::helpers::filesystem::is_hidden(current))
{
const std::filesystem::path resolved_path = resolve_variables_in_path(current.path());

View File

@@ -446,69 +446,4 @@ namespace newplus::utilities
return hr;
}
constexpr wchar_t built_in_new_registry_path[] = LR"(Software\Classes\Directory\Background\ShellEx\ContextMenuHandlers\New)";
constexpr wchar_t built_in_new_registry_disabled_value_prefix[] = L"disabled_";
inline bool disable_built_in_new_via_registry()
{
// This is implemented to support where New+ GPO is configured to
// hide the built-in New context menu but Settings UI hasn't been launched
// Mirrors the logic in DisableBuiltInNewViaRegistry in .cs
HKEY key{};
if (RegCreateKeyExW(HKEY_CURRENT_USER,
built_in_new_registry_path,
0,
nullptr,
REG_OPTION_NON_VOLATILE,
KEY_ALL_ACCESS,
nullptr,
&key,
nullptr) != ERROR_SUCCESS)
{
return false;
}
const auto built_in_new_registry_disabled_value_prefix_len = lstrlenW(built_in_new_registry_disabled_value_prefix);
if (RegSetValueExW(key, nullptr, 0, REG_SZ, reinterpret_cast<const BYTE*>(&built_in_new_registry_disabled_value_prefix), built_in_new_registry_disabled_value_prefix_len) != ERROR_SUCCESS)
{
RegCloseKey(key);
return true;
}
RegCloseKey(key);
return false;
}
inline bool enable_built_in_new_via_registry()
{
// This is implemented to support where New+ GPO is configured to
// display the built-in New context menu but Settings UI hasn't been launched
// Mirrors the logic in EnableBuiltInNewViaRegistry in .cs
HKEY key{};
if (RegOpenKeyExW(HKEY_CURRENT_USER,
built_in_new_registry_path,
0,
KEY_ALL_ACCESS,
&key) != ERROR_SUCCESS)
{
return true;
}
if (RegDeleteValueW(key, nullptr) != ERROR_SUCCESS)
{
RegCloseKey(key);
return true;
}
RegCloseKey(key);
return false;
}
}

View File

@@ -172,22 +172,7 @@ private:
void init_settings()
{
powertoy_new_enabled = NewSettingsInstance().GetEnabled();
UpdateRegistration(powertoy_new_enabled);
if (powertoy_new_enabled)
{
// NOTE: This requires that the runner is running and have loaded the new plus module.
// It's not enough for user to just invoke the context menu.
if (NewSettingsInstance().GetHideBuiltInNew())
{
newplus::utilities::disable_built_in_new_via_registry();
}
else
{
newplus::utilities::enable_built_in_new_via_registry();
}
}
}
};

View File

@@ -45,7 +45,6 @@ void NewSettings::Save()
values.add_property(newplus::constants::non_localizable::settings_json_key_hide_starting_digits, new_settings.hide_starting_digits);
values.add_property(newplus::constants::non_localizable::settings_json_key_replace_variables, new_settings.replace_variables);
values.add_property(newplus::constants::non_localizable::settings_json_key_template_location, new_settings.template_location);
values.add_property(newplus::constants::non_localizable::settings_json_key_hide_built_in_new, new_settings.hide_built_in_new_preference);
values.save_to_settings_file();
@@ -76,9 +75,6 @@ void NewSettings::InitializeWithDefaultSettings()
SetReplaceVariables(false);
SetTemplateLocation(GetTemplateLocationDefaultPath());
// By default we show the built-in New context menu
SetHideBuiltInNew(false);
}
void NewSettings::RefreshEnabledState()
@@ -153,12 +149,6 @@ void NewSettings::ParseJson()
new_settings.replace_variables = resolveVariables.value();
}
const auto hideBuiltInNewValue = settings.get_bool_value(newplus::constants::non_localizable::settings_json_key_hide_built_in_new);
if (hideBuiltInNewValue.has_value())
{
new_settings.hide_built_in_new_preference = hideBuiltInNewValue.value();
}
GetSystemTimeAsFileTime(&new_settings_last_loaded_timestamp);
}
@@ -249,26 +239,6 @@ std::wstring NewSettings::GetTemplateLocationDefaultPath() const
return full_path;
}
bool NewSettings::GetHideBuiltInNew()
{
const auto gpoSetting = powertoys_gpo::getConfiguredNewPlusHideBuiltInNewContextMenuValue();
if (gpoSetting == powertoys_gpo::gpo_rule_configured_enabled)
{
return true;
}
else if (gpoSetting == powertoys_gpo::gpo_rule_configured_disabled)
{
return false;
}
return new_settings.hide_built_in_new_preference;
}
void NewSettings::SetHideBuiltInNew(const bool hide_built_in_new)
{
new_settings.hide_built_in_new_preference = hide_built_in_new;
}
NewSettings& NewSettingsInstance()
{
static NewSettings instance;

View File

@@ -16,8 +16,6 @@ public:
void SetReplaceVariables(const bool resolve_variables);
std::wstring GetTemplateLocation() const;
void SetTemplateLocation(const std::wstring template_location);
bool GetHideBuiltInNew();
void SetHideBuiltInNew(const bool hide_built_in_new);
void Save();
void Load();
@@ -31,7 +29,6 @@ private:
bool hide_starting_digits{ true };
bool replace_variables{ true };
std::wstring template_location;
bool hide_built_in_new_preference{ false };
};
void RefreshEnabledState();

View File

@@ -35,7 +35,7 @@ void template_folder::rescan_template_folder()
}
else
{
if (!newplus::helpers::variables::exclude_item(entry.path()))
if (!helpers::filesystem::is_hidden(entry.path()))
{
files.push_back({ entry.path().wstring(), new template_item(entry) });
}

View File

@@ -1,3 +1,5 @@
#include "pch.h"
#include "template_item.h"
#include <shellapi.h>
@@ -58,91 +60,10 @@ std::wstring template_item::get_target_filename(const bool include_starting_digi
std::wstring template_item::remove_starting_digits_from_filename(std::wstring filename) const
{
// Filename cases to support
// type | filename | result
// [file] | 01. First entry.txt | First entry.txt
// [folder] | 02. Second entry | Second entry
// [folder] | 03 Third entry | Third entry
// [file] | 04 Fourth entry.txt | Fourth entry.txt
// [file] | 05.Fifth entry.txt | Fifth entry.txt
// [folder] | 001231 | 001231
// [file] | 001231.txt | 001231.txt
// [file] | 13. 0123456789012345.txt | 0123456789012345.txt
filename.erase(0, std::min(filename.find_first_not_of(L"0123456789"), filename.size()));
filename.erase(0, std::min(filename.find_first_not_of(L" ."), filename.size()));
std::filesystem::path filename_path(filename);
const std::wstring stem = filename_path.stem().wstring();
bool stem_is_only_digits = !stem.empty();
for (const wchar_t c : stem)
{
if (c < L'0' || c > L'9')
{
stem_is_only_digits = false;
break;
}
}
if (stem_is_only_digits)
{
// Edge cases where digits ARE the filename.
// If it's a file, we always keep it (e.g. 001231.txt or 001231).
// If it's a folder, we only strip if it looks like it has an extension (which is actually part of the name for folders).
// e.g. "0123.Name" -> Strip. "001231" -> Keep.
const bool is_folder = helpers::filesystem::is_directory(path);
const bool has_extension = filename_path.has_extension();
if (!is_folder || !has_extension)
{
return filename;
}
}
// Find end of leading digits
size_t digits_end_index = 0;
while (digits_end_index < filename.length() && filename[digits_end_index] >= L'0' && filename[digits_end_index] <= L'9')
{
digits_end_index++;
}
if (digits_end_index == 0)
{
// No leading digits
return filename;
}
// Determine if we should also strip a separator (dot or space)
size_t strip_length = digits_end_index;
// Check patterns to strip separators:
// 1. "01. Name" -> Strip "01. "
// 2. "01 .Name" -> Strip "01 ."
// 3. "01.Name" -> Strip "01."
// 4. "01 Name" -> Strip "01 "
// 5. "01Name" -> Strip "01" (No separator)
if (strip_length < filename.length())
{
if (filename[strip_length] == L'.')
{
strip_length++;
// If dot is followed by space, strip that too (e.g. "01. Name")
if (strip_length < filename.length() && filename[strip_length] == L' ')
{
strip_length++;
}
}
else if (filename[strip_length] == L' ')
{
strip_length++;
// If space is followed by dot, strip that too (e.g. "01 .Name")
if (strip_length < filename.length() && filename[strip_length] == L'.')
{
strip_length++;
}
}
}
return filename.substr(strip_length);
return filename;
}
std::wstring template_item::get_explorer_icon() const

View File

@@ -22,7 +22,6 @@ namespace NonLocalizable
const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow";
const static wchar_t* WINDOW_IS_PINNED_PROP = L"AlwaysOnTop_Pinned";
constexpr UINT SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND = 0xEFE0;
constexpr ULONG_PTR SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG = 0x414F5450;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_START = 0x0006;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_END = 0x0007;
}
@@ -41,29 +40,6 @@ namespace
hooks.clear();
}
bool HasMenuCommand(HMENU menu, UINT commandId) noexcept
{
return menu && GetMenuState(menu, commandId, MF_BYCOMMAND) != static_cast<UINT>(-1);
}
bool IsAlwaysOnTopMenuCommand(HMENU menu) noexcept
{
if (!HasMenuCommand(menu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
{
return false;
}
MENUITEMINFOW menuItemInfo{};
menuItemInfo.cbSize = sizeof(menuItemInfo);
menuItemInfo.fMask = MIIM_DATA;
return GetMenuItemInfoW(menu,
NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND,
FALSE,
&menuItemInfo) &&
menuItemInfo.dwItemData == NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG;
}
}
bool isExcluded(HWND window)
@@ -527,7 +503,7 @@ void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
{
if (IsAlwaysOnTopMenuCommand(systemMenu))
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1))
{
RemoveMenu(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND);
}
@@ -537,26 +513,20 @@ void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
auto text = GET_RESOURCE_STRING(IDS_SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP);
MENUITEMINFOW menuItemInfo{};
menuItemInfo.cbSize = sizeof(menuItemInfo);
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING | MIIM_DATA;
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
menuItemInfo.wID = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND;
menuItemInfo.fState = IsPinned(window) ? MFS_CHECKED : MFS_UNCHECKED;
menuItemInfo.dwTypeData = text.data();
menuItemInfo.dwItemData = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG;
if (!HasMenuCommand(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) == static_cast<UINT>(-1))
{
InsertMenuItemW(systemMenu, SC_CLOSE, FALSE, &menuItemInfo);
}
else if (IsAlwaysOnTopMenuCommand(systemMenu))
else
{
menuItemInfo.fMask = MIIM_STATE | MIIM_STRING;
SetMenuItemInfoW(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, FALSE, &menuItemInfo);
}
else
{
Logger::warn(L"Skipping Always On Top system menu command registration because ID 0x{:X} is already in use by another item.",
NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND);
}
}
void AlwaysOnTop::UnpinAll()
@@ -682,7 +652,8 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
}
const auto systemMenu = GetSystemMenu(window, false);
return systemMenu && IsAlwaysOnTopMenuCommand(systemMenu);
return systemMenu &&
GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1);
};
HWND commandWindow = nullptr;

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Common.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {

View File

@@ -2,54 +2,31 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class CommandBarViewModel : ObservableObject,
public partial class CommandBarViewModel : ObservableObject,
IRecipient<UpdateCommandBarMessage>
{
private readonly DispatcherQueueTimer _debounceTimer;
private volatile ICommandBarContext? _pendingSelectedItem;
public ICommandBarContext? SelectedItem
{
get;
get => field;
set
{
// TODO: verify if we can safely return early
// if (ReferenceEquals(field, value))
// {
// return;
// }
if (field is not null)
if (field != null)
{
field.PropertyChanged -= SelectedItemPropertyChanged;
}
field = value;
SetSelectedItem(value);
if (field is not null)
{
PrimaryCommand = field.PrimaryCommand;
field.PropertyChanged += SelectedItemPropertyChanged;
}
else
{
PrimaryCommand = null;
}
UpdateContextItems();
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedItem));
}
}
@@ -57,8 +34,6 @@ public sealed partial class CommandBarViewModel : ObservableObject,
[NotifyPropertyChangedFor(nameof(HasPrimaryCommand))]
public partial CommandItemViewModel? PrimaryCommand { get; set; }
// TODO: PrimaryCommand.ShouldBeVisible is not observed, if it changes the bar won't refresh;
// but at this moment CommandItemViewModel won't raise INPC for ShouldBeVisible anyway.
public bool HasPrimaryCommand => PrimaryCommand is not null && PrimaryCommand.ShouldBeVisible;
[ObservableProperty]
@@ -75,31 +50,29 @@ public sealed partial class CommandBarViewModel : ObservableObject,
public CommandBarViewModel()
{
var dispatcherQueue = DispatcherQueue.GetForCurrentThread();
if (dispatcherQueue is null)
{
throw new InvalidOperationException("DispatcherQueue is not available for the current thread.");
}
_debounceTimer = dispatcherQueue.CreateTimer();
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
}
public void Receive(UpdateCommandBarMessage message)
{
_pendingSelectedItem = message.ViewModel;
public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel;
// immediate: false is intentional — the timer tick always fires on the
// dispatcher queue thread, which guarantees ApplyPendingSelectedItem
// runs on the UI thread even if Receive is called from a background
// thread. Using immediate: true would invoke the delegate synchronously
// on the calling thread, bypassing the dispatcher.
_debounceTimer.Debounce(ApplyPendingSelectedItem, TimeSpan.FromMilliseconds(50));
}
private void ApplyPendingSelectedItem()
private void SetSelectedItem(ICommandBarContext? value)
{
SelectedItem = _pendingSelectedItem;
if (value is not null)
{
PrimaryCommand = value.PrimaryCommand;
value.PropertyChanged += SelectedItemPropertyChanged;
}
else
{
if (SelectedItem is not null)
{
SelectedItem.PropertyChanged -= SelectedItemPropertyChanged;
}
PrimaryCommand = null;
}
UpdateContextItems();
}
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)

View File

@@ -215,7 +215,9 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
catch (Exception e)
{
Logger.LogError($"Failed to load commands from extension {Extension!.PackageFamilyName}", e);
Logger.LogError("Failed to load commands from extension");
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
Logger.LogError(e.ToString());
if (!displayInfoInitialized)
{

View File

@@ -39,7 +39,6 @@ public sealed partial class MainListPage : DynamicListPage,
// recognise them across successive GetItems() calls
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
@@ -120,7 +119,7 @@ public sealed partial class MainListPage : DynamicListPage,
}
else
{
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
RaiseItemsChanged();
}
}
@@ -197,7 +196,7 @@ public sealed partial class MainListPage : DynamicListPage,
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _commandsSeparator;
result[0] = _resultsSeparator;
// Second pass: populate
var writeIndex = 1;
@@ -331,7 +330,7 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
RaiseItemsChanged();
RaiseItemsChanged(commands.Count);
return;
}
@@ -503,7 +502,7 @@ public sealed partial class MainListPage : DynamicListPage,
return;
}
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
RaiseItemsChanged();
}
},
token);

View File

@@ -18,8 +18,6 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListViewModel : PageViewModel, IDisposable
{
public const int IncrementalRefresh = -2;
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
@@ -85,11 +83,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
private ListItemViewModel? _lastSelectedItem;
// Persists across cancelled FetchItems calls so a forceFirstItem=true
// intent is never lost when FetchItems(false) is cancelled by a
// subsequent FetchItems(true).
private volatile bool _forceFirstItemPending;
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
private CancellationTokenSource? _selectedItemCts;
@@ -122,7 +115,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(args.TotalItems == IncrementalRefresh);
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
protected override void OnSearchTextBoxUpdated(string searchTextBox)
{
@@ -198,15 +191,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems(bool keepSelection)
private void FetchItems()
{
// If this fetch should reset selection, remember that intent even if
// a later incremental fetch cancels us.
if (!keepSelection)
{
_forceFirstItemPending = true;
}
// Cancel any previous FetchItems operation
_fetchItemsCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Dispose();
@@ -396,12 +382,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
UpdateEmptyContent();
}
// Consume the pending flag on the UI thread so a
// forceFirstItem=true intent survives cancellation.
var forceFirst = _forceFirstItemPending;
_forceFirstItemPending = false;
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(forceFirstItem: IsRootPage && forceFirst));
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
_isLoading.Clear();
});
}
@@ -586,12 +567,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
var suggestion = item.TextToSuggest;
DoOnUiThread(() =>
{
TextToSuggest = suggestion;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(suggestion));
});
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
},
ct);
}
@@ -680,7 +657,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
Filters?.InitializeProperties();
UpdateProperty(nameof(Filters));
FetchItems(true);
FetchItems();
model.ItemsChanged += Model_ItemsChanged;
}

View File

@@ -528,15 +528,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Commands.
/// </summary>
public static string home_sections_commands_title {
get {
return ResourceManager.GetString("home_sections_commands_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
@@ -545,8 +536,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Results.
/// </summary>
public static string results {

View File

@@ -283,7 +283,7 @@
<data name="dock_settings_name" xml:space="preserve">
<value>Dock settings</value>
<comment>Command name for opening dock settings</comment>
</data>
</data>
<data name="ShowDetailsCommand" xml:space="preserve">
<value>Show details</value>
<comment>Name for the command that shows details of an item</comment>
@@ -296,7 +296,4 @@
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
<data name="home_sections_commands_title" xml:space="preserve">
<value>Commands</value>
</data>
</root>

View File

@@ -5,7 +5,6 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
@@ -20,18 +19,13 @@ using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelCommandManager : ObservableObject,
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<PinCommandItemMessage>,
IRecipient<UnpinCommandItemMessage>,
IRecipient<PinToDockMessage>,
IDisposable
{
private static readonly TimeSpan ExtensionStartTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan CommandLoadTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan BackgroundStartTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan BackgroundCommandLoadTimeout = TimeSpan.FromSeconds(60);
private readonly IServiceProvider _serviceProvider;
private readonly ICommandProviderCache _commandProviderCache;
private readonly TaskScheduler _taskScheduler;
@@ -45,14 +39,11 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
// deadlock.
private readonly Lock _dockBandsLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
private CancellationTokenSource _extensionLoadCts = new();
private CancellationToken _currentExtensionLoadCancellationToken;
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
_serviceProvider = serviceProvider;
_commandProviderCache = commandProviderCache;
_currentExtensionLoadCancellationToken = _extensionLoadCts.Token;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
@@ -269,15 +260,8 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken)
{
IsLoading = true;
// Invalidate any background continuations from the previous load cycle
await _extensionLoadCts.CancelAsync().ConfigureAwait(false);
_extensionLoadCts.Dispose();
_extensionLoadCts = new();
_currentExtensionLoadCancellationToken = _extensionLoadCts.Token;
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
await extensionService.SignalStopExtensionsAsync().ConfigureAwait(false);
await extensionService.SignalStopExtensionsAsync();
lock (TopLevelCommands)
{
@@ -289,8 +273,8 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
DockBands.Clear();
}
await LoadBuiltinsAsync().ConfigureAwait(false);
_ = Task.Run(LoadExtensionsAsync, cancellationToken);
await LoadBuiltinsAsync();
_ = Task.Run(LoadExtensionsAsync);
}
// Load commands from our extensions. Called on a background thread.
@@ -308,15 +292,16 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
extensionService.OnExtensionAdded -= ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved -= ExtensionService_OnExtensionRemoved;
var ct = _currentExtensionLoadCancellationToken;
var extensions = (await extensionService.GetInstalledExtensionsAsync().ConfigureAwait(false)).ToImmutableList();
var extensions = (await extensionService.GetInstalledExtensionsAsync()).ToImmutableList();
lock (_commandProvidersLock)
{
_extensionCommandProviders.Clear();
}
await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false);
if (extensions is not null)
{
await StartExtensionsAndGetCommands(extensions);
}
extensionService.OnExtensionAdded += ExtensionService_OnExtensionAdded;
extensionService.OnExtensionRemoved += ExtensionService_OnExtensionRemoved;
@@ -331,219 +316,46 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
private void ExtensionService_OnExtensionAdded(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
{
var ct = _currentExtensionLoadCancellationToken;
// When we get an extension install event, hop off to a BG thread
_ = Task.Run(
async () =>
{
// for each newly installed extension, start it and get commands
// from it. One single package might have more than one
// IExtensionWrapper in it.
await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false);
},
ct);
_ = Task.Run(async () =>
{
// for each newly installed extension, start it and get commands
// from it. One single package might have more than one
// IExtensionWrapper in it.
await StartExtensionsAndGetCommands(extensions);
});
}
private async Task StartExtensionsAndGetCommands(IEnumerable<IExtensionWrapper> extensions, CancellationToken ct)
private async Task StartExtensionsAndGetCommands(IEnumerable<IExtensionWrapper> extensions)
{
var timer = Stopwatch.StartNew();
var timer = new Stopwatch();
timer.Start();
// Start all extensions in parallel
var startResults = await Task.WhenAll(extensions.Select(TryStartExtensionAsync)).ConfigureAwait(false);
var startTasks = extensions.Select(StartExtensionWithTimeoutAsync);
var startedWrappers = new List<CommandProviderWrapper>();
foreach (var r in startResults)
{
if (r.IsStarted)
{
startedWrappers.Add(r.Wrapper);
}
else if (r.IsTimedOut)
{
_ = StartExtensionWhenReadyAsync(r.Extension, r.PendingStartTask, r.Stopwatch, ct);
}
}
// Wait for all extensions to start
var wrappers = (await Task.WhenAll(startTasks)).Where(wrapper => wrapper is not null).Select(w => w!).ToList();
// Register started extensions and load their commands
var loadSummary = await RegisterAndLoadCommandsAsync(startedWrappers, ct).ConfigureAwait(false);
timer.Stop();
Logger.LogInfo($"Loaded {loadSummary.CommandCount} command(s) and {loadSummary.DockBandCount} band(s) from {startedWrappers.Count} extension(s) in {timer.ElapsedMilliseconds} ms");
}
private async Task<RegisterAndLoadSummary> RegisterAndLoadCommandsAsync(ICollection<CommandProviderWrapper> wrappers, CancellationToken ct)
{
lock (_commandProvidersLock)
{
_extensionCommandProviders.AddRange(wrappers);
}
// Load the commands from the providers in parallel
var loadResults = await Task.WhenAll(wrappers.Select(w => TryLoadCommandsAsync(w, ct))).ConfigureAwait(false);
var loadTasks = wrappers.Select(LoadCommandsWithTimeoutAsync);
var totalCommands = 0;
var totalDockBands = 0;
var timedOut = new List<CommandLoadResult>();
List<TopLevelViewModel> commandsToAdd = [];
List<TopLevelViewModel> dockBandsToAdd = [];
var commandSets = (await Task.WhenAll(loadTasks)).Where(results => results is not null).Select(r => r!).ToList();
foreach (var r in loadResults)
foreach (var providerObjects in commandSets)
{
if (r.IsLoaded)
var commandsCount = providerObjects.Commands?.Count() ?? 0;
var bandsCount = providerObjects.DockBands?.Count() ?? 0;
Logger.LogDebug($"(some provider) Loaded {commandsCount} commands and {bandsCount} bands");
lock (TopLevelCommands)
{
var commands = r.TopLevelObjectSets.Commands;
if (commands is not null)
{
foreach (var c in commands)
{
commandsToAdd.Add(c);
totalCommands++;
}
}
var bands = r.TopLevelObjectSets.DockBands;
if (bands is not null)
{
foreach (var b in bands)
{
dockBandsToAdd.Add(b);
totalDockBands++;
}
}
}
else if (r.IsTimedOut)
{
timedOut.Add(r);
}
}
lock (TopLevelCommands)
{
foreach (var c in commandsToAdd)
{
TopLevelCommands.Add(c);
}
}
lock (_dockBandsLock)
{
foreach (var b in dockBandsToAdd)
{
DockBands.Add(b);
}
}
// Fire background continuations for timed-out loads outside the lock
foreach (var r in timedOut)
{
// It's weird to repeat the condition here, but it allows the compiler to track nullability of other properties
if (r.IsTimedOut)
{
_ = AppendCommandsWhenReadyAsync(r.Wrapper, r.PendingLoadTask, r.Stopwatch, ct);
}
}
return new RegisterAndLoadSummary(totalCommands, totalDockBands);
}
private async Task<ExtensionStartResult> TryStartExtensionAsync(IExtensionWrapper extension)
{
Logger.LogDebug($"Starting {extension.PackageFullName}");
var sw = Stopwatch.StartNew();
var ct = _currentExtensionLoadCancellationToken;
var startTask = extension.StartExtensionAsync();
try
{
await startTask.WaitAsync(ExtensionStartTimeout, ct).ConfigureAwait(false);
Logger.LogInfo($"Started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Started(extension, new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache));
}
catch (TimeoutException)
{
Logger.LogWarning($"Starting extension {extension.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
return ExtensionStartResult.TimedOut(extension, startTask, sw);
}
catch (OperationCanceledException)
{
Logger.LogDebug($"Starting extension {extension.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms");
return ExtensionStartResult.Failed(extension);
}
catch (Exception ex)
{
Logger.LogError($"Failed to start extension {extension.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}");
return ExtensionStartResult.Failed(extension);
}
}
private async Task StartExtensionWhenReadyAsync(
IExtensionWrapper extension,
Task startTask,
Stopwatch sw,
CancellationToken ct)
{
try
{
await startTask.WaitAsync(BackgroundStartTimeout, ct).ConfigureAwait(false);
var wrapper = new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
Logger.LogInfo($"Late-started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms, loading commands and bands");
await RegisterAndLoadCommandsAsync([wrapper], ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Reload happened -- discard stale results
}
catch (Exception ex)
{
Logger.LogError($"Background start/load of extension {extension.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
}
}
private async Task<CommandLoadResult> TryLoadCommandsAsync(CommandProviderWrapper wrapper, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var loadTask = LoadTopLevelCommandsFromProvider(wrapper);
try
{
var result = await loadTask.WaitAsync(CommandLoadTimeout, ct).ConfigureAwait(false);
var commandCount = result.Commands?.Count ?? 0;
var dockBandCount = result.DockBands?.Count ?? 0;
Logger.LogInfo($"Loaded {commandCount} command(s) and {dockBandCount} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms");
return CommandLoadResult.Loaded(wrapper, result);
}
catch (TimeoutException)
{
Logger.LogWarning($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background");
return CommandLoadResult.TimedOut(wrapper, loadTask, sw);
}
catch (OperationCanceledException)
{
Logger.LogDebug($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms");
return CommandLoadResult.Failed(wrapper);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load commands and bands for extension {wrapper.ExtensionHost?.Extension?.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}");
return CommandLoadResult.Failed(wrapper);
}
}
private async Task AppendCommandsWhenReadyAsync(
CommandProviderWrapper wrapper,
Task<TopLevelObjectSets> loadTask,
Stopwatch sw,
CancellationToken ct)
{
try
{
var topLevelObjectSets = await loadTask.WaitAsync(BackgroundCommandLoadTimeout, ct).ConfigureAwait(false);
var commands = topLevelObjectSets.Commands;
if (commands is not null)
{
lock (TopLevelCommands)
if (providerObjects.Commands is IEnumerable<TopLevelViewModel> commands)
{
foreach (var c in commands)
{
@@ -552,30 +364,57 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
}
var dockBands = topLevelObjectSets.DockBands;
if (dockBands is not null)
lock (_dockBandsLock)
{
lock (_dockBandsLock)
if (providerObjects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var band in dockBands)
foreach (var c in bands)
{
DockBands.Add(band);
DockBands.Add(c);
}
}
}
Logger.LogInfo($"Late-loaded {commands?.Count ?? 0} command(s) and {dockBands?.Count ?? 0} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms");
}
catch (OperationCanceledException)
timer.Stop();
Logger.LogDebug($"Loading extensions took {timer.ElapsedMilliseconds} ms");
}
private async Task<CommandProviderWrapper?> StartExtensionWithTimeoutAsync(IExtensionWrapper extension)
{
Logger.LogDebug($"Starting {extension.PackageFullName}");
try
{
// Reload happened - discard stale results
await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10));
return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
}
catch (Exception ex)
{
Logger.LogError($"Background loading of commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}");
Logger.LogError($"Failed to start extension {extension.PackageFullName}: {ex}");
return null; // Return null for failed extensions
}
}
private record TopLevelObjectSets(IEnumerable<TopLevelViewModel>? Commands, IEnumerable<TopLevelViewModel>? DockBands);
private async Task<TopLevelObjectSets?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
{
try
{
return await LoadTopLevelCommandsFromProvider(wrapper!).WaitAsync(TimeSpan.FromSeconds(10));
}
catch (TimeoutException)
{
Logger.LogError($"Loading commands from {wrapper!.ExtensionHost?.Extension?.PackageFullName} timed out");
}
catch (Exception ex)
{
Logger.LogError($"Failed to load commands for extension {wrapper!.ExtensionHost?.Extension?.PackageFullName}: {ex}");
}
return null;
}
private void ExtensionService_OnExtensionRemoved(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions)
{
// When we get an extension uninstall event, hop off to a BG thread
@@ -676,7 +515,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
public void Receive(ReloadCommandsMessage message) =>
_ = ReloadAllCommandsAsync();
ReloadAllCommandsAsync().ConfigureAwait(false);
public void Receive(PinCommandItemMessage message)
{
@@ -772,87 +611,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
public void Dispose()
{
_extensionLoadCts.Cancel();
_extensionLoadCts.Dispose();
_reloadCommandsGate.Dispose();
GC.SuppressFinalize(this);
}
private sealed class ExtensionStartResult
{
public IExtensionWrapper Extension { get; }
public CommandProviderWrapper? Wrapper { get; private init; }
public Task? PendingStartTask { get; private init; }
public Stopwatch? Stopwatch { get; private init; }
[MemberNotNullWhen(true, nameof(Wrapper))]
public bool IsStarted => Wrapper is not null;
[MemberNotNullWhen(true, nameof(PendingStartTask), nameof(Stopwatch))]
public bool IsTimedOut => PendingStartTask is not null;
private ExtensionStartResult(IExtensionWrapper extension)
{
Extension = extension;
}
public static ExtensionStartResult Started(IExtensionWrapper extension, CommandProviderWrapper wrapper)
{
return new ExtensionStartResult(extension) { Wrapper = wrapper };
}
public static ExtensionStartResult TimedOut(IExtensionWrapper extension, Task pendingStartTask, Stopwatch sw)
{
return new ExtensionStartResult(extension) { PendingStartTask = pendingStartTask, Stopwatch = sw };
}
public static ExtensionStartResult Failed(IExtensionWrapper extension)
{
return new ExtensionStartResult(extension);
}
}
private sealed class CommandLoadResult
{
public TopLevelObjectSets? TopLevelObjectSets { get; private init; }
public CommandProviderWrapper Wrapper { get; }
public Task<TopLevelObjectSets>? PendingLoadTask { get; private init; }
public Stopwatch? Stopwatch { get; private init; }
[MemberNotNullWhen(true, nameof(TopLevelObjectSets))]
public bool IsLoaded => TopLevelObjectSets is not null;
[MemberNotNullWhen(true, nameof(PendingLoadTask), nameof(Stopwatch))]
public bool IsTimedOut => PendingLoadTask is not null;
private CommandLoadResult(CommandProviderWrapper wrapper)
{
Wrapper = wrapper;
}
public static CommandLoadResult Loaded(CommandProviderWrapper wrapper, TopLevelObjectSets topLevelObjectSets)
{
return new CommandLoadResult(wrapper) { TopLevelObjectSets = topLevelObjectSets };
}
public static CommandLoadResult TimedOut(CommandProviderWrapper wrapper, Task<TopLevelObjectSets> pendingLoadTask, Stopwatch sw)
{
return new CommandLoadResult(wrapper) { PendingLoadTask = pendingLoadTask, Stopwatch = sw };
}
public static CommandLoadResult Failed(CommandProviderWrapper wrapper)
{
return new CommandLoadResult(wrapper);
}
}
private readonly record struct RegisterAndLoadSummary(int CommandCount, int DockBandCount);
private record TopLevelObjectSets(ICollection<TopLevelViewModel>? Commands, ICollection<TopLevelViewModel>? DockBands);
}

View File

@@ -193,11 +193,7 @@
SelectionMode="None">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<controls:UniformGrid ui:FrameworkElementExtensions.AncestorType="local:ColorPalette">
<controls:UniformGrid.Columns>
<Binding Path="(ui:FrameworkElementExtensions.Ancestor).CustomPaletteColumnCount" RelativeSource="{RelativeSource Self}" />
</controls:UniformGrid.Columns>
</controls:UniformGrid>
<controls:UniformGrid ui:FrameworkElementExtensions.AncestorType="local:ColorPalette" Columns="{Binding (ui:FrameworkElementExtensions.Ancestor).CustomPaletteColumnCount, RelativeSource={RelativeSource Self}}" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemTemplate>

View File

@@ -53,15 +53,7 @@
TextTrimming="WordEllipsis"
TextWrapping="NoWrap">
<ToolTipService.ToolTip>
<ToolTip x:DataType="TextBlock" Content="{x:Bind Title, Mode=OneWay}">
<ToolTip.Visibility>
<Binding
Converter="{StaticResource BoolToVisibilityConverter}"
ElementName="TitleTextBlock"
Mode="OneWay"
Path="IsTextTrimmed" />
</ToolTip.Visibility>
</ToolTip>
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
@@ -103,15 +95,7 @@
TextTrimming="WordEllipsis"
TextWrapping="NoWrap">
<ToolTipService.ToolTip>
<ToolTip Content="{x:Bind Title, Mode=OneWay}">
<ToolTip.Visibility>
<Binding
Converter="{StaticResource BoolToVisibilityConverter}"
ElementName="TitleTextBlock"
Mode="OneWay"
Path="IsTextTrimmed" />
</ToolTip.Visibility>
</ToolTip>
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
@@ -177,17 +161,6 @@
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="ContextFilterBox_TextChanged" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="FilterBoxVisibility">
<VisualState x:Name="FilterBoxVisible" />
<VisualState x:Name="FilterBoxCollapsed">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind ShowFilterBox, Mode=OneWay}" To="False" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContextFilterBox.Visibility" Value="Collapsed" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="ContextMenuOrder">
<VisualState x:Name="FilterOnTop">
<VisualState.StateTriggers>

View File

@@ -5,15 +5,16 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.System;
using Windows.UI.Core;
namespace Microsoft.CmdPal.UI.Controls;
@@ -22,15 +23,6 @@ public sealed partial class ContextMenu : UserControl,
IRecipient<UpdateCommandBarMessage>,
IRecipient<TryCommandKeybindingMessage>
{
public static readonly DependencyProperty ShowFilterBoxProperty =
DependencyProperty.Register(nameof(ShowFilterBox), typeof(bool), typeof(ContextMenu), new PropertyMetadata(true));
public bool ShowFilterBox
{
get => (bool)GetValue(ShowFilterBoxProperty);
set => SetValue(ShowFilterBoxProperty, value);
}
public ContextMenuViewModel ViewModel { get; }
public ContextMenu()
@@ -100,9 +92,13 @@ public sealed partial class ContextMenu : UserControl,
return;
}
var mods = KeyModifiers.GetCurrent();
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
var result = ViewModel?.CheckKeybinding(mods.Ctrl, mods.Alt, mods.Shift, mods.Win, e.Key);
var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
if (result == ContextKeybindingResult.Hide)
{
@@ -160,7 +156,11 @@ public sealed partial class ContextMenu : UserControl,
private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e)
{
var modifiers = KeyModifiers.GetCurrent();
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
if (e.Key == VirtualKey.Enter)
{
@@ -177,7 +177,7 @@ public sealed partial class ContextMenu : UserControl,
}
}
else if (e.Key == VirtualKey.Escape ||
(e.Key == VirtualKey.Left && modifiers.Alt))
(e.Key == VirtualKey.Left && altPressed))
{
if (ViewModel.CanPopContextStack())
{

View File

@@ -22,9 +22,20 @@
Default="{StaticResource FilterItemViewModelTemplate}"
Separator="{StaticResource SeparatorViewModelTemplate}" />
<Style
x:Name="ComboBoxStyle"
BasedOn="{StaticResource DefaultComboBoxStyle}"
TargetType="ComboBox">
<Style.Setters>
<Setter Property="Visibility" Value="Collapsed" />
<Setter Property="Margin" Value="0,0,12,0" />
<Setter Property="Padding" Value="16,4" />
</Style.Setters>
</Style>
<!-- Template for the filter items -->
<DataTemplate x:Key="FilterItemViewModelTemplate" x:DataType="viewModels:FilterItemViewModel">
<Grid Padding="12,8,12,8" AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -47,100 +58,34 @@
<DataTemplate x:Key="SeparatorViewModelTemplate" x:DataType="viewModels:SeparatorViewModel">
<Rectangle
Height="1"
Margin="0,2,0,2"
Margin="-16,-12,-12,-12"
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
</DataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<DropDownButton
x:Name="FilterDropDownButton"
x:Uid="FiltersDropDown"
<ComboBox
Name="FiltersComboBox"
x:Uid="FiltersComboBox"
MinWidth="200"
Margin="0,0,12,0"
Padding="16,5,8,5"
VerticalAlignment="Center"
HorizontalContentAlignment="Left"
PreviewKeyDown="FilterDropDownButton_PreviewKeyDown"
ItemTemplateSelector="{StaticResource FilterTemplateSelector}"
ItemsSource="{x:Bind ViewModel.Filters, Mode=OneWay}"
PlaceholderText="Filters"
PreviewKeyDown="FiltersComboBox_PreviewKeyDown"
SelectedValue="{x:Bind ViewModel.CurrentFilter, Mode=OneWay}"
SelectionChanged="FiltersComboBox_SelectionChanged"
Style="{StaticResource ComboBoxStyle}"
Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}">
<DropDownButton.Content>
<StackPanel Orientation="Horizontal">
<cpcontrols:IconBox
x:Name="SelectedFilterIcon"
Width="16"
Margin="0,0,8,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}"
Visibility="Collapsed" />
<TextBlock x:Name="SelectedFilterText" VerticalAlignment="Center" />
</StackPanel>
</DropDownButton.Content>
<DropDownButton.Flyout>
<Flyout
x:Name="FilterFlyout"
Closed="FilterFlyout_Closed"
Opened="FilterFlyout_Opened"
Placement="BottomEdgeAlignedRight">
<Flyout.FlyoutPresenterStyle>
<Style BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" TargetType="FlyoutPresenter">
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
</Style>
</Flyout.FlyoutPresenterStyle>
<Grid MinWidth="200">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBox
x:Name="FilterSearchBox"
x:Uid="FilterSearchBox"
Margin="0"
Padding="10,7,6,8"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8,8,0,0"
PreviewKeyDown="FilterSearchBox_PreviewKeyDown"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterSearchBox_TextChanged" />
<Border
Grid.Row="1"
BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}"
BorderThickness="0,0,0,1" />
<ListView
x:Name="FilterListView"
x:Uid="FilterListView"
Grid.Row="2"
MaxHeight="300"
Margin="0,4,0,4"
IsItemClickEnabled="True"
ItemClick="FilterListView_ItemClick"
ItemTemplateSelector="{StaticResource FilterTemplateSelector}"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<TextBlock
x:Name="NoResultsText"
x:Uid="FiltersDropDown_NoResults"
Grid.Row="2"
Margin="0,16"
HorizontalAlignment="Center"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed" />
</Grid>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
<ComboBox.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultComboBoxItemStyle}" TargetType="ComboBoxItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.ItemContainerTransitions>
<TransitionCollection />
</ComboBox.ItemContainerTransitions>
</ComboBox>
</UserControl>

View File

@@ -2,17 +2,11 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
using Windows.System;
namespace Microsoft.CmdPal.UI.Controls;
@@ -20,11 +14,6 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class FiltersDropDown : UserControl,
ICurrentPageAware
{
private bool _isDropDownOpen;
private string? _pendingSearchText;
private IFilterItemViewModel[] _allItems = [];
private FilterItemViewModel? _lastSelectedFilter;
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -73,146 +62,11 @@ public sealed partial class FiltersDropDown : UserControl,
}
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnViewModelChanged));
/// <summary>
/// Gets a value indicating whether the dropdown is currently open or the button has keyboard focus.
/// </summary>
public bool IsActive => _isDropDownOpen ||
FilterDropDownButton.FocusState != FocusState.Unfocused;
/// <summary>
/// Gets a value indicating whether the filter control is visible (has filters to show).
/// </summary>
public bool IsFilterVisible => ViewModel?.ShouldShowFilters ?? false;
private static readonly string _defaultFilterText = ResourceLoaderInstance.GetString("FiltersDropDown_DefaultText");
DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null));
public FiltersDropDown()
{
this.InitializeComponent();
SelectedFilterText.Text = _defaultFilterText;
FilterDropDownButton.AddHandler(
CharacterReceivedEvent,
new TypedEventHandler<UIElement, CharacterReceivedRoutedEventArgs>(FilterDropDownButton_CharacterReceived),
true);
}
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FiltersDropDown @this)
{
return;
}
if (e.OldValue is FiltersViewModel oldVm)
{
oldVm.PropertyChanged -= @this.ViewModel_PropertyChanged;
}
if (e.NewValue is FiltersViewModel newVm)
{
newVm.PropertyChanged += @this.ViewModel_PropertyChanged;
}
@this.OnFiltersChanged();
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(FiltersViewModel.Filters)
or nameof(FiltersViewModel.CurrentFilter)
or nameof(FiltersViewModel.ShouldShowFilters))
{
OnFiltersChanged();
}
}
private void OnFiltersChanged()
{
_allItems = ViewModel?.Filters ?? [];
UpdateFilteredList();
UpdateSelectedFilterDisplay();
}
private void UpdateSelectedFilterDisplay()
{
if (ViewModel?.CurrentFilter is FilterItemViewModel filter)
{
SelectedFilterText.Text = filter.Name;
SelectedFilterIcon.SourceKey = filter.Icon;
SelectedFilterIcon.Visibility = Visibility.Visible;
}
else
{
SelectedFilterText.Text = _defaultFilterText;
SelectedFilterIcon.SourceKey = null;
SelectedFilterIcon.Visibility = Visibility.Collapsed;
}
}
private void UpdateFilteredList()
{
if (FilterListView == null)
{
return;
}
var searchText = FilterSearchBox?.Text?.Trim() ?? string.Empty;
IFilterItemViewModel[] filtered;
if (string.IsNullOrEmpty(searchText))
{
filtered = _allItems;
}
else
{
var list = new List<IFilterItemViewModel>();
foreach (var item in _allItems)
{
if (item is FilterItemViewModel filterItem &&
filterItem.Name.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) > -1)
{
list.Add(item);
}
}
filtered = list.ToArray();
}
FilterListView.ItemsSource = filtered;
var hasResults = filtered.Length > 0;
FilterListView.Visibility = hasResults ? Visibility.Visible : Visibility.Collapsed;
NoResultsText.Visibility = hasResults ? Visibility.Collapsed : Visibility.Visible;
// Restore selection to current filter if present
if (_lastSelectedFilter != null && Array.IndexOf(filtered, _lastSelectedFilter) >= 0)
{
FilterListView.SelectedItem = _lastSelectedFilter;
}
else if (ViewModel?.CurrentFilter != null && Array.IndexOf(filtered, ViewModel.CurrentFilter) >= 0)
{
FilterListView.SelectedItem = ViewModel.CurrentFilter;
}
else if (hasResults)
{
// Select the first non-separator item
IFilterItemViewModel? first = null;
foreach (var item in filtered)
{
if (item is not SeparatorViewModel)
{
first = item;
break;
}
}
if (first != null)
{
FilterListView.SelectedItem = first;
}
}
}
// Used to handle the case when a ListPage's `Filters` may have changed
@@ -229,239 +83,55 @@ public sealed partial class FiltersDropDown : UserControl,
}
}
private void FilterDropDownButton_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args)
private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Redirect printable (non-space) characters to open flyout and type into search
if (!char.IsControl(args.Character) && args.Character != ' ')
{
OpenFlyoutAndType(args.Character.ToString());
args.Handled = true;
}
}
private void FilterDropDownButton_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
var modifiers = KeyModifiers.GetCurrent();
switch (e.Key)
{
case VirtualKey.Down when modifiers.OnlyAlt:
goto case VirtualKey.F4;
case VirtualKey.Down or VirtualKey.Up:
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
if (e.Key == VirtualKey.Down)
{
NavigateDown();
}
else
{
NavigateUp();
}
e.Handled = true;
break;
}
case VirtualKey.F4:
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
e.Handled = true;
break;
}
}
}
private void FilterSearchBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
var modifiers = KeyModifiers.GetCurrent();
switch (e.Key)
{
case VirtualKey.Down:
NavigateDown();
e.Handled = true;
break;
case VirtualKey.Up:
NavigateUp();
e.Handled = true;
break;
case VirtualKey.Enter:
SelectCurrentAndClose();
e.Handled = true;
break;
case VirtualKey.Escape:
if (!string.IsNullOrEmpty(FilterSearchBox.Text))
{
FilterSearchBox.Text = string.Empty;
}
else
{
CloseDropDownAndFocusSearch();
}
e.Handled = true;
break;
case VirtualKey.F when modifiers.Alt:
CloseDropDownAndFocusSearch();
e.Handled = true;
break;
}
}
private void FilterSearchBox_TextChanged(object sender, TextChangedEventArgs e) =>
UpdateFilteredList();
private void FilterListView_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is FilterItemViewModel filterItem)
{
SelectFilter(filterItem);
CloseDropDownAndFocusSearch();
}
}
private void FilterFlyout_Opened(object sender, object e)
{
_isDropDownOpen = true;
FilterSearchBox.Text = _pendingSearchText ?? string.Empty;
FilterSearchBox.SelectionStart = FilterSearchBox.Text.Length;
_pendingSearchText = null;
UpdateFilteredList();
FilterSearchBox.Focus(FocusState.Programmatic);
}
private void FilterFlyout_Closed(object sender, object e)
{
_isDropDownOpen = false;
_pendingSearchText = null;
FilterSearchBox.Text = string.Empty;
}
private void OpenFlyoutAndType(string text)
{
_pendingSearchText = (_pendingSearchText ?? string.Empty) + text;
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
else
{
FilterSearchBox.Text = _pendingSearchText;
FilterSearchBox.SelectionStart = FilterSearchBox.Text.Length;
FilterSearchBox.Focus(FocusState.Programmatic);
_pendingSearchText = null;
}
}
/// <summary>
/// Opens the filter dropdown flyout.
/// </summary>
public void OpenDropDown()
{
if (!_isDropDownOpen)
{
FilterFlyout.ShowAt(FilterDropDownButton);
}
}
/// <summary>
/// Closes the filter dropdown flyout and returns focus to the main search box.
/// </summary>
public void CloseDropDownAndFocusSearch()
{
if (_isDropDownOpen)
{
FilterFlyout.Hide();
}
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
/// <summary>
/// Closes the filter dropdown flyout.
/// </summary>
public void CloseDropDown()
{
if (_isDropDownOpen)
{
FilterFlyout.Hide();
}
FilterDropDownButton.Focus(FocusState.Programmatic);
}
/// <summary>
/// Moves focus to this control (the dropdown button).
/// </summary>
public void FocusControl()
{
FilterDropDownButton.Focus(FocusState.Programmatic);
}
private void SelectCurrentAndClose()
{
if (FilterListView.SelectedItem is FilterItemViewModel filterItem)
{
SelectFilter(filterItem);
}
CloseDropDownAndFocusSearch();
}
private void SelectFilter(FilterItemViewModel filterItem)
{
_lastSelectedFilter = filterItem;
if (CurrentPageViewModel is ListViewModel listViewModel)
if (CurrentPageViewModel is ListViewModel listViewModel &&
FiltersComboBox.SelectedItem is FilterItemViewModel filterItem)
{
listViewModel.UpdateCurrentFilter(filterItem.Id);
}
}
// Update display immediately (UpdateCurrentFilter is async)
SelectedFilterText.Text = filterItem.Name;
SelectedFilterIcon.SourceKey = filterItem.Icon;
SelectedFilterIcon.Visibility = Visibility.Visible;
private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Up)
{
NavigateUp();
e.Handled = true;
}
else if (e.Key == VirtualKey.Down)
{
NavigateDown();
e.Handled = true;
}
}
private void NavigateUp()
{
if (FilterListView.ItemsSource is not IFilterItemViewModel[] items || items.Length == 0)
{
return;
}
var newIndex = FiltersComboBox.SelectedIndex;
if (!HasSelectableItem(items))
{
return;
}
var newIndex = FilterListView.SelectedIndex;
if (newIndex > 0)
if (FiltersComboBox.SelectedIndex > 0)
{
newIndex--;
while (newIndex >= 0 && IsSeparator(items[newIndex]))
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex--;
}
if (newIndex < 0)
{
newIndex = items.Length - 1;
while (newIndex >= 0 && IsSeparator(items[newIndex]))
newIndex = FiltersComboBox.Items.Count - 1;
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex--;
}
@@ -469,35 +139,17 @@ public sealed partial class FiltersDropDown : UserControl,
}
else
{
newIndex = items.Length - 1;
while (newIndex >= 0 && IsSeparator(items[newIndex]))
{
newIndex--;
}
newIndex = FiltersComboBox.Items.Count - 1;
}
if (newIndex >= 0)
{
FilterListView.SelectedIndex = newIndex;
FilterListView.ScrollIntoView(FilterListView.SelectedItem);
}
FiltersComboBox.SelectedIndex = newIndex;
}
private void NavigateDown()
{
if (FilterListView.ItemsSource is not IFilterItemViewModel[] items || items.Length == 0)
{
return;
}
var newIndex = FiltersComboBox.SelectedIndex;
if (!HasSelectableItem(items))
{
return;
}
var newIndex = FilterListView.SelectedIndex;
if (newIndex >= items.Length - 1)
if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1)
{
newIndex = 0;
}
@@ -505,40 +157,33 @@ public sealed partial class FiltersDropDown : UserControl,
{
newIndex++;
while (newIndex < items.Length && IsSeparator(items[newIndex]))
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex++;
}
if (newIndex >= items.Length)
if (newIndex >= FiltersComboBox.Items.Count)
{
newIndex = 0;
while (newIndex < items.Length && IsSeparator(items[newIndex]))
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex++;
}
}
}
if (newIndex < items.Length)
{
FilterListView.SelectedIndex = newIndex;
FilterListView.ScrollIntoView(FilterListView.SelectedItem);
}
FiltersComboBox.SelectedIndex = newIndex;
}
private static bool IsSeparator(object item) => item is SeparatorViewModel;
private static bool HasSelectableItem(IFilterItemViewModel[] items)
private bool IsSeparator(object item)
{
foreach (var item in items)
{
if (!IsSeparator(item))
{
return true;
}
}
return false;
return item is SeparatorViewModel;
}
}

View File

@@ -15,8 +15,7 @@
<Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" />
<Setter Property="BorderBrush" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,0,8,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
@@ -142,10 +141,10 @@
Grid.Column="1">
<ScrollViewer
x:Name="scroller"
HorizontalScrollBarVisibility="Disabled"
HorizontalScrollBarVisibility="Hidden"
HorizontalScrollMode="Enabled"
SizeChanged="Scroller_SizeChanged"
VerticalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Hidden"
VerticalScrollMode="Disabled"
ViewChanging="Scroller_ViewChanging">
<Grid x:Name="ContentGrid">
@@ -155,7 +154,7 @@
<Button
x:Name="ScrollBackBtn"
Margin="8,0,0,0"
Padding="4,8,4,8"
Padding="2,8,2,8"
HorizontalAlignment="Left"
VerticalAlignment="Center"
AutomationProperties.Name="Scroll left"
@@ -170,8 +169,8 @@
</Button>
<Button
x:Name="ScrollForwardBtn"
Margin="8,0,0,0"
Padding="4,8,4,8"
Margin="0,0,8,0"
Padding="2,8,2,8"
HorizontalAlignment="Right"
VerticalAlignment="Center"
AutomationProperties.Name="Scroll right"
@@ -181,7 +180,7 @@
<FontIcon
x:Name="ScrollForwardIcon"
FontSize="{ThemeResource FlipViewButtonFontSize}"
Glyph="&#xE72A;" />
Glyph="&#xEDDA;" />
</Button>
</Grid>
@@ -189,18 +188,18 @@
<VisualStateGroup x:Name="OrientationStates">
<VisualState x:Name="HorizontalState">
<VisualState.Setters>
<Setter Target="scroller.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Target="scroller.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Target="scroller.HorizontalScrollMode" Value="Enabled" />
<Setter Target="scroller.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Target="scroller.VerticalScrollBarVisibility" Value="Hidden" />
<Setter Target="scroller.VerticalScrollMode" Value="Disabled" />
<Setter Target="ScrollBackBtn.Padding" Value="4,8,4,8" />
<Setter Target="ScrollBackBtn.Padding" Value="4,12,4,12" />
<Setter Target="ScrollBackBtn.Margin" Value="8,0,0,0" />
<Setter Target="ScrollBackBtn.HorizontalAlignment" Value="Left" />
<Setter Target="ScrollBackBtn.VerticalAlignment" Value="Center" />
<Setter Target="ScrollBackBtn.(AutomationProperties.Name)" Value="Scroll left" />
<Setter Target="ScrollBackBtn.(ToolTipService.ToolTip)" Value="Scroll left" />
<Setter Target="ScrollBackIcon.Glyph" Value="&#xEDD9;" />
<Setter Target="ScrollForwardBtn.Padding" Value="4,8,4,8" />
<Setter Target="ScrollForwardBtn.Padding" Value="4,12,4,12" />
<Setter Target="ScrollForwardBtn.Margin" Value="0,0,8,0" />
<Setter Target="ScrollForwardBtn.HorizontalAlignment" Value="Right" />
<Setter Target="ScrollForwardBtn.VerticalAlignment" Value="Center" />
@@ -211,9 +210,9 @@
</VisualState>
<VisualState x:Name="VerticalState">
<VisualState.Setters>
<Setter Target="scroller.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Target="scroller.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Target="scroller.HorizontalScrollMode" Value="Disabled" />
<Setter Target="scroller.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Target="scroller.VerticalScrollBarVisibility" Value="Hidden" />
<Setter Target="scroller.VerticalScrollMode" Value="Enabled" />
<Setter Target="ScrollBackBtn.Padding" Value="12,4,12,4" />
<Setter Target="ScrollBackBtn.Margin" Value="0,8,0,0" />
@@ -228,7 +227,7 @@
<Setter Target="ScrollForwardBtn.VerticalAlignment" Value="Bottom" />
<Setter Target="ScrollForwardBtn.(AutomationProperties.Name)" Value="Scroll down" />
<Setter Target="ScrollForwardBtn.(ToolTipService.ToolTip)" Value="Scroll down" />
<Setter Target="ScrollForwardIcon.Glyph" Value="&#xE72A;" />
<Setter Target="ScrollForwardIcon.Glyph" Value="&#xEDDC;" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -5,16 +5,17 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using CoreVirtualKeyStates = Windows.UI.Core.CoreVirtualKeyStates;
using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Controls;
@@ -124,7 +125,8 @@ public sealed partial class SearchBar : UserControl,
return;
}
if (KeyModifiers.GetCurrent().Ctrl && e.Key == VirtualKey.I)
var ctrlPressed = (InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down;
if (ctrlPressed && e.Key == VirtualKey.I)
{
// Today you learned that Ctrl+I in a TextBox will insert a tab
// We don't want that, so we'll suppress it, this way it can be used for other purposes

View File

@@ -74,14 +74,8 @@
Grid.Column="0"
Width="12"
Height="12"
SourceKey="{TemplateBinding Icon}">
<local:IconBox.Margin>
<Binding
Converter="{StaticResource IconMarginConverter}"
Path="Text"
RelativeSource="{RelativeSource TemplatedParent}" />
</local:IconBox.Margin>
</local:IconBox>
Margin="{Binding Text, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource IconMarginConverter}}"
SourceKey="{TemplateBinding Icon}" />
<TextBlock
Grid.Column="1"
Margin="0,-1,0,0"

View File

@@ -16,38 +16,21 @@ internal sealed partial class FilterTemplateSelector : DataTemplateSelector
public DataTemplate? Separator { get; set; }
[DynamicDependency(DynamicallyAccessedMemberTypes.All, "Microsoft.UI.Xaml.Controls.ComboBoxItem", "Microsoft.WinUI")]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, "Microsoft.UI.Xaml.Controls.ListViewItem", "Microsoft.WinUI")]
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject)
{
DataTemplate? dataTemplate = Default;
var isSeparator = item is SeparatorViewModel;
switch (dependencyObject)
if (dependencyObject is ComboBoxItem comboBoxItem)
{
case ComboBoxItem comboBoxItem:
comboBoxItem.IsEnabled = !isSeparator;
if (isSeparator)
{
comboBoxItem.AllowFocusWhenDisabled = false;
comboBoxItem.AllowFocusOnInteraction = false;
}
comboBoxItem.IsEnabled = true;
break;
case ListViewItem listViewItem:
listViewItem.IsEnabled = !isSeparator;
if (isSeparator)
{
listViewItem.MinHeight = 0;
listViewItem.IsHitTestVisible = false;
}
break;
}
if (isSeparator)
{
dataTemplate = Separator;
if (item is SeparatorViewModel)
{
comboBoxItem.IsEnabled = false;
comboBoxItem.AllowFocusWhenDisabled = false;
comboBoxItem.AllowFocusOnInteraction = false;
dataTemplate = Separator;
}
}
return dataTemplate;

View File

@@ -1,138 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Dock.DockContentControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
<Grid x:Name="ContentGrid" Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="StartColumn" Width="*" />
<ColumnDefinition x:Name="CenterColumn" Width="Auto" />
<ColumnDefinition x:Name="EndColumn" Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition x:Name="StartRow" Height="*" />
<RowDefinition x:Name="CenterRow" Height="Auto" />
<RowDefinition x:Name="EndRow" Height="*" />
</Grid.RowDefinitions>
<cpcontrols:ScrollContainer
x:Name="StartScroller"
Grid.RowSpan="3"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"
ActionButton="{x:Bind StartActionButton, Mode=OneWay}"
Source="{x:Bind StartSource, Mode=OneWay}" />
<cpcontrols:ScrollContainer
x:Name="CenterScroller"
Grid.RowSpan="3"
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Stretch"
ActionButton="{x:Bind CenterActionButton, Mode=OneWay}"
Source="{x:Bind CenterSource, Mode=OneWay}" />
<cpcontrols:ScrollContainer
x:Name="EndScroller"
Grid.RowSpan="3"
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
ActionButton="{x:Bind EndActionButton, Mode=OneWay}"
ContentAlignment="End"
Source="{x:Bind EndSource, Mode=OneWay}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="OrientationStates">
<VisualState x:Name="HorizontalState" />
<VisualState x:Name="VerticalState">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind Orientation, Mode=OneWay}" To="Vertical" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="StartColumn.Width" Value="*" />
<Setter Target="CenterColumn.Width" Value="*" />
<Setter Target="EndColumn.Width" Value="*" />
<Setter Target="StartRow.Height" Value="*" />
<Setter Target="CenterRow.Height" Value="Auto" />
<Setter Target="EndRow.Height" Value="*" />
<Setter Target="StartScroller.(Grid.Row)" Value="0" />
<Setter Target="StartScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="StartScroller.(Grid.Column)" Value="0" />
<Setter Target="StartScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="StartScroller.Orientation" Value="Vertical" />
<Setter Target="StartScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="StartScroller.VerticalAlignment" Value="Top" />
<Setter Target="CenterScroller.(Grid.Row)" Value="1" />
<Setter Target="CenterScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="CenterScroller.(Grid.Column)" Value="0" />
<Setter Target="CenterScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="CenterScroller.Orientation" Value="Vertical" />
<Setter Target="CenterScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="CenterScroller.VerticalAlignment" Value="Center" />
<Setter Target="EndScroller.(Grid.Row)" Value="2" />
<Setter Target="EndScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="EndScroller.(Grid.Column)" Value="0" />
<Setter Target="EndScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="EndScroller.Orientation" Value="Vertical" />
<Setter Target="EndScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="EndScroller.VerticalAlignment" Value="Bottom" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="CenterVisibilityStates">
<VisualState x:Name="CenterVisibleState" />
<VisualState x:Name="CenterCollapsedState">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind IsCenterVisible, Mode=OneWay}" To="False" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CenterColumn.Width" Value="0" />
<Setter Target="CenterRow.Height" Value="0" />
<Setter Target="EndColumn.Width" Value="Auto" />
<Setter Target="EndRow.Height" Value="Auto" />
<Setter Target="CenterScroller.Visibility" Value="Collapsed" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="EditModeStates">
<VisualState x:Name="EditModeOff" />
<VisualState x:Name="EditModeOn">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind IsEditMode, Mode=OneWay}" To="True" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CenterScroller.MinWidth" Value="48" />
<Setter Target="StartScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="StartScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="StartScroller.BorderThickness" Value="1" />
<Setter Target="StartScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="CenterScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="CenterScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="CenterScroller.BorderThickness" Value="1" />
<Setter Target="CenterScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="EndScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="EndScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="EndScroller.BorderThickness" Value="1" />
<Setter Target="EndScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="StartScroller.ActionButtonVisibility" Value="Visible" />
<Setter Target="CenterScroller.ActionButtonVisibility" Value="Visible" />
<Setter Target="EndScroller.ActionButtonVisibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -1,103 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI.Dock;
/// <summary>
/// A control that arranges Start, Center, and End sections in a dock layout
/// with built-in ScrollContainers. When <see cref="IsCenterVisible"/> is false,
/// the center is collapsed, the start section stretches, and the end section
/// auto-sizes. Supports horizontal/vertical orientation and edit mode styling.
/// </summary>
public sealed partial class DockContentControl : UserControl
{
public static readonly DependencyProperty OrientationProperty =
DependencyProperty.Register(nameof(Orientation), typeof(Orientation), typeof(DockContentControl), new PropertyMetadata(Orientation.Horizontal));
public Orientation Orientation
{
get => (Orientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
public static readonly DependencyProperty IsCenterVisibleProperty =
DependencyProperty.Register(nameof(IsCenterVisible), typeof(bool), typeof(DockContentControl), new PropertyMetadata(true));
public bool IsCenterVisible
{
get => (bool)GetValue(IsCenterVisibleProperty);
set => SetValue(IsCenterVisibleProperty, value);
}
public static readonly DependencyProperty IsEditModeProperty =
DependencyProperty.Register(nameof(IsEditMode), typeof(bool), typeof(DockContentControl), new PropertyMetadata(false));
public bool IsEditMode
{
get => (bool)GetValue(IsEditModeProperty);
set => SetValue(IsEditModeProperty, value);
}
public static readonly DependencyProperty StartSourceProperty =
DependencyProperty.Register(nameof(StartSource), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object StartSource
{
get => GetValue(StartSourceProperty);
set => SetValue(StartSourceProperty, value);
}
public static readonly DependencyProperty StartActionButtonProperty =
DependencyProperty.Register(nameof(StartActionButton), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object StartActionButton
{
get => GetValue(StartActionButtonProperty);
set => SetValue(StartActionButtonProperty, value);
}
public static readonly DependencyProperty CenterSourceProperty =
DependencyProperty.Register(nameof(CenterSource), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object CenterSource
{
get => GetValue(CenterSourceProperty);
set => SetValue(CenterSourceProperty, value);
}
public static readonly DependencyProperty CenterActionButtonProperty =
DependencyProperty.Register(nameof(CenterActionButton), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object CenterActionButton
{
get => GetValue(CenterActionButtonProperty);
set => SetValue(CenterActionButtonProperty, value);
}
public static readonly DependencyProperty EndSourceProperty =
DependencyProperty.Register(nameof(EndSource), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object EndSource
{
get => GetValue(EndSourceProperty);
set => SetValue(EndSourceProperty, value);
}
public static readonly DependencyProperty EndActionButtonProperty =
DependencyProperty.Register(nameof(EndActionButton), typeof(object), typeof(DockContentControl), new PropertyMetadata(null));
public object EndActionButton
{
get => GetValue(EndActionButtonProperty);
set => SetValue(EndActionButtonProperty, value);
}
public DockContentControl()
{
this.InitializeComponent();
}
}

View File

@@ -29,10 +29,7 @@
</ItemsPanelTemplate>
<DataTemplate x:Key="DockBandTemplate" x:DataType="dockVm:DockBandViewModel">
<ItemsRepeater
ItemsSource="{x:Bind Items, Mode=OneWay}"
Layout="{StaticResource ItemsOrientationLayout}"
TabFocusNavigation="Local">
<ItemsRepeater ItemsSource="{x:Bind Items, Mode=OneWay}" Layout="{StaticResource ItemsOrientationLayout}">
<ItemsRepeater.Transitions>
<TransitionCollection />
</ItemsRepeater.Transitions>
@@ -66,12 +63,10 @@
<Setter Property="Padding" Value="0" />
<Setter Property="IsItemClickEnabled" Value="False" />
<Setter Property="SelectionMode" Value="None" />
<Setter Property="IsTabStop" Value="False" />
<!-- Drag properties controlled by code-behind based on IsEditMode -->
<Setter Property="CanDragItems" Value="False" />
<Setter Property="CanReorderItems" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="TabFocusNavigation" Value="Local" />
</Style>
<Style x:Key="DockBandListViewItemStyle" TargetType="ListViewItem">
@@ -81,7 +76,6 @@
<Setter Property="MinWidth" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="IsTabStop" Value="False" />
</Style>
<Style
@@ -184,138 +178,168 @@
x:Name="RootGrid"
BorderThickness="0,0,0,1"
RightTapped="RootGrid_RightTapped">
<!-- Dock content with Start / Center / End sections -->
<local:DockContentControl
<!-- Edit Mode Overlay - shown when in edit mode -->
<Grid
x:Name="ContentGrid"
Margin="4"
Padding="0,0,0,0"
Padding="4,0,4,0"
Background="Transparent"
IsEditMode="{x:Bind IsEditMode, Mode=OneWay}"
RightTapped="RootGrid_RightTapped">
<local:DockContentControl.StartSource>
<ListView
x:Name="StartListView"
HorizontalAlignment="Stretch"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="StartListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.StartItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}" />
</local:DockContentControl.StartSource>
<local:DockContentControl.StartActionButton>
<Button
x:Name="StartAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="Start"
ToolTipService.ToolTip="Add band to Start">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.StartActionButton>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<local:DockContentControl.CenterSource>
<ListView
x:Name="CenterListView"
HorizontalAlignment="Stretch"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="CenterListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.CenterItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}" />
</local:DockContentControl.CenterSource>
<local:DockContentControl.CenterActionButton>
<Button
x:Name="CenterAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="Center"
ToolTipService.ToolTip="Add band to Center">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.CenterActionButton>
<local:DockContentControl.EndSource>
<ListView
x:Name="EndListView"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="EndListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.EndItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</local:DockContentControl.EndSource>
<local:DockContentControl.EndActionButton>
<Button
x:Name="EndAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="End"
ToolTipService.ToolTip="Add band to End">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.EndActionButton>
</local:DockContentControl>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
PreferredPlacement="Bottom"
ShouldConstrainToRootBounds="False"
Style="{StaticResource TeachingTipWithoutCloseButtonStyle}"
Target="{x:Bind ContentGrid}">
<TeachingTip.Content>
<StackPanel
x:Name="EditButtonsPanel"
HorizontalAlignment="Stretch"
Orientation="Vertical"
Spacing="4">
<cpcontrols:ScrollContainer
x:Name="StartScroller"
Grid.Row="0"
Grid.RowSpan="3"
HorizontalAlignment="Left"
VerticalAlignment="Stretch">
<cpcontrols:ScrollContainer.ActionButton>
<Button
x:Name="StartAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="Start"
ToolTipService.ToolTip="Add band to Start">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</cpcontrols:ScrollContainer.ActionButton>
<cpcontrols:ScrollContainer.Source>
<ListView
x:Name="StartListView"
HorizontalAlignment="Stretch"
Click="DoneEditingButton_Click"
Content="Save"
Style="{StaticResource AccentButtonStyle}" />
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="StartListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.StartItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}" />
</cpcontrols:ScrollContainer.Source>
</cpcontrols:ScrollContainer>
<cpcontrols:ScrollContainer
x:Name="CenterScroller"
Grid.Row="0"
Grid.RowSpan="3"
Grid.Column="1"
MinWidth="48"
HorizontalAlignment="Center"
VerticalAlignment="Stretch">
<cpcontrols:ScrollContainer.ActionButton>
<Button
x:Name="CenterAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="Center"
ToolTipService.ToolTip="Add band to Center">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</cpcontrols:ScrollContainer.ActionButton>
<cpcontrols:ScrollContainer.Source>
<ListView
x:Name="CenterListView"
HorizontalAlignment="Stretch"
Click="DiscardEditingButton_Click"
Content="Discard" />
</StackPanel>
</TeachingTip.Content>
</TeachingTip>
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="CenterListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.CenterItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}" />
</cpcontrols:ScrollContainer.Source>
</cpcontrols:ScrollContainer>
<cpcontrols:ScrollContainer
x:Name="EndScroller"
Grid.Row="0"
Grid.RowSpan="3"
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
ContentAlignment="End">
<cpcontrols:ScrollContainer.ActionButton>
<Button
x:Name="EndAddButton"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="End"
ToolTipService.ToolTip="Add band to End">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</cpcontrols:ScrollContainer.ActionButton>
<cpcontrols:ScrollContainer.Source>
<ListView
x:Name="EndListView"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
DragOver="BandListView_DragOver"
Drop="EndListView_Drop"
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
ItemTemplate="{StaticResource DockBandTemplate}"
ItemsPanel="{StaticResource HorizontalItemsPanel}"
ItemsSource="{x:Bind ViewModel.EndItems, Mode=OneWay}"
SelectionMode="None"
Style="{StaticResource DockBandListViewStyle}">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</cpcontrols:ScrollContainer.Source>
</cpcontrols:ScrollContainer>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
PreferredPlacement="Bottom"
ShouldConstrainToRootBounds="False"
Style="{StaticResource TeachingTipWithoutCloseButtonStyle}"
Target="{x:Bind ContentGrid}">
<TeachingTip.Content>
<StackPanel
x:Name="EditButtonsPanel"
HorizontalAlignment="Stretch"
Orientation="Vertical"
Spacing="4">
<Button
HorizontalAlignment="Stretch"
Click="DoneEditingButton_Click"
Content="Save"
Style="{StaticResource AccentButtonStyle}" />
<Button
HorizontalAlignment="Stretch"
Click="DiscardEditingButton_Click"
Content="Discard" />
</StackPanel>
</TeachingTip.Content>
</TeachingTip>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="DockOrientation">
<VisualState x:Name="DockOnTop">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Top" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="4,0,4,4" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="DockOnBottom">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Bottom" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="4,4,4,0" />
<Setter Target="RootGrid.BorderThickness" Value="0,1,0,0" />
</VisualState.Setters>
</VisualState>
@@ -324,10 +348,34 @@
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Left" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Orientation" Value="Vertical" />
<Setter Target="ContentGrid.Margin" Value="0,0,4,4" />
<Setter Target="ContentGrid.Padding" Value="0,0,4,8" />
<Setter Target="StartScroller.(Grid.Row)" Value="0" />
<Setter Target="StartScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="StartScroller.(Grid.Column)" Value="0" />
<Setter Target="StartScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="StartScroller.Orientation" Value="Vertical" />
<Setter Target="StartScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="StartScroller.VerticalAlignment" Value="Top" />
<Setter Target="StartScroller.Orientation" Value="Vertical" />
<Setter Target="CenterScroller.(Grid.Row)" Value="1" />
<Setter Target="CenterScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="CenterScroller.(Grid.Column)" Value="0" />
<Setter Target="CenterScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="CenterScroller.Orientation" Value="Vertical" />
<Setter Target="CenterScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="CenterScroller.VerticalAlignment" Value="Center" />
<Setter Target="CenterScroller.Orientation" Value="Vertical" />
<Setter Target="EndScroller.Orientation" Value="Vertical" />
<Setter Target="EndScroller.(Grid.Row)" Value="2" />
<Setter Target="EndScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="EndScroller.(Grid.Column)" Value="0" />
<Setter Target="EndScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="EndScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="EndScroller.VerticalAlignment" Value="Bottom" />
<Setter Target="ContentGrid.Padding" Value="4,8,4,8" />
<Setter Target="RootGrid.BorderThickness" Value="0,0,1,0" />
<Setter Target="StartListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
<Setter Target="CenterListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
<Setter Target="EndListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
@@ -338,16 +386,64 @@
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Right" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Orientation" Value="Vertical" />
<Setter Target="ContentGrid.Margin" Value="4,0,0,4" />
<Setter Target="ContentGrid.Padding" Value="4,0,0,8" />
<Setter Target="StartScroller.(Grid.Row)" Value="0" />
<Setter Target="StartScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="StartScroller.(Grid.Column)" Value="0" />
<Setter Target="StartScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="StartScroller.Orientation" Value="Vertical" />
<Setter Target="StartScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="StartScroller.VerticalAlignment" Value="Top" />
<Setter Target="StartScroller.Orientation" Value="Vertical" />
<Setter Target="CenterScroller.(Grid.Row)" Value="1" />
<Setter Target="CenterScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="CenterScroller.(Grid.Column)" Value="0" />
<Setter Target="CenterScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="CenterScroller.Orientation" Value="Vertical" />
<Setter Target="CenterScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="CenterScroller.VerticalAlignment" Value="Center" />
<Setter Target="CenterScroller.Orientation" Value="Vertical" />
<Setter Target="EndScroller.Orientation" Value="Vertical" />
<Setter Target="EndScroller.(Grid.Row)" Value="2" />
<Setter Target="EndScroller.(Grid.RowSpan)" Value="1" />
<Setter Target="EndScroller.(Grid.Column)" Value="0" />
<Setter Target="EndScroller.(Grid.ColumnSpan)" Value="3" />
<Setter Target="EndScroller.HorizontalAlignment" Value="Stretch" />
<Setter Target="EndScroller.VerticalAlignment" Value="Bottom" />
<Setter Target="ContentGrid.Padding" Value="4,8,4,8" />
<Setter Target="RootGrid.BorderThickness" Value="1,0,0,0" />
<Setter Target="StartListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
<Setter Target="CenterListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
<Setter Target="EndListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<!-- Edit Mode Visual States -->
<VisualStateGroup x:Name="EditModeStates">
<VisualState x:Name="EditModeOff" />
<VisualState x:Name="EditModeOn">
<VisualState.Setters>
<Setter Target="StartScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="StartScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="StartScroller.BorderThickness" Value="1" />
<Setter Target="StartScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="CenterScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="CenterScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="CenterScroller.BorderThickness" Value="1" />
<Setter Target="CenterScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="EndScroller.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Target="EndScroller.Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Target="EndScroller.BorderThickness" Value="1" />
<Setter Target="EndScroller.CornerRadius" Value="{StaticResource OverlayCornerRadius}" />
<Setter Target="StartScroller.ActionButtonVisibility" Value="Visible" />
<Setter Target="CenterScroller.ActionButtonVisibility" Value="Visible" />
<Setter Target="EndScroller.ActionButtonVisibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
@@ -70,22 +69,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
// Start with edit mode disabled - normal click behavior
UpdateEditMode(false);
}
private void CenterItems_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
UpdateCenterVisibility();
}
private void UpdateCenterVisibility()
{
ContentGrid.IsCenterVisible = IsEditMode || ViewModel.CenterItems.Count > 0;
}
public void Receive(EnterDockEditModeMessage message)
{
// Message may arrive from a background thread, dispatch to UI thread
@@ -97,9 +84,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void UpdateEditMode(bool isEditMode)
{
// Update center visibility based on edit mode and center items
UpdateCenterVisibility();
// Enable/disable drag-and-drop based on edit mode
StartListView.CanDragItems = isEditMode;
StartListView.CanReorderItems = isEditMode;
@@ -126,6 +110,9 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
}
EditButtonsTeachingTip.IsOpen = isEditMode;
// Update visual state
VisualStateManager.GoToState(this, isEditMode ? "EditModeOn" : "EditModeOff", true);
}
internal void EnterEditMode()
@@ -231,7 +218,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
if (item.HasMoreCommands)
{
ContextControl.ViewModel.SelectedItem = item;
ContextControl.ShowFilterBox = true;
ContextMenuFlyout.ShowAt(
dockItem,
new FlyoutShowOptions()
@@ -313,7 +299,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
if (item.HasMoreCommands)
{
ContextControl.ViewModel.SelectedItem = item;
ContextControl.ShowFilterBox = false;
ContextMenuFlyout.ShowAt(
this.RootGrid,
new FlyoutShowOptions()

View File

@@ -54,76 +54,66 @@
<Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" />
<Setter Property="CornerRadius" Value="{StaticResource DockItemCornerRadius}" />
<Setter Property="TextVisibility" Value="Visible" />
<Setter Property="IsTabStop" Value="True" />
<Setter Property="UseSystemFocusVisuals" Value="True" />
<Setter Property="FocusVisualMargin" Value="-2" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockItemControl">
<Grid
x:Name="PART_HitTestGrid"
Background="Transparent"
x:Name="PART_RootGrid"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
ToolTipService.ToolTip="{TemplateBinding ToolTip}">
<Grid
x:Name="PART_RootGrid"
MinWidth="32"
MinHeight="30"
Margin="{TemplateBinding InnerMargin}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid
x:Name="ContentGrid"
AutomationProperties.Name="{TemplateBinding Title}"
Background="Transparent"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
x:Name="ContentGrid"
AutomationProperties.Name="{TemplateBinding Title}"
Background="Transparent"
ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Icon -->
<ContentPresenter
x:Name="IconPresenter"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}" />
<!-- Icon -->
<ContentPresenter
x:Name="IconPresenter"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}" />
<!-- Text (Title + Subtitle) -->
<StackPanel
x:Name="TextPanel"
Grid.Column="1"
<!-- Text (Title + Subtitle) -->
<StackPanel
x:Name="TextPanel"
Grid.Column="1"
VerticalAlignment="Center"
Visibility="{TemplateBinding TextVisibility}">
<TextBlock
x:Name="TitleText"
MinWidth="24"
MaxWidth="100"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Visibility="{TemplateBinding TextVisibility}">
<TextBlock
x:Name="TitleText"
MinWidth="24"
MaxWidth="100"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontFamily="Segoe UI"
FontSize="12"
Text="{TemplateBinding Title}"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
x:Name="SubtitleText"
MaxWidth="100"
Margin="0,-4,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontFamily="Segoe UI"
FontSize="10"
Foreground="{ThemeResource TextFillColorTertiary}"
Text="{TemplateBinding Subtitle}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</StackPanel>
</Grid>
FontFamily="Segoe UI"
FontSize="12"
Text="{TemplateBinding Title}"
TextAlignment="Left"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
x:Name="SubtitleText"
MaxWidth="100"
Margin="0,-4,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontFamily="Segoe UI"
FontSize="10"
Foreground="{ThemeResource TextFillColorTertiary}"
Text="{TemplateBinding Subtitle}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</StackPanel>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
@@ -166,7 +156,6 @@
<VisualState x:Name="TextHidden">
<VisualState.Setters>
<Setter Target="ContentGrid.ColumnSpacing" Value="0" />
<Setter Target="ContentGrid.HorizontalAlignment" Value="Center" />
<Setter Target="TextPanel.Visibility" Value="Collapsed" />
</VisualState.Setters>
</VisualState>

View File

@@ -5,12 +5,10 @@
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Markup;
using Microsoft.UI.Xaml.Media;
namespace Microsoft.CmdPal.UI.Dock;
@@ -58,15 +56,6 @@ public sealed partial class DockItemControl : Control
set => SetValue(IconProperty, value);
}
public static readonly DependencyProperty InnerMarginProperty =
DependencyProperty.Register(nameof(InnerMargin), typeof(Thickness), typeof(DockItemControl), new PropertyMetadata(new Thickness(0)));
public Thickness InnerMargin
{
get => (Thickness)GetValue(InnerMarginProperty);
set => SetValue(InnerMarginProperty, value);
}
public static readonly DependencyProperty TextVisibilityProperty =
DependencyProperty.Register(nameof(TextVisibility), typeof(Visibility), typeof(DockItemControl), new PropertyMetadata(null, OnTextPropertyChanged));
@@ -79,8 +68,6 @@ public sealed partial class DockItemControl : Control
private const string IconPresenterName = "IconPresenter";
private FrameworkElement? _iconPresenter;
private DockControl? _parentDock;
private long _dockSideCallbackToken = -1;
private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
@@ -170,7 +157,7 @@ public sealed partial class DockItemControl : Control
}
}
HorizontalAlignment = HorizontalAlignment.Stretch;
HorizontalAlignment = HorizontalAlignment.Center;
}
private void UpdateAllVisibility()
@@ -187,13 +174,9 @@ public sealed partial class DockItemControl : Control
PointerEntered -= Control_PointerEntered;
PointerExited -= Control_PointerExited;
Loaded -= DockItemControl_Loaded;
Unloaded -= DockItemControl_Unloaded;
PointerEntered += Control_PointerEntered;
PointerExited += Control_PointerExited;
Loaded += DockItemControl_Loaded;
Unloaded += DockItemControl_Unloaded;
IsEnabledChanged += OnIsEnabledChanged;
@@ -204,62 +187,6 @@ public sealed partial class DockItemControl : Control
UpdateAllVisibility();
}
private void DockItemControl_Loaded(object sender, RoutedEventArgs e)
{
// Walk the visual tree to find our parent DockControl and watch its DockSide.
// This lets us extend the hit-test area toward the screen edge.
DependencyObject? parent = VisualTreeHelper.GetParent(this);
while (parent is not null and not DockControl)
{
parent = VisualTreeHelper.GetParent(parent);
}
if (parent is DockControl dock)
{
_parentDock = dock;
UpdateInnerMarginForDockSide(dock.DockSide);
_dockSideCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSideProperty,
OnParentDockSideChanged);
}
}
private void DockItemControl_Unloaded(object sender, RoutedEventArgs e)
{
if (_parentDock is not null && _dockSideCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
_parentDock = null;
}
}
private void OnParentDockSideChanged(DependencyObject sender, DependencyProperty dp)
{
if (sender is DockControl dock)
{
UpdateInnerMarginForDockSide(dock.DockSide);
}
}
private void UpdateInnerMarginForDockSide(DockSide side)
{
// Push the visual (PART_RootGrid) inward on the screen-edge side so
// the transparent hit-test area extends all the way to the edge.
// The values here compensate for the margin/padding removed from the
// DockControl's ContentGrid on the screen-edge side.
InnerMargin = side switch
{
DockSide.Top => new Thickness(0, 4, 0, 0),
DockSide.Bottom => new Thickness(0, 0, 0, 4),
DockSide.Left => new Thickness(8, 0, 0, 0),
DockSide.Right => new Thickness(0, 0, 8, 0),
_ => new Thickness(0),
};
}
private void Control_PointerEntered(object sender, PointerRoutedEventArgs e)
{
VisualStateManager.GoToState(this, "PointerOver", true);

View File

@@ -231,7 +231,25 @@ public sealed partial class ListPage : Page,
if (_scrollOnNextSelectionChange)
{
_scrollOnNextSelectionChange = false;
ScrollToItem(li);
var scrollTarget = li;
// If the previous item is a separator, also scroll it into view to provide
// better context for the user
var index = ItemView.Items.IndexOf(li);
if (index > 0)
{
var prevItem = ItemView.Items[index - 1] as ListItemViewModel;
if (prevItem?.Type == ListItemType.SectionHeader)
{
scrollTarget = prevItem;
}
}
if (scrollTarget is not null)
{
ItemView.ScrollIntoView(scrollTarget);
}
}
// Automation notification for screen readers
@@ -245,28 +263,6 @@ public sealed partial class ListPage : Page,
}
}
private void ScrollToItem(ListItemViewModel li)
{
var scrollTarget = li;
// If the previous item is a separator, also scroll it into view to provide
// better context for the user
var index = ItemView.Items.IndexOf(li);
if (index > 0)
{
var prevItem = ItemView.Items[index - 1] as ListItemViewModel;
if (prevItem?.Type == ListItemType.SectionHeader)
{
scrollTarget = prevItem;
}
}
if (scrollTarget is not null)
{
ItemView.ScrollIntoView(scrollTarget);
}
}
private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e)
{
if (e.OriginalSource is FrameworkElement element &&
@@ -721,8 +717,6 @@ public sealed partial class ListPage : Page,
{
using (SuppressSelectionChangedScope())
{
ListItemViewModel? stickyRestored = null;
if (!forceFirstItem &&
_stickySelectedItem is not null &&
items.Contains(_stickySelectedItem) &&
@@ -730,7 +724,6 @@ public sealed partial class ListPage : Page,
{
// Preserve sticky selection for nested dynamic updates.
ItemView.SelectedItem = _stickySelectedItem;
stickyRestored = _stickySelectedItem;
}
else
{
@@ -748,26 +741,7 @@ public sealed partial class ListPage : Page,
return;
}
if (stickyRestored is not null)
{
ScrollToItem(stickyRestored);
}
else
{
ResetScrollToTop();
}
});
}
}
else
{
// Selection is valid and unchanged, just make sure the item is visible
if (_stickySelectedItem is ListItemViewModel li)
{
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
{
ItemView.UpdateLayout();
ScrollToItem(li);
ResetScrollToTop();
});
}
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Input;
using Windows.System;
using Windows.UI.Core;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// Snapshot of the current keyboard modifier state (Ctrl, Alt, Shift, Win).
/// </summary>
internal readonly struct KeyModifiers
{
public bool Ctrl { get; }
public bool Alt { get; }
public bool Shift { get; }
public bool Win { get; }
private KeyModifiers(bool ctrl, bool alt, bool shift, bool win)
{
Ctrl = ctrl;
Alt = alt;
Shift = shift;
Win = win;
}
/// <summary>
/// Gets a snapshot of the modifier keys currently held down.
/// </summary>
public static KeyModifiers GetCurrent()
{
var ctrl = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var alt = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shift = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var win = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
return new KeyModifiers(ctrl, alt, shift, win);
}
public bool OnlyAlt => Alt && !Ctrl && !Shift && !Win;
public bool OnlyCtrl => Ctrl && !Alt && !Shift && !Win;
public bool None => !Ctrl && !Alt && !Shift && !Win;
}

Some files were not shown because too many files have changed in this diff Show More