Compare commits

..

11 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
64c6f3557e Add Group Policy to force-disable Python scripts feature
Enterprise administrators can now disable the Python scripts feature via
GPO policy 'AllowAdvancedPastePythonScripts' under the Advanced Paste
category (HKLM\SOFTWARE\Policies\PowerToys\AllowAdvancedPastePythonScripts).

When disabled:
- Settings UI mode selector is grayed out and forced to 'Disabled'
- Info bar shows that the setting is managed by organization policy
- Runtime enforces disabled state even if settings.json is manually edited

Implementation follows the same pattern as the existing AI models GPO policy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-03 14:25:53 +08:00
Muyuan Li (from Dev Box)
c73d51f804 Apply XAML styling fixes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 17:29:18 +08:00
Muyuan Li (from Dev Box)
20ea79af28 Address PR review round 4: stale UI, debounce, import validation, trust dialog
- Fix early return in RefreshWslDistros to still update with default-only list
- Store all PythonScriptActions (not just IsShown=true) so hide logic works
- Pass cancellation token to Task.Delay in debounce handler
- Use FileNotFoundException(message, fileName) constructor properly
- Display script SHA-256 hash in trust dialog for user verification
- Validate Python import names before embedding in shell commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:32:03 +08:00
Muyuan Li (from Dev Box)
ea63d6cd1d Address PR review round 3: error handling, resource cleanup, process lifecycle
- Fix PythonScriptNotFound to format {0} placeholder and use PasteActionException
- Dispose old CancellationTokenSource before creating new one in debounce handler
- Remove .Wait() on UI-scheduled task (fire-and-forget is sufficient)
- Add WaitForExit after Kill() in RefreshWslDistros to prevent ReadToEnd blocking
- Localize '(System default)' WSL distro display name via resource string
- Kill child Python/WSL process on user cancellation to prevent orphaned processes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:50:34 +08:00
Muyuan Li (from Dev Box)
dfb553dd20 Address PR review round 2: HTML format, presence-based tags, clipboard handling
- Fix TryParseTag to support presence-based @advancedpaste:disabled tag
- Change 'if not input_value' to 'if input_value is None' (empty string is valid)
- Wrap HTML output with HtmlFormatHelper.CreateHtmlFormat for CF_HTML compliance
- Add try-catch for FileNotFoundException during ComputeHash
- Change else-if to sequential ifs in DataPackageFromViewAsync (preserve all formats)
- Remove unused System.* PackageReferences from UITest project
- Fix '..' typo to '...' in search placeholder text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:27:54 +08:00
Muyuan Li (from Dev Box)
86ddc6b5d1 Address PR review comments: security, UX, and correctness fixes
- Fix ScriptsFolder to fall back to default folder when setting is blank
- Align Settings tag parsing with runtime: use @advancedpaste:disabled
- Remove misleading 'written back to headers' description string
- Add modifier release/restore to SendInput Ctrl+C path (matches Ctrl+V)
- Sanitize pip package names to prevent shell metacharacter injection
- Fix RefreshWslDistros to WaitForExit before ReadToEnd (prevents hang)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:01:18 +08:00
Muyuan Li (from Dev Box)
950ee6ae7d Remove unnecessary System.Text.Json PackageReference (NU1510 after rebase)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 17:16:16 +08:00
Muyuan Li (from Dev Box)
c3b87795b3 Address review round 3: remove PythonScript kernel function, fix files format matching
- Remove KernelFunctionDescription from PythonScript to prevent broken AI kernel registration
- Normalize file/files format names in _runner.py so files-input scripts match correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:28:41 +08:00
Muyuan Li (from Dev Box)
961ff9319c Address review round 2: serialization context, discovery alignment, remove unused file write
- Register Python script settings types in SettingsSerializationContext for AOT safety
- Align Settings discovery with runtime: scan entire file, reject multi-function scripts
- Remove unused ap_input.json file write on Windows execution path (stdin is used)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:16:36 +08:00
Muyuan Li (from Dev Box)
541eb8a440 Address review round 1: fix reverted behaviors, null guard, localization, docs
- Restore FuzzTests to use UTF-8 decoding and GetAwaiter().GetResult()
- Restore defensive try/catch in JsonHelper.ToJsonFromXmlOrCsvAsync
- Add null guard for ReadMetadata in PasteFormatExecutor
- Restore is_enabled_by_default() override in dllmain.cpp
- Localize hard-coded strings in AdvancedPastePage.xaml via x:Uid
- Fix _runner.py docstring to include audio/video input types
- Fix typos in UITestAdvancedPaste.md
- Filter Settings script list to match runtime discovery behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:01:02 +08:00
Muyuan Li (from Dev Box)
7a2b07a3c9 Advanced Paste: Add Python script extension support
Enable users to write custom clipboard transformation scripts in Python
that integrate directly into the Advanced Paste menu. Scripts are auto-
discovered from a configurable folder and can run on native Windows
Python or inside WSL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 15:42:18 +08:00
186 changed files with 7007 additions and 8835 deletions

View File

@@ -838,7 +838,6 @@ INTRESOURCE
INVALIDARG
invalidoperatioexception
invokecommand
iOS
ipcmanager
ipreviewhandlervisualssetfont
IPTC
@@ -1235,8 +1234,6 @@ NOTSRCCOPY
NOTSRCERASE
Notupdated
notwindows
NOTXORPEN
Nouveaut
nowarn
NOZORDER
NPH

View File

@@ -1,6 +1,6 @@
---
name: wpf-to-winui3-migration
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
license: Complete terms in LICENSE.txt
---

View File

@@ -211,12 +211,12 @@
"WinUI3Apps\\NewPlusPackage.msix",
"WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll",
"WinUI3Apps\\PowerAccent.Core.dll",
"WinUI3Apps\\PowerAccent.Common.dll",
"WinUI3Apps\\PowerToys.PowerAccent.dll",
"WinUI3Apps\\PowerToys.PowerAccent.exe",
"PowerAccent.Core.dll",
"PowerAccent.Common.dll",
"PowerToys.PowerAccent.dll",
"PowerToys.PowerAccent.exe",
"PowerToys.PowerAccentModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",

View File

@@ -26,7 +26,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<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" />
@@ -38,7 +38,7 @@
<PackageVersion Include="Mages" Version="3.0.0" />
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
<!-- 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.7" />
<PackageVersion Include="MessagePack" Version="3.1.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
@@ -64,7 +64,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
@@ -76,7 +76,7 @@
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
-->
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
@@ -151,4 +151,4 @@
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
</ItemGroup>
</Project>
</Project>

View File

@@ -806,10 +806,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

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

@@ -1,113 +0,0 @@
# Input Highlighter (dev notes)
> Working design doc for merging the keystroke overlay (team4 `KeystrokeOverlay`) into
> the existing **Mouse Highlighter** module to form a single utility, **Input Highlighter**.
> Status: in progress. See the session plan for phase/todo tracking.
## Summary
Input Highlighter is the existing Mouse Highlighter module extended to also visualize
keyboard input. It keeps Mouse Highlighter's internal identity for a frictionless upgrade
(see *Identity & migration*) and only changes the user-facing display name.
All rendering is native: **C++ + Windows.UI.Composition + Direct2D/DirectWrite**, in a
single in-process module. team4's *capture* and *display logic* are reused (ported to
C++); team4's WinUI UI, named-pipe IPC, and separate-process design are dropped.
## Identity & migration (Option A)
- Internal module key/GUID stays `MouseHighlighter`; DLL stays
`PowerToys.MouseHighlighter.dll`. Runner (`src/runner/main.cpp` known-DLL list), the GPO
key (`getConfiguredMouseHighlighterEnabledValue`), and the installer component are
therefore unchanged.
- Only display strings become "Input Highlighter" (`Resources.resw`, dashboard, OOBE,
`ModuleHelper`).
- Settings migrate in place via `MouseHighlighterSettings.UpgradeSettingsConfiguration()`
(`ISettingsConfig`) with a `Version` bump — same pattern as `ColorPickerSettings`.
New keystroke fields get defaults; existing mouse settings + enabled state are
preserved. Migrated users default to **mouse-only** (`show_keystrokes = false`); fresh
installs enable both.
## Architecture
Single native DLL, evolved from `src/modules/MouseUtils/MouseHighlighter`.
```
WH_MOUSE_LL hook ─┐ (existing, unchanged)
├─► Highlighter (Composition ShapeVisual) ─► ripples/spotlight
WH_KEYBOARD_LL hook ─┐ │
(new) │ │
▼ │
in-process SPSC queue │ (ported EventQueue.h — no named pipe)
│ │
▼ ▼
KeystrokeProcessor (pure C++) ── display-mode state machine
Keystroke renderer (Composition + D2D/DirectWrite key "pills")
```
### Threading
- The LL keyboard hook callback stays lean: format nothing, allocate nothing — just push
a POD `KeystrokeEvent` onto a lock-free SPSC ring (ported from team4 `EventQueue.h`).
- The Composition/dispatcher thread (the module already owns a `DispatcherQueue` — see
`Highlighter::CreateHighlighter`) drains the queue, runs `KeystrokeProcessor`, and
updates the visual tree.
### Rendering keystroke "pills" in Composition
Mouse Highlighter already builds a `Compositor` + `DesktopWindowTarget` + `ContainerVisual`
root with a `ShapeVisual` (`MouseHighlighter.cpp: CreateHighlighter`). Text/glyph content
is added as follows:
- Create a `CompositionGraphicsDevice` from the compositor
(`ICompositorInterop::CreateGraphicsDevice`) backed by a D3D/D2D device.
- Each key pill = a `SpriteVisual` whose brush is a `CompositionSurfaceBrush` over a
`CompositionDrawingSurface`. Draw with Direct2D: rounded-rect chrome + text/glyph via
DirectWrite (**Segoe Fluent Icons** for glyphs, matching team4's KeyVisual).
- Pills are arranged in a horizontal container; newest at one end. Per-position opacity
fade + expiry animations via Composition (`ScalarKeyFrameAnimation`), mirroring team4's
opacity ordering and `TimeoutMs` removal.
### Overlay behavior
- Reuse Mouse Highlighter's layered, click-through, no-activate overlay window
(`WS_EX_TRANSPARENT | WS_EX_NOACTIVATE | WS_EX_LAYERED`). This fixes team4's WinUI
click-interception problem for free.
- Positioning: corner + per-monitor (settings), with a monitor-switch hotkey. Dragging
is an **opt-in "move mode"** hotkey that temporarily clears `WS_EX_TRANSPARENT`, rather
than an always-draggable window (which would defeat click-through).
## Logic ported from team4 (reference: `origin/feature/kbhighlighter-team4`)
| team4 source | Ported to | Notes |
|---|---|---|
| `KeyboardService/KeyboardListener.cpp` | native capture in-module | `WH_KEYBOARD_LL`, `ToUnicodeEx`, modifier snapshot. Drop the pipe/batcher; push to in-process queue. |
| `KeyboardService/EventQueue.h` | in-process queue | lock-free SPSC ring; keep as-is. |
| `Controls/KeystrokeEvent.cs` | `KeystrokeFormatter` (C++) | `ToString()` (display string), `IsShortcut`, `IsCommandKey`, `GetKeyName`, `GetModifierSymbol`. Pure + unit-testable. |
| `Services/KeystrokeProcessor.cs` | `KeystrokeProcessor` (C++) | `Add`/`ReplaceLast`/`RemoveLast`/`None` + stream buffer/backspace. Pure + unit-testable. |
| `Controls/KeyVisual/KeyVisual.xaml.cs` | D2D glyph map | Segoe Fluent Icons: Enter `\uE751`, Back `\uE750`, Shift `\uE752`, arrows `\uE0E2..\uE0E5`. |
| `Models/Enums/DisplayMode.cs` | `DisplayMode` enum | Last5 / SingleCharactersOnly / ShortcutsOnly / Stream. |
## Settings (extends the `MouseHighlighter` blob)
Existing mouse props unchanged. New (namespaced `keystroke_*`) props:
`show_mouse`, `show_keystrokes`, `keystroke_display_mode`, `keystroke_timeout_ms`,
`keystroke_text_size`, `keystroke_text_color`, `keystroke_background_color`,
`keystroke_text_opacity`, `keystroke_bg_opacity`, `keystroke_position`,
`keystroke_switch_monitor_hotkey`, `keystroke_switch_display_mode_hotkey`.
Touched files: `MouseHighlighterProperties.cs`, `MouseHighlighterSettings.cs` (+Version,
+`UpgradeSettingsConfiguration`), `SndMouseHighlighterSettings.cs`,
`MouseHighlighterSettingsIPCMessage.cs`, `SettingsSerializationContext`, native
`MouseHighlighter.h` settings struct + `dllmain.cpp` parse/apply.
## Testing
- Unit tests for `KeystrokeFormatter` (glyph/shortcut/char formatting) and
`KeystrokeProcessor` (mode transitions, stream backspace, expiry ordering) — factor the
pure logic so it builds without Win32.
- **Fuzz test** for keystroke input parsing/formatting (required for a new user-input
surface per repo guidelines).
## Open risks
- Direct2D/DirectWrite text quality vs. the XAML KeyVisual look — **de-risk with a spike
before full parity** (render one pill on the existing overlay window).
- Segoe Fluent Icons availability/fallback across supported OS versions.
- Keeping the keyboard-hook hot path allocation-free while marshaling to the Composition
thread.

View File

@@ -15,15 +15,14 @@ Quick Accent (formerly known as Power Accent) is a PowerToys module that allows
## Architecture
The Quick Accent module consists of five projects:
The Quick Accent module consists of four main components:
```
poweraccent/
├── PowerAccent.Common/ # Language data, character mappings, LetterKey enum
├── PowerAccent.Core/ # Accent logic, settings, positioning, usage statistics
├── PowerAccent.UI/ # WinUI 3 character selector app (PowerToys.PowerAccent.exe)
── PowerAccentKeyboardService/ # WinRT keyboard-hook component
└── PowerAccentModuleInterface/ # Native runner module DLL
├── PowerAccent.Core/ # Core component containing Language Sets
├── PowerAccent.UI/ # The character selector UI
├── PowerAccentKeyboardService/ # Keyboard Hook
── PowerAccentModuleInterface/ # DLL interface
```
### Module Interface (PowerAccentModuleInterface)
@@ -33,32 +32,21 @@ The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, i
- Managing module lifecycle (enable/disable/settings)
- Launching and terminating the PowerToys.PowerAccent.exe process
### Shared Data (PowerAccent.Common)
`PowerAccent.Common` holds the UI- and runtime-agnostic data the other projects share:
- The language / character-set definitions and per-letter accent mappings
- The managed `LetterKey` enum (kept in sync with the WinRT `LetterKey` in `PowerAccentKeyboardService/KeyboardListener.idl`)
It has no UI or WinRT dependencies and is unit-tested in isolation (`PowerAccent.Common.UnitTests`).
### Core Logic (PowerAccent.Core)
The Core component contains:
- Main accent character logic, consuming the language data from `PowerAccent.Common`
- Toolbar positioning math (9 anchor points with per-monitor DPI) and settings handling
- Management of special characters (currency, math symbols, etc.) and usage statistics
Core carries no UI-framework dependency: it raises events and accepts a UI-thread marshaller delegate instead of touching WPF/WinUI directly, and its positioning math is covered by `PowerAccent.Core.UnitTests`.
- Main accent character logic
- Keyboard input detection
- Character mappings for different languages
- Management of language sets and special characters (currency, math symbols, etc.)
- Usage statistics for frequently used characters
### UI Layer (PowerAccent.UI)
The UI component is a self-contained **WinUI 3 (Windows App SDK)** app, migrated from WPF.
It is responsible for:
- Displaying the accent toolbar — a non-activating, always-on-top `TransparentWindow` overlay shown with `SW_SHOWNA` so it never steals focus from the app being typed into
- Handling selection and the toolbar's sizing / positioning
- Following the system theme while the long-lived process runs
It builds to `PowerToys.PowerAccent.exe` together with its `.pri` and the bundled Windows App SDK runtime, all under the `WinUI3Apps` output folder.
The UI component is responsible for:
- Displaying the toolbar with accent options
- Handling user selection of accented characters
- Managing the visual positioning of the toolbar
### Keyboard Service (PowerAccentKeyboardService)
@@ -71,26 +59,13 @@ This component:
### Activation Mechanism
Quick Accent supports two activation styles, selected by the **Activation key** setting.
**Trigger-key modes** (`Left/Right arrow`, `Space`, or `Both` — the default):
The Quick Accent is activated when:
1. A user presses and holds a character key (e.g., 'a')
2. User presses the trigger key
3. After a brief delay (around 300ms per setting), the accent toolbar appears
4. The user can select an accented variant using the trigger key
5. Upon releasing the keys, the selected accented character is inserted
**Press-and-hold mode** (`Press and hold the letter`, iOS/macOS style, opt-in):
1. A user presses and holds an accent-capable character key (e.g., 'a'); the base
letter is typed immediately
2. After the configured **Hold duration** (around 500ms per setting), the accent
toolbar appears automatically — no separate trigger key is required
3. The user navigates the options with the arrow keys or Space
4. Upon releasing the letter, the selected accent replaces the base letter; if no
option was selected, the base letter that was already typed simply remains
5. A quick tap (shorter than the Hold duration) types the base letter only, and
modifier combinations (Ctrl/Alt/AltGr/Win + letter) are left untouched
### Character Sets
The module includes multiple language-specific character sets and special character sets:
@@ -140,5 +115,5 @@ To directly debug the Quick Accent UI component:
5. Start debugging by pressing `F5` or clicking the "*Start*" button
6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code
**Known issue**: A first incremental build can surface transient errors (for example from CsWinRT projection / WinUI XAML codegen ordering).<br>
**Solution**: Right-click the **PowerAccent** folder in Solution Explorer and select "*Rebuild*", then start debugging again.
**Known issue**: You may encounter approximately 78 errors during the start of debugging.<br>
**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again.

View File

@@ -0,0 +1,5 @@
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
This tool is currently very outdated and just cleans up the registry keys of some few modules.

View File

@@ -10,6 +10,7 @@ Following tools are currently available:
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.

View File

@@ -109,7 +109,7 @@ Per Application/Package one or more Keyboard manifests can be declared. Every ma
<details>
<summary><b>SectionName</b> - Name of the category of shortcuts</summary>
Name of the section of shortcuts. Use sentence case, the same convention described under `Name` below.
Name of the section of shortcuts.
**Special sections**:
@@ -126,10 +126,6 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
Name of the shortcut. This is the name that will be displayed in the interpreter.
**Casing**:
By convention, shortcut names (and `SectionName` values) use **sentence case**: capitalize only the first word plus any proper nouns or product/feature names. For example, prefer `Reopen last closed tab` over `Reopen Last Closed Tab`, but keep `Open History`, `Quit Slack`, and `Show Quick Access` capitalized because those are application feature names. Match the casing the application uses for its own features rather than copying the title-case styling some apps apply to their entire shortcut list.
</details>

View File

@@ -54,7 +54,6 @@ Contact the developers of a plugin directly for assistance with a specific plugi
| [Project Launcher Plugin](https://github.com/artickc/ProjectLauncherPowerToysPlugin) | [artickc](https://github.com/artickc) | Access your projects using Project Launcher and PowerToys Run |
| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. |
| [QuickAI](https://github.com/ruslanlap/PowerToysRun-QuickAi) | [ruslanlap](https://github.com/ruslanlap) | AI-powered assistance with instant, smart responses from multiple providers (Groq, Together, Fireworks, OpenRouter, Cohere) |
| [Launchy](https://github.com/PsychodelEKS/PowerToysRun-Launchy) | [PsychodelEKS](https://github.com/PsychodelEKS) | Index and launch files from configured folders |
## Extending software plugins

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>

View File

@@ -5,8 +5,6 @@
using System.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
using WinUIEx;
@@ -39,18 +37,6 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
/// content has finished animating out. With no listener the window simply shows
/// or hides immediately.</para>
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
/// may host on the same window by each calling
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
/// window is hidden only after <em>all</em> surfaces have finished animating
/// out. To let each surface play its own distinct transition, call the
/// parameterless <see cref="Show()"/> (so every surface uses its configured
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
/// overload instead broadcasts a single transition to all surfaces. Sizing the
/// window and positioning each surface within it remain the consumer's
/// responsibility (this window owns no layout).</para>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
@@ -66,9 +52,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
private readonly nint _hwnd;
private bool _inputHooked;
private bool _seenActivated;
public TransparentWindow()
{
AppWindow.Hide();
@@ -91,30 +74,8 @@ public partial class TransparentWindow : WinUIEx.WindowEx
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
Activated += OnActivatedForDismiss;
}
/// <summary>
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
/// Defaults to <see langword="false"/>. The window is shown without
/// activation, so the consumer must activate it for its content to receive
/// keyboard input.
/// </summary>
public bool DismissOnEscape { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the window dismisses itself
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
/// window has been activated at least once since the last <see cref="Show()"/>,
/// so the transient deactivation that can occur during the show sequence does
/// not dismiss it prematurely. The window is shown without activation, so the
/// consumer must activate it for this to apply.
/// </summary>
public bool DismissOnFocusLost { get; set; }
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
@@ -151,8 +112,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
DispatcherQueuePriority.Low,
() =>
{
_seenActivated = false;
EnsureInputHooks();
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
@@ -175,41 +134,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
});
}
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
{
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
if (DismissOnFocusLost && _seenActivated)
{
Hide();
}
return;
}
_seenActivated = true;
}
private void EnsureInputHooks()
{
if (_inputHooked || Content is not UIElement element)
{
return;
}
element.KeyDown += OnContentKeyDown;
_inputHooked = true;
}
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
{
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
{
e.Handled = true;
Hide();
}
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)

View File

@@ -232,6 +232,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue());
}
GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPastePythonScriptsValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPastePythonScriptsValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusEnabledValue());

View File

@@ -64,6 +64,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();

View File

@@ -68,6 +68,7 @@ namespace PowerToys
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();

View File

@@ -93,6 +93,7 @@ namespace powertoys_gpo
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle";
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama";
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal";
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS = L"AllowAdvancedPastePythonScripts";
const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled";
const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled";
const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface";
@@ -637,6 +638,11 @@ namespace powertoys_gpo
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL);
}
inline gpo_rule_configured_t getAllowedAdvancedPastePythonScriptsValue()
{
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS);
}
inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue()
{
return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED);

View File

@@ -718,6 +718,16 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="AllowAdvancedPastePythonScripts" class="Both" displayName="$(string.AllowAdvancedPastePythonScripts)" explainText="$(string.AllowAdvancedPastePythonScriptsDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPastePythonScripts">
<parentCategory ref="AdvancedPaste" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_99_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy name="MwbClipboardSharingEnabled" class="Both" displayName="$(string.MwbClipboardSharingEnabled)" explainText="$(string.MwbClipboardSharingEnabledDescription)" key="Software\Policies\PowerToys" valueName="MwbClipboardSharingEnabled">
<parentCategory ref="MouseWithoutBorders" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_83_0" />

View File

@@ -345,6 +345,12 @@ If you disable this policy, users will not be able to select or use Ollama endpo
If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider.
If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings.</string>
<string id="AllowAdvancedPastePythonScripts">Advanced Paste: Allow Python scripts</string>
<string id="AllowAdvancedPastePythonScriptsDescription">This policy controls whether users can enable and execute Python scripts in Advanced Paste.
If you enable or don't configure this policy, users can enable the Python scripts feature (Disabled/Windows/WSL modes) and run custom Python scripts for clipboard transformations.
If you disable this policy, the Python scripts mode selector will be forced to Disabled and users will not be able to enable or run Python scripts through Advanced Paste.</string>
<string id="MwbClipboardSharingEnabled">Clipboard sharing enabled</string>
<string id="MwbFileTransferEnabled">File transfer enabled</string>
<string id="MwbUseOriginalUserInterface">Original user interface is available</string>

View File

@@ -47,8 +47,6 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public bool ShowCustomPreview => false;
public bool ShowAIPaste => true;
public bool CloseAfterLosingFocus => false;
public bool EnableClipboardPreview => true;
@@ -59,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)
@@ -67,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

@@ -153,5 +153,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

@@ -70,12 +70,12 @@
Spacing="2">
<TextBlock
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind Header, Mode=OneTime}"
Text="{x:Bind Header, Mode=OneWay}"
TextWrapping="NoWrap" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneTime}" />
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneWay}" />
</StackPanel>
</Grid>
</UserControl>

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

@@ -29,31 +29,31 @@
Padding="-9,0,0,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneTime}"
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneWay}"
AutomationProperties.AutomationControlType="ListItem"
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneTime}"
AutomationProperties.HelpText="{x:Bind Name, Mode=OneTime}"
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneTime}">
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneWay}"
AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}"
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ToolTipService.ToolTip>
<TextBlock Text="{x:Bind ToolTip, Mode=OneTime}" />
<TextBlock Text="{x:Bind ToolTip, Mode=OneWay}" />
</ToolTipService.ToolTip>
<FontIcon
Margin="0,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name, Mode=OneTime}" />
Text="{x:Bind Name, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Margin="0,0,8,0"
@@ -61,7 +61,7 @@
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ShortcutText, Mode=OneTime}" />
Text="{x:Bind ShortcutText, Mode=OneWay}" />
</Grid>
</DataTemplate>
</controls:PasteFormatTemplateSelector.ItemTemplate>
@@ -83,13 +83,13 @@
Margin="0,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name, Mode=OneTime}" />
Text="{x:Bind Name, Mode=OneWay}" />
</Grid>
</DataTemplate>
</controls:PasteFormatTemplateSelector.ItemTemplateDisabled>
@@ -144,6 +144,7 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -198,7 +199,7 @@
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="local:ClipboardItem">
<ItemContainer
AutomationProperties.Name="{x:Bind Description, Mode=OneTime}"
AutomationProperties.Name="{x:Bind Description, Mode=OneWay}"
CornerRadius="16"
ToolTipService.ToolTip="{x:Bind Content}">
<Grid
@@ -250,11 +251,10 @@
Grid.Row="1"
Margin="20,0,20,0"
x:FieldModifier="public"
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
TabIndex="0"
Visibility="{x:Bind ViewModel.ShowAIPasteSection, Converter={StaticResource BoolToVisibilityConverter}, 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"
@@ -300,19 +300,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}"
MaxLines="2"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<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}"
@@ -342,6 +393,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

@@ -17,8 +17,6 @@ namespace AdvancedPaste.Settings
public bool ShowCustomPreview { get; }
public bool ShowAIPaste { get; }
public bool CloseAfterLosingFocus { get; }
public bool EnableClipboardPreview { get; }
@@ -29,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;
@@ -38,8 +43,6 @@ namespace AdvancedPaste.Settings
public bool ShowCustomPreview { get; private set; }
public bool ShowAIPaste { get; private set; }
public bool CloseAfterLosingFocus { get; private set; }
public bool EnableClipboardPreview { get; private set; }
@@ -50,18 +53,39 @@ 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);
IsAIEnabled = false;
ShowCustomPreview = true;
ShowAIPaste = true;
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();
@@ -69,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)
@@ -112,7 +144,6 @@ namespace AdvancedPaste.Settings
IsAIEnabled = properties.IsAIEnabled;
ShowCustomPreview = properties.ShowCustomPreview;
ShowAIPaste = properties.ShowAIPaste;
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
EnableClipboardPreview = properties.EnableClipboardPreview;
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
@@ -135,6 +166,49 @@ 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";
// Enforce GPO: if Python scripts are disallowed by policy, force disabled.
if (PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPastePythonScriptsValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
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);
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -299,6 +373,103 @@ 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?.Dispose();
_scriptFolderDebounce = new CancellationTokenSource();
var token = _scriptFolderDebounce.Token;
Task.Delay(TimeSpan.FromMilliseconds(500), token)
.ContinueWith(
_ =>
{
Task.Factory
.StartNew(
() => Changed?.Invoke(this, EventArgs.Empty),
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler);
},
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))
@@ -391,6 +562,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,12 @@ 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)]
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,111 @@ 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))
{
string hash;
try
{
hash = _pythonScriptTrustService.ComputeHash(scriptPath);
}
catch (System.IO.FileNotFoundException)
{
throw new PasteActionException(
string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptNotFound"), scriptPath),
new System.IO.FileNotFoundException(null, 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);
if (metadata is null)
{
throw new InvalidOperationException($"Script '{scriptPath}' does not define a valid advanced_paste_from_*_to_*() function.");
}
// 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());
}
if (view.Contains(StandardDataFormats.Html))
{
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
}
if (view.Contains(StandardDataFormats.StorageItems))
{
var items = await view.GetStorageItemsAsync();
pkg.SetStorageItems(items);
}
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,127 @@
// 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,
hash),
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,255 @@
# 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, audio, video, 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]
# Normalize: treat "file" and "files" as equivalent so that
# advanced_paste_from_files_to_* scripts match the C# ClipboardFormat.File flag.
normalized_formats = set(formats)
if "file" in normalized_formats:
normalized_formats.add("files")
if "files" in normalized_formats:
normalized_formats.add("file")
if input_type not in normalized_formats:
print(
f"Error: script expects '{input_type}' input but clipboard has [{', '.join(formats)}].",
file=sys.stderr,
)
sys.exit(1)
if input_value is None:
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,75 @@
<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}
SHA256: {1}</value>
<comment>{0} is the script file path. {1} is the SHA-256 hash of the script. Do not translate {0} or {1}.</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
@@ -234,8 +238,6 @@ namespace AdvancedPaste.ViewModels
public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled;
public bool ShowAIPasteSection => _userSettings.ShowAIPaste && IsAllowedByGPO;
public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress);
private PasteFormats CustomAIFormat =>
@@ -260,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) =>
@@ -322,7 +325,6 @@ namespace AdvancedPaste.ViewModels
OnPropertyChanged(nameof(AIProviders));
OnPropertyChanged(nameof(AllowedAIProviders));
OnPropertyChanged(nameof(ShowClipboardPreview));
OnPropertyChanged(nameof(ShowAIPasteSection));
NotifyActiveProviderChanged();
@@ -416,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()
@@ -695,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
{
@@ -735,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

@@ -496,119 +496,23 @@ private:
if (!GetGUIThreadInfo(0, &gui_info))
{
Logger::warn(L"Auto-copy: GetGUIThreadInfo failed (error={})", GetLastError());
return false;
}
HWND target = gui_info.hwndFocus ? gui_info.hwndFocus : gui_info.hwndActive;
if (!target)
{
Logger::warn(L"Auto-copy: no focused or active window found");
return false;
}
DWORD_PTR result = 0;
auto sendResult = SendMessageTimeout(target, WM_COPY, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result);
return sendResult != 0;
}
// Helper: poll clipboard sequence number for a change from initial_sequence.
// Returns true if the sequence number changed within the given number of polls.
bool poll_clipboard_sequence(DWORD initial_sequence, int poll_attempts, std::chrono::milliseconds poll_delay)
{
for (int poll = 0; poll < poll_attempts; ++poll)
{
if (GetClipboardSequenceNumber() != initial_sequence)
{
return true;
}
std::this_thread::sleep_for(poll_delay);
}
return false;
}
// Helper: send Ctrl+C via SendInput, releasing any held modifier keys first
// (the hotkey combination may still have modifiers physically pressed).
bool send_ctrl_c_input()
{
std::vector<INPUT> inputs;
// Release all modifier keys that are currently held down from the hotkey.
// Without this, the target app sees e.g. Win+Shift+Ctrl+C instead of just Ctrl+C.
try_inject_modifier_key_up(inputs, VK_LCONTROL);
try_inject_modifier_key_up(inputs, VK_RCONTROL);
try_inject_modifier_key_up(inputs, VK_LWIN);
try_inject_modifier_key_up(inputs, VK_RWIN);
try_inject_modifier_key_up(inputs, VK_LSHIFT);
try_inject_modifier_key_up(inputs, VK_RSHIFT);
try_inject_modifier_key_up(inputs, VK_LMENU);
try_inject_modifier_key_up(inputs, VK_RMENU);
// Ctrl down
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// C down
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// C up
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Ctrl up
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Restore modifiers that were held down
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
try_inject_modifier_key_restore(inputs, VK_LWIN);
try_inject_modifier_key_restore(inputs, VK_RWIN);
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
try_inject_modifier_key_restore(inputs, VK_LMENU);
try_inject_modifier_key_restore(inputs, VK_RMENU);
// Prevent Start Menu from activating after Win key release/restore
INPUT dummyEvent = {};
dummyEvent.type = INPUT_KEYBOARD;
dummyEvent.ki.wVk = 0xFF;
dummyEvent.ki.dwFlags = KEYEVENTF_KEYUP;
inputs.push_back(dummyEvent);
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
if (uSent != inputs.size())
{
DWORD errorCode = GetLastError();
auto errorMessage = get_last_error_message(errorCode);
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
return false;
}
return true;
return SendMessageTimeout(target,
WM_COPY,
0,
0,
SMTO_ABORTIFHUNG | SMTO_BLOCK,
50,
&result) != 0;
}
bool send_copy_selection()
@@ -622,30 +526,99 @@ private:
for (int attempt = 0; attempt < copy_attempts; ++attempt)
{
const auto initial_sequence = GetClipboardSequenceNumber();
copy_succeeded = try_send_copy_message();
// Strategy 1: Try WM_COPY message (works for standard Win32 controls)
bool wm_copy_sent = try_send_copy_message();
if (wm_copy_sent)
if (!copy_succeeded)
{
if (poll_clipboard_sequence(initial_sequence, clipboard_poll_attempts, clipboard_poll_delay))
std::vector<INPUT> inputs;
// Release any held modifiers (from the Advanced Paste hotkey) before sending Ctrl+C.
// Without this, apps may receive Win+Shift+Ctrl+C instead of Ctrl+C.
try_inject_modifier_key_up(inputs, VK_LCONTROL);
try_inject_modifier_key_up(inputs, VK_RCONTROL);
try_inject_modifier_key_up(inputs, VK_LWIN);
try_inject_modifier_key_up(inputs, VK_RWIN);
try_inject_modifier_key_up(inputs, VK_LSHIFT);
try_inject_modifier_key_up(inputs, VK_RSHIFT);
try_inject_modifier_key_up(inputs, VK_LMENU);
try_inject_modifier_key_up(inputs, VK_RMENU);
// send Ctrl+C (key downs and key ups)
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
// Avoid triggering detection by the centralized keyboard hook.
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
// Avoid triggering detection by the centralized keyboard hook.
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Restore modifiers that were released above.
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
try_inject_modifier_key_restore(inputs, VK_LWIN);
try_inject_modifier_key_restore(inputs, VK_RWIN);
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
try_inject_modifier_key_restore(inputs, VK_LMENU);
try_inject_modifier_key_restore(inputs, VK_RMENU);
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
if (uSent != inputs.size())
{
DWORD errorCode = GetLastError();
auto errorMessage = get_last_error_message(errorCode);
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
}
else
{
copy_succeeded = true;
}
}
// Strategy 2: If WM_COPY didn't work, try SendInput Ctrl+C (works for Electron, browsers, etc.)
if (!copy_succeeded)
if (copy_succeeded)
{
const auto sequence_before_ctrl_c = GetClipboardSequenceNumber();
if (send_ctrl_c_input())
bool sequence_changed = false;
for (int poll_attempt = 0; poll_attempt < clipboard_poll_attempts; ++poll_attempt)
{
if (poll_clipboard_sequence(sequence_before_ctrl_c, clipboard_poll_attempts, clipboard_poll_delay))
if (GetClipboardSequenceNumber() != initial_sequence)
{
copy_succeeded = true;
sequence_changed = true;
break;
}
std::this_thread::sleep_for(clipboard_poll_delay);
}
copy_succeeded = sequence_changed;
}
if (copy_succeeded)
@@ -659,11 +632,6 @@ private:
}
}
if (!copy_succeeded)
{
Logger::warn(L"Auto-copy: all {} copy attempts failed — the target application did not update the clipboard after WM_COPY and Ctrl+C", copy_attempts);
}
return copy_succeeded;
}
@@ -1036,7 +1004,6 @@ public:
{
if (!send_copy_selection())
{
Logger::warn(L"Auto-copy: failed to copy selection for custom action index {} — aborting action", custom_action_index);
return false;
}
}

View File

@@ -1 +1 @@
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"ShowAIPaste":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}

View File

@@ -32,17 +32,6 @@ namespace EnvironmentVariables
var loader = ResourceLoaderInstance.ResourceLoader;
var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
// The WinUI TitleBar control reads the owning window's title (AppWindow.Title) during a
// deferred layout pass. If the native window title is empty at that instant, the windowing
// layer can fault while resolving it and terminate the process. ResourceLoader.GetString
// returns an empty string when the resource map can't be resolved at runtime, which would
// leave the title empty here, so fall back to a non-empty product name to keep the native
// window title populated.
if (string.IsNullOrEmpty(title))
{
title = "Environment Variables";
}
Title = title;
titleBar.Title = title;

View File

@@ -25,15 +25,6 @@ namespace FileLocksmithUI
var loader = ResourceLoaderInstance.ResourceLoader;
var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle");
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "File Locksmith";
}
Title = title;
titleBar.Title = title;
}

View File

@@ -38,7 +38,6 @@ static GrabAndMoveModifier g_modifierKey = GrabAndMoveModifier::Alt;
static bool g_altPressed = false;
static bool g_winPressed = false; // true while LWIN or RWIN is held (Win modifier mode)
static bool g_ignoreModifier = false; // true if the user pressed the modifier then clicked the mouse without dragging
static bool g_winAbsorbed = false; // true if we absorbed a Win keydown
static bool g_dragging = false;
static bool g_dragFirstMove = false; // true until first WM_MOUSEMOVE of a drag
@@ -117,30 +116,6 @@ static POINT g_resizeLast = {}; // cursor pos from previous frame
static RECT g_resizeWndRect = {}; // current window rect (updated each frame)
static ResizeHandle g_currentHandle = RESIZE_NONE;
// Deferred activation state: a modifier+button press is held "pending" until the
// cursor moves past the drag threshold (promoting it to a real drag/resize) or the
// button is released first (replaying a normal modifier+click to the target).
static bool g_pendingDrag = false;
static bool g_pendingResize = false;
static HWND g_pendingTarget = nullptr;
static POINT g_pendingDownPt = {};
// Set when a pending press was replayed to the target on modifier release while the
// physical button was still down; the matching physical button-up is then swallowed.
static bool g_swallowNextLButtonUp = false;
static bool g_swallowNextRButtonUp = false;
// Set once ReplayPendingClick has replayed the absorbed modifier key-down for a pending
// click. The absorbed-modifier flags stay set so the keyboard hook still forwards the
// real modifier key-up; this guard stops the key-down from being replayed a second time.
static bool g_pendingModifierReplayed = false;
// Set once a drag or resize has actually started during the current modifier hold.
// While set, subsequent modifier+button presses skip the deferred/pending phase and
// begin the drag/resize immediately - the user has already committed to Grab and Move.
// Cleared when the modifier key is released.
static bool g_activatedDuringHold = false;
static const int MIN_WINDOW_WIDTH = 150;
static const int MIN_WINDOW_HEIGHT = 50;
@@ -250,14 +225,6 @@ static void CALLBACK WinEventProc(HWINEVENTHOOK, DWORD, HWND hwnd, LONG, LONG, D
g_winAbsorbed = false;
g_altAbsorbed = false;
g_dragConsumedAlt = false;
g_ignoreModifier = false;
g_activatedDuringHold = false;
g_pendingDrag = false;
g_pendingResize = false;
g_pendingTarget = nullptr;
g_swallowNextLButtonUp = false;
g_swallowNextRButtonUp = false;
g_pendingModifierReplayed = false;
EndInteraction(true, true);
}
}
@@ -275,10 +242,6 @@ static bool IsSuppressedByGameMode()
static bool IsActivationModifierPressed()
{
if (g_ignoreModifier)
{
return false;
}
if (g_modifierKey == GrabAndMoveModifier::Win)
return g_winPressed;
return g_altPressed;
@@ -817,85 +780,6 @@ static void ReplayAbsorbedModifier(bool alsoKeyUp)
SendInput(alsoKeyUp ? 2 : 1, inputs, sizeof(INPUT));
}
// ---------------------------------------------------------------------------
// Deferred activation helpers.
// A modifier+button press does not start an interaction immediately; it is held
// "pending" until the cursor moves past the drag threshold (then it becomes a
// real drag/resize) or the button is released first (then the absorbed input is
// replayed so the target application receives a normal modifier+click).
// ---------------------------------------------------------------------------
static void BeginDrag(HWND hwnd, POINT pt)
{
g_dragging = true;
g_dragFirstMove = true;
g_dragConsumedAlt = true;
g_activatedDuringHold = true;
g_dragTarget = hwnd;
g_dragStart = pt;
GetWindowRect(hwnd, &g_dragWndRect);
PrepareOverlayMetrics(hwnd);
ShowOverlay(g_dragWndRect, g_curSizeAll);
TraceShortcutUse(true, GrabAndMoveShortcutAction::Move, L"started");
}
static void BeginResize(HWND hwnd, POINT pt)
{
g_resizing = true;
g_resizeFirstMove = true;
g_dragConsumedAlt = true;
g_activatedDuringHold = true;
g_resizeTarget = hwnd;
g_resizeLast = pt;
GetWindowRect(hwnd, &g_resizeWndRect);
PrepareOverlayMetrics(hwnd);
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
TraceShortcutUse(true, GrabAndMoveShortcutAction::Resize, L"started");
}
// Replays a modifier+click to the target: first the absorbed modifier key-down
// (if it was swallowed), then the button click. The modifier key-up is not
// synthesized here - the real key-up still reaches the target when the user
// releases the modifier. The absorbed-modifier flags are intentionally left set so
// the keyboard hook forwards that real key-up; g_pendingModifierReplayed guards
// against replaying the key-down more than once (e.g. across repeated clicks).
static void ReplayPendingClick(bool isRight)
{
if ((g_altAbsorbed || g_winAbsorbed) && !g_pendingModifierReplayed)
{
g_pendingModifierReplayed = true;
ReplayAbsorbedModifier(false); // modifier down only
}
INPUT inputs[2] = {};
inputs[0].type = INPUT_MOUSE;
inputs[0].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_LEFTDOWN;
inputs[1].type = INPUT_MOUSE;
inputs[1].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_LEFTUP;
SendInput(2, inputs, sizeof(INPUT));
}
// Called when the modifier is released while a press is still pending (button
// held, no move yet). Delivers the click to the target now and swallows the
// matching physical button-up that will arrive later.
static void FlushPendingClickOnModifierRelease()
{
if (g_pendingDrag)
{
g_pendingDrag = false;
g_pendingTarget = nullptr;
ReplayPendingClick(false);
g_swallowNextLButtonUp = true;
}
else if (g_pendingResize)
{
g_pendingResize = false;
g_pendingTarget = nullptr;
ReplayPendingClick(true);
g_swallowNextRButtonUp = true;
}
}
// ---------------------------------------------------------------------------
// System tray helpers
// ---------------------------------------------------------------------------
@@ -1116,7 +1000,6 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
g_winAbsorbed = true;
g_dragConsumedAlt = false;
g_pendingModifierReplayed = false;
g_absorbedVk = kb->vkCode;
g_absorbedScanCode = kb->scanCode;
g_absorbedFlags = kb->flags;
@@ -1126,10 +1009,6 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
{
g_winPressed = false;
g_ignoreModifier = false;
g_activatedDuringHold = false;
// If a press was still pending (button held, no move), deliver it now.
FlushPendingClickOnModifierRelease();
bool wasDragging = g_dragging;
bool wasResizing = g_resizing;
EndInteraction(true, true);
@@ -1142,18 +1021,9 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
g_dragConsumedAlt = false;
return 1;
}
if (g_pendingModifierReplayed)
{
// The key-down was already replayed for a pending click; let the
// real key-up reach the target (falls through to goto forward).
g_pendingModifierReplayed = false;
}
else
{
// No drag happened; replay the keydown, THEN the keyup
ReplayAbsorbedModifier(true);
return 1; // swallow this keyup since the replay already sent one
}
// No drag happened; replay the keydown, THEN the keyup
ReplayAbsorbedModifier(true);
return 1; // swallow this keyup since the replay already sent one
}
}
goto forward; // let Win keyup pass through
@@ -1187,7 +1057,6 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
g_altAbsorbed = true;
g_dragConsumedAlt = false;
g_pendingModifierReplayed = false;
g_absorbedVk = kb->vkCode;
g_absorbedScanCode = kb->scanCode;
g_absorbedFlags = kb->flags;
@@ -1198,10 +1067,6 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
{
g_altPressed = false;
g_ignoreModifier = false;
g_activatedDuringHold = false;
// If a press was still pending (button held, no move), deliver it now.
FlushPendingClickOnModifierRelease();
bool wasDragging = g_dragging;
bool wasResizing = g_resizing;
EndInteraction(true, true);
@@ -1214,24 +1079,15 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
g_dragConsumedAlt = false;
return 1;
}
if (g_pendingModifierReplayed)
{
// The key-down was already replayed for a pending click; let
// the real key-up reach the target.
g_pendingModifierReplayed = false;
}
else
{
// No drag happened; replay the keydown, then let keyup through
ReplayAbsorbedModifier(false);
}
// No drag happened; replay the keydown, then let keyup through
ReplayAbsorbedModifier(false);
}
}
}
else
{
// Non-Alt key while Alt was absorbed without a drag: replay Alt first
if (g_altAbsorbed && !g_dragConsumedAlt && !g_pendingModifierReplayed)
if (g_altAbsorbed && !g_dragConsumedAlt)
{
g_altAbsorbed = false;
g_altPressed = false;
@@ -1241,7 +1097,7 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
}
// Non-Win key while Win was absorbed without a drag: replay Win first
if (g_winAbsorbed && !g_dragConsumedAlt && !isWinKey && !g_pendingModifierReplayed)
if (g_winAbsorbed && !g_dragConsumedAlt && !isWinKey)
{
g_winAbsorbed = false;
g_winPressed = false;
@@ -1343,26 +1199,9 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
if (ms->flags & LLMHF_INJECTED)
goto forward;
// Deliver-as-click bookkeeping: swallow the physical button-up that matches a
// press we already replayed to the target on modifier release.
if (wParam == WM_LBUTTONUP && g_swallowNextLButtonUp)
{
g_swallowNextLButtonUp = false;
return 1;
}
if (wParam == WM_RBUTTONUP && g_swallowNextRButtonUp)
{
g_swallowNextRButtonUp = false;
return 1;
}
// Recovery path: if a non-modifier click occurs while stale drag/resize/pending state exists, clear it.
// Recovery path: if a non-modifier click occurs while stale drag/resize state exists, clear it.
if ((wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN) && !IsActivationModifierPressed())
{
g_pendingDrag = false;
g_pendingResize = false;
g_pendingTarget = nullptr;
g_pendingModifierReplayed = false;
EndInteraction(true, true);
}
@@ -1371,7 +1210,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
HideOverlay();
}
// ----- Modifier + Left Button Down: arm a pending drag (deferred until move) -----
// ----- Alt + Left Button Down: start drag -----
if (wParam == WM_LBUTTONDOWN && IsActivationModifierPressed())
{
if (IsSuppressedByGameMode())
@@ -1389,60 +1228,23 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
goto forward;
}
// Already committed to Grab and Move this hold: start dragging immediately.
if (g_activatedDuringHold)
{
BeginDrag(hwnd, pt);
return 1; // swallow the press; the drag is now active
}
g_dragging = true;
g_dragFirstMove = true;
g_dragConsumedAlt = true;
g_dragTarget = hwnd;
g_dragStart = pt;
GetWindowRect(hwnd, &g_dragWndRect);
PrepareOverlayMetrics(hwnd);
// A press is already pending (the other button is still held): don't
// overwrite it. Forward this one so we never strand the already-swallowed
// button-down (which would leak an unmatched button-up to the target).
if (g_pendingDrag || g_pendingResize)
{
goto forward;
}
// Show the semi-transparent overlay on top of the target (persistent window fix #9)
ShowOverlay(g_dragWndRect, g_curSizeAll);
// Defer: absorb the button-down but do nothing until the cursor moves.
g_pendingDrag = true;
g_pendingResize = false;
g_pendingTarget = hwnd;
g_pendingDownPt = pt;
return 1; // swallow the press for now; replayed on release if no move
// Swallow the click so the target window doesn't get it
TraceShortcutUse(true, GrabAndMoveShortcutAction::Move, L"started");
return 1;
}
}
// ----- Promote a pending press to a real drag/resize once past the threshold -----
if (wParam == WM_MOUSEMOVE && (g_pendingDrag || g_pendingResize))
{
int ddx = ms->pt.x - g_pendingDownPt.x;
int ddy = ms->pt.y - g_pendingDownPt.y;
if (ddx < 0) ddx = -ddx;
if (ddy < 0) ddy = -ddy;
if (ddx >= GetSystemMetrics(SM_CXDRAG) || ddy >= GetSystemMetrics(SM_CYDRAG))
{
bool wasResize = g_pendingResize;
HWND target = g_pendingTarget;
POINT downPt = g_pendingDownPt;
g_pendingDrag = false;
g_pendingResize = false;
g_pendingTarget = nullptr;
if (wasResize)
{
BeginResize(target, downPt);
HandleDragResize(ms->pt);
}
else
{
BeginDrag(target, downPt);
HandleDragMove(ms->pt);
}
}
return 0; // keep the cursor moving while waiting for the threshold
}
// ----- Mouse Move while dragging -----
if (wParam == WM_MOUSEMOVE && g_dragging && g_dragTarget)
{
@@ -1455,16 +1257,6 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
return 0;
}
// ----- Left Button Up while a drag is pending (no move): deliver the click -----
if (wParam == WM_LBUTTONUP && g_pendingDrag)
{
g_ignoreModifier = true;
g_pendingDrag = false;
g_pendingTarget = nullptr;
ReplayPendingClick(false);
return 1; // swallow the physical up; the injected click replaces it
}
// ----- Left Button Up: end drag -----
if (wParam == WM_LBUTTONUP && g_dragging)
{
@@ -1481,7 +1273,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
return 1; // swallow the release
}
// ----- Modifier + Right Button Down: arm a pending resize (deferred until move) -----
// ----- Alt + Right Button Down: start resize -----
if (wParam == WM_RBUTTONDOWN && g_useAltResize && IsActivationModifierPressed())
{
if (IsSuppressedByGameMode())
@@ -1504,27 +1296,19 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
goto forward;
}
// Already committed to Grab and Move this hold: start resizing immediately.
if (g_activatedDuringHold)
{
BeginResize(hwnd, pt);
return 1; // swallow the press; the resize is now active
}
g_resizing = true;
g_resizeFirstMove = true;
g_dragConsumedAlt = true;
g_resizeTarget = hwnd;
g_resizeLast = pt;
GetWindowRect(hwnd, &g_resizeWndRect);
PrepareOverlayMetrics(hwnd);
// A press is already pending (the other button is still held): don't
// overwrite it. Forward this one so we never strand the already-swallowed
// button-down (which would leak an unmatched button-up to the target).
if (g_pendingDrag || g_pendingResize)
{
goto forward;
}
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
// Defer: absorb the button-down but do nothing until the cursor moves.
g_pendingResize = true;
g_pendingDrag = false;
g_pendingTarget = hwnd;
g_pendingDownPt = pt;
return 1; // swallow the press for now; replayed on release if no move
TraceShortcutUse(true, GrabAndMoveShortcutAction::Resize, L"started");
return 1; // swallow the right button press
}
}
@@ -1540,16 +1324,6 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
return 0;
}
// ----- Right Button Up while a resize is pending (no move): deliver the click -----
if (wParam == WM_RBUTTONUP && g_pendingResize)
{
g_ignoreModifier = true;
g_pendingResize = false;
g_pendingTarget = nullptr;
ReplayPendingClick(true);
return 1; // swallow the physical up; the injected click replaces it
}
// ----- Right Button Up: end resize -----
if (wParam == WM_RBUTTONUP && g_resizing)
{
@@ -1585,7 +1359,6 @@ static void HandleDragMove(POINT pt)
RECT maxRect;
GetWindowRect(g_dragTarget, &maxRect);
int maxW = maxRect.right - maxRect.left;
int maxH = maxRect.bottom - maxRect.top;
ShowWindow(g_dragTarget, SW_RESTORE);
@@ -1593,12 +1366,9 @@ static void HandleDragMove(POINT pt)
int restoredW = g_dragWndRect.right - g_dragWndRect.left;
int restoredH = g_dragWndRect.bottom - g_dragWndRect.top;
// Preserve the relative grab position in both axes so the cursor stays
// at the same proportional spot within the restored window.
float ratioL = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
float ratioT = (maxH > 0) ? static_cast<float>(g_dragStart.y - maxRect.top) / maxH : 0.5f;
int newX = g_dragStart.x - static_cast<int>(restoredW * ratioL);
int newY = g_dragStart.y - static_cast<int>(restoredH * ratioT);
float ratio = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
int newX = g_dragStart.x - static_cast<int>(restoredW * ratio);
int newY = g_dragStart.y - (GetSystemMetrics(SM_CYFRAME) + GetSystemMetrics(SM_CYCAPTION) / 2);
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);

View File

@@ -33,15 +33,6 @@ namespace Hosts
var loader = new ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "Hosts File Editor";
}
Title = title;
titleBar.Title = title;

View File

@@ -1,105 +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.
#include "pch.h"
#include "KeystrokeFormatter.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
using namespace InputHighlighter;
namespace
{
#pragma warning(push)
#pragma warning(disable : 26497) // test helper; constexpr not required
KeystrokeEvent MakeKey(uint32_t vk, char32_t ch, bool ctrl = false, bool alt = false, bool shift = false, bool win = false)
{
KeystrokeEvent e{};
e.type = KeystrokeEventType::Down;
e.vk = vk;
e.ch = ch;
e.mods = { ctrl, alt, shift, win };
return e;
}
#pragma warning(pop)
}
namespace MouseHighlighterKeystrokeTests
{
TEST_CLASS(KeystrokeFormatterTests)
{
public:
TEST_METHOD(PlainLetter_ShowsCharacter)
{
const auto e = MakeKey('A', U'a');
Assert::AreEqual(std::wstring(L"a"), Formatter::Format(e));
Assert::IsFalse(Formatter::IsShortcut(e));
}
TEST_METHOD(ShiftedCharacter_HidesShiftModifier)
{
// Shift is held to produce "!", but the glyph already implies it.
const auto e = MakeKey('1', U'!', false, false, true, false);
Assert::AreEqual(std::wstring(L"!"), Formatter::Format(e));
// Any held modifier still classifies it as a shortcut.
Assert::IsTrue(Formatter::IsShortcut(e));
}
TEST_METHOD(CtrlC_ShowsCombination)
{
// Ctrl+C yields a non-printable char, so ch is 0.
const auto e = MakeKey('C', 0, true, false, false, false);
Assert::AreEqual(std::wstring(L"Ctrl + C"), Formatter::Format(e));
Assert::IsTrue(Formatter::IsShortcut(e));
}
TEST_METHOD(EnterKey_ShowsName_AndIsShortcut)
{
const auto e = MakeKey(VK_RETURN, 0);
Assert::AreEqual(std::wstring(L"Enter"), Formatter::Format(e));
Assert::IsTrue(Formatter::IsShortcut(e));
}
TEST_METHOD(ModifierKeyAlone_IsNotDuplicated)
{
// Pressing Ctrl by itself: modifier snapshot has Ctrl, key is Ctrl.
const auto e = MakeKey(VK_CONTROL, 0, true, false, false, false);
Assert::AreEqual(std::wstring(L"Ctrl"), Formatter::Format(e));
}
TEST_METHOD(ArrowKey_UsesGlyph)
{
const auto e = MakeKey(VK_LEFT, 0);
Assert::AreEqual(std::wstring(L"\u2190"), Formatter::Format(e));
}
TEST_METHOD(WinPlusLetter_ShowsWinGlyph)
{
const auto e = MakeKey('D', U'd', false, false, false, true);
Assert::AreEqual(std::wstring(L"\u229E + D"), Formatter::Format(e));
}
TEST_METHOD(KeyUp_ReturnsEmpty)
{
auto e = MakeKey('A', U'a');
e.type = KeystrokeEventType::Up;
Assert::AreEqual(std::wstring(L""), Formatter::Format(e));
}
TEST_METHOD(Whitespace_ReturnsEmpty)
{
const auto e = MakeKey(VK_SPACE, U' ');
Assert::AreEqual(std::wstring(L""), Formatter::Format(e));
}
TEST_METHOD(IsCommandKey_Classification)
{
Assert::IsTrue(Formatter::IsCommandKey(VK_RETURN));
Assert::IsTrue(Formatter::IsCommandKey(VK_F5));
Assert::IsTrue(Formatter::IsCommandKey(VK_LEFT));
Assert::IsFalse(Formatter::IsCommandKey('A'));
Assert::IsFalse(Formatter::IsCommandKey('1'));
}
};
}

View File

@@ -1,106 +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.
#include "pch.h"
#include "KeystrokeProcessor.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
using namespace InputHighlighter;
namespace
{
#pragma warning(push)
#pragma warning(disable : 26497) // test helper; constexpr not required
KeystrokeEvent MakeKey(uint32_t vk, char32_t ch, bool ctrl = false, bool alt = false, bool shift = false, bool win = false)
{
KeystrokeEvent e{};
e.type = KeystrokeEventType::Down;
e.vk = vk;
e.ch = ch;
e.mods = { ctrl, alt, shift, win };
return e;
}
#pragma warning(pop)
}
namespace MouseHighlighterKeystrokeTests
{
TEST_CLASS(KeystrokeProcessorTests)
{
public:
TEST_METHOD(Last5_AlwaysAdds)
{
KeystrokeProcessor p;
auto r = p.Process(MakeKey('A', U'a'), DisplayMode::Last5);
Assert::IsTrue(r.action == KeystrokeAction::Add);
Assert::AreEqual(std::wstring(L"a"), r.text);
}
TEST_METHOD(SingleCharactersOnly_IgnoresShortcuts)
{
KeystrokeProcessor p;
auto plain = p.Process(MakeKey('A', U'a'), DisplayMode::SingleCharactersOnly);
Assert::IsTrue(plain.action == KeystrokeAction::Add);
auto shortcut = p.Process(MakeKey('C', 0, true), DisplayMode::SingleCharactersOnly);
Assert::IsTrue(shortcut.action == KeystrokeAction::None);
}
TEST_METHOD(ShortcutsOnly_IgnoresPlainCharacters)
{
KeystrokeProcessor p;
auto plain = p.Process(MakeKey('A', U'a'), DisplayMode::ShortcutsOnly);
Assert::IsTrue(plain.action == KeystrokeAction::None);
auto shortcut = p.Process(MakeKey('C', 0, true), DisplayMode::ShortcutsOnly);
Assert::IsTrue(shortcut.action == KeystrokeAction::Add);
Assert::AreEqual(std::wstring(L"Ctrl + C"), shortcut.text);
}
TEST_METHOD(Stream_BuildsAndReplacesWord)
{
KeystrokeProcessor p;
auto h = p.Process(MakeKey('H', U'h'), DisplayMode::Stream);
Assert::IsTrue(h.action == KeystrokeAction::Add);
Assert::AreEqual(std::wstring(L"h"), h.text);
auto i = p.Process(MakeKey('I', U'i'), DisplayMode::Stream);
Assert::IsTrue(i.action == KeystrokeAction::ReplaceLast);
Assert::AreEqual(std::wstring(L"hi"), i.text);
}
TEST_METHOD(Stream_BackspaceEditsThenRemoves)
{
KeystrokeProcessor p;
p.Process(MakeKey('H', U'h'), DisplayMode::Stream);
p.Process(MakeKey('I', U'i'), DisplayMode::Stream);
auto back1 = p.Process(MakeKey(VK_BACK, 0), DisplayMode::Stream);
Assert::IsTrue(back1.action == KeystrokeAction::ReplaceLast);
Assert::AreEqual(std::wstring(L"h"), back1.text);
auto back2 = p.Process(MakeKey(VK_BACK, 0), DisplayMode::Stream);
Assert::IsTrue(back2.action == KeystrokeAction::RemoveLast);
auto back3 = p.Process(MakeKey(VK_BACK, 0), DisplayMode::Stream);
Assert::IsTrue(back3.action == KeystrokeAction::None);
}
TEST_METHOD(Stream_ShortcutResetsBuffer)
{
KeystrokeProcessor p;
p.Process(MakeKey('A', U'a'), DisplayMode::Stream); // buffer "a"
auto shortcut = p.Process(MakeKey('S', 0, true), DisplayMode::Stream);
Assert::IsTrue(shortcut.action == KeystrokeAction::Add);
Assert::AreEqual(std::wstring(L"Ctrl + S"), shortcut.text);
// Buffer was reset, so the next character starts a new pill.
auto b = p.Process(MakeKey('B', U'b'), DisplayMode::Stream);
Assert::IsTrue(b.action == KeystrokeAction::Add);
Assert::AreEqual(std::wstring(L"b"), b.text);
}
};
}

View File

@@ -1,66 +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|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>16.0</VCProjectVersion>
<ProjectGuid>{4E2D9B71-3C6A-4F1E-9D2B-7A8C5E1F0B34}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>MouseHighlighterKeystrokeTests</RootNamespace>
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
<ProjectName>MouseHighlighter.UnitTests</ProjectName>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseOfMfc>false</UseOfMfc>
<PlatformToolset>v143</PlatformToolset>
<OutDir>$(SolutionDir)$(Platform)\$(Configuration)\tests\MouseHighlighterUnitTests\</OutDir>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>..\MouseHighlighter\Keystrokes;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
<Link>
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="KeystrokeFormatter.Tests.cpp" />
<ClCompile Include="KeystrokeProcessor.Tests.cpp" />
<ClCompile Include="..\MouseHighlighter\Keystrokes\KeystrokeFormatter.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="..\MouseHighlighter\Keystrokes\KeystrokeProcessor.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="pch.h" />
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeTypes.h" />
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeFormatter.h" />
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeProcessor.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

View File

@@ -1,45 +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>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
</Filter>
<Filter Include="Module Source">
<UniqueIdentifier>{7E4A9C21-1D3B-4E5F-8A6C-2B9D0E1F3A45}</UniqueIdentifier>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="pch.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="KeystrokeFormatter.Tests.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="KeystrokeProcessor.Tests.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\MouseHighlighter\Keystrokes\KeystrokeFormatter.cpp">
<Filter>Module Source</Filter>
</ClCompile>
<ClCompile Include="..\MouseHighlighter\Keystrokes\KeystrokeProcessor.cpp">
<Filter>Module Source</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="pch.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeTypes.h">
<Filter>Module Source</Filter>
</ClInclude>
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeFormatter.h">
<Filter>Module Source</Filter>
</ClInclude>
<ClInclude Include="..\MouseHighlighter\Keystrokes\KeystrokeProcessor.h">
<Filter>Module Source</Filter>
</ClInclude>
</ItemGroup>
</Project>

View File

@@ -1,2 +0,0 @@
// pch.cpp: source file corresponding to the pre-compiled header.
#include "pch.h"

View File

@@ -1,17 +0,0 @@
// pch.h: precompiled header for the Input Highlighter keystroke unit tests.
#ifndef PCH_H
#define PCH_H
#include <Windows.h>
#include <array>
#include <string>
#include <vector>
// Suppressing 26466 - Don't use static_cast downcasts - in CppUnitTest.h
#pragma warning(push)
#pragma warning(disable : 26466)
#include "CppUnitTest.h"
#pragma warning(pop)
#endif // PCH_H

View File

@@ -1,254 +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.
#include "KeyboardCapture.h"
#include <array>
namespace InputHighlighter
{
KeyboardCapture* KeyboardCapture::s_instance = nullptr;
namespace
{
uint64_t NowMicros()
{
static LARGE_INTEGER freq = [] {
LARGE_INTEGER f;
QueryPerformanceFrequency(&f);
return f;
}();
LARGE_INTEGER c;
QueryPerformanceCounter(&c);
return static_cast<uint64_t>((c.QuadPart * 1'000'000) / freq.QuadPart);
}
#pragma warning(push)
#pragma warning(disable : 26497) // runtime key-state calls; cannot be constexpr
// Read the *physical* key state. A low-level keyboard hook runs on a
// dedicated background thread that never owns keyboard focus, so the
// per-thread state (GetKeyState/GetKeyboardState) is not synchronized
// with the real keys. That makes held modifiers read as up whenever focus
// is on another process (e.g. the desktop), which breaks shortcut
// detection. GetAsyncKeyState reports the true hardware state regardless
// of focus.
std::array<bool, 4> SnapshotMods()
{
auto down = [](int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; };
return std::array<bool, 4>{
down(VK_CONTROL),
down(VK_MENU),
down(VK_SHIFT),
(down(VK_LWIN) || down(VK_RWIN)),
};
}
// The keyboard layout of whatever window currently has focus, falling
// back to the hook thread's own layout. Ensures characters translate
// using the layout the user is actually typing against.
HKL ForegroundLayout()
{
const HWND fg = GetForegroundWindow();
const DWORD tid = fg ? GetWindowThreadProcessId(fg, nullptr) : 0;
return GetKeyboardLayout(tid);
}
// Translate a virtual key + scan code into a printable character, if any.
// Non-printable keys (Enter, arrows, control combos, ...) yield 0.
char32_t VkToChar(UINT vk, UINT scanCode)
{
// Seed toggle keys (Caps/Num/Scroll Lock) from the cached state, then
// overlay the live physical modifier state so Shift/AltGr characters
// are correct even when another process (e.g. the desktop) has focus.
BYTE keyState[256] = {};
GetKeyboardState(keyState);
auto syncAsync = [&keyState](int key) {
keyState[key] = static_cast<BYTE>((GetAsyncKeyState(key) & 0x8000) ? 0x80 : 0x00);
};
for (const int key : { VK_SHIFT, VK_LSHIFT, VK_RSHIFT,
VK_CONTROL, VK_LCONTROL, VK_RCONTROL,
VK_MENU, VK_LMENU, VK_RMENU })
{
syncAsync(key);
}
WCHAR buf[4] = { 0 };
HKL layout = ForegroundLayout();
// ToUnicodeEx can mutate dead-key state; acceptable for an overlay.
const int rc = ToUnicodeEx(vk, scanCode, keyState, buf, ARRAYSIZE(buf), 0, layout);
if (rc <= 0)
{
return 0;
}
if (!iswprint(buf[0]))
{
return 0;
}
return static_cast<char32_t>(buf[0]);
}
#pragma warning(pop)
}
KeyboardCapture::~KeyboardCapture()
{
Stop();
}
bool KeyboardCapture::Start(std::function<void()> notify)
{
if (m_running.load(std::memory_order_acquire))
{
return true;
}
m_notify = std::move(notify);
s_instance = this;
m_running.store(true, std::memory_order_release);
m_thread = std::thread(&KeyboardCapture::ThreadMain, this);
return true;
}
void KeyboardCapture::Stop()
{
if (!m_running.exchange(false))
{
if (m_thread.joinable())
{
m_thread.join();
}
return;
}
if (m_threadId != 0)
{
PostThreadMessageW(m_threadId, WM_QUIT, 0, 0);
}
if (m_thread.joinable())
{
m_thread.join();
}
if (s_instance == this)
{
s_instance = nullptr;
}
}
void KeyboardCapture::ThreadMain()
{
m_threadId = GetCurrentThreadId();
m_hook = SetWindowsHookExW(WH_KEYBOARD_LL, HookProc, GetModuleHandleW(nullptr), 0);
if (m_hook == nullptr)
{
m_running.store(false, std::memory_order_release);
return;
}
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (m_hook != nullptr)
{
UnhookWindowsHookEx(m_hook);
m_hook = nullptr;
}
m_threadId = 0;
}
void KeyboardCapture::EmitDown(UINT vk, UINT scanCode)
{
KeystrokeEvent e{};
e.type = KeystrokeEventType::Down;
e.vk = vk;
e.ch = VkToChar(vk, scanCode);
e.mods = SnapshotMods();
e.tsMicros = NowMicros();
m_queue.try_push(e);
if (m_notify)
{
m_notify();
}
}
bool KeyboardCapture::HandleKeyDown(UINT vk, UINT scanCode)
{
const auto mods = SnapshotMods();
int hotkeyId = -1;
if (m_switchMonitorHotkey.Matches(vk, mods))
{
hotkeyId = 0;
}
else if (m_cycleDisplayModeHotkey.Matches(vk, mods))
{
hotkeyId = 1;
}
if (hotkeyId >= 0)
{
// Fire once per physical press; swallow auto-repeat while held.
if (m_activeHotkeyVk != vk)
{
m_activeHotkeyVk = vk;
if (m_onHotkey)
{
m_onHotkey(hotkeyId);
}
}
return true; // swallow: don't type or display the shortcut key
}
EmitDown(vk, scanCode);
return false;
}
void KeyboardCapture::HandleKeyUp(UINT vk)
{
if (m_activeHotkeyVk == vk)
{
m_activeHotkeyVk = 0;
}
}
LRESULT CALLBACK KeyboardCapture::HookProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode == HC_ACTION && s_instance != nullptr)
{
const KBDLLHOOKSTRUCT* p = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
switch (wParam)
{
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (s_instance->HandleKeyDown(p->vkCode, p->scanCode))
{
return 1; // swallow matched control shortcut
}
break;
case WM_KEYUP:
case WM_SYSKEYUP:
s_instance->HandleKeyUp(p->vkCode);
break;
default:
break;
}
}
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
}

View File

@@ -1,79 +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.
// Installs a low-level keyboard hook on a dedicated message-loop thread and
// translates raw input into KeystrokeEvent values on an in-process lock-free
// queue. This replaces team4's separate keyboard service + named-pipe IPC with
// direct in-process capture, mirroring how Mouse Highlighter hooks the mouse.
#pragma once
#include <functional>
#include <thread>
#include <Windows.h>
#include "KeystrokeTypes.h"
namespace InputHighlighter
{
class KeyboardCapture
{
public:
KeyboardCapture() = default;
~KeyboardCapture();
KeyboardCapture(const KeyboardCapture&) = delete;
KeyboardCapture& operator=(const KeyboardCapture&) = delete;
// Starts capturing. 'notify' is invoked (on the capture thread) after events
// are enqueued so the consumer can drain via TryPop; keep it lean (e.g. post
// a message or set an event). Returns false if the hook could not be set.
bool Start(std::function<void()> notify);
// Registers a callback (invoked on the capture thread) fired when one of the
// configured overlay control shortcuts is pressed. The int argument is the
// hotkey id: 0 = switch monitor, 1 = cycle display mode. Set before Start.
void SetHotkeyHandler(std::function<void(int)> onHotkey) { m_onHotkey = std::move(onHotkey); }
// Updates the overlay control shortcuts. Safe to call at any time; a matched
// chord is swallowed (not shown as a keystroke).
void SetHotkeys(const HotkeyChord& switchMonitor, const HotkeyChord& cycleDisplayMode)
{
m_switchMonitorHotkey = switchMonitor;
m_cycleDisplayModeHotkey = cycleDisplayMode;
}
// Stops capturing and joins the capture thread.
void Stop();
// Consumer-side drain. Safe to call from a single consumer thread.
bool TryPop(KeystrokeEvent& out) { return m_queue.try_pop(out); }
bool IsRunning() const { return m_running.load(std::memory_order_acquire); }
private:
static LRESULT CALLBACK HookProc(int nCode, WPARAM wParam, LPARAM lParam);
void ThreadMain();
void EmitDown(UINT vk, UINT scanCode);
// Returns true if the key-down matched a control shortcut and should be
// swallowed (not forwarded, not shown). Runs on the capture thread.
bool HandleKeyDown(UINT vk, UINT scanCode);
void HandleKeyUp(UINT vk);
static KeyboardCapture* s_instance;
SpscRing<KeystrokeEvent, 1024> m_queue;
std::function<void()> m_notify;
std::function<void(int)> m_onHotkey;
HotkeyChord m_switchMonitorHotkey;
HotkeyChord m_cycleDisplayModeHotkey;
// vk of the control shortcut currently held down, so auto-repeat fires the
// action only once. Read/written only on the capture thread.
UINT m_activeHotkeyVk = 0;
std::thread m_thread;
HHOOK m_hook = nullptr;
DWORD m_threadId = 0;
std::atomic<bool> m_running{ false };
};
}

View File

@@ -1,365 +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.
#include "KeystrokeFormatter.h"
#include <algorithm>
#include <cwctype>
#include <vector>
#include <Windows.h>
namespace InputHighlighter::Formatter
{
namespace
{
// Modifier glyphs (Segoe UI Symbol compatible).
constexpr wchar_t kShiftGlyph[] = L"\u21E7"; // upwards white arrow
constexpr wchar_t kWinGlyph[] = L"\u229E"; // squared plus
std::wstring Utf32ToWide(char32_t ch)
{
if (ch == 0)
{
return std::wstring();
}
if (ch <= 0xFFFF)
{
return std::wstring(1, static_cast<wchar_t>(ch));
}
// Encode as a UTF-16 surrogate pair.
const char32_t v = ch - 0x10000;
std::wstring result;
result.push_back(static_cast<wchar_t>(0xD800 + (v >> 10)));
result.push_back(static_cast<wchar_t>(0xDC00 + (v & 0x3FF)));
return result;
}
bool IsAllWhitespace(const std::wstring& s)
{
if (s.empty())
{
return true;
}
for (const wchar_t c : s)
{
if (!std::iswspace(c))
{
return false;
}
}
return true;
}
// Present modifiers in a stable order matching the capture snapshot.
std::vector<std::wstring> GetModifierList(const std::array<bool, 4>& mods)
{
std::vector<std::wstring> list;
if (mods[Mod_Ctrl])
{
list.push_back(L"Ctrl");
}
if (mods[Mod_Alt])
{
list.push_back(L"Alt");
}
if (mods[Mod_Shift])
{
list.push_back(L"Shift");
}
if (mods[Mod_Win])
{
list.push_back(L"Win");
}
return list;
}
bool Contains(const std::vector<std::wstring>& v, const std::wstring& s)
{
return std::find(v.begin(), v.end(), s) != v.end();
}
}
bool IsCommandKey(uint32_t vk)
{
switch (vk)
{
case VK_SPACE:
case VK_RETURN:
case VK_TAB:
case VK_BACK:
case VK_ESCAPE:
case VK_DELETE:
case VK_INSERT:
case VK_HOME:
case VK_END:
case VK_PRIOR: // Page Up
case VK_NEXT: // Page Down
case VK_LEFT:
case VK_RIGHT:
case VK_UP:
case VK_DOWN:
case VK_SNAPSHOT: // Print Screen
case VK_PAUSE:
case VK_CAPITAL: // Caps Lock
case VK_LWIN:
case VK_RWIN:
return true;
default:
break;
}
// Function keys F1..F24.
if (vk >= VK_F1 && vk <= VK_F24)
{
return true;
}
return false;
}
std::wstring GetModifierSymbol(const std::wstring& modifier)
{
if (modifier == L"Ctrl")
{
return L"Ctrl";
}
if (modifier == L"Alt")
{
return L"Alt";
}
if (modifier == L"Shift")
{
return kShiftGlyph;
}
if (modifier == L"Win")
{
return kWinGlyph;
}
return modifier;
}
std::wstring GetKeyName(uint32_t vk)
{
switch (vk)
{
case VK_LSHIFT:
case VK_RSHIFT:
case VK_SHIFT:
return kShiftGlyph;
case VK_CONTROL:
case VK_LCONTROL:
case VK_RCONTROL:
return L"Ctrl";
case VK_MENU:
case VK_LMENU:
case VK_RMENU:
return L"Alt";
case VK_LWIN:
case VK_RWIN:
return kWinGlyph;
case VK_SPACE:
return L"Space";
case VK_RETURN:
return L"Enter";
case VK_BACK:
return L"Backspace";
case VK_TAB:
return L"Tab";
case VK_ESCAPE:
return L"Esc";
case VK_DELETE:
return L"Del";
case VK_INSERT:
return L"Ins";
case VK_LEFT:
return L"\u2190"; // left arrow
case VK_RIGHT:
return L"\u2192"; // right arrow
case VK_UP:
return L"\u2191"; // up arrow
case VK_DOWN:
return L"\u2193"; // down arrow
default:
break;
}
// Letters A-Z.
if (vk >= 'A' && vk <= 'Z')
{
return std::wstring(1, static_cast<wchar_t>(vk));
}
// Top-row digits 0-9.
if (vk >= '0' && vk <= '9')
{
return std::wstring(1, static_cast<wchar_t>(vk));
}
// Numpad 0-9.
if (vk >= VK_NUMPAD0 && vk <= VK_NUMPAD9)
{
return L"Num " + std::wstring(1, static_cast<wchar_t>(L'0' + (vk - VK_NUMPAD0)));
}
// Function keys.
if (vk >= VK_F1 && vk <= VK_F24)
{
return L"F" + std::to_wstring(vk - VK_F1 + 1);
}
// Punctuation / OEM keys (US layout labels).
switch (vk)
{
case VK_OEM_1:
return L";";
case VK_OEM_PLUS:
return L"=";
case VK_OEM_COMMA:
return L",";
case VK_OEM_MINUS:
return L"-";
case VK_OEM_PERIOD:
return L".";
case VK_OEM_2:
return L"/";
case VK_OEM_3:
return L"`";
case VK_OEM_4:
return L"[";
case VK_OEM_5:
return L"\\";
case VK_OEM_6:
return L"]";
case VK_OEM_7:
return L"'";
case VK_VOLUME_MUTE:
return L"Mute";
case VK_VOLUME_DOWN:
return L"Vol -";
case VK_VOLUME_UP:
return L"Vol +";
case VK_MEDIA_NEXT_TRACK:
return L"Next";
case VK_MEDIA_PREV_TRACK:
return L"Prev";
case VK_MEDIA_PLAY_PAUSE:
return L"Play/Pause";
default:
break;
}
return std::wstring();
}
bool IsShortcut(const KeystrokeEvent& e)
{
// Any modifier held makes this a shortcut.
for (const bool held : e.mods)
{
if (held)
{
return true;
}
}
// Command keys (Enter, Esc, F1, ...) count as shortcuts too.
if (IsCommandKey(e.vk))
{
return true;
}
return false;
}
std::wstring Format(const KeystrokeEvent& e)
{
if (e.type == KeystrokeEventType::Up)
{
return std::wstring();
}
const bool isCharEvent = e.ch != 0;
const std::wstring text = isCharEvent ? Utf32ToWide(e.ch) : std::wstring();
const bool hasCtrl = e.mods[Mod_Ctrl];
const bool hasAlt = e.mods[Mod_Alt];
const bool hasWin = e.mods[Mod_Win];
std::wstring keyName;
bool haveKeyName = false;
if (isCharEvent && !hasWin)
{
if (IsAllWhitespace(text))
{
return std::wstring();
}
keyName = text;
haveKeyName = true;
}
else
{
if (IsCommandKey(e.vk) || hasCtrl || hasAlt || hasWin)
{
keyName = GetKeyName(e.vk);
haveKeyName = !keyName.empty();
}
}
if (!haveKeyName)
{
return std::wstring();
}
std::vector<std::wstring> displayParts;
for (const auto& mod : GetModifierList(e.mods))
{
// Don't show Shift when a shifted character already implies it (e.g. "!").
if (isCharEvent && !hasWin && mod == L"Shift" && !hasCtrl && !hasAlt)
{
continue;
}
const std::wstring symbol = GetModifierSymbol(mod);
if (!Contains(displayParts, symbol))
{
displayParts.push_back(symbol);
}
}
// Avoid duplicating the key with an already-shown modifier (e.g. Ctrl + Ctrl).
const std::wstring modSym = GetModifierSymbol(keyName);
if (!Contains(displayParts, keyName) && !Contains(displayParts, modSym))
{
displayParts.push_back(keyName);
}
std::wstring result;
for (size_t i = 0; i < displayParts.size(); ++i)
{
if (i != 0)
{
result += L" + ";
}
result += displayParts[i];
}
return result;
}
}

View File

@@ -1,32 +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.
// Pure keystroke -> display-string formatting, ported from the team4
// KeystrokeOverlay KeystrokeEvent.cs (ToString / IsShortcut / GetKeyName / ...).
// No UI dependencies so it can be unit tested directly.
#pragma once
#include <string>
#include "KeystrokeTypes.h"
namespace InputHighlighter::Formatter
{
// Human-readable display string for the keystroke, or an empty string when
// there is nothing meaningful to show (e.g. key-up, whitespace char).
std::wstring Format(const KeystrokeEvent& e);
// A keystroke is a "shortcut" if it carries a modifier or is a command key.
bool IsShortcut(const KeystrokeEvent& e);
// Command keys are non-character keys we still want to surface (Enter, arrows,
// function keys, ...). Plain letters/digits/punctuation are handled as chars.
bool IsCommandKey(uint32_t vk);
// Friendly name/glyph for a virtual key (e.g. "Ctrl", "Enter", arrow glyphs).
std::wstring GetKeyName(uint32_t vk);
// Symbol used for a modifier label ("Shift" -> the shift glyph, etc.).
std::wstring GetModifierSymbol(const std::wstring& modifier);
}

View File

@@ -1,141 +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.
#include "KeystrokeProcessor.h"
#include <cwctype>
#include <Windows.h>
#include "KeystrokeFormatter.h"
namespace InputHighlighter
{
namespace
{
std::wstring CharText(const KeystrokeEvent& e)
{
if (e.ch == 0)
{
return std::wstring();
}
if (e.ch <= 0xFFFF)
{
return std::wstring(1, static_cast<wchar_t>(e.ch));
}
const char32_t v = e.ch - 0x10000;
std::wstring result;
result.push_back(static_cast<wchar_t>(0xD800 + (v >> 10)));
result.push_back(static_cast<wchar_t>(0xDC00 + (v & 0x3FF)));
return result;
}
bool IsNullOrWhiteSpace(const std::wstring& s)
{
if (s.empty())
{
return true;
}
for (const wchar_t c : s)
{
if (!std::iswspace(c))
{
return false;
}
}
return true;
}
}
KeystrokeResult KeystrokeProcessor::Process(const KeystrokeEvent& e, DisplayMode displayMode)
{
const std::wstring formattedText = Formatter::Format(e);
// Nothing to display.
if (formattedText.empty())
{
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
const bool isShortcut = Formatter::IsShortcut(e);
switch (displayMode)
{
case DisplayMode::Last5:
return KeystrokeResult{ KeystrokeAction::Add, formattedText };
case DisplayMode::SingleCharactersOnly:
if (isShortcut)
{
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
return KeystrokeResult{ KeystrokeAction::Add, formattedText };
case DisplayMode::ShortcutsOnly:
if (!isShortcut)
{
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
return KeystrokeResult{ KeystrokeAction::Add, formattedText };
case DisplayMode::Stream:
return ProcessStreamMode(e, isShortcut, formattedText);
default:
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
}
KeystrokeResult KeystrokeProcessor::ProcessStreamMode(const KeystrokeEvent& e, bool isShortcut, const std::wstring& formattedText)
{
// Backspace edits the current stream buffer.
if (e.vk == VK_BACK)
{
if (!m_streamBuffer.empty())
{
m_streamBuffer.pop_back();
if (m_streamBuffer.empty())
{
return KeystrokeResult{ KeystrokeAction::RemoveLast, std::wstring() };
}
return KeystrokeResult{ KeystrokeAction::ReplaceLast, m_streamBuffer };
}
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
// A shortcut (other than Space) resets the stream and shows itself.
if (isShortcut && e.vk != VK_SPACE)
{
ResetBuffer();
return KeystrokeResult{ KeystrokeAction::Add, formattedText };
}
const std::wstring text = CharText(e);
// Whitespace ends the current word so the next character starts fresh.
if (IsNullOrWhiteSpace(text))
{
ResetBuffer();
return KeystrokeResult{ KeystrokeAction::None, std::wstring() };
}
m_streamBuffer += text;
if (m_streamBuffer.size() == 1)
{
return KeystrokeResult{ KeystrokeAction::Add, m_streamBuffer };
}
return KeystrokeResult{ KeystrokeAction::ReplaceLast, m_streamBuffer };
}
}

View File

@@ -1,29 +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.
// Pure keystroke display state machine, ported from the team4 KeystrokeOverlay
// KeystrokeProcessor.cs. Decides whether an incoming key adds a new pill,
// replaces/removes the current one, or is ignored, based on the display mode.
#pragma once
#include <string>
#include "KeystrokeTypes.h"
namespace InputHighlighter
{
class KeystrokeProcessor
{
public:
// Determine the visual action for an incoming key in the given display mode.
KeystrokeResult Process(const KeystrokeEvent& e, DisplayMode displayMode);
void ResetBuffer() { m_streamBuffer.clear(); }
private:
KeystrokeResult ProcessStreamMode(const KeystrokeEvent& e, bool isShortcut, const std::wstring& formattedText);
std::wstring m_streamBuffer;
};
}

View File

@@ -1,512 +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.
#include "KeystrokeRenderer.h"
#include <windows.ui.composition.interop.h>
#include <winrt/Windows.Graphics.DirectX.h>
#include <algorithm>
#include <cmath>
#pragma comment(lib, "d3d11.lib")
#pragma comment(lib, "d2d1.lib")
#pragma comment(lib, "dwrite.lib")
#pragma comment(lib, "dxgi.lib")
// This file performs a lot of low-level D2D/DWrite/Composition interop; several
// C++ Core Guidelines analysis checks are noisy here and are suppressed to match
// the pragmatic style used elsewhere in the native rendering code.
#pragma warning(disable : 26451 26429 26446 26447 26461 26472 26481 26490 26493 26496 26497 26812)
namespace ABI
{
using namespace ABI::Windows::UI::Composition;
}
using namespace winrt::Windows::UI;
using namespace winrt::Windows::UI::Composition;
using namespace winrt::Windows::Foundation;
using namespace winrt::Windows::Foundation::Numerics;
namespace WGDX = winrt::Windows::Graphics::DirectX;
namespace InputHighlighter
{
namespace
{
constexpr float kMarginDip = 24.0f;
constexpr float kGapDip = 10.0f;
constexpr wchar_t kFontFamily[] = L"Segoe UI";
}
KeystrokeRenderer::~KeystrokeRenderer()
{
// Drop references without touching the (possibly torn-down) parent tree.
m_pills.clear();
m_container = nullptr;
m_graphicsDevice = nullptr;
}
bool KeystrokeRenderer::CreateDevices()
{
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
HRESULT hr = D3D11CreateDevice(
nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, nullptr, 0, D3D11_SDK_VERSION, m_d3dDevice.put(), nullptr, nullptr);
if (hr == DXGI_ERROR_UNSUPPORTED)
{
hr = D3D11CreateDevice(
nullptr, D3D_DRIVER_TYPE_WARP, nullptr, flags, nullptr, 0, D3D11_SDK_VERSION, m_d3dDevice.put(), nullptr, nullptr);
}
if (FAILED(hr))
{
return false;
}
auto dxgiDevice = m_d3dDevice.as<IDXGIDevice>();
D2D1_FACTORY_OPTIONS d2dOptions{};
hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, __uuidof(ID2D1Factory1), &d2dOptions, m_d2dFactory.put_void());
if (FAILED(hr))
{
return false;
}
hr = m_d2dFactory->CreateDevice(dxgiDevice.get(), m_d2dDevice.put());
if (FAILED(hr))
{
return false;
}
hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), reinterpret_cast<::IUnknown**>(m_dwriteFactory.put()));
if (FAILED(hr))
{
return false;
}
// Bind the D2D device to the compositor to create Composition-hosted surfaces.
auto interop = m_compositor.as<ABI::ICompositorInterop>();
ABI::ICompositionGraphicsDevice* rawDevice = nullptr;
hr = interop->CreateGraphicsDevice(m_d2dDevice.get(), &rawDevice);
if (FAILED(hr))
{
return false;
}
winrt::attach_abi(m_graphicsDevice, rawDevice);
return true;
}
bool KeystrokeRenderer::Initialize(const Compositor& compositor, const ContainerVisual& parentRoot, HWND hwnd)
{
if (m_initialized)
{
return true;
}
m_compositor = compositor;
m_hwnd = hwnd;
try
{
if (!CreateDevices())
{
return false;
}
m_container = m_compositor.CreateContainerVisual();
m_container.RelativeSizeAdjustment({ 1.0f, 1.0f });
parentRoot.Children().InsertAtTop(m_container);
}
catch (...)
{
return false;
}
m_initialized = true;
return true;
}
void KeystrokeRenderer::Uninitialize()
{
if (!m_initialized)
{
return;
}
Clear();
if (m_container)
{
try
{
auto parent = m_container.Parent();
if (parent)
{
parent.Children().Remove(m_container);
}
}
catch (...)
{
}
}
m_container = nullptr;
m_graphicsDevice = nullptr;
m_d2dDevice = nullptr;
m_d2dFactory = nullptr;
m_dwriteFactory = nullptr;
m_d3dDevice = nullptr;
m_initialized = false;
}
void KeystrokeRenderer::ApplySettings(const KeystrokeRendererSettings& settings)
{
m_settings = settings;
// Mode / style changes reset the current stack; new pills pick up the new look.
Clear();
}
void KeystrokeRenderer::SetAnchorRect(const D2D1_RECT_F& clientRect)
{
m_anchorRect = clientRect;
Relayout();
}
float KeystrokeRenderer::DpiScale() const
{
const UINT dpi = m_hwnd ? GetDpiForWindow(m_hwnd) : 96;
return static_cast<float>(dpi == 0 ? 96 : dpi) / 96.0f;
}
size_t KeystrokeRenderer::MaxPills() const
{
switch (m_settings.displayMode)
{
case DisplayMode::Last5:
return 5;
case DisplayMode::Stream:
return 8;
case DisplayMode::SingleCharactersOnly:
case DisplayMode::ShortcutsOnly:
default:
return 5;
}
}
void KeystrokeRenderer::DrawPill(Pill& pill, const std::wstring& text)
{
if (!m_initialized)
{
return;
}
const float dpiScale = DpiScale();
const float fontPx = (std::max)(8.0f, m_settings.textSize) * dpiScale;
// Measure the string with DirectWrite.
winrt::com_ptr<IDWriteTextFormat> format;
if (FAILED(m_dwriteFactory->CreateTextFormat(
kFontFamily, nullptr, DWRITE_FONT_WEIGHT_SEMI_BOLD, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, fontPx, L"", format.put())))
{
return;
}
format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
format->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);
winrt::com_ptr<IDWriteTextLayout> layout;
if (FAILED(m_dwriteFactory->CreateTextLayout(
text.c_str(), static_cast<UINT32>(text.length()), format.get(), 4096.0f, 4096.0f, layout.put())))
{
return;
}
DWRITE_TEXT_METRICS metrics{};
layout->GetMetrics(&metrics);
const float padX = fontPx * 0.55f;
const float padY = fontPx * 0.30f;
float widthPx = std::ceil(metrics.width + 2.0f * padX);
float heightPx = std::ceil(metrics.height + 2.0f * padY);
widthPx = (std::max)(widthPx, heightPx); // keep short pills from looking cramped
layout->SetMaxWidth(widthPx);
layout->SetMaxHeight(heightPx);
// (Re)create the drawing surface at the measured pixel size.
auto surface = m_graphicsDevice.CreateDrawingSurface(
Size{ widthPx, heightPx }, WGDX::DirectXPixelFormat::B8G8R8A8UIntNormalized, WGDX::DirectXAlphaMode::Premultiplied);
auto surfaceInterop = surface.as<ABI::ICompositionDrawingSurfaceInterop>();
winrt::com_ptr<ID2D1DeviceContext> dc;
POINT offset{};
if (FAILED(surfaceInterop->BeginDraw(nullptr, __uuidof(ID2D1DeviceContext), dc.put_void(), &offset)))
{
return;
}
dc->SetDpi(96.0f, 96.0f);
dc->SetTransform(D2D1::Matrix3x2F::Translation(static_cast<float>(offset.x), static_cast<float>(offset.y)));
dc->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f));
const auto& bg = m_settings.backgroundColor;
const auto& fg = m_settings.textColor;
winrt::com_ptr<ID2D1SolidColorBrush> bgBrush;
dc->CreateSolidColorBrush(D2D1::ColorF(bg.R / 255.0f, bg.G / 255.0f, bg.B / 255.0f, bg.A / 255.0f), bgBrush.put());
winrt::com_ptr<ID2D1SolidColorBrush> fgBrush;
dc->CreateSolidColorBrush(D2D1::ColorF(fg.R / 255.0f, fg.G / 255.0f, fg.B / 255.0f, fg.A / 255.0f), fgBrush.put());
const float radius = heightPx * 0.28f;
const D2D1_ROUNDED_RECT rr = D2D1::RoundedRect(D2D1::RectF(0.5f, 0.5f, widthPx - 0.5f, heightPx - 0.5f), radius, radius);
if (bgBrush)
{
dc->FillRoundedRectangle(rr, bgBrush.get());
}
if (fgBrush)
{
dc->DrawTextLayout(D2D1::Point2F(0.0f, 0.0f), layout.get(), fgBrush.get(), D2D1_DRAW_TEXT_OPTIONS_NONE);
}
if (m_settings.strokeThickness > 0 && m_settings.strokeColor.A > 0)
{
const auto& sc = m_settings.strokeColor;
winrt::com_ptr<ID2D1SolidColorBrush> strokeBrush;
dc->CreateSolidColorBrush(D2D1::ColorF(sc.R / 255.0f, sc.G / 255.0f, sc.B / 255.0f, sc.A / 255.0f), strokeBrush.put());
if (strokeBrush)
{
const float sw = m_settings.strokeThickness * DpiScale();
const float inset = 0.5f + sw / 2.0f;
const D2D1_ROUNDED_RECT sr = D2D1::RoundedRect(D2D1::RectF(inset, inset, widthPx - inset, heightPx - inset), radius, radius);
dc->DrawRoundedRectangle(sr, strokeBrush.get(), sw);
}
}
surfaceInterop->EndDraw();
auto brush = m_compositor.CreateSurfaceBrush(surface);
brush.Stretch(CompositionStretch::Fill);
if (!pill.visual)
{
pill.visual = m_compositor.CreateSpriteVisual();
m_container.Children().InsertAtTop(pill.visual);
}
pill.visual.Brush(brush);
pill.visual.Size({ widthPx, heightPx });
pill.brush = brush;
pill.surface = surface;
pill.text = text;
pill.width = widthPx;
pill.height = heightPx;
}
void KeystrokeRenderer::AnimateEntrance(const Pill& pill, float /*targetOpacity*/)
{
if (!pill.visual)
{
return;
}
pill.visual.CenterPoint({ pill.width / 2.0f, pill.height / 2.0f, 0.0f });
auto anim = m_compositor.CreateVector3KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { 0.7f, 0.7f, 1.0f });
anim.InsertKeyFrame(1.0f, { 1.0f, 1.0f, 1.0f });
anim.Duration(std::chrono::milliseconds(120));
pill.visual.StartAnimation(L"Scale", anim);
}
void KeystrokeRenderer::EnforceCap()
{
const size_t cap = MaxPills();
while (m_pills.size() > cap)
{
auto& front = m_pills.front();
if (front.visual && m_container)
{
m_container.Children().Remove(front.visual);
}
m_pills.pop_front();
}
}
void KeystrokeRenderer::Relayout()
{
if (!m_initialized || m_pills.empty())
{
return;
}
const float dpiScale = DpiScale();
const float margin = kMarginDip * dpiScale;
const float gap = kGapDip * dpiScale;
float totalW = 0.0f;
float maxH = 0.0f;
for (const auto& p : m_pills)
{
totalW += p.width;
maxH = (std::max)(maxH, p.height);
}
totalW += gap * static_cast<float>(m_pills.size() - 1);
const float left = m_anchorRect.left + margin;
const float right = m_anchorRect.right - margin;
const float centerX = (m_anchorRect.left + m_anchorRect.right) / 2.0f;
float startX = left;
switch (m_settings.position)
{
case KeystrokePosition::TopLeft:
case KeystrokePosition::BottomLeft:
startX = left;
break;
case KeystrokePosition::TopCenter:
case KeystrokePosition::BottomCenter:
startX = centerX - totalW / 2.0f;
break;
case KeystrokePosition::TopRight:
case KeystrokePosition::BottomRight:
startX = right - totalW;
break;
}
float baseY = m_anchorRect.top + margin;
switch (m_settings.position)
{
case KeystrokePosition::BottomLeft:
case KeystrokePosition::BottomCenter:
case KeystrokePosition::BottomRight:
baseY = m_anchorRect.bottom - margin - maxH;
break;
default:
break;
}
const size_t n = m_pills.size();
float x = startX;
for (size_t i = 0; i < n; ++i)
{
auto& p = m_pills[i];
const float y = baseY + (maxH - p.height) / 2.0f;
p.visual.Offset({ x, y, 0.0f });
// Older pills (towards the front) fade out slightly.
const size_t ageFromNewest = (n - 1) - i;
const float opacity = (std::max)(0.4f, 1.0f - 0.12f * static_cast<float>(ageFromNewest));
p.visual.Opacity(opacity);
x += p.width + gap;
}
}
void KeystrokeRenderer::OnResult(const KeystrokeResult& result)
{
if (!m_initialized || result.action == KeystrokeAction::None)
{
return;
}
const ULONGLONG deadline = (m_settings.timeoutMs > 0) ? (GetTickCount64() + static_cast<ULONGLONG>(m_settings.timeoutMs)) : 0;
switch (result.action)
{
case KeystrokeAction::Add:
{
Pill pill;
DrawPill(pill, result.text);
pill.expireAt = deadline;
m_pills.push_back(std::move(pill));
EnforceCap();
Relayout();
AnimateEntrance(m_pills.back(), 1.0f);
break;
}
case KeystrokeAction::ReplaceLast:
{
if (m_pills.empty())
{
Pill pill;
DrawPill(pill, result.text);
pill.expireAt = deadline;
m_pills.push_back(std::move(pill));
EnforceCap();
Relayout();
AnimateEntrance(m_pills.back(), 1.0f);
}
else
{
auto& back = m_pills.back();
DrawPill(back, result.text);
back.expireAt = deadline;
Relayout();
}
break;
}
case KeystrokeAction::RemoveLast:
{
if (!m_pills.empty())
{
auto& back = m_pills.back();
if (back.visual && m_container)
{
m_container.Children().Remove(back.visual);
}
m_pills.pop_back();
Relayout();
}
break;
}
case KeystrokeAction::None:
default:
break;
}
}
bool KeystrokeRenderer::Tick()
{
if (m_pills.empty())
{
return false;
}
const ULONGLONG now = GetTickCount64();
bool changed = false;
// Oldest pills (front) expire first.
while (!m_pills.empty())
{
auto& front = m_pills.front();
if (front.expireAt == 0 || now < front.expireAt)
{
break;
}
if (front.visual && m_container)
{
m_container.Children().Remove(front.visual);
}
m_pills.pop_front();
changed = true;
}
if (changed)
{
Relayout();
}
return changed;
}
void KeystrokeRenderer::Clear()
{
if (m_container)
{
for (auto& p : m_pills)
{
if (p.visual)
{
m_container.Children().Remove(p.visual);
}
}
}
m_pills.clear();
}
}

View File

@@ -1,130 +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.
// KeystrokeRenderer: draws the captured keystrokes as horizontally stacked
// "key pill" visuals on the existing Mouse/Input Highlighter Composition overlay.
//
// Text is rasterized with Direct2D + DirectWrite onto CompositionDrawingSurfaces,
// which are hosted by SpriteVisuals under a dedicated container in the overlay's
// visual tree. All public methods must be called on the composition (overlay UI)
// thread.
#pragma once
#include <windows.h>
#include <deque>
#include <string>
#include <winrt/Windows.UI.h>
#include <winrt/Windows.UI.Composition.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Numerics.h>
#include <d2d1_1.h>
#include <dwrite.h>
#include <d3d11.h>
#include "KeystrokeTypes.h"
namespace InputHighlighter
{
// Anchor position of the keystroke stack, matching the settings enum (0-5).
enum class KeystrokePosition
{
TopLeft = 0,
TopCenter = 1,
TopRight = 2,
BottomLeft = 3,
BottomCenter = 4,
BottomRight = 5,
};
struct KeystrokeRendererSettings
{
DisplayMode displayMode = DisplayMode::Last5;
KeystrokePosition position = KeystrokePosition::BottomCenter;
int timeoutMs = 2000;
float textSize = 24.0f; // DIP font size
winrt::Windows::UI::Color textColor{ 255, 255, 255, 255 };
winrt::Windows::UI::Color backgroundColor{ 200, 32, 32, 32 };
winrt::Windows::UI::Color strokeColor{ 0, 255, 255, 255 };
int strokeThickness = 0; // DIP; 0 = no border
};
class KeystrokeRenderer
{
public:
KeystrokeRenderer() = default;
~KeystrokeRenderer();
KeystrokeRenderer(const KeystrokeRenderer&) = delete;
KeystrokeRenderer& operator=(const KeystrokeRenderer&) = delete;
// Creates the D2D/DWrite devices and the Composition graphics device, and
// adds a container visual to parentRoot. hwnd is used for DPI queries and
// to size/anchor the stack. Returns false on failure (rendering disabled).
bool Initialize(const winrt::Windows::UI::Composition::Compositor& compositor,
const winrt::Windows::UI::Composition::ContainerVisual& parentRoot,
HWND hwnd);
void Uninitialize();
bool IsInitialized() const noexcept { return m_initialized; }
void ApplySettings(const KeystrokeRendererSettings& settings);
// The client-space rectangle the stack is anchored within (typically the
// work area of the active monitor, translated to overlay client coords).
void SetAnchorRect(const D2D1_RECT_F& clientRect);
// Applies a processor result (Add / ReplaceLast / RemoveLast / None).
void OnResult(const KeystrokeResult& result);
// Called periodically to fade/remove expired pills. Returns true if the
// set of visible pills changed (so the caller may stop the timer when empty).
bool Tick();
bool HasVisiblePills() const noexcept { return !m_pills.empty(); }
// Removes all pills immediately (e.g. when drawing stops).
void Clear();
private:
struct Pill
{
winrt::Windows::UI::Composition::SpriteVisual visual{ nullptr };
winrt::Windows::UI::Composition::CompositionSurfaceBrush brush{ nullptr };
winrt::Windows::UI::Composition::CompositionDrawingSurface surface{ nullptr };
std::wstring text;
ULONGLONG expireAt = 0; // GetTickCount64 deadline; 0 = never
float width = 0.0f; // DIP
float height = 0.0f; // DIP
};
bool CreateDevices();
float DpiScale() const;
void DrawPill(Pill& pill, const std::wstring& text);
void Relayout();
void EnforceCap();
size_t MaxPills() const;
void AnimateEntrance(const Pill& pill, float targetOpacity);
bool m_initialized = false;
HWND m_hwnd = nullptr;
KeystrokeRendererSettings m_settings;
D2D1_RECT_F m_anchorRect{ 0, 0, 0, 0 };
winrt::Windows::UI::Composition::Compositor m_compositor{ nullptr };
winrt::Windows::UI::Composition::ContainerVisual m_container{ nullptr };
winrt::Windows::UI::Composition::CompositionGraphicsDevice m_graphicsDevice{ nullptr };
winrt::com_ptr<ID3D11Device> m_d3dDevice;
winrt::com_ptr<ID2D1Device> m_d2dDevice;
winrt::com_ptr<ID2D1Factory1> m_d2dFactory;
winrt::com_ptr<IDWriteFactory> m_dwriteFactory;
std::deque<Pill> m_pills;
};
}

View File

@@ -1,125 +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.
// Core value types for the Input Highlighter keystroke overlay. These are pure
// (no WinRT/UI dependencies) so the formatter and processor can be unit tested.
#pragma once
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <string>
namespace InputHighlighter
{
// Indices into KeystrokeEvent::mods (order matches the keyboard snapshot).
enum ModifierIndex
{
Mod_Ctrl = 0,
Mod_Alt = 1,
Mod_Shift = 2,
Mod_Win = 3,
};
enum class KeystrokeEventType : uint8_t
{
Down,
Up,
};
// A single captured keystroke. POD so it can be copied cheaply across the
// producer (hook thread) / consumer (composition thread) boundary.
struct KeystrokeEvent
{
KeystrokeEventType type = KeystrokeEventType::Down;
uint32_t vk = 0; // virtual key code
char32_t ch = 0; // printable character for this key (0 when non-printable)
std::array<bool, 4> mods = { false, false, false, false }; // Ctrl, Alt, Shift, Win
uint64_t tsMicros = 0;
};
// A keyboard shortcut (modifier chord + trigger key) used to drive overlay
// control actions from within the keystroke capture hook. Modifier order
// matches KeystrokeEvent::mods (Ctrl, Alt, Shift, Win).
struct HotkeyChord
{
bool ctrl = false;
bool alt = false;
bool shift = false;
bool win = false;
uint32_t vk = 0; // 0 = unbound
bool IsBound() const { return vk != 0; }
bool Matches(uint32_t downVk, const std::array<bool, 4>& mods) const
{
return vk != 0 && vk == downVk &&
ctrl == mods[Mod_Ctrl] &&
alt == mods[Mod_Alt] &&
shift == mods[Mod_Shift] &&
win == mods[Mod_Win];
}
};
// Display modes, ported from the team4 KeystrokeOverlay DisplayMode enum.
enum class DisplayMode
{
Last5 = 0,
SingleCharactersOnly = 1,
ShortcutsOnly = 2,
Stream = 3,
};
enum class KeystrokeAction
{
None, // Do nothing
Add, // Create a new visual "pill"
ReplaceLast, // Update the current pill (e.g. "Hell" -> "Hello")
RemoveLast, // Backspace an entire pill
};
struct KeystrokeResult
{
KeystrokeAction action = KeystrokeAction::None;
std::wstring text;
};
// Single-producer / single-consumer lock-free ring buffer. The producer is the
// low-level keyboard hook thread; the consumer is the composition thread.
template<typename T, size_t N>
class SpscRing
{
public:
bool try_push(const T& v)
{
const auto head = _head.load(std::memory_order_relaxed);
const auto next = (head + 1) % N;
if (next == _tail.load(std::memory_order_acquire))
{
return false; // full
}
_buf[head] = v;
_head.store(next, std::memory_order_release);
return true;
}
bool try_pop(T& out)
{
const auto tail = _tail.load(std::memory_order_relaxed);
if (tail == _head.load(std::memory_order_acquire))
{
return false; // empty
}
out = _buf[tail];
_tail.store((tail + 1) % N, std::memory_order_release);
return true;
}
private:
std::array<T, N> _buf{};
std::atomic<size_t> _head{ 0 };
std::atomic<size_t> _tail{ 0 };
};
}

View File

@@ -4,9 +4,6 @@
#include "pch.h"
#include "MouseHighlighter.h"
#include "trace.h"
#include "Keystrokes/KeyboardCapture.h"
#include "Keystrokes/KeystrokeProcessor.h"
#include "Keystrokes/KeystrokeRenderer.h"
#include <cmath>
#include <algorithm>
#include <memory>
@@ -65,17 +62,6 @@ private:
// a mouse button is held and restores it on release.
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
// Keystroke overlay (Input Highlighter) lifecycle + draining.
void StartKeystrokes();
void StopKeystrokes();
void DrainKeystrokes();
D2D1_RECT_F ComputeKeystrokeAnchorRect() const;
// Overlay control-shortcut actions (posted from the capture hook).
void CycleKeystrokeMonitor();
void CycleKeystrokeDisplayMode();
static BOOL CALLBACK MonitorEnumProc(HMONITOR monitor, HDC hdc, LPRECT rect, LPARAM data) noexcept;
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -88,10 +74,6 @@ private:
HWND m_hwnd = NULL;
HINSTANCE m_hinstance = NULL;
static constexpr DWORD WM_SWITCH_ACTIVATION_MODE = WM_APP;
static constexpr DWORD WM_DRAIN_KEYSTROKES = WM_APP + 1;
static constexpr DWORD WM_KEYSTROKE_HOTKEY = WM_APP + 2;
static constexpr UINT_PTR KEYSTROKE_TICK_TIMER_ID = 130;
static constexpr UINT KEYSTROKE_TICK_INTERVAL_MS = 100;
winrt::DispatcherQueueController m_dispatcherQueueController{ nullptr };
winrt::Compositor m_compositor{ nullptr };
@@ -155,18 +137,6 @@ private:
winrt::Windows::UI::Color m_leftClickColor = MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR;
winrt::Windows::UI::Color m_rightClickColor = MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR;
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
// Input Highlighter: sub-toggles + keystroke overlay pipeline.
bool m_showMouse = INPUT_HIGHLIGHTER_DEFAULT_SHOW_MOUSE;
bool m_showKeystrokes = INPUT_HIGHLIGHTER_DEFAULT_SHOW_KEYSTROKES;
InputHighlighter::KeystrokeRendererSettings m_keystrokeSettings;
InputHighlighter::KeyboardCapture m_keyboardCapture;
InputHighlighter::KeystrokeProcessor m_keystrokeProcessor;
InputHighlighter::KeystrokeRenderer m_keystrokeRenderer;
// Overlay control shortcuts + current monitor override (-1 = follow cursor).
InputHighlighter::HotkeyChord m_switchMonitorHotkey;
InputHighlighter::HotkeyChord m_switchDisplayModeHotkey;
int m_keystrokeMonitorOverride = -1;
};
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
@@ -235,18 +205,6 @@ bool Highlighter::CreateHighlighter()
m_overlay.IsVisible(false);
m_root.Children().InsertAtTop(m_overlay);
// Initialize the keystroke overlay renderer on the same visual tree. If it
// fails (e.g. no D3D device), keystroke display is disabled but mouse
// highlighting continues to work.
if (!m_keystrokeRenderer.Initialize(m_compositor, m_root, m_hwnd))
{
Logger::warn("Keystroke renderer initialization failed; keystroke overlay disabled.");
}
else
{
m_keystrokeRenderer.ApplySettings(m_keystrokeSettings);
}
return true;
}
catch (...)
@@ -680,16 +638,9 @@ void Highlighter::StartDrawing()
ClearDrawing();
ShowWindow(m_hwnd, SW_SHOWNOACTIVATE);
if (m_showMouse)
{
instance->AddDrawingPoint(Highlighter::MouseButton::None);
m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, m_hinstance, 0);
}
instance->AddDrawingPoint(Highlighter::MouseButton::None);
if (m_showKeystrokes)
{
StartKeystrokes();
}
m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, m_hinstance, 0);
}
void Highlighter::StopDrawing()
@@ -713,13 +664,9 @@ void Highlighter::StopDrawing()
m_overlay.IsVisible(false);
}
ShowWindow(m_hwnd, SW_HIDE);
if (m_mouseHook)
{
UnhookWindowsHookEx(m_mouseHook);
m_mouseHook = NULL;
}
StopKeystrokes();
UnhookWindowsHookEx(m_mouseHook);
ClearDrawing();
m_mouseHook = NULL;
}
void Highlighter::SwitchActivationMode()
@@ -727,181 +674,6 @@ void Highlighter::SwitchActivationMode()
PostMessage(m_hwnd, WM_SWITCH_ACTIVATION_MODE, 0, 0);
}
// Compute the anchor rectangle (in overlay client coordinates) for the keystroke
// stack. Anchors to the override monitor if the user cycled displays, otherwise
// to the monitor under the cursor. The overlay's client origin is the
// virtual-screen top-left (+1px, see StartDrawing).
D2D1_RECT_F Highlighter::ComputeKeystrokeAnchorRect() const
{
const int originX = GetSystemMetrics(SM_XVIRTUALSCREEN) + 1;
const int originY = GetSystemMetrics(SM_YVIRTUALSCREEN) + 1;
RECT work{};
// If the user cycled to a specific monitor, anchor there.
if (m_keystrokeMonitorOverride >= 0)
{
std::vector<MONITORINFO> monitors;
EnumDisplayMonitors(nullptr, nullptr, MonitorEnumProc, reinterpret_cast<LPARAM>(&monitors));
if (m_keystrokeMonitorOverride < static_cast<int>(monitors.size()))
{
work = monitors[m_keystrokeMonitorOverride].rcWork;
}
}
// Otherwise anchor to the monitor under the cursor so the overlay follows the
// user across displays; fall back to the primary work area.
if (work.right == 0 && work.bottom == 0)
{
POINT pt{};
HMONITOR mon = nullptr;
if (GetCursorPos(&pt))
{
mon = MonitorFromPoint(pt, MONITOR_DEFAULTTOPRIMARY);
}
if (mon)
{
MONITORINFO mi{ sizeof(mi) };
if (GetMonitorInfo(mon, &mi))
{
work = mi.rcWork;
}
}
}
if (work.right == 0 && work.bottom == 0)
{
if (!SystemParametersInfo(SPI_GETWORKAREA, 0, &work, 0))
{
work = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
}
}
return D2D1_RECT_F{
static_cast<float>(work.left - originX),
static_cast<float>(work.top - originY),
static_cast<float>(work.right - originX),
static_cast<float>(work.bottom - originY),
};
}
BOOL CALLBACK Highlighter::MonitorEnumProc(HMONITOR monitor, HDC /*hdc*/, LPRECT /*rect*/, LPARAM data) noexcept
{
auto* monitors = reinterpret_cast<std::vector<MONITORINFO>*>(data);
MONITORINFO mi{ sizeof(mi) };
if (GetMonitorInfo(monitor, &mi))
{
monitors->push_back(mi);
}
return TRUE;
}
// Move the keystroke overlay to the next monitor (wrapping). Starts from the
// monitor under the cursor the first time the user cycles.
void Highlighter::CycleKeystrokeMonitor()
{
std::vector<MONITORINFO> monitors;
EnumDisplayMonitors(nullptr, nullptr, MonitorEnumProc, reinterpret_cast<LPARAM>(&monitors));
const int count = static_cast<int>(monitors.size());
if (count == 0)
{
return;
}
int index = m_keystrokeMonitorOverride;
if (index < 0 || index >= count)
{
// Seed from the monitor currently under the cursor.
index = 0;
POINT pt{};
if (GetCursorPos(&pt))
{
for (int i = 0; i < count; ++i)
{
if (PtInRect(&monitors[i].rcMonitor, pt))
{
index = i;
break;
}
}
}
}
m_keystrokeMonitorOverride = (index + 1) % count;
m_keystrokeRenderer.SetAnchorRect(ComputeKeystrokeAnchorRect());
}
// Cycle to the next keystroke display mode at runtime (not persisted).
void Highlighter::CycleKeystrokeDisplayMode()
{
const int next = (static_cast<int>(m_keystrokeSettings.displayMode) + 1) % 4;
m_keystrokeSettings.displayMode = static_cast<InputHighlighter::DisplayMode>(next);
m_keystrokeProcessor.ResetBuffer();
m_keystrokeRenderer.Clear();
m_keystrokeRenderer.ApplySettings(m_keystrokeSettings);
}
void Highlighter::StartKeystrokes()
{
if (!m_keystrokeRenderer.IsInitialized())
{
return;
}
m_keystrokeProcessor.ResetBuffer();
m_keystrokeRenderer.ApplySettings(m_keystrokeSettings);
m_keystrokeRenderer.SetAnchorRect(ComputeKeystrokeAnchorRect());
HWND hwnd = m_hwnd;
m_keyboardCapture.SetHotkeyHandler([hwnd](int hotkeyId) {
// Runs on the capture thread: hand off to the composition thread.
PostMessage(hwnd, WM_KEYSTROKE_HOTKEY, static_cast<WPARAM>(hotkeyId), 0);
});
m_keyboardCapture.SetHotkeys(m_switchMonitorHotkey, m_switchDisplayModeHotkey);
const bool started = m_keyboardCapture.Start([hwnd]() {
// Runs on the capture thread: hand off to the composition thread to drain.
PostMessage(hwnd, WM_DRAIN_KEYSTROKES, 0, 0);
});
if (started)
{
SetTimer(m_hwnd, KEYSTROKE_TICK_TIMER_ID, KEYSTROKE_TICK_INTERVAL_MS, nullptr);
}
else
{
Logger::warn("Failed to start keyboard capture for keystroke overlay.");
}
}
void Highlighter::StopKeystrokes()
{
m_keyboardCapture.Stop();
KillTimer(m_hwnd, KEYSTROKE_TICK_TIMER_ID);
m_keystrokeRenderer.Clear();
m_keystrokeProcessor.ResetBuffer();
m_keystrokeMonitorOverride = -1;
}
void Highlighter::DrainKeystrokes()
{
if (!m_keystrokeRenderer.IsInitialized())
{
return;
}
const auto mode = static_cast<InputHighlighter::DisplayMode>(m_keystrokeSettings.displayMode);
InputHighlighter::KeystrokeEvent e;
while (m_keyboardCapture.TryPop(e))
{
const auto result = m_keystrokeProcessor.Process(e, mode);
if (result.action != InputHighlighter::KeystrokeAction::None)
{
m_keystrokeRenderer.OnResult(result);
}
}
}
void Highlighter::ApplySettings(MouseHighlighterSettings settings)
{
m_radius = static_cast<float>(settings.radius);
@@ -921,41 +693,6 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rippleShowDragTrail = settings.rippleShowDragTrail;
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
// Input Highlighter sub-toggles.
m_showMouse = settings.showMouse;
m_showKeystrokes = settings.showKeystrokes;
// Translate keystroke settings into the renderer's settings snapshot.
m_keystrokeSettings.displayMode = static_cast<InputHighlighter::DisplayMode>(settings.keystrokeDisplayMode);
m_keystrokeSettings.position = static_cast<InputHighlighter::KeystrokePosition>(settings.keystrokePosition);
m_keystrokeSettings.timeoutMs = settings.keystrokeTimeoutMs;
m_keystrokeSettings.textSize = static_cast<float>(settings.keystrokeTextSize);
m_keystrokeSettings.textColor = settings.keystrokeTextColor;
m_keystrokeSettings.backgroundColor = settings.keystrokeBackgroundColor;
m_keystrokeSettings.strokeColor = settings.keystrokeStrokeColor;
m_keystrokeSettings.strokeThickness = settings.keystrokeStrokeThickness;
// Overlay control shortcuts; push live so changes take effect without restart.
m_switchMonitorHotkey = settings.keystrokeSwitchMonitorHotkey;
m_switchDisplayModeHotkey = settings.keystrokeSwitchDisplayModeHotkey;
m_keyboardCapture.SetHotkeys(m_switchMonitorHotkey, m_switchDisplayModeHotkey);
if (m_keystrokeRenderer.IsInitialized())
{
m_keystrokeRenderer.ApplySettings(m_keystrokeSettings);
}
// When the mouse half is disabled, force all pointer visuals off so only the
// keystroke overlay is active.
if (!m_showMouse)
{
m_leftPointerEnabled = false;
m_rightPointerEnabled = false;
m_alwaysPointerEnabled = false;
m_spotlightMode = false;
m_rippleMode = false;
}
// Reset transient pressed-state flag so a settings change while a button
// happens to be down doesn't leave the spotlight stuck at a shrunken size.
m_spotlightPressed = false;
@@ -1016,19 +753,6 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->StartDrawing();
}
break;
case WM_DRAIN_KEYSTROKES:
instance->DrainKeystrokes();
break;
case WM_KEYSTROKE_HOTKEY:
if (wParam == 0)
{
instance->CycleKeystrokeMonitor();
}
else if (wParam == 1)
{
instance->CycleKeystrokeDisplayMode();
}
break;
case WM_DESTROY:
instance->DestroyHighlighter();
break;
@@ -1070,9 +794,6 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->SpawnRippleHoldDot(MouseButton::Right);
}
break;
case KEYSTROKE_TICK_TIMER_ID:
instance->m_keystrokeRenderer.Tick();
break;
}
break;
}

View File

@@ -1,8 +1,6 @@
#pragma once
#include "pch.h"
#include "Keystrokes/KeystrokeTypes.h"
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
@@ -16,18 +14,6 @@ constexpr double MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY = 0.7;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS = 480;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL = true;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE = true;
// Input Highlighter keystroke-overlay defaults.
const winrt::Windows::UI::Color INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TEXT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255);
const winrt::Windows::UI::Color INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 0, 0, 0);
const winrt::Windows::UI::Color INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_STROKE_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 255, 255);
constexpr int INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_STROKE_THICKNESS = 0;
constexpr bool INPUT_HIGHLIGHTER_DEFAULT_SHOW_MOUSE = true;
constexpr bool INPUT_HIGHLIGHTER_DEFAULT_SHOW_KEYSTROKES = true;
constexpr int INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_DISPLAY_MODE = 0; // Last5
constexpr int INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_POSITION = 4; // BottomCenter
constexpr int INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TIMEOUT_MS = 3000;
constexpr int INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TEXT_SIZE = 24;
constexpr bool INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_DRAGGABLE = true;
struct MouseHighlighterSettings
{
@@ -45,22 +31,6 @@ struct MouseHighlighterSettings
int rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
// Input Highlighter sub-toggles + keystroke overlay settings.
bool showMouse = INPUT_HIGHLIGHTER_DEFAULT_SHOW_MOUSE;
bool showKeystrokes = INPUT_HIGHLIGHTER_DEFAULT_SHOW_KEYSTROKES;
int keystrokeDisplayMode = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_DISPLAY_MODE;
int keystrokePosition = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_POSITION;
int keystrokeTimeoutMs = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TIMEOUT_MS;
int keystrokeTextSize = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TEXT_SIZE;
winrt::Windows::UI::Color keystrokeTextColor = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_TEXT_COLOR;
winrt::Windows::UI::Color keystrokeBackgroundColor = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_BACKGROUND_COLOR;
winrt::Windows::UI::Color keystrokeStrokeColor = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_STROKE_COLOR;
int keystrokeStrokeThickness = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_STROKE_THICKNESS;
bool keystrokeDraggable = INPUT_HIGHLIGHTER_DEFAULT_KEYSTROKE_DRAGGABLE;
// Overlay control shortcuts (handled inside the keystroke capture hook).
// Defaults mirror the C# model: Ctrl+Win+/ and Ctrl+Win+D.
InputHighlighter::HotkeyChord keystrokeSwitchMonitorHotkey{ true, false, false, true, 0xBF };
InputHighlighter::HotkeyChord keystrokeSwitchDisplayModeHotkey{ true, false, false, true, 0x44 };
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -87,11 +87,6 @@
<ClInclude Include="pch.h" />
<ClInclude Include="trace.h" />
<ClInclude Include="resource.h" />
<ClInclude Include="Keystrokes\KeystrokeTypes.h" />
<ClInclude Include="Keystrokes\KeystrokeFormatter.h" />
<ClInclude Include="Keystrokes\KeystrokeProcessor.h" />
<ClInclude Include="Keystrokes\KeyboardCapture.h" />
<ClInclude Include="Keystrokes\KeystrokeRenderer.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="dllmain.cpp" />
@@ -100,18 +95,6 @@
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="trace.cpp" />
<ClCompile Include="Keystrokes\KeystrokeFormatter.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="Keystrokes\KeystrokeProcessor.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="Keystrokes\KeyboardCapture.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="Keystrokes\KeystrokeRenderer.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="MouseHighlighter.rc" />

View File

@@ -13,18 +13,6 @@
<ClCompile Include="MouseHighlighter.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="Keystrokes\KeystrokeFormatter.cpp">
<Filter>Keystrokes</Filter>
</ClCompile>
<ClCompile Include="Keystrokes\KeystrokeProcessor.cpp">
<Filter>Keystrokes</Filter>
</ClCompile>
<ClCompile Include="Keystrokes\KeyboardCapture.cpp">
<Filter>Keystrokes</Filter>
</ClCompile>
<ClCompile Include="Keystrokes\KeystrokeRenderer.cpp">
<Filter>Keystrokes</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="pch.h">
@@ -39,21 +27,6 @@
<ClInclude Include="MouseHighlighter.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="Keystrokes\KeystrokeTypes.h">
<Filter>Keystrokes</Filter>
</ClInclude>
<ClInclude Include="Keystrokes\KeystrokeFormatter.h">
<Filter>Keystrokes</Filter>
</ClInclude>
<ClInclude Include="Keystrokes\KeystrokeProcessor.h">
<Filter>Keystrokes</Filter>
</ClInclude>
<ClInclude Include="Keystrokes\KeyboardCapture.h">
<Filter>Keystrokes</Filter>
</ClInclude>
<ClInclude Include="Keystrokes\KeystrokeRenderer.h">
<Filter>Keystrokes</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<Filter Include="Source Files">
@@ -64,9 +37,6 @@
<UniqueIdentifier>{c8345550-9836-40a0-b473-0f4bf6129568}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Keystrokes">
<UniqueIdentifier>{a4f2d3e1-6b7c-4c9a-8f2e-2d1c3b4a5e6f}</UniqueIdentifier>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{7934ee5b-8427-486d-9324-73b6bcf60eed}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>

View File

@@ -27,20 +27,6 @@ namespace
const wchar_t JSON_KEY_RIPPLE_DURATION_MS[] = L"ripple_duration_ms";
const wchar_t JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL[] = L"ripple_show_drag_trail";
const wchar_t JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE[] = L"ripple_show_release_pulse";
// Input Highlighter keystroke-overlay keys.
const wchar_t JSON_KEY_SHOW_MOUSE[] = L"show_mouse";
const wchar_t JSON_KEY_SHOW_KEYSTROKES[] = L"show_keystrokes";
const wchar_t JSON_KEY_KEYSTROKE_DISPLAY_MODE[] = L"keystroke_display_mode";
const wchar_t JSON_KEY_KEYSTROKE_POSITION[] = L"keystroke_position";
const wchar_t JSON_KEY_KEYSTROKE_TIMEOUT_MS[] = L"keystroke_timeout_ms";
const wchar_t JSON_KEY_KEYSTROKE_TEXT_SIZE[] = L"keystroke_text_size";
const wchar_t JSON_KEY_KEYSTROKE_TEXT_COLOR[] = L"keystroke_text_color";
const wchar_t JSON_KEY_KEYSTROKE_BACKGROUND_COLOR[] = L"keystroke_background_color";
const wchar_t JSON_KEY_KEYSTROKE_STROKE_COLOR[] = L"keystroke_stroke_color";
const wchar_t JSON_KEY_KEYSTROKE_STROKE_THICKNESS[] = L"keystroke_stroke_thickness";
const wchar_t JSON_KEY_KEYSTROKE_DRAGGABLE[] = L"keystroke_draggable";
const wchar_t JSON_KEY_KEYSTROKE_SWITCH_MONITOR_HOTKEY[] = L"keystroke_switch_monitor_hotkey";
const wchar_t JSON_KEY_KEYSTROKE_SWITCH_DISPLAY_MODE_HOTKEY[] = L"keystroke_switch_display_mode_hotkey";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -496,199 +482,6 @@ public:
{
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
}
// ---- Input Highlighter sub-toggles + keystroke overlay ----
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHOW_MOUSE);
highlightSettings.showMouse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize show mouse from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHOW_KEYSTROKES);
highlightSettings.showKeystrokes = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize show keystrokes from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_DISPLAY_MODE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0 && value <= 3)
{
highlightSettings.keystrokeDisplayMode = value;
}
else
{
throw std::runtime_error("Invalid keystroke display mode value");
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke display mode from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_POSITION);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0 && value <= 5)
{
highlightSettings.keystrokePosition = value;
}
else
{
throw std::runtime_error("Invalid keystroke position value");
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke position from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_TIMEOUT_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.keystrokeTimeoutMs = value;
}
else
{
throw std::runtime_error("Invalid keystroke timeout value");
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke timeout from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_TEXT_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.keystrokeTextSize = value;
}
else
{
throw std::runtime_error("Invalid keystroke text size value");
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke text size from settings. Will use default value");
}
// Text/background/stroke colors are stored as "#AARRGGBB" (alpha baked in).
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_TEXT_COLOR);
auto textColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t a, r, g, b;
if (!checkValidARGB(textColor, &a, &r, &g, &b))
{
Logger::error("Keystroke text color ARGB value is invalid. Will use default value");
}
else
{
highlightSettings.keystrokeTextColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b);
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke text color from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_BACKGROUND_COLOR);
auto backgroundColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t a, r, g, b;
if (!checkValidARGB(backgroundColor, &a, &r, &g, &b))
{
Logger::error("Keystroke background color ARGB value is invalid. Will use default value");
}
else
{
highlightSettings.keystrokeBackgroundColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b);
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke background color from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_STROKE_COLOR);
auto strokeColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t a, r, g, b;
if (!checkValidARGB(strokeColor, &a, &r, &g, &b))
{
Logger::error("Keystroke stroke color ARGB value is invalid. Will use default value");
}
else
{
highlightSettings.keystrokeStrokeColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b);
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke stroke color from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_STROKE_THICKNESS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
highlightSettings.keystrokeStrokeThickness = value;
}
}
catch (...)
{
Logger::warn("Failed to initialize keystroke stroke thickness from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_DRAGGABLE);
highlightSettings.keystrokeDraggable = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize keystroke draggable from settings. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_SWITCH_MONITOR_HOTKEY);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
highlightSettings.keystrokeSwitchMonitorHotkey.win = hotkey.win_pressed();
highlightSettings.keystrokeSwitchMonitorHotkey.ctrl = hotkey.ctrl_pressed();
highlightSettings.keystrokeSwitchMonitorHotkey.shift = hotkey.shift_pressed();
highlightSettings.keystrokeSwitchMonitorHotkey.alt = hotkey.alt_pressed();
highlightSettings.keystrokeSwitchMonitorHotkey.vk = hotkey.get_code();
}
catch (...)
{
Logger::warn("Failed to initialize keystroke switch-monitor shortcut. Will use default value");
}
try
{
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_KEYSTROKE_SWITCH_DISPLAY_MODE_HOTKEY);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
highlightSettings.keystrokeSwitchDisplayModeHotkey.win = hotkey.win_pressed();
highlightSettings.keystrokeSwitchDisplayModeHotkey.ctrl = hotkey.ctrl_pressed();
highlightSettings.keystrokeSwitchDisplayModeHotkey.shift = hotkey.shift_pressed();
highlightSettings.keystrokeSwitchDisplayModeHotkey.alt = hotkey.alt_pressed();
highlightSettings.keystrokeSwitchDisplayModeHotkey.vk = hotkey.get_code();
}
catch (...)
{
Logger::warn("Failed to initialize keystroke switch-display-mode shortcut. Will use default value");
}
}
else
{

View File

@@ -39,7 +39,6 @@
</ItemGroup>
<ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>

View File

@@ -119,7 +119,7 @@
</resheader>
<data name="context_menu_item_new" xml:space="preserve">
<value>New+</value>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
</data>
<data name="context_menu_item_open_templates" xml:space="preserve">
<value>Open templates</value>

View File

@@ -119,7 +119,7 @@
</resheader>
<data name="context_menu_item_new" xml:space="preserve">
<value>New+</value>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
</data>
<data name="context_menu_item_open_templates" xml:space="preserve">
<value>Open templates</value>

View File

@@ -1,295 +0,0 @@
PackageName: +Microsoft.OutlookForWindows
Name: Outlook (new)
WindowFilter: "olk.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Frequently used
Properties:
- Name: Create new message or calendar event
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Send email
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Enter>"
- Name: Reply to email
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- Name: Reply all
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- R
- Name: Forward message
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- F
- Name: Delete message or item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Delete>"
- Name: Permanently delete message
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- "<Delete>"
- Name: Mark as read
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Q
- Name: Mark as unread
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- Name: Search
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- SectionName: Navigate Outlook
Properties:
- Name: Go to Mail
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<1>"
- Name: Go to Calendar
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<2>"
- Name: Go to People
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<3>"
- Name: Go to To Do
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<4>"
- Name: Go to next message
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- ">"
- Name: Go to previous message
Description: Press Ctrl+Shift+Comma (rendered as Ctrl+<) to go back.
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- ","
- SectionName: Text editing
Properties:
- Name: Cut
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Copy
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Paste
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- V
- Name: Undo
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Y
- Name: Insert hyperlink
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- K
- Name: Delete word to the left
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Backspace>"
- SectionName: Format text
Properties:
- Name: Bold
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Italic
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Underline
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- SectionName: Calendar
Properties:
- Name: Create new calendar item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Switch to Day view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<1>"
- Name: Switch to Work week view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<2>"
- Name: Switch to full Week view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<3>"
- Name: Switch to Month view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<4>"
- Name: Go to previous time period
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<Left>"
- Name: Go to next time period
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- "<Right>"
- Name: Go to today
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: true
Keys:
- Y

View File

@@ -1,226 +0,0 @@
PackageName: AgileBits.1Password
Name: 1Password
WindowFilter: "1Password.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Basics
Properties:
- Name: View keyboard shortcuts
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- "/"
- Name: Show Quick Access
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- "<Space>"
- Name: Lock 1Password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- L
- SectionName: Navigation
Properties:
- Name: Find
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- F
- Name: Switch to all accounts
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<1>"
- Name: "Switch accounts & collections"
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- '2 - 9'
- Name: Back
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Left>"
- Name: Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Right>"
- Name: Focus next row
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Down>"
- Name: Focus previous row
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Up>"
- Name: Focus right section
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Right>"
- Name: Focus left section
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Left>"
- SectionName: Selected item
Properties:
- Name: Copy primary field
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Copy password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- C
- Name: Copy one-time password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- C
- Name: "Open & fill in web browser"
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- F
- Name: Open item in new window
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- O
- Name: Edit item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Save item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Reveal concealed fields
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- Name: Archive item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Delete>"
- Name: Delete item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Delete>"
- SectionName: View
Properties:
- Name: Show/hide sidebar
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- D
- Name: Zoom in
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "+"
- Name: Zoom out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "-"
- Name: Actual size
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<0>"

View File

@@ -1,196 +0,0 @@
PackageName: Obsidian.Obsidian
Name: Obsidian
WindowFilter: "Obsidian.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Navigation
Properties:
- Name: Open command palette
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- P
- Name: Open quick switcher
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- O
- Name: Search in all files
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- F
- Name: Toggle reading/editing view
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- SectionName: Tabs
Properties:
- Name: New tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- T
- Name: Next tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Tab>"
- Name: Previous tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- "<Tab>"
- Name: Switch to tab at position (18)
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- '1 - 8'
- Name: Switch to last tab
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<9>"
- Name: Reopen last closed tab
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- T
- SectionName: Editing
Properties:
- Name: Bold
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Italic
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Undo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Y
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Z
- Name: Paste without formatting
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- V
- Name: Select all
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- A
- Name: Delete current line
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- K
- Name: Delete previous word
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Backspace>"
- Name: Delete next word
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Delete>"
- Name: Move cursor to beginning of note
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Home>"
- Name: Move cursor to end of note
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<End>"
- Name: Add file property
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- ";"

View File

@@ -1,126 +0,0 @@
PackageName: Zoom.Zoom
Name: Zoom Workspace
WindowFilter: "zoom.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: General
Properties:
- Name: Navigate between Zoom windows
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Show or hide floating meeting controls
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: true
Keys:
- H
- SectionName: View
Properties:
- Name: Switch to active speaker view
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F1
- Name: Switch to gallery view
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F2
- Name: Enter or exit full screen
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F
- Name: View previous page in gallery
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: View next page in gallery
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Meeting controls
Properties:
- Name: Mute or unmute audio
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- A
- Name: Start or stop video
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- V
- Name: Raise or lower hand
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- Y
- Name: Open invite window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- I
- Name: Open share screen window or stop sharing
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Pause or resume screen sharing
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- T
- Name: Prompt to leave or end meeting
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- Q

View File

@@ -57,17 +57,7 @@ namespace ShortcutGuide
return _currentApplicationIds;
});
var title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
// control while it reads AppWindow.Title during a deferred layout pass.
if (string.IsNullOrEmpty(title))
{
title = "Shortcut Guide";
}
Title = title;
Title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
ExtendsContentIntoTitleBar = true;
#if !DEBUG

View File

@@ -171,7 +171,6 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,230,18
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10
@@ -183,6 +182,8 @@ BEGIN
LTEXT "4.0",IDC_STATIC,190,136,12,8
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18
END
DRAW DIALOGEX 0, 0, 260, 228
@@ -314,31 +315,26 @@ BEGIN
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
END
SNIP DIALOGEX 0, 0, 272, 105
SNIP DIALOGEX 0, 0, 260, 80
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Copy a region of the screen to the clipboard, or save it to a file using the save shortcut.",IDC_STATIC,7,7,230,18
RTEXT "Snip Toggle:",IDC_STATIC,22,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,32,80,12
RTEXT "Snip Save Toggle:",IDC_STATIC,7,49,60,8
CONTROL "",IDC_SNIP_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,48,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,66,230,10
RTEXT "Text Toggle:",IDC_STATIC,12,82,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,81,80,12
LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.",IDC_STATIC,7,7,230,19
LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,32,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,50,230,10
LTEXT "Text Toggle:",IDC_STATIC,7,65,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,63,80,12
END
PANORAMA DIALOGEX 0, 0, 260, 140
PANORAMA DIALOGEX 0, 0, 260, 105
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas.",IDC_STATIC,7,7,245,30
LTEXT "Press the panorama toggle again to copy to the clipboard, or use the save shortcut to save to a file.",IDC_STATIC,7,39,245,18
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,62,245,30
LTEXT "Panorama Toggle:",IDC_STATIC,7,95,80,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,93,80,12
LTEXT "Panorama Save Toggle:",IDC_STATIC,7,111,80,8
CONTROL "",IDC_SNIP_PANORAMA_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,109,80,12
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas. Press the hotkey again or with Shift to save to a file.",IDC_STATIC,7,7,245,33
LTEXT "Panorama Toggle:",IDC_STATIC,7,74,63,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,72,80,12
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,41,245,30
END
DEMOTYPE DIALOGEX 0, 0, 260, 249
@@ -460,9 +456,7 @@ BEGIN
"SNIP", DIALOG
BEGIN
LEFTMARGIN, 7
RIGHTMARGIN, 265
TOPMARGIN, 7
BOTTOMMARGIN, 98
END
"PANORAMA", DIALOG

View File

@@ -17,9 +17,7 @@ DWORD g_BreakToggleKey = ((HOTKEYF_CONTROL) << 8)| '3';
DWORD g_DemoTypeToggleKey = ((HOTKEYF_CONTROL) << 8) | '7';
DWORD g_RecordToggleKey = ((HOTKEYF_CONTROL) << 8) | '5';
DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
DWORD g_SnipSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '6';
DWORD g_SnipPanoramaToggleKey = ((HOTKEYF_CONTROL) << 8) | '8';
DWORD g_SnipPanoramaSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '8';
DWORD g_SnipOcrToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_ALT) << 8) | '6';
DWORD g_ShowExpiredTime = 1;
@@ -82,9 +80,7 @@ REG_SETTING RegSettings[] = {
{ L"DrawToggleKey", SETTING_TYPE_DWORD, 0, &g_DrawToggleKey, static_cast<DOUBLE>(g_DrawToggleKey) },
{ L"RecordToggleKey", SETTING_TYPE_DWORD, 0, &g_RecordToggleKey, static_cast<DOUBLE>(g_RecordToggleKey) },
{ L"SnipToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipToggleKey, static_cast<DOUBLE>(g_SnipToggleKey) },
{ L"SnipSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipSaveToggleKey, static_cast<DOUBLE>(g_SnipSaveToggleKey) },
{ L"SnipPanoramaToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaToggleKey, static_cast<DOUBLE>(g_SnipPanoramaToggleKey) },
{ L"SnipPanoramaSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaSaveToggleKey, static_cast<DOUBLE>(g_SnipPanoramaSaveToggleKey) },
{ L"SnipOcrToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipOcrToggleKey, static_cast<DOUBLE>(g_SnipOcrToggleKey) },
{ L"PenColor", SETTING_TYPE_DWORD, 0, &g_PenColor, static_cast<DOUBLE>(g_PenColor) },
{ L"PenWidth", SETTING_TYPE_DWORD, 0, &g_RootPenWidth, static_cast<DOUBLE>(g_RootPenWidth) },

View File

@@ -174,8 +174,6 @@ DWORD g_RecordToggleMod;
DWORD g_SnipToggleMod;
DWORD g_SnipPanoramaToggleMod;
DWORD g_SnipOcrToggleMod;
DWORD g_SnipSaveToggleMod;
DWORD g_SnipPanoramaSaveToggleMod;
BOOLEAN g_ZoomOnLiveZoom = FALSE;
DWORD g_PenWidth = PEN_WIDTH;
@@ -214,10 +212,7 @@ BOOL g_RecordToggle = FALSE;
BOOL g_RecordCropping = FALSE;
SelectRectangle g_SelectRectangle;
WebcamPreviewWindow g_WebcamPreview;
// The full path of the last saved recording file.
std::wstring g_RecordingSaveLocation;
// The last user-chosen recording filename. Used to construct unique recording filenames.
std::wstring g_RecordingSaveBaseFilename;
std::wstring g_ScreenshotSaveLocation;
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr;
@@ -3587,16 +3582,12 @@ void RegisterAllHotkeys(HWND hWnd)
}
if (g_SnipToggleKey) {
registerHotkey( SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF );
registerHotkey( SNIP_SAVE_HOTKEY, ( g_SnipToggleMod ^ MOD_SHIFT ), g_SnipToggleKey & 0xFF );
}
if (g_SnipSaveToggleKey) {
registerHotkey( SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF);
}
if (g_SnipPanoramaToggleKey &&
if( g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) ) {
registerHotkey( SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
}
if (g_SnipPanoramaSaveToggleKey) {
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF );
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
}
if (g_SnipOcrToggleKey) {
registerHotkey( SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF );
@@ -4825,8 +4816,6 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
TCHAR text[32];
DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey, newSnipPanoramaToggleKey, newSnipOcrToggleKey;
DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod, newSnipPanoramaToggleMod, newSnipOcrToggleMod;
DWORD newSnipSaveToggleKey, newSnipSaveToggleMod;
DWORD newSnipPanoramaSaveToggleKey, newSnipPanoramaSaveToggleMod;
DWORD newLiveZoomToggleKey, newLiveZoomToggleMod;
static std::vector<std::pair<std::wstring, std::wstring>> microphones;
@@ -5061,9 +5050,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 );
if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 );
if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 );
if( g_SnipSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipSaveToggleKey, 0 );
if( g_SnipPanoramaToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaToggleKey, 0 );
if( g_SnipPanoramaSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaSaveToggleKey, 0 );
if( g_SnipOcrToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_SETHOTKEY, g_SnipOcrToggleKey, 0 );
CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON,
g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED );
@@ -5525,9 +5512,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_GETHOTKEY, 0, 0 ));
newRecordToggleKey = static_cast<DWORD>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_GETHOTKEY, 0, 0));
newSnipToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipOcrToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newToggleMod = GetKeyMod( newToggleKey );
@@ -5537,9 +5522,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleMod = GetKeyMod( newDemoTypeToggleKey );
newRecordToggleMod = GetKeyMod(newRecordToggleKey);
newSnipToggleMod = GetKeyMod( newSnipToggleKey );
newSnipSaveToggleMod = GetKeyMod( newSnipSaveToggleKey );
newSnipPanoramaToggleMod = GetKeyMod( newSnipPanoramaToggleKey );
newSnipPanoramaSaveToggleMod = GetKeyMod( newSnipPanoramaSaveToggleKey );
newSnipOcrToggleMod = GetKeyMod( newSnipOcrToggleKey );
g_SliderZoomLevel = static_cast<int>(SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_GETPOS, 0, 0 ));
@@ -5608,41 +5591,25 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
}
else if (newSnipToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF)) {
(!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) {
MessageBox(hDlg, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, newSnipSaveToggleMod, newSnipSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaToggleKey &&
(newSnipPanoramaToggleKey != newSnipToggleKey || newSnipPanoramaToggleMod != newSnipToggleMod) &&
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF)) {
(!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, ( newSnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF))) {
MessageBox(hDlg, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, newSnipPanoramaSaveToggleMod | MOD_NOREPEAT, newSnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipOcrToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_OCR_HOTKEY, newSnipOcrToggleMod, newSnipOcrToggleKey & 0xFF)) {
@@ -5678,12 +5645,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
g_RecordToggleMod = newRecordToggleMod;
g_SnipToggleKey = newSnipToggleKey;
g_SnipToggleMod = newSnipToggleMod;
g_SnipSaveToggleKey = newSnipSaveToggleKey;
g_SnipSaveToggleMod = newSnipSaveToggleMod;
g_SnipPanoramaToggleKey = newSnipPanoramaToggleKey;
g_SnipPanoramaToggleMod = newSnipPanoramaToggleMod;
g_SnipPanoramaSaveToggleKey = newSnipPanoramaSaveToggleKey;
g_SnipPanoramaSaveToggleMod = newSnipPanoramaSaveToggleMod;
g_SnipOcrToggleKey = newSnipOcrToggleKey;
g_SnipOcrToggleMod = newSnipOcrToggleMod;
reg.WriteRegSettings( RegSettings );
@@ -6774,45 +6737,6 @@ void StopRecording()
}
}
//----------------------------------------------------------------------------
// GetTimestampSuffix
//
// Returns a timestamp string for disambiguating filenames.
// Format: " YYYY-MM-DD HHMMSS", e.g." 2025-11-02 143000".
//
// Used as a suffix for the default recording filename. Ensures
// chronological name sorting in Explorer.
//
//----------------------------------------------------------------------------
static std::wstring GetTimestampSuffix()
{
auto const now = std::chrono::system_clock::now();
auto const in_time_t = std::chrono::system_clock::to_time_t( now );
std::tm buf{};
localtime_s( &buf, &in_time_t );
std::wstringstream ss;
ss << L" " << std::put_time( &buf, L"%Y-%m-%d %H%M%S" );
return ss.str();
}
//----------------------------------------------------------------------------
// IsDefaultRecordingFilename
//
// Determines if the provided filename matches the default recording name.
// Case-insensitive comparison.
//
// Returns:
// true if filename is the default; otherwise false.
//
//----------------------------------------------------------------------------
static bool IsDefaultRecordingFilename(const std::wstring& filename)
{
return CompareStringOrdinal( DEFAULT_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL
|| CompareStringOrdinal( DEFAULT_GIF_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL;
}
//----------------------------------------------------------------------------
//
@@ -6867,70 +6791,19 @@ std::wstring GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t*
//
// GetUniqueRecordingFilename
//
// Generates a unique filename to be suggested in the "Save As" recording
// dialog, based on the user's last chosen filename and save location.
// This allows the user to quickly save a recording without worrying about
// manual renaming to prevent overwriting earlier recordings.
//
// There are two distinct behaviors based on the last used filename:
//
// 1. For the default filename ("Recording.mp4"):
// Generates a more descriptive name by appending a timestamp, e.g.
// "Recording 2025-11-03 143015.mp4". This ensures chronological sorting
// in Explorer when ordered by name and is consistent with other tools.
//
// 2. For custom filenames (e.g. "Presentation.mp4"):
// Appends a numeric suffix if the file already exists, e.g.
// "Presentation (1).mp4", "Presentation (2).mp4", etc.
//
// Returns:
// A unique filename (without folder path).
//
// Relies upon the global state of `g_RecordingSaveLocation` and
// `g_RecordingSaveBaseFilename`.
// Gets a unique file name for recording saves, using the " (N)" suffix
// approach so that the user can hit OK without worrying about overwriting
// if they are making multiple recordings in one session or don't want to
// always see an overwrite dialog or stop to clean up files.
//
//----------------------------------------------------------------------------
static auto GetUniqueRecordingFilename()
auto GetUniqueRecordingFilename()
{
const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF)
? DEFAULT_GIF_RECORDING_FILE
: DEFAULT_RECORDING_FILE;
// Without a remembered filename, suggest the default name for the current format.
std::wstring baseFilename = g_RecordingSaveBaseFilename.empty()
? std::wstring( defaultFile )
: g_RecordingSaveBaseFilename;
std::filesystem::path basePath{ baseFilename };
// For the default filename, append a timestamp so successive default saves stay
// unique and sort chronologically in Explorer.
if ( IsDefaultRecordingFilename( basePath.filename().wstring() ) )
{
return basePath.stem().wstring() + GetTimestampSuffix() + basePath.extension().wstring();
}
// For custom filenames, append a numeric suffix to avoid collisions.
std::filesystem::path directory;
if ( !g_RecordingSaveLocation.empty() )
directory = std::filesystem::path( g_RecordingSaveLocation ).parent_path();
if ( directory.empty() )
{
wil::unique_cotaskmem_string folderPath;
if ( SUCCEEDED( SHGetKnownFolderPath( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, folderPath.put() ) ) )
directory = folderPath.get();
}
std::wstring baseStem = basePath.stem().wstring();
std::wstring baseExtension = basePath.extension().wstring();
std::filesystem::path testPath = directory / ( baseStem + baseExtension );
for ( int index = 1; std::filesystem::exists( testPath ); index++ )
{
testPath = directory / ( baseStem + L" (" + std::to_wstring( index ) + L')' + baseExtension );
}
return testPath.filename().wstring();
return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos);
}
//----------------------------------------------------------------------------
@@ -6962,7 +6835,7 @@ auto GetUniqueScreenshotFilename()
//
// StartRecordingAsync
//
// Initiates screen recording and handles the save dialog workflow.
// Starts the screen recording.
//
//----------------------------------------------------------------------------
winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try
@@ -7207,30 +7080,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
if (!finalPath.empty())
{
auto path = std::filesystem::path(finalPath);
// Remember the user's chosen filename and apply a timestamp to default
// names so successive saves stay unique and sort chronologically.
std::wstring filename = path.filename().wstring();
std::wstring finalFilename = filename;
if ( IsDefaultRecordingFilename( filename ) )
{
// The user accepted or re-typed the default filename. Remember it so the
// next suggestion also uses a timestamp, and append one to this save.
g_RecordingSaveBaseFilename = filename;
finalFilename = path.stem().wstring() + GetTimestampSuffix() + path.extension().wstring();
}
else if ( CompareStringOrdinal( suggestedName.c_str(), -1, filename.c_str(), -1, TRUE ) != CSTR_EQUAL )
{
// The user chose their own filename instead of the suggested one. Remember
// it so future suggestions use numeric suffixes based on this name.
g_RecordingSaveBaseFilename = filename;
}
// The path actually written to disk (with any timestamp applied).
std::wstring savedPath = ( path.parent_path() / finalFilename ).wstring();
winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) };
destFile = co_await folder.CreateFileAsync(finalFilename.c_str(), winrt::CreationCollisionOption::ReplaceExisting);
destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting);
// If user trimmed, use the trimmed file
winrt::StorageFile sourceFile = file;
@@ -7248,8 +7099,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
try { co_await file.DeleteAsync(); } catch (...) {}
}
// Use savedPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = savedPath;
// Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = finalPath;
// Update the registry buffer and save to persist across app restarts
wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE);
reg.WriteRegSettings(RegSettings);
@@ -7749,9 +7600,7 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod( g_BreakToggleKey );
g_DemoTypeToggleMod = GetKeyMod( g_DemoTypeToggleKey );
g_SnipToggleMod = GetKeyMod( g_SnipToggleKey );
g_SnipSaveToggleMod = GetKeyMod( g_SnipSaveToggleKey );
g_SnipPanoramaToggleMod = GetKeyMod( g_SnipPanoramaToggleKey );
g_SnipPanoramaSaveToggleMod = GetKeyMod( g_SnipPanoramaSaveToggleKey );
g_SnipOcrToggleMod = GetKeyMod( g_SnipOcrToggleKey );
g_RecordToggleMod = GetKeyMod( g_RecordToggleKey );
@@ -7802,37 +7651,23 @@ LRESULT APIENTRY MainWndProc(
}
else if (g_SnipToggleKey &&
!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF)) {
(!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) {
MessageBox(hWnd, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) &&
!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF)) {
(!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))) {
MessageBox(hWnd, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipOcrToggleKey &&
!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF)) {
@@ -10419,9 +10254,7 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod(g_BreakToggleKey);
g_DemoTypeToggleMod = GetKeyMod(g_DemoTypeToggleKey);
g_SnipToggleMod = GetKeyMod(g_SnipToggleKey);
g_SnipSaveToggleMod = GetKeyMod(g_SnipSaveToggleKey);
g_SnipPanoramaToggleMod = GetKeyMod(g_SnipPanoramaToggleKey);
g_SnipPanoramaSaveToggleMod = GetKeyMod(g_SnipPanoramaSaveToggleKey);
g_SnipOcrToggleMod = GetKeyMod(g_SnipOcrToggleKey);
g_RecordToggleMod = GetKeyMod(g_RecordToggleKey);
BOOL showOptions = FALSE;
@@ -10484,7 +10317,8 @@ LRESULT APIENTRY MainWndProc(
}
if (g_SnipToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10493,21 +10327,11 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod))
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10516,17 +10340,6 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipPanoramaSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipOcrToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF))

View File

@@ -93,6 +93,7 @@
#include <algorithm>
#include <filesystem>
#include <future>
#include <regex>
#include <fstream>
#include <sstream>

View File

@@ -137,8 +137,6 @@
#define IDC_WEBCAM_BRIGHTNESS_LABEL 1131
#define IDC_WEBCAM_BRIGHTNESS_SLIDER 1132
#define IDC_NOISE_CANCELLATION 1133
#define IDC_SNIP_SAVE_HOTKEY 1134
#define IDC_SNIP_PANORAMA_SAVE_HOTKEY 1135
#define IDC_SAVE 40002
#define IDC_COPY 40004
#define IDC_RECORD 40006
@@ -153,8 +151,8 @@
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 120
#define _APS_NEXT_COMMAND_VALUE 40012
#define _APS_NEXT_CONTROL_VALUE 1136
#define _APS_NEXT_COMMAND_VALUE 40015
#define _APS_NEXT_CONTROL_VALUE 1134
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif

View File

@@ -70,10 +70,8 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation
{ L"DrawToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"RecordToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipOcrToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipPanoramaToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"SnipPanoramaSaveToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"BreakTimerKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"DemoTypeToggleKey", SPECIAL_SEMANTICS_SHORTCUT },
{ L"PenColor", SPECIAL_SEMANTICS_COLOR },

View File

@@ -160,7 +160,7 @@ bool WindowBorder::Init(HINSTANCE hinstance)
void WindowBorder::UpdateBorderPosition() const
{
if (!m_trackingWindow || !m_frameDrawer || !m_window)
if (!m_trackingWindow)
{
return;
}

View File

@@ -1,98 +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 System.Text;
namespace Microsoft.CmdPal.Common.Helpers;
public static class ShellArgumentBuilder
{
public static string BuildArguments(params string[] arguments)
{
if (arguments.Length <= 0)
{
return string.Empty;
}
var stringBuilder = new StringBuilder();
foreach (var argument in arguments)
{
AppendArgument(stringBuilder, argument);
}
return stringBuilder.ToString();
}
private static void AppendArgument(StringBuilder stringBuilder, string argument)
{
if (stringBuilder.Length > 0)
{
stringBuilder.Append(' ');
}
if (argument.Length == 0 || ShouldBeQuoted(argument))
{
stringBuilder.Append('"');
var index = 0;
while (index < argument.Length)
{
var c = argument[index++];
if (c == '\\')
{
var numBackSlash = 1;
while (index < argument.Length && argument[index] == '\\')
{
index++;
numBackSlash++;
}
if (index == argument.Length)
{
stringBuilder.Append('\\', numBackSlash * 2);
}
else if (argument[index] == '"')
{
stringBuilder.Append('\\', (numBackSlash * 2) + 1);
stringBuilder.Append('"');
index++;
}
else
{
stringBuilder.Append('\\', numBackSlash);
}
continue;
}
if (c == '"')
{
stringBuilder.Append('\\');
stringBuilder.Append('"');
continue;
}
stringBuilder.Append(c);
}
stringBuilder.Append('"');
}
else
{
stringBuilder.Append(argument);
}
}
private static bool ShouldBeQuoted(string argument)
{
foreach (var c in argument)
{
if (char.IsWhiteSpace(c) || c == '"')
{
return true;
}
}
return false;
}
}

View File

@@ -94,7 +94,6 @@ internal sealed partial class HttpCachingClient : IDisposable
public void Dispose()
{
_httpClient.Dispose();
_cacheHandler.Dispose();
}
private static bool IsSupportedHttpUri(Uri resourceUri)

View File

@@ -146,7 +146,13 @@ public sealed partial class MainListPage : DynamicListPage,
// The all apps page will kick off a BG thread to start loading apps.
// We just want to know when it is done.
var allApps = AllAppsCommandProvider.Page;
allApps.PropChanged += AllApps_PropChanged;
allApps.PropChanged += (s, p) =>
{
if (p.PropertyName == nameof(allApps.IsLoading))
{
IsLoading = ActuallyLoading();
}
};
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
@@ -166,14 +172,6 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
private void AllApps_PropChanged(object? sender, IPropChangedEventArgs e)
{
if (e.PropertyName == nameof(AllAppsCommandProvider.Page.IsLoading))
{
IsLoading = ActuallyLoading();
}
}
private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_defaultViewDirty = true;
@@ -784,8 +782,6 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
_tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged;
AllAppsCommandProvider.Page.PropChanged -= AllApps_PropChanged;
if (_settingsService is not null)
{
_settingsService.SettingsChanged -= SettingsChangedHandler;

View File

@@ -541,9 +541,6 @@ public partial class WinRTExtensionService : IExtensionService, IDisposable
{
if (disposing)
{
_catalog.PackageInstalling -= Catalog_PackageInstalling;
_catalog.PackageUninstalling -= Catalog_PackageUninstalling;
_catalog.PackageUpdating -= Catalog_PackageUpdating;
_getInstalledExtensionsLock.Dispose();
}

View File

@@ -94,7 +94,6 @@ internal sealed partial class BlurImageControl : Control
private SpriteVisual? _effectVisual;
private CompositionEffectBrush? _effectBrush;
private CompositionSurfaceBrush? _imageBrush;
private LoadedImageSurface? _lastLoadedSurface;
public BlurImageControl()
{
@@ -380,20 +379,10 @@ internal sealed partial class BlurImageControl : Control
}
Logger.LogDebug($"Starting load of BlurImageControl from '{bitmapImage.UriSource}'");
// Each call to LoadImageAsync creates a new LoadedImageSurface backed by native
// composition resources. The old surface becomes unrooted once the brush points at
// the new one, so it isn't leaked, but dispose it explicitly so the unmanaged
// resources are released deterministically instead of waiting for finalization.
var previousSurface = _lastLoadedSurface;
var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource);
_lastLoadedSurface = loadedSurface;
loadedSurface.LoadCompleted += OnLoadedSurfaceOnLoadCompleted;
SetLoadedSurfaceToBrush(loadedSurface);
_effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush);
previousSurface?.Dispose();
}
catch (Exception ex)
{

View File

@@ -126,16 +126,6 @@ public sealed partial class CmdPalMainControl : UserControl
return CardBorder.ActualHeight;
}
/// <summary>
/// When <paramref name="stretch"/> is <see langword="true"/>, the card stretches to fill
/// the entire window vertically (non-compact mode). When <see langword="false"/>, the card
/// sizes itself to its content and anchors to the top of the window (compact mode).
/// </summary>
public void SetCardStretch(bool stretch)
{
CardBorder.VerticalAlignment = stretch ? VerticalAlignment.Stretch : VerticalAlignment.Top;
}
/// <summary>
/// Forwards the host window's activation state to the current backdrop so the system can
/// render its active / inactive appearance correctly.

View File

@@ -8,9 +8,7 @@ using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Windows.System;
namespace Microsoft.CmdPal.UI.Controls;
@@ -24,7 +22,6 @@ public sealed partial class ContentFormControl : UserControl
// tree. If this gets GC'ed, then it'll revoke our Action handler, and the
// form will do seemingly nothing.
private RenderedAdaptiveCard? _renderedCard;
private AdaptiveCard? _adaptiveCard;
public ContentFormViewModel? ViewModel { get => _viewModel; set => AttachViewModel(value); }
@@ -98,11 +95,9 @@ public sealed partial class ContentFormControl : UserControl
private void DisplayCard(AdaptiveCardParseResult result)
{
_renderedCard = _renderer.RenderAdaptiveCard(result.AdaptiveCard);
_adaptiveCard = result.AdaptiveCard;
ContentGrid.Children.Clear();
if (_renderedCard.FrameworkElement is not null)
{
_renderedCard.FrameworkElement.KeyDown += OnFormKeyDown;
ContentGrid.Children.Add(_renderedCard.FrameworkElement);
// Use the Loaded event to ensure we focus after the card is in the visual tree
@@ -119,9 +114,8 @@ public sealed partial class ContentFormControl : UserControl
private void OnFrameworkElementLayoutUpdated(object? sender, object e)
{
// Only fix once — unhook from sender (not _renderedCard, which may have been
// reassigned by the time this fires).
if (sender is FrameworkElement element)
// Only fix once — unhook after first layout pass
if (_renderedCard?.FrameworkElement is FrameworkElement element)
{
element.LayoutUpdated -= OnFrameworkElementLayoutUpdated;
FixToggleAccessibilityNames(element);
@@ -282,50 +276,6 @@ public sealed partial class ContentFormControl : UserControl
return null;
}
private void OnFormKeyDown(object sender, KeyRoutedEventArgs e)
{
// Snapshot the fields so a subsequent DisplayCard call can't swap the
// rendered/parsed card out from under us mid-method. This keeps the
// resolved submit action and the gathered inputs from the same card.
var renderedCard = _renderedCard;
var adaptiveCard = _adaptiveCard;
if (e.Key != VirtualKey.Enter || renderedCard == null || adaptiveCard == null)
{
return;
}
// Only submit when Enter is pressed inside a single-line TextBox
if (e.OriginalSource is TextBox textBox && !textBox.AcceptsReturn)
{
// Find the first Submit or Execute action on the card
IAdaptiveActionElement? submitAction = null;
foreach (var action in adaptiveCard.Actions)
{
if (action is AdaptiveSubmitAction or AdaptiveExecuteAction)
{
submitAction = action;
break;
}
}
if (submitAction != null)
{
e.Handled = true;
// Validate (and gather) the inputs before submitting. AsJson() only
// returns the values cached by a successful ValidateInputs() call, so
// skipping this would submit an empty payload. This mirrors what the
// renderer does internally when a submit button is clicked.
var inputs = renderedCard.UserInputs;
if (inputs.ValidateInputs(submitAction))
{
ViewModel?.HandleSubmit(submitAction, inputs.AsJson());
}
}
}
}
private void Rendered_Action(RenderedAdaptiveCard sender, AdaptiveActionEventArgs args) =>
ViewModel?.HandleSubmit(args.Action, args.Inputs.AsJson());
}

View File

@@ -27,6 +27,7 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
INotifyPropertyChanged,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
IRecipient<FocusParamMessage>,
ICurrentPageAware
@@ -71,8 +72,6 @@ public sealed partial class SearchBar : UserControl,
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler? ActiveFocusTargetChanged;
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
//// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work...
@@ -103,9 +102,8 @@ public sealed partial class SearchBar : UserControl,
@this?.PropertyChanged?.Invoke(@this, new(nameof(PageType)));
@this?.PropertyChanged?.Invoke(@this, new(nameof(Parameters)));
// Let the shell decide if it's safe to restore focus after the
// SwitchPresenter swaps to a different top-bar input.
@this?.ActiveFocusTargetChanged?.Invoke(@this, EventArgs.Empty);
// Attempt to focus us again, once we evaluate what input is visible
@this?.Focus();
}
public string PageType => CurrentPageViewModel switch
@@ -122,6 +120,7 @@ public sealed partial class SearchBar : UserControl,
{
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
WeakReferenceMessenger.Default.Register<FocusParamMessage>(this);
}
@@ -498,7 +497,9 @@ public sealed partial class SearchBar : UserControl,
}
}
internal void FocusActiveControl()
public void Receive(FocusSearchBoxMessage message) => Focus();
private void Focus()
{
this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{

View File

@@ -113,8 +113,6 @@ public sealed partial class MainWindow : WindowEx,
public bool IsVisibleToUser { get; private set; } = true;
public event EventHandler? IsVisibleToUserChanged;
public MainWindow()
{
InitializeComponent();
@@ -194,7 +192,7 @@ public sealed partial class MainWindow : WindowEx,
App.Current.Services.GetRequiredService<ISettingsService>().SettingsChanged += SettingsChangedHandler;
// Make sure that we update the acrylic theme when the OS theme changes
RootElement.ActualThemeChanged += RootElement_ActualThemeChanged;
RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop);
// Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h
NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () =>
@@ -224,11 +222,6 @@ public sealed partial class MainWindow : WindowEx,
UpdateBackdrop();
}
private void RootElement_ActualThemeChanged(FrameworkElement sender, object args)
{
DispatcherQueue.TryEnqueue(UpdateBackdrop);
}
private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e)
{
if (e.Key == VirtualKey.GoBack)
@@ -733,10 +726,6 @@ public sealed partial class MainWindow : WindowEx,
// forces the NC repaint every time we show, so the frame is gone from
// the very first summon.
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED);
// Treat the overall show/hide lifecycle as the authoritative
// visibility transition, not the lower-level cloak/uncloak helpers.
SetIsVisibleToUser(true);
}
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
@@ -921,10 +910,6 @@ public sealed partial class MainWindow : WindowEx,
// Sure, it's not ideal, but at least it's not visible.
}
// Treat the overall show/hide lifecycle as the authoritative
// visibility transition, not the lower-level cloak/uncloak helpers.
SetIsVisibleToUser(false);
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
// Start auto-go-home timer
@@ -958,6 +943,10 @@ public sealed partial class MainWindow : WindowEx,
{
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
}
else
{
IsVisibleToUser = false;
}
wasCloaked = hr.Succeeded;
}
@@ -971,20 +960,10 @@ public sealed partial class MainWindow : WindowEx,
{
BOOL value = false;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
IsVisibleToUser = true;
}
}
private void SetIsVisibleToUser(bool isVisibleToUser)
{
if (IsVisibleToUser == isVisibleToUser)
{
return;
}
IsVisibleToUser = isVisibleToUser;
IsVisibleToUserChanged?.Invoke(this, EventArgs.Empty);
}
internal void MainWindow_Closed(object sender, WindowEventArgs args)
{
var serviceProvider = App.Current.Services;
@@ -1518,7 +1497,6 @@ public sealed partial class MainWindow : WindowEx,
// but that's the price to pay for having the HWND not light-dismiss while we're debugging.
Cloak();
this.Hide();
SetIsVisibleToUser(false);
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
return;
@@ -1705,9 +1683,6 @@ public sealed partial class MainWindow : WindowEx,
public void Dispose()
{
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
App.Current.Services.GetRequiredService<ISettingsService>().SettingsChanged -= SettingsChangedHandler;
_localKeyboardListener.Dispose();
_windowThemeSynchronizer.Dispose();
DisposeAcrylic();
@@ -1776,26 +1751,17 @@ public sealed partial class MainWindow : WindowEx,
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
if (expanded && settings.CompactMode && IsCenteringSummon(settings))
{
// When compact mode is off the card is always static and fills the entire window,
// regardless of how much content is currently displayed.
RootElement.SetCardStretch(true);
RootElement.SetCardMaxHeight(double.PositiveInfinity);
RootElement.SetCardMaxHeight(ComputeExpandedCardMaxHeightDip());
}
else
{
// In compact mode the card sizes itself to its content and anchors to the top.
RootElement.SetCardStretch(false);
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
var cardMaxHeight = expanded && IsCenteringSummon(settings)
? ComputeExpandedCardMaxHeightDip()
: double.PositiveInfinity;
RootElement.SetCardMaxHeight(cardMaxHeight);
RootElement.SetCardMaxHeight(double.PositiveInfinity);
}
}

View File

@@ -353,7 +353,6 @@
<cpcontrols:SearchBar
x:Name="SearchBox"
HorizontalAlignment="Stretch"
ActiveFocusTargetChanged="SearchBox_ActiveFocusTargetChanged"
CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
<Grid.Transitions>
<TransitionCollection>
@@ -602,4 +601,4 @@
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</Page>
</Page>

View File

@@ -38,7 +38,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<OpenSettingsMessage>,
IRecipient<HotkeySummonMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<ShowDetailsMessage>,
IRecipient<HideDetailsMessage>,
IRecipient<ClearSearchMessage>,
@@ -68,12 +67,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly CompositeFormat _pageNavigatedAnnouncement;
private readonly ISettingsService _settingsService;
// The last compact-mode setting we reacted to. Lets us ignore hot-reloads of unrelated
// settings and only re-evaluate the layout when compact mode itself changes.
private bool _compactMode;
private SettingsWindow? _settingsWindow;
private DockWindowManager? _dockWindowManager;
@@ -86,46 +79,20 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// select-all the character the user just typed (which triggered the expand). This
// one-shot flag suppresses that select for the expand-driven load.
private bool _suppressSelectOnNextLoad;
private bool _pendingTopBarFocusRestore;
private bool _isDisposed;
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
public event PropertyChangedEventHandler? PropertyChanged;
private IHostWindow? _hostWindow;
public IHostWindow? HostWindow
{
get => _hostWindow;
set
{
if (ReferenceEquals(_hostWindow, value))
{
return;
}
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
}
_hostWindow = value;
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged += HostWindow_IsVisibleToUserChanged;
}
}
}
public IHostWindow? HostWindow { get; set; }
public bool ExpandedMode { get; set; }
public ShellPage()
{
_settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
_compactMode = _settingsService.Settings.CompactMode;
this.ExpandedMode = !_compactMode;
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
this.ExpandedMode = !settings.CompactMode;
this.InitializeComponent();
@@ -133,7 +100,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
WeakReferenceMessenger.Default.Register<ShowDetailsMessage>(this);
@@ -153,11 +119,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
// The compact-mode setting can be toggled while the palette is open. React to the
// hot-reload so the expanded/collapsed layout updates immediately instead of waiting
// for the next navigation or search-text change.
_settingsService.SettingsChanged += OnSettingsChanged;
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
@@ -469,8 +430,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public void Receive(ClearSearchMessage message) => SearchBox.ClearSearch();
public void Receive(FocusSearchBoxMessage message) => RequestTopBarFocusRestore();
public void Receive(HotkeySummonMessage message) => _ = DispatcherQueue.TryEnqueue(() => SummonOnUiThread(message));
public void Receive(SettingsWindowClosedMessage message) => _settingsWindow = null;
@@ -651,40 +610,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
private void SearchBox_ActiveFocusTargetChanged(object? sender, EventArgs e)
{
RequestTopBarFocusRestore();
}
private void HostWindow_IsVisibleToUserChanged(object? sender, EventArgs e)
{
if (HostWindow?.IsVisibleToUser == true &&
_pendingTopBarFocusRestore &&
ViewModel.CurrentPage?.HasSearchBox == true)
{
_pendingTopBarFocusRestore = false;
SearchBox.FocusActiveControl();
}
}
private void RequestTopBarFocusRestore()
{
if (ViewModel.CurrentPage?.HasSearchBox != true)
{
_pendingTopBarFocusRestore = false;
return;
}
if (HostWindow?.IsVisibleToUser == true)
{
_pendingTopBarFocusRestore = false;
SearchBox.FocusActiveControl();
return;
}
_pendingTopBarFocusRestore = true;
}
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
@@ -749,11 +674,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
{
// Compact mode is off: the shell always shows the full expanded UI. Set it
// explicitly (rather than trusting the constructor's initial value) so toggling
// the setting off at runtime restores the list and command bar when the palette
// was collapsed.
HandleExpandCompactOnUiThread(true);
return;
}
@@ -1016,24 +936,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
this.DispatcherQueue.TryEnqueue(UpdateCompactModeForCurrentPage);
}
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
// Only the compact-mode setting affects the expanded/collapsed layout, so ignore
// hot-reloads that leave it unchanged. Comparing and updating _compactMode on the UI
// thread keeps it single-threaded regardless of which thread raises the event.
var compactMode = args.CompactMode;
this.DispatcherQueue.TryEnqueue(() =>
{
if (compactMode == _compactMode)
{
return;
}
_compactMode = compactMode;
UpdateCompactModeForCurrentPage();
});
}
private void HandleExpandCompactOnUiThread(bool expanded)
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
@@ -1077,13 +979,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_isDisposed = true;
WeakReferenceMessenger.Default.UnregisterAll(this);
_settingsService.SettingsChanged -= OnSettingsChanged;
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
_hostWindow = null;
}
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();

View File

@@ -9,11 +9,6 @@ namespace Microsoft.CmdPal.UI.Services;
/// </summary>
public interface IHostWindow
{
/// <summary>
/// Raised when <see cref="IsVisibleToUser"/> changes.
/// </summary>
event EventHandler? IsVisibleToUserChanged;
/// <summary>
/// Gets a value indicating whether the window is visible to the user, taking account not only window visibility but also cloaking.
/// </summary>

View File

@@ -22,7 +22,6 @@ public sealed partial class ExtensionsPage : Page
private readonly SettingsViewModel? viewModel;
private readonly Dictionary<string, WeakReference<SettingsCard>> _vmToCardMap = new();
private readonly Dictionary<SettingsCard, ProviderSettingsViewModel> _cardToVmMap = new();
public ExtensionsPage()
{
@@ -32,23 +31,6 @@ public sealed partial class ExtensionsPage : Page
var themeService = App.Current.Services.GetService<IThemeService>()!;
var settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
Unloaded += ExtensionsPage_Unloaded;
}
private void ExtensionsPage_Unloaded(object sender, RoutedEventArgs e)
{
// ProviderSettingsViewModel subscribes to its CommandProviderWrapper (owned by the
// singleton TopLevelCommandManager), so a live VM roots this page through the
// PropertyChanged handler below. Drain any VMs still hooked when the page is torn
// down; SettingsCard_DataContextChanged only unhooks the ones that get recycled.
foreach (var vm in _cardToVmMap.Values)
{
vm.PropertyChanged -= ProviderViewModel_PropertyChanged;
}
_cardToVmMap.Clear();
_vmToCardMap.Clear();
}
private void SettingsCard_Click(object sender, RoutedEventArgs e)
@@ -64,28 +46,16 @@ public sealed partial class ExtensionsPage : Page
private void SettingsCard_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
if (sender is SettingsCard card)
// Store the card reference keyed by Id (not the VM itself) to avoid leaking VM references
if (sender is SettingsCard card && card.DataContext is ProviderSettingsViewModel newVm)
{
// Unsubscribe from the previous ViewModel to prevent handler accumulation
// when virtualization recycles items with a new DataContext.
if (_cardToVmMap.TryGetValue(card, out var oldVm))
{
oldVm.PropertyChanged -= ProviderViewModel_PropertyChanged;
_cardToVmMap.Remove(card);
}
_vmToCardMap[newVm.Id] = new WeakReference<SettingsCard>(card);
newVm.PropertyChanged += ProviderViewModel_PropertyChanged;
// Store the card reference keyed by Id (not the VM itself) to avoid leaking VM references
if (card.DataContext is ProviderSettingsViewModel newVm)
// Immediately update automation name in case DisplayName is already available
if (card.Content is ToggleSwitch toggle && !string.IsNullOrEmpty(newVm.DisplayName))
{
_vmToCardMap[newVm.Id] = new WeakReference<SettingsCard>(card);
_cardToVmMap[card] = newVm;
newVm.PropertyChanged += ProviderViewModel_PropertyChanged;
// Immediately update automation name in case DisplayName is already available
if (card.Content is ToggleSwitch toggle && !string.IsNullOrEmpty(newVm.DisplayName))
{
AutomationProperties.SetName(toggle, newVm.DisplayName);
}
AutomationProperties.SetName(toggle, newVm.DisplayName);
}
}
}

View File

@@ -1,32 +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.CmdPal.Common.Helpers;
namespace Microsoft.CmdPal.Common.UnitTests.Helpers;
[TestClass]
public class ShellArgumentBuilderTests
{
[DataTestMethod]
[DataRow("plain", "plain")]
[DataRow("C:\\Program Files\\PowerToys", "\"C:\\Program Files\\PowerToys\"")]
[DataRow("say \"hello\"", "\"say \\\"hello\\\"\"")]
[DataRow("", "\"\"")]
[DataRow("C:\\Program Files\\", "\"C:\\Program Files\\\\\"")]
public void BuildArguments_FormatsSingleArgument(string argument, string expected)
{
var actual = ShellArgumentBuilder.BuildArguments(argument);
Assert.AreEqual(expected, actual);
}
[TestMethod]
public void BuildArguments_FormatsMultipleArguments()
{
var actual = ShellArgumentBuilder.BuildArguments("plain", "C:\\Program Files\\PowerToys", "two words");
Assert.AreEqual("plain \"C:\\Program Files\\PowerToys\" \"two words\"", actual);
}
}

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