Compare commits

...

29 Commits

Author SHA1 Message Date
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
Dave Rayment
8bd5c1be6f [Quick Accent] Additions and reorg for IPA set. Additions to Special set (#49030)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #48840
- [x] Closes: #32437
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

The following additions and changes were made in response to the
feedback given in #48840 and also following an audit of the IPA set.

#### IPA

| Character | Action
|--|--|
| `ɱ` | Added to **M** |
| `ʍ` | Added to **W** | 
| `ɚ` | Added to **E** |
| ` ͡ ` and `  ͜  ` | Added to **PERIOD**
| `ɡ` | Added to **G**
| `ɫ` | Added to **I**
| `ʱ` | Added to **H**
| `◌̝`, `◌̥`, `◌̚`, `ˈ` and `ˌ` | added to **PERIOD**
| `ʎ` | Added to **Y**
| `ʔ` | Added to **COMMA** and **SLASH**
| `æ` | Added to **A** and **E**
| `œ` | Added to **O** and **E**
| `ʘ` | Added to **B** and **O**
| `β`, `ɓ` | Added to **B**
| `χ` | Added to **C** and **X**
| `ç`, `ǂ` | Added to **C**
| `ð`, `ɗ`, `ɖ`, `ǀ` | Added to **D**
| `ɠ`, `ʛ` | Added to **G**
| `ħ`, `ɥ`, `ɧ` | Added to **H**
| `ʄ` | Added to **J**
| `ɫ`, `ǁ` | Added to **L**
| `ø` | Added to **O**

I removed the caron vowel characters `ǎ`, `ǒ` and `ǔ`, as they should
not have been in the IPA set. These characters are available in the
Pinyin set.

A small number of keys had entries reordered where common mappings would
have been towards the end.

This IPA update includes:

- Click Consonants (`ʘ`, `ǀ`, `ǃ`, `ǂ`, `ǁ`), which are used in the
phonetic transcription of Southern and Eastern African languages, most
notably the Khoisan language groups and several Bantu languages (like
Zulu and Xhosa).
- Implosives and Ejectives (`ɓ`, `ɗ`, `ʼ`, etc.), which are essential
for transcribing languages across the globe, including Indigenous
languages of the Americas (e.g., Navajo and Mayan), the Caucasus (e.g.,
Georgian), Southeast Asia (e.g., Vietnamese), and widely across the
African continent.

#### SPECIAL set

| Character | Action | Notes
|--|--|--|
| `‽` and `⸘` | Added to **SLASH** |
| `⟨`, `⟩`, `⟪` and `⟫` | Replaced full-width CJK brackets with the
Western versions.
| `‰` and `‱` | Added to **P**

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

Manual testing, with specific tests to confirm that the new combining
marks display OK in the UI.
2026-06-30 10:06:02 +08:00
Clint Rutkas
7b19b4c219 Add build-time guard for Windows long path support (#49028)
PowerToys has deeply nested source paths that exceed the legacy
260-character MAX_PATH limit. Contributors who haven't enabled Windows
long path support hit cryptic 'path too long' / 'could not find file'
errors during their first build.

Add an EnsureLongPathsEnabled MSBuild target in Directory.Build.targets
that reads
HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled and
fails fast with an actionable error (PTLONGPATH) pointing at
tools\build\setup-dev-environment.ps1. Covers both Visual Studio and the
command-line build scripts, skips design-time builds, and can be
bypassed with /p:SkipLongPathsCheck=true.

**What happens if Long file path isn't enabled.**
<img width="824" height="916" alt="image"
src="https://github.com/user-attachments/assets/30731f65-4011-48c0-94b9-e521b4c7d266"
/>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 21:29:14 +00:00
Jessica Dene Earley-Cha
b73fd670be [CmdPal] Fix excessive Narrator announcements on More button open (#48928)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Opening the More button caused Narrator to read a long cascade of
overlapping announcements — popup window, filter TextBox placeholder,
ListView item name, position ("1 of 4"), keyboard shortcut text — all
from a single keypress with no further input.

This PR replaces that cascade with a single, clean announcement:
**"Menu, {0} commands. {1}, {2} of {0}"**
(num of commands, first item in list, num of order in list, num of
commands)
**"Menu, 3 commands. Calculator, 1 of 3."**


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #48899
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments


| File | Change |
|------|--------|
| `ContextMenu.xaml` | Set `AccessibilityView="Raw"` on ListView and
TextBox; added `NarratorAnnouncer` TextBlock for raising UIA
notifications |
| `ContextMenu.xaml.cs` | Added `AnnounceOpened()`,
`AnnounceSelectedItem()`, and `_isOpening` guard to prevent programmatic
selection changes from triggering UIA events during the flyout
transition |
| `CommandBar.xaml.cs` | Call `AnnounceOpened()` on flyout open |
| `DockControl.xaml.cs` | Same for dock context menu |
| `Resources.resw` | Added `ScreenReader_Announcement_ContextMenuOpened`
localized format string |

## How it works

- ListView and TextBox are permanently `AccessibilityView="Raw"` —
invisible
  to Narrator, preventing all system-driven UIA announcements
- A zero-size `NarratorAnnouncer` TextBlock
(`AccessibilityView="Content"`,
  `LiveSetting="Assertive"`) serves as the sole notification source
- On open: a deferred `RaiseNotificationEvent` fires one consolidated
announcement
- On arrow navigation: `AnnounceSelectedItem()` fires item name and
position


<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed



https://github.com/user-attachments/assets/4660741f-6b91-4a32-9a56-83c64343b67a



- [x] Build succeeds (0 errors)
- [x] 124 ViewModel unit tests pass
- [x] Manual Narrator testing: open, arrow navigate, close, reopen
- [x] Keyboard filtering (type to filter) still works
- [x] Enter to invoke selected command still works
- [x] Escape to close still works
2026-06-29 21:39:12 +02:00
Niels Laute
a46a4437e5 Fix unreadable What's New titles/links when OS and PowerToys themes differ (#48910)
## Summary of the Pull Request

When the OS theme differs from the PowerToys theme (e.g. OS in Light,
PowerToys set to Dark), the **What's New / release-notes (Scoobe)** page
renders heading titles and hyperlinks with brushes pinned to the wrong
theme, making them unreadable. The system caption buttons
(min/max/close) are also not tinted to match the window theme.

The release-notes page uses the CommunityToolkit `MarkdownTextBlock`,
which captures its heading and link brushes from
`Application.Current.Resources` when its theme config is created. Those
resolve against the **OS (application) theme** rather than the window's
selected element theme, so headings/links break whenever the two themes
differ.

## PR Checklist

- [x] Closes: #43970
- [x] Closes: #48832
- [x] **Communication:** Local workaround for a known upstream control
bug
- [ ] **Tests:** Manually validated (UI/theming change)
- [x] **Localization:** No new end-user-facing strings
- [ ] **Dev docs:** N/A

## Detailed Description of the Pull Request / Additional comments

These are deliberately **local workarounds** until the upstream control
resolves brushes against the element theme via
[CommunityToolkit/Labs-Windows#785](https://github.com/CommunityToolkit/Labs-Windows/pull/785).
Comments + `TODO`s in the code point at that PR so the workaround can be
removed once it ships.

**`ScoobeReleaseNotesPage`**
- Pin the `MarkdownTextBlock.RequestedTheme` to the selected app theme
and reassign the `H1`–`H6` heading brushes and the link brush (resolved
for that theme) before the markdown is rendered. The themed brushes are
read from the control's own `Foreground` (`TextFillColorPrimaryBrush`)
and a hidden `LinkBrushProvider` carrier element
(`AccentTextFillColorPrimaryBrush`).
- Re-run the workaround and force a re-render on runtime theme changes
(subscribe to the page's `ActualThemeChanged`), so titles/links stay
readable when the user switches Light/Dark while the window is open.

**`TitleBarHelper` (new) + `ScoobeWindow`**
- Add a small shared
`TitleBarHelper.ApplySystemThemeToCaptionButtons(window, theme)` (port
of the WinUI Gallery helper + PowerToys conventions) and drive the
Scoobe window's caption-button colors from the content's actual theme,
updating on `ActualThemeChanged`. `ScoobeWindow` uses the built-in WinUI
`TitleBar`, which — unlike the custom PowerToys `TitleBar` control used
by the other Settings windows — does not tint the system caption buttons
to the app theme.

## Validation Steps Performed

- OS Light + PowerToys Dark: opened What's New → headings, hyperlinks,
body text and caption buttons all render readable/dark-themed
(previously black/unreadable titles). Confirmed working.
- Switched OS Light → Dark while the Scoobe window was open → markdown
content and caption buttons update live.
- OS Dark: content pane, tables and titles render with the dark theme
(addresses the "light pane in a dark window" report).

<img width="1673" height="929" alt="Screenshot 2026-06-26 130638"
src="https://github.com/user-attachments/assets/1db7fb37-f5ee-485b-863e-fc1ba0d13f6f"
/>

_OS is in Light Mode, while the app settings are set to Dark mode_

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 20:33:40 +02:00
Clint Rutkas
3bf682048e Grab and Move: square overlay corners in remote sessions (#48999) 2026-06-29 07:05:19 -07:00
moooyo
28a9bbe8f0 [PowerDisplay] Add configurable mouse wheel increment for slider controls (#49002)
## Summary of the Pull Request

Adds a Settings option that controls how much the PowerDisplay flyout
sliders (brightness, contrast, volume) change per mouse-wheel notch. The
value is chosen from a **preset dropdown** (`1, 2, 5, 10, 15, 20, 25`)
and defaults to **5**, preserving today's behavior. Previously the
per-notch step was hardcoded as
`helpers:SliderExtensions.MouseWheelChange="5"` in four places in the
flyout.

<img width="1282" height="620" alt="image"
src="https://github.com/user-attachments/assets/3b299a47-eb7b-4b53-b3dc-0540fbb25bfc"
/>


## PR Checklist

- [x] Closes: #48805
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass — _added
`MouseWheelIncrementSettingsTests` to the existing
`PowerDisplay.Lib.UnitTests`; passing via `vstest.console.exe`._
- [x] **Localization:** All end-user-facing strings can be localized —
_new `PowerDisplay_MouseWheelIncrement.Header`/`.Description` in
`en-us/Resources.resw`, surfaced via `x:Uid`._
- [ ] **Dev docs:** Added/updated — _N/A: small settings addition, no
behavioral/architecture docs affected._
- [ ] **New binaries:** Added on the required places — _N/A: no new
binaries or projects (the unit test was added to an existing test
project)._
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** _N/A._

## Detailed Description of the Pull Request / Additional comments

A single `int MouseWheelIncrement` is added to `PowerDisplayProperties`
and persisted to `settings.json` under `mouse_wheel_increment`; it is
edited in the Settings UI and read by the flyout app. The value applies
uniformly to all four flyout sliders.

**Data model** —
`src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs`
- `MouseWheelIncrement` (`int`,
`[JsonPropertyName("mouse_wheel_increment")]`, default `5` set in the
constructor, mirroring `MonitorRefreshDelay`). Old `settings.json`
without the key deserializes to `5` (no migration). The whole type is
already registered with the source-generated JSON contexts, so the new
property serializes on both the Settings and flyout sides with no
context change.

**Settings UI (write side)** — `src/settings-ui/Settings.UI/...`
- `PowerDisplayViewModel`: `MouseWheelIncrement` (get/set via
`SetSettingsProperty`, calls `SignalSettingsUpdated()` so an open flyout
updates live) and `MouseWheelIncrementOptions` = `{ 1, 2, 5, 10, 15, 20,
25 }`.
- `PowerDisplayPage.xaml`: a `ComboBox` `SettingsCard` in the
flyout-settings expander, immediately after "Monitor refresh delay",
matching that card's markup.
- `Strings/en-us/Resources.resw`: `Mouse wheel increment` + description.

**Flyout (read side)** — `src/modules/powerdisplay/PowerDisplay/...`
- `MainViewModel`: `[ObservableProperty] int MouseWheelIncrement`
(default 5), loaded from settings in `LoadUIDisplaySettings()` (runs at
startup and on the settings-updated IPC event, so the all-displays
slider updates live).
- `MonitorViewModel`: a read-only proxy `MouseWheelIncrement =>
_mainViewModel?.MouseWheelIncrement ?? 5` plus
`RefreshMouseWheelIncrement()`, called from `ApplySettingsFromUI`'s
per-monitor loop so the per-monitor sliders update live.
- `MainWindow.xaml`: the four sliders'
`SliderExtensions.MouseWheelChange` now bind to the setting — the
all-displays slider to `ViewModel.MouseWheelIncrement`, the three
per-monitor sliders (brightness/contrast/volume, inside the
`MonitorViewModel` `DataTemplate`) to `MouseWheelIncrement`.

## Validation Steps Performed

**Automated**
- Unit tests
(`PowerDisplay.Lib.UnitTests/MouseWheelIncrementSettingsTests.cs`), run
via `vstest.console.exe` — passing:
  - default value is `5`;
- legacy `settings.json` missing the key deserializes to `5` (no
migration);
  - round-trip preserves a non-default value;
  - serialization emits the `mouse_wheel_increment` snake_case key.
- `PowerToys.Settings` and the PowerDisplay flyout app both compile
clean (the XAML compiler validates the new `x:Bind` bindings).
- Confirmed the property flows through the source-generated JSON
contexts (whole-type `[JsonSerializable(typeof(PowerDisplaySettings))]`)
on both the Settings and flyout sides.

**Pending (manual, on-device — reason this PR is a draft)**
- [ ] Settings: the dropdown shows `5` on a fresh profile; changing it
writes the new `mouse_wheel_increment` value to `settings.json`.
- [ ] Flyout: scrolling each slider (all-displays brightness,
per-monitor brightness/contrast/volume) steps by the selected value,
including live update while the flyout is open.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:56:44 +00:00
Niels Laute
536e768cac Extend MSIX context-menu icon sizing to the standard logo set (#48925)
## Summary

Addresses #48924. Several MSIX context-menu items did not ship the full
standard logo set (e.g. File Locksmith was missing the 44x44 logo), so
Windows fell back to a default icon in some surfaces.

This updates the MSIX assets for **File Locksmith**, **Image Resizer**
and **PowerRename** to provide the full logo set: 44x44 logo,
small/large/150x150 tiles, and store logo.

## PR Checklist
- [x] Closes #48924
- [ ] Tested manually

## Validation
Icon-asset only change; no code modified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 09:33:50 +02:00
Copilot
70ff4013b9 Add Shortcut Guide V2 manifest spec link to copilot-instructions.md (#48967)
Adds a reference to the WinGet Manifest Keyboard Shortcuts schema spec
in `.github/copilot-instructions.md` so AI agents know where to find the
correct field definitions, file naming conventions, and the `+` prefix
rule when creating or editing Shortcut Guide V2 manifest files.

## Summary of the Pull Request

Adds a new `## Shortcut Guide V2 Manifests` section to
`.github/copilot-instructions.md` linking to [`doc/specs/WinGet Manifest
Keyboard Shortcuts
schema.md`](../doc/specs/WinGet%20Manifest%20Keyboard%20Shortcuts%20schema.md).
This ensures agents authoring new manifest files follow the correct
schema and naming scheme (e.g., `<PackageId>.<locale>.yml`, `+` prefix
for apps without a WinGet package).

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

`.github/copilot-instructions.md` gains a dedicated section:

```markdown
## Shortcut Guide V2 Manifests

When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:

- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
```

No production code changes.

## Validation Steps Performed

- Verified the relative link resolves to the correct spec file in the
repository.
- Confirmed the section is correctly placed before "Detailed
Documentation".

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-27 21:07:48 +00:00
ABHIJEET KALE
7a04d4c270 [ShortcutGuide] Add DaVinci Resolve keyboard shortcut manifest (#48651) (#48652)
Adds a shortcut manifest for DaVinci Resolve (professional video editing
and color grading application by Blackmagic Design) so it appears in the
Shortcut Guide overlay when the app is focused.

The manifest contains 88 of the most commonly used DaVinci Resolve
keyboard shortcuts organized into 8 categories:

| Section | Shortcuts | Highlights |
|---------|-----------|------------|
| Popular shortcuts | 25 | Page navigation (F5-F8), Playback (JKL,
Space), Edit basics (Cut, Blade, Ripple Delete) |
| Timeline navigation | 13 | Frame/clip/track navigation, zoom, edit
point jumping |
| Edit | 14 | Cut/Copy/Paste/Duplicate, Render in Place, Compound Clip |
| Color | 17 | Node management (Alt+S/P/L), viewer modes (1-5),
Grade/Keyframe |
| Fairlight | 8 | Mute/Solo/Record/Automation, Bounce Mix |
| Fusion | 8 | View switching (1-4), Merge, Keyframe |
| Media | 8 | Import, Smart Bin, Reveal in Explorer, Rename |
| Deliver | 5 | Render Queue, Start Render, Output settings |

The manifest follows the same YAML schema as existing manifests. No code
changes needed -- manifests are auto-discovered at startup.
2026-06-27 19:48:39 +00:00
Michael Jolley
8c434cd6f4 CmdPal: Fix LayoutCycleException in gallery screenshot strip (#48090)
## Summary

Extension Gallery detail pages crash with `LayoutCycleException` /
`AG_E_LAYOUT_CYCLE` when scrolling the screenshot strip horizontally on
extensions that have many screenshots.

## Root Cause

The screenshot `ItemsView` uses a horizontal `StackLayout` and is placed
in a Grid row with `Height="Auto"`, inside a vertical `ScrollViewer`.
The `ItemsView` itself contains an internal `ScrollView` for horizontal
scrolling.

When screenshots load asynchronously (`BitmapImage` decode completes),
this triggers a layout feedback loop:

1. Image decodes → `ItemContainer` re-measures
2. Auto-height Grid row re-measures, offering `ItemsView` new available
height
3. `ItemsView`'s internal `ScrollView` recalculates extents
4. Parent Grid row invalidates → outer `ScrollViewer` re-layouts →
re-measures children → back to step 1

## Fix

Two XAML-only changes to `ExtensionGalleryItemPage.xaml`:

1. **Fixed Grid row height**: Changed the screenshot strip row from
`Height="Auto"` to `Height="232"` (200px image + 16px top/bottom
padding). The parent no longer queries `ItemsView` for desired height,
breaking the cycle.

2. **Fixed item width**: Added `Width="356"` to the screenshot `Border`
template (16:9 ratio: 200 × 16/9 ≈ 356). Provides a stable size before
async image decode, preventing re-measure triggers.

Fixes #47901

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 12:32:13 +02:00
ScymicX
d983dbc285 Fix VS Code workspace UNC paths (#48922)
## Summary of the Pull Request

Fixes #47719

VS Code stores recently opened workspaces as URIs. A workspace on a
Windows network share can be stored as
`file://server/share/workspace.code-workspace`.

The VS Code Workspaces plugin interpreted the server part of that URI as
a VS Code remote authority. Because it was not a recognized remote
authority, valid UNC workspaces were discarded.

This change recognizes file URIs with a server authority as local UNC
paths and converts them to the Windows UNC format: `\\server\share\...`.

## PR Checklist

* [x] Closes #47719
* [x] **Communication:** This implementation follows the suggested
direction in the issue discussion.
* [ ] **Tests:** No automated regression test added.
* [x] **Localization:** No user-facing strings were changed.
* [x] **New binaries:** No binaries were added.
* [x] **Documentation updated:** No documentation changes are required.

## Validation Steps Performed

* Built the full PowerToys solution locally in `Release | x64` with 0
errors.
* Started the locally built PowerToys instance.
* Verified that the VS Code Workspaces plugin finds local workspaces.
* Verified that UNC workspaces using both a hostname and an IP address
appear in PowerToys Run.
* Opened a UNC workspace successfully from PowerToys Run.
2026-06-26 22:00:25 +00:00
Niels Laute
fb6843b0f1 Refactor transparent overlay into TransparentWindow + TransientSurface (#48915)
## Summary

Refactors the reusable transparent-overlay infrastructure in
`src/common/Common.UI.Controls/` into a clean separation between a pure
host window and a self-animating acrylic surface.

### What changed

- **`TransparentWindow`** is now animation-agnostic. It raises `Showing`
/ `Hiding` events; `Hiding` exposes a deferral so the HWND stays visible
until the surface's out-animation finishes.
- **`TransientSurface`** (renamed from `TransparentCard`) is a
self-animating "pseudo-window" content control. It owns all chrome —
`ThemeShadow`, always-active desktop acrylic, 1px border, rounded
corners — and its own show/hide slide animations.
- `SlideFrom` (`None`/`Left`/`Top`/`Right`/`Bottom`) selects the slide
edge. `None` is the default and plays **no animation at all** (instant
show/hide).
- `AcrylicKind` (new) is exposed and bound to the backdrop via
`TemplateBinding`, defaulting to **thin acrylic**. Consumers can
override to `Default`/`Base`.
- **`AlwaysActiveDesktopAcrylicBackdrop`** gains a matching `Kind`
dependency property.
- **CmdPal `ToastWindow`** is migrated to the new pattern as the proving
consumer (`Surface.SubscribeTo(this)`).

### Coordination model

A module declares a `<TransientSurface>` as the window's content and
calls `SubscribeTo(window)` once. The window raises `Showing`/`Hiding`;
the surface animates itself in/out and uses the `Hiding` deferral to
keep the window alive until the out-animation completes.

## Testing

- `Common.UI.Controls` builds clean (x64 Debug, exit 0).
- `Microsoft.CmdPal.UI` builds clean (x64 Debug, exit 0).
- ToastWindow keeps its slide-up animation (`SlideFrom="Bottom"`).


https://github.com/user-attachments/assets/a06b0f1a-740a-4fcd-bba8-6f7a64ed261b

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 21:25:17 +00:00
Clint Rutkas
6dd1ce5dd1 Dev/crutkas/ripple v2.1 + spelling allow-list follow-up (#48232)
## Summary of the Pull Request

Adds a follow-up metadata fix to the existing Mouse Highlighter ripple
v2.1 work by allowing the term `xhair` in repo spell-check
configuration.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

- Added `xhair` to `.github/actions/spell-check/expect.txt`.
- This addresses spelling feedback on MouseHighlighter ripple/crosshair
code without changing runtime behavior.
- No functional changes to Mouse Highlighter logic were made in this
follow-up commit.

## Validation Steps Performed

- Verified the only content change is the new `xhair` entry in
spell-check expected words.
- Ran secret scanning on changed file
(`.github/actions/spell-check/expect.txt`) with no findings.
- Ran parallel validation:
  - Code Review: no issues.
  - CodeQL: skipped as trivial metadata-only change.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-26 21:35:27 +02:00
Michael Jolley
9ea30ec523 CmdPal: Fix fallback results showing when disabled in Command Palette (#48777)
Fallback results were showing in Command Palette search results even
when the user had disabled them in settings.

When a fallback command is disabled (e.g., VS Code for Command Palette
with `IsEnabled = false`), it is excluded from
`configuredGlobalFallbackIds` (which only contains enabled + global
fallbacks). However, during search filtering, all fallbacks NOT in
`configuredGlobalFallbackIds` were unconditionally added to
`commonFallbacks`, which gets scored and displayed in results. This
means disabled fallbacks still appeared.

To fix, I added an `IsEnabled` check when building the `commonFallbacks`
list in `MainListPage.cs`. Disabled fallback commands are now properly
excluded from search results.

Fixes #48504

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 12:40:53 -05:00
Niels Laute
c777fcc1e4 Fix CmdPal gallery crash when extension has no homepage (#48869)
## Summary

Selecting an extension in the CmdPal Extension Gallery crashed the app
when that extension had **no `homepage`** defined.

## Root cause

In `ExtensionGalleryItemPage.xaml`, the "View repository"
`HyperlinkButton` bound its `NavigateUri` (a `System.Uri`) directly to
the raw string property `ViewModel.Homepage`:

```xml
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
```

`x:Bind` evaluates **all** bindings on an element regardless of
`Visibility`, and to assign a `string` to a `Uri` target it generates a
`new Uri(value)` conversion. When `homepage` is undefined, `Homepage` is
`null`, so the binding executes `new Uri(null)` →
`ArgumentNullException` → the page crashes on load. The
`Visibility="{... HasHomepage}"` collapse did not help because the
`NavigateUri` binding still runs.

Every other `NavigateUri` x:Bind in the codebase (`Link`, `LinkUri`) is
already bound to a `Uri?`, so the homepage hyperlink was the lone
offender.

## Fix

- **`ExtensionGalleryItemViewModel.cs`** — Added a validated `Uri?
HomepageUri` property backed by the existing `_homepageHttpUri` (already
`null` for missing or non-web homepages).
- **`ExtensionGalleryItemPage.xaml`** — Bound `NavigateUri` to
`ViewModel.HomepageUri` instead of the raw string. `null` is valid for
`NavigateUri`, so no conversion occurs. The tooltip still shows the
`Homepage` string.
- **Tests** — Added coverage asserting `HomepageUri` is set for a web
homepage and `null` when missing.

## Verification

- `Microsoft.CmdPal.UI.ViewModels`, `Microsoft.CmdPal.UI` (XAML
compile), and the unit test project all built cleanly (x64/Release, exit
code 0).
- All 24 `ExtensionGalleryItemViewModelTests` pass, including the two
new cases.
- Manually verified in VS that opening an extension without a homepage
no longer crashes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>



https://github.com/user-attachments/assets/b268bafb-6bee-4862-9fbf-7a0e06675e36

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 17:14:37 -05:00
Michael Jolley
28e078897a [CmdPal] Fix memory leak in PerformanceWidgetsPage network band items (#48880)
## Summary

Fixes a memory leak in the Performance Monitor dock extension where
`GetItems()` created **new** `ListItem` instances for `_networkUpItem`
and `_networkDownItem` on every call.

## Problem

When the dock subscribes to `ItemsChanged` and calls `GetItems()` to
refresh, the band page path allocates 2 new `ListItem` objects each time
— the old ones are replaced in the fields but never collected (they
remain referenced by the `DockItemViewModel` wrappers until the next
refresh cycle). Under normal operation this leaks ~2 objects/second
indefinitely.

## Fix

Move `_networkUpItem`/`_networkDownItem` creation into the constructor
(matching the pattern used by CPU, Memory, GPU, and Battery items).
`GetItems()` now returns stable references. The `Updated` event handler
already updates their `.Title` properties, which propagates to the UI
via `PropChanged` → `CommandItemViewModel.Model_PropChanged`.

## Validation

- Build succeeds (`Microsoft.CmdPal.Ext.PerformanceMonitor.csproj`)
- Network up/down band items still receive title updates via the
existing `Updated` handler
- No `RaiseItemsChanged()` needed — `ListItem.Title` setter fires
`PropChanged`, which `DockItemViewModel` already observes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 21:06:13 +00:00
Michael Jolley
64f1243bdf Skip auto-labeling PRs that already have labels (#48877)
## Summary

The auto-labeler workflow now skips pull requests that already have
labels applied before running the AI classification. This avoids
overwriting or duplicating labels that were manually set by contributors
or maintainers.

## Changes

- Added a check in `labelIssue()` that returns early for PRs with
existing labels, logging which labels are already present.
- Issues continue to be labeled regardless (only PRs get the skip
logic).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 19:22:58 +00:00
Mario Hewardt
e1074bc835 ZoomIt - Update notices (#48843)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Update notices for ZoomIt dependency

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-25 17:31:51 +02:00
Michael Jolley
2390aacbfc CmdPal: Prevent same-page settings navigation (#48703)
Fixes #48698 by preventing the Command Palette settings frame from
navigating to the same page again, which avoids adding a self-navigation
entry to the back stack.

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 14:54:39 +02:00
107 changed files with 8976 additions and 712 deletions

View File

@@ -135,6 +135,7 @@ BITMAPINFO
BITMAPINFOHEADER
BITSPERPEL
BITSPIXEL
Blackmagic
bla
BLENDFUNCTION
blittable
@@ -539,6 +540,7 @@ EXTRINSICPROPERTIES
eyetracker
FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
Fairlight
FARPROC
fdw
fdx
@@ -2178,6 +2180,7 @@ xclip
xcopy
xdf
xfd
xhair
xmp
Xoshiro
xsi

View File

@@ -30,6 +30,12 @@ These are auto-applied based on file location:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
## Shortcut Guide V2 Manifests
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)

View File

@@ -73,6 +73,13 @@ jobs:
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
// Skip pull requests that already have labels applied.
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
const existingLabels = issue.labels.map(l => l.name).join(', ');
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';

View File

@@ -4,6 +4,29 @@
<Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
<Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
<!--
Onboarding guard: PowerToys has deeply nested source paths that exceed the legacy
260-character MAX_PATH limit. Without Windows long path support enabled, the build
fails with cryptic "path too long" / "could not find file" errors that are hard for
new contributors to diagnose. Detect the missing registry setting up front and emit a
clear, actionable error before the confusing failures occur.
- Covers both Visual Studio (Ctrl+Shift+B) and the command-line build scripts.
- Runs only during real builds (skips design-time/IntelliSense passes).
- Bypass with /p:SkipLongPathsCheck=true if you know what you're doing.
See tools\build\setup-dev-environment.ps1 to enable everything automatically.
-->
<Target Name="EnsureLongPathsEnabled"
BeforeTargets="PrepareForBuild"
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipLongPathsCheck)' != 'true' and '$(OS)' == 'Windows_NT'">
<PropertyGroup>
<_LongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64))</_LongPathsEnabled>
</PropertyGroup>
<Error Condition="'$(_LongPathsEnabled)' != '1'"
Code="PTLONGPATH"
Text="Windows long path support is not enabled. PowerToys source paths exceed the 260-character MAX_PATH limit, so the build will fail with cryptic 'path too long' errors. Fix it by running (from an elevated PowerShell): .\tools\build\setup-dev-environment.ps1 -- or set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 (DWORD) and restart Windows. To bypass this check, build with /p:SkipLongPathsCheck=true." />
</Target>
<!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. -->
<PropertyGroup Label="ManifestToolOverride">
<ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool>

View File

@@ -12,6 +12,7 @@ This software incorporates material from third parties.
- Peek
- PowerDisplay
- Registry Preview
- ZoomIt
## Utility: Color Picker
@@ -1549,6 +1550,69 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Utility: ZoomIt
### libwebp
ZoomIt uses libwebp to encode screenshots in the WebP image format.
**Source**: <https://github.com/webmproject/libwebp>
BSD-3-Clause License
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Additional IP Rights Grant (Patents)
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
## NuGet Packages used by PowerToys
- AdaptiveCards.ObjectModel.WinUI3

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

@@ -29,8 +29,30 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
/// </remarks>
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
{
/// <summary>
/// Identifies the <see cref="Kind"/> dependency property.
/// </summary>
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
nameof(Kind),
typeof(DesktopAcrylicKind),
typeof(AlwaysActiveDesktopAcrylicBackdrop),
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
/// <summary>
/// Gets or sets the desktop acrylic material variant to render. Defaults to
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
/// material. Changing this updates any live backdrop targets immediately.
/// </summary>
public DesktopAcrylicKind Kind
{
get => (DesktopAcrylicKind)GetValue(KindProperty);
set => SetValue(KindProperty, value);
}
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
@@ -41,7 +63,10 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
Theme = ResolveTheme(xamlRoot),
};
var controller = new DesktopAcrylicController();
var controller = new DesktopAcrylicController
{
Kind = Kind,
};
controller.SetSystemBackdropConfiguration(configuration);
controller.AddSystemBackdropTarget(connectedTarget);
@@ -70,6 +95,17 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
}
}
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
var kind = (DesktopAcrylicKind)e.NewValue;
foreach (var target in self._targets.Values)
{
target.Controller.Kind = kind;
}
}
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
xamlRoot.Content is FrameworkElement rootElement
? rootElement.ActualTheme switch

View File

@@ -5,9 +5,9 @@
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
@@ -16,7 +16,7 @@
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:TransparentCard">
<ControlTemplate TargetType="local:TransientSurface">
<Grid
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
@@ -27,7 +27,7 @@
</Grid.Shadow>
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
<SystemBackdropElement.SystemBackdrop>
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
</SystemBackdropElement.SystemBackdrop>
</SystemBackdropElement>
<ContentPresenter
@@ -41,5 +41,4 @@
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,467 @@
// 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 CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating, self-animating "pseudo window" surface for transient PowerToys
/// overlays (toasts, banners, indicators). It looks like a control but behaves
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
/// its own show/hide animations.
/// </summary>
/// <remarks>
/// <para>Designed to be declared as the root content of a
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
/// animates itself in/out whenever the window is shown or hidden, and uses the
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
/// until its out-animation finishes.</para>
/// <para>The show transition comes from the window's
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
/// <see cref="ShowTransition"/> when shown without one); the hide transition
/// always comes from <see cref="HideTransition"/>. Animations target the
/// surface itself, so the entire surface (border, acrylic, shadow, inner
/// content) animates as one. Apps that want a different look supply their own
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
/// restyle path.</para>
/// </remarks>
public sealed partial class TransientSurface : ContentControl
{
private const float ShadowDepth = 32f;
private const double SlideInOffset = 24;
private const double SlideOutOffset = 12;
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
// fade is kept fast so the surface reads as an instant, light pop.
//
// The fade must run at least as long as the scale: if the scale outlasted the
// fade, the surface would reach full opacity while still visibly growing,
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
// hides the growth under the opacity ramp, so by the time it is fully opaque
// it is already at 100% size.
private const float PopScaleFrom = 0.96f;
private const double PopFadeShowMs = 180;
private const double PopScaleShowMs = 150;
private const double PopFadeHideMs = 120;
private const double PopScaleHideMs = 120;
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
nameof(ShowTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
nameof(HideTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
nameof(AcrylicKind),
typeof(DesktopAcrylicKind),
typeof(TransientSurface),
new PropertyMetadata(DesktopAcrylicKind.Thin));
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly ImplicitAnimationSet _noAnimations = new();
private ImplicitAnimationSet _showAnimations = new();
private ImplicitAnimationSet _hideAnimations = new();
private bool _hasCustomShowAnimations;
private bool _hasCustomHideAnimations;
private Action? _abandonPendingHide;
public TransientSurface()
{
DefaultStyleKey = typeof(TransientSurface);
RebuildDefaultAnimations();
// Pin the scale center to the surface's center so the "Pop" transition
// grows/shrinks from the middle, not the top-left corner. An expression
// animation bound to the visual's own size keeps the center correct from
// the very first frame (a SizeChanged handler would race the show
// animation and let the first pop scale from 0,0).
PinScaleCenter();
// Start hidden so the first Show() animates in from the configured pose.
Visibility = Visibility.Collapsed;
}
/// <summary>
/// Raised after <see cref="Hide"/> once the longest animation in
/// <see cref="HideAnimations"/> (delay + duration) has completed.
/// </summary>
public event EventHandler? HideCompleted;
/// <summary>
/// Gets or sets the transition played when the surface is shown without an
/// explicit one (see <see cref="Show()"/>). Defaults to
/// <see cref="Transition.None"/>, which plays no animation at all (the
/// surface appears instantly); a directional value adds a fade plus a slide
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
/// unless it has been set explicitly.
/// </summary>
public Transition ShowTransition
{
get => (Transition)GetValue(ShowTransitionProperty);
set => SetValue(ShowTransitionProperty, value);
}
/// <summary>
/// Gets or sets the transition played when the surface is hidden (see
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
/// plays no animation at all (the surface disappears instantly); a
/// directional value adds a fade plus a slide out toward that edge, and
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
/// this regenerates the default <see cref="HideAnimations"/> unless it has
/// been set explicitly.
/// </summary>
public Transition HideTransition
{
get => (Transition)GetValue(HideTransitionProperty);
set => SetValue(HideTransitionProperty, value);
}
/// <summary>
/// Gets or sets the desktop acrylic material variant painted behind the
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
/// for the base material. Has no effect when a custom template without the
/// default acrylic backdrop is applied.
/// </summary>
public DesktopAcrylicKind AcrylicKind
{
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
set => SetValue(AcrylicKindProperty, value);
}
/// <summary>
/// Gets or sets the animations played when <see cref="Show()"/> flips the
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set
{
_showAnimations = value ?? new ImplicitAnimationSet();
_hasCustomShowAnimations = true;
}
}
/// <summary>
/// Gets or sets the animations played when <see cref="Hide"/> flips the
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set
{
_hideAnimations = value ?? new ImplicitAnimationSet();
_hasCustomHideAnimations = true;
}
}
/// <summary>
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
/// animates itself in and out in response to the window's
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
/// events. Call this once after the surface has been set as (or placed within)
/// the window's content.
/// </summary>
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
public void SubscribeTo(TransparentWindow host)
{
ArgumentNullException.ThrowIfNull(host);
host.Showing += OnHostShowing;
host.Hiding += OnHostHiding;
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
/// using <paramref name="transition"/> as the show transition.
/// </summary>
/// <param name="transition">The transition to play when showing.</param>
public void Show(Transition transition)
{
ShowTransition = transition;
Show();
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls re-trigger the show animation cleanly and cancel any
/// pending <see cref="HideCompleted"/> notification.
/// </summary>
public void Show()
{
_hideCompletedTimer.Stop();
// If a hide from a previous cycle is still in flight, abandon it: drop its
// pending HideCompleted handler so the outstanding deferral is never
// completed. We are showing again, so the host must keep the window
// visible instead of later hiding it for this interrupted cycle.
_abandonPendingHide?.Invoke();
_abandonPendingHide = null;
// Attach the show animation and detach any hide animation: when Show() is
// called while the surface is still visible, the Collapsed -> Visible
// restart below would otherwise play the hide animation (a fade/scale out)
// immediately before the show, producing a visible flash. The real hide
// animation is re-attached just-in-time in Hide().
Implicit.SetShowAnimations(this, _showAnimations);
Implicit.SetHideAnimations(this, _noAnimations);
// Reset to the hidden pose so the show animation always animates from the
// configured starting frame.
Visibility = Visibility.Collapsed;
Visibility = Visibility.Visible;
}
/// <summary>
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
/// once the longest animation in <see cref="HideAnimations"/> (delay +
/// duration) has completed.
/// </summary>
public void Hide()
{
// Attach the hide animation just before collapsing (Show() detaches it to
// avoid a flash when re-showing an already-visible surface).
Implicit.SetHideAnimations(this, _hideAnimations);
Visibility = Visibility.Collapsed;
_hideCompletedTimer.Debounce(
() => HideCompleted?.Invoke(this, EventArgs.Empty),
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
}
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((TransientSurface)d).RebuildDefaultAnimations();
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
{
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
_ => (null, null),
};
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
{
if (e.Transition is Transition transition)
{
Show(transition);
}
else
{
Show();
}
}
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
{
// Take a deferral so the host keeps its window visible until our
// out-animation has finished, then complete it from HideCompleted.
var deferral = e.GetDeferral();
void OnHideCompleted(object? s, EventArgs args)
{
HideCompleted -= OnHideCompleted;
_abandonPendingHide = null;
deferral.Complete();
}
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
// handler so the deferral is never completed (the window stays visible)
// rather than firing AppWindow.Hide for an interrupted cycle.
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
HideCompleted += OnHideCompleted;
Hide();
}
private void RebuildDefaultAnimations()
{
if (!_hasCustomShowAnimations)
{
_showAnimations = BuildShowAnimations(ShowTransition);
}
if (!_hasCustomHideAnimations)
{
_hideAnimations = BuildHideAnimations(HideTransition);
}
}
private void PinScaleCenter()
{
var visual = ElementCompositionPreview.GetElementVisual(this);
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
visual.StartAnimation("CenterPoint", center);
}
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = $"{PopScaleFrom},{PopScaleFrom},1",
To = "1,1,1",
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
var (slideFrom, _) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = slideFrom,
To = $"0,0,{ShadowDepth}",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = "1,1,1",
To = $"{PopScaleFrom},{PopScaleFrom},1",
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
var (_, slideTo) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = $"0,0,{ShadowDepth}",
To = slideTo,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
}

View File

@@ -0,0 +1,35 @@
// 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 Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
/// plays when it is shown or hidden. The directional values describe an edge —
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
/// </summary>
public enum Transition
{
/// <summary>No animation; the surface appears and disappears instantly.</summary>
None,
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
Left,
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
Top,
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
Right,
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
Bottom,
/// <summary>
/// A subtle "pop": a quick fade combined with a small scale between 96% and
/// 100% from the surface's center. Stays in place — no slide.
/// </summary>
Pop,
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating "card" surface for transient PowerToys overlays (toasts,
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
/// </summary>
/// <remarks>
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
/// in resources — the standard WinUI restyle path.
/// </remarks>
public sealed partial class TransparentCard : ContentControl
{
public TransparentCard()
{
DefaultStyleKey = typeof(TransparentCard);
}
}

View File

@@ -4,6 +4,6 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
@@ -187,16 +187,13 @@ public static partial class FlyoutWindowHelper
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
/// target display (invisible at that size), then the real position+size while the window
/// is already on that monitor. Skips the teleport when already on the target display.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;

View File

@@ -1,290 +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;
using System.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Markup;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// </list>
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
/// in a <see cref="TransparentCard"/> that the constructor assigns to
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
/// be written as <c>&lt;common:TransparentWindow&gt;&lt;TextBlock/&gt;&lt;/common:TransparentWindow&gt;</c>.</para>
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
/// fully see-through. That buffer area is NOT click-through, so consumers
/// should keep it as small as possible (just enough to give the card's
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
/// toggle on the card, and a debounced
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
/// animation in <see cref="HideAnimations"/>. Animations target the card so
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
/// </remarks>
[ContentProperty(Name = nameof(InnerContent))]
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly nint _hwnd;
private readonly TransparentCard _card;
private ImplicitAnimationSet _showAnimations;
private ImplicitAnimationSet _hideAnimations;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
_showAnimations = BuildDefaultShowAnimations();
_hideAnimations = BuildDefaultHideAnimations();
_card = new TransparentCard();
Content = _card;
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Gets the <see cref="TransparentCard"/> that provides the window's
/// visible chrome (acrylic + border + shadow). Consumers can configure
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
/// window, or apply a custom <c>Style</c> to change its look.
/// </summary>
public TransparentCard Card => _card;
/// <summary>
/// Gets or sets the visual hosted inside the window's
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
/// child elements declared between the opening and closing
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
/// </summary>
public object? InnerContent
{
get => _card.Content;
set => _card.Content = value;
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
/// fade-in plus a 250 ms slide-up of 24 px.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set => _showAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
/// fade-out plus a 180 ms slide-down of 12 px.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set => _hideAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls reset the content to its hidden pose first so the show
/// animation re-triggers cleanly. Any pending hide is cancelled.
/// </summary>
public void Show()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_hideCloseTimer.Stop();
if (Content is UIElement content)
{
// Re-apply each call so swapping animation collections at
// runtime takes effect on the next show/hide cycle.
Implicit.SetShowAnimations(content, _showAnimations);
Implicit.SetHideAnimations(content, _hideAnimations);
// Reset to the hidden pose so the show animation always
// animates from the configured starting frame.
content.Visibility = Visibility.Collapsed;
}
_ = ShowWindow(_hwnd, SwShowNa);
if (Content is UIElement c2)
{
c2.Visibility = Visibility.Visible;
}
});
}
/// <summary>
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
/// plays, then hides the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
/// animation in <see cref="HideAnimations"/> (delay + duration) has
/// completed.
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
if (Content is UIElement content)
{
content.Visibility = Visibility.Collapsed;
}
_hideCloseTimer.Debounce(
AppWindow.Hide,
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
});
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
{
new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,24,32",
To = "0,0,32",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
};
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
{
new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,0,32",
To = "0,12,32",
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
};
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -0,0 +1,61 @@
// 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.Threading;
using Deferral = global::Windows.Foundation.Deferral;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
/// animated surface can keep the window visible until its out-animation has
/// finished. If no handler takes a deferral, the window hides immediately.
/// </summary>
public sealed class HidingEventArgs : EventArgs
{
private int _outstanding;
private bool _raised;
private Action? _continuation;
/// <summary>
/// Requests that the window stay visible until the returned deferral is
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
/// has finished.
/// </summary>
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
public Deferral GetDeferral()
{
Interlocked.Increment(ref _outstanding);
return new Deferral(OnDeferralCompleted);
}
/// <summary>
/// Called by the window after raising the event to register what should run
/// once every outstanding deferral has completed (or immediately if none
/// were taken).
/// </summary>
internal void RunWhenComplete(Action continuation)
{
_continuation = continuation;
_raised = true;
TryComplete();
}
private void OnDeferralCompleted()
{
Interlocked.Decrement(ref _outstanding);
TryComplete();
}
private void TryComplete()
{
if (_raised && Volatile.Read(ref _outstanding) == 0)
{
var continuation = _continuation;
_continuation = null;
continuation?.Invoke();
}
}
}

View File

@@ -0,0 +1,26 @@
// 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;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
/// content should play, or <see langword="null"/> to let the content use its own
/// configured show transition.
/// </summary>
public sealed class ShowingEventArgs : EventArgs
{
public ShowingEventArgs(Transition? transition)
{
Transition = transition;
}
/// <summary>
/// Gets the transition the content should play, or <see langword="null"/> to
/// use the content's own configured show transition.
/// </summary>
public Transition? Transition { get; }
}

View File

@@ -0,0 +1,164 @@
// 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.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Windows.Foundation;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
/// see-through and the visible chrome can be drawn by the content.</item>
/// </list>
/// <para>This window is intentionally animation-agnostic: it does not own any
/// chrome or motion. Consumers supply their own content (typically a
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
/// deferrals, so the underlying
/// <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>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly nint _hwnd;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
/// using <see cref="ShowingEventArgs.Transition"/>.
/// </summary>
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
/// <summary>
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
/// surface subscribes to this to play its out-animation, taking a deferral
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
/// visible until the animation completes.
/// </summary>
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> without a transition, so subscribed content animates
/// in using its own configured show transition.
/// </summary>
public void Show() => RaiseShow(null);
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> so subscribed content animates in using
/// <paramref name="transition"/>, overriding its configured show transition.
/// </summary>
/// <param name="transition">The transition the content should play.</param>
public void Show(Transition transition) => RaiseShow(transition);
private void RaiseShow(Transition? transition)
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
}
/// <summary>
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
/// deferral taken by a handler has completed (immediately if none were taken).
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
var args = new HidingEventArgs();
Hiding?.Invoke(this, args);
args.RunWhenComplete(AppWindow.Hide);
});
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

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,42 @@ namespace AdvancedPaste.Settings
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
pythonScripts.MigrateLegacyIfNeeded();
var mode = pythonScripts.Mode ?? "disabled";
IsPythonScriptsEnabled = !string.Equals(mode, "disabled", StringComparison.OrdinalIgnoreCase);
PythonUseWsl = string.Equals(mode, "wsl", StringComparison.OrdinalIgnoreCase);
if (PythonUseWsl)
{
var wslSettings = pythonScripts.WslSettings ?? new PythonScriptWslSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(wslSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: wslSettings.ScriptsFolder;
PythonExecutablePath = string.Empty;
PythonWslDistribution = wslSettings.Distribution ?? string.Empty;
}
else
{
var winSettings = pythonScripts.WindowsSettings ?? new PythonScriptWindowsSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(winSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: winSettings.ScriptsFolder;
PythonExecutablePath = winSettings.PythonExecutablePath ?? string.Empty;
PythonWslDistribution = string.Empty;
}
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
TrustedScriptHashes = new Dictionary<string, string>(
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
_pythonScriptActions.Clear();
_pythonScriptActions.AddRange(pythonScripts.Value);
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -299,6 +366,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 +555,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"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -373,6 +373,13 @@ static int g_overlayRenderedH = 0;
// Always On Top (WindowCornerUtils::CornersRadius).
static int CornerRadiusForWindow(HWND hwnd)
{
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
if (GetSystemMetrics(SM_REMOTESESSION))
{
return 0;
}
int pref = 0; // DWMWCP_DEFAULT
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
{

View File

@@ -6,6 +6,8 @@
#include "trace.h"
#include <cmath>
#include <algorithm>
#include <memory>
#include <vector>
#ifdef COMPOSITION
namespace winrt
@@ -48,6 +50,18 @@ private:
void ClearDrawingPoint();
void ClearDrawing();
void BringToFront();
// Ripple mode: spawn the press/hold ring + glow at the click point and
// continue the animation into a fade-out on release. The held ring may
// optionally follow the cursor while held (gated by m_rippleShowDragTrail).
void SpawnRippleHoldDot(MouseButton button);
void FadeRippleHoldDot(MouseButton button);
// Ripple mode: emit a single self-contained ripple (grow + fade) for a quick
// click, independent of any held indicator.
void EmitSingleRipple(MouseButton button);
// Spotlight mode: pressed-state animation that shrinks the mask while
// a mouse button is held and restores it on release.
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -71,6 +85,16 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
// Ellipse geometries kept alongside the pointer shapes so press-down /
// release animations can target the radius directly.
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
// Ripple-mode held glow (the soft halo behind the ring) — paired with
// m_left/rightPointer (which holds the ring shape) while a button is held.
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
// Spotlight overlay (mask with soft feathered edge)
winrt::SpriteVisual m_overlay{ nullptr };
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
@@ -84,9 +108,22 @@ private:
bool m_rightPointerEnabled = true;
bool m_alwaysPointerEnabled = true;
bool m_spotlightMode = false;
bool m_spotlightPressed = false;
bool m_rippleMode = true;
bool m_rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool m_rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
float m_rippleSize = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
float m_rippleIntensity = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
int m_rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool m_leftButtonPressed = false;
bool m_rightButtonPressed = false;
// Pending hold-detection timers. A ripple "held indicator" is only spawned
// once the button has been held past a short threshold; a quick click that
// releases before then emits a single self-contained ripple instead. This
// prevents a single click from rendering two ripples (press + release).
UINT_PTR m_leftHoldTimer = 0;
UINT_PTR m_rightHoldTimer = 0;
UINT_PTR m_timer_id = 0;
bool m_visible = false;
@@ -102,6 +139,11 @@ private:
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
};
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
static const uint32_t HOLD_RIPPLE_TIMER_RIGHT = 125;
// How long a ripple button must be held before the persistent "held indicator"
// is shown. Releasing before this is treated as a quick click (single ripple).
static const uint32_t HOLD_RIPPLE_THRESHOLD_MS = 180;
Highlighter* Highlighter::instance = nullptr;
bool Highlighter::CreateHighlighter()
@@ -194,11 +236,34 @@ void Highlighter::AddDrawingPoint(MouseButton button)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
m_leftPointer = circleShape;
m_leftGeometry = circleGeometry;
// Niels-style press-down shrink: holding the button squeezes the
// circle to 70% over 180ms after a 150ms delay so quick clicks skip
// it. StartDrawingPointFading stops this animation on release.
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else if (button == MouseButton::Right)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
m_rightPointer = circleShape;
m_rightGeometry = circleGeometry;
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else
{
@@ -238,17 +303,36 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
if (button == MouseButton::Left)
{
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_leftRippleGlow)
{
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else if (button == MouseButton::Right)
{
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_rightRippleGlow)
{
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
// always / spotlight idle
if (m_spotlightMode)
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
if (m_spotlightPressed)
{
// Only update position while pressed — radius is being animated
if (m_spotlightMaskGradient)
{
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
}
else if (m_alwaysPointer)
{
@@ -259,14 +343,24 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
void Highlighter::StartDrawingPointFading(MouseButton button)
{
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
if (button == MouseButton::Left)
{
circleShape = m_leftPointer;
geom = m_leftGeometry;
}
else
{
// right
circleShape = m_rightPointer;
geom = m_rightGeometry;
}
// Stop any in-flight press-down shrink so the geometry doesn't keep
// animating while the fill is being faded out.
if (geom && m_compositor)
{
geom.StopAnimation(L"Radius");
}
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
@@ -329,6 +423,30 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
switch (wParam)
{
case WM_LBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_leftPointerEnabled)
{
// Defer the held indicator: only spawn it if the button is
// still down after the hold threshold. A quick click handled
// on button-up emits a single ripple instead.
instance->m_leftButtonPressed = true;
if (instance->m_leftHoldTimer == 0)
{
instance->m_leftHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_LEFT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_leftPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
@@ -354,6 +472,28 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_rightPointerEnabled)
{
// Defer the held indicator (see WM_LBUTTONDOWN).
instance->m_rightButtonPressed = true;
if (instance->m_rightHoldTimer == 0)
{
instance->m_rightHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_RIGHT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_rightPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
@@ -376,6 +516,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_MOUSEMOVE:
if (instance->m_rippleMode)
{
// Held ripple ring follows the cursor while a button is down,
// gated by the "follow cursor while held" setting. When the
// setting is off, the ring stays anchored at the click point.
if (instance->m_rippleShowDragTrail)
{
if (instance->m_leftButtonPressed && instance->m_leftPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
}
if (instance->m_rightButtonPressed && instance->m_rightPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Right);
}
}
break;
}
if (instance->m_leftButtonPressed)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
@@ -390,11 +548,33 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_LBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_leftButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Left);
if (instance->m_rippleMode)
{
if (instance->m_leftHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Left);
}
else
{
// Held indicator was already shown; expand + fade it.
instance->FadeRippleHoldDot(MouseButton::Left);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Left);
}
instance->m_leftButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
{
// Add AlwaysPointer only when it's enabled and RightPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -402,11 +582,32 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_rightButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Right);
if (instance->m_rippleMode)
{
if (instance->m_rightHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Right);
}
else
{
instance->FadeRippleHoldDot(MouseButton::Right);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Right);
}
instance->m_rightButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
{
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -448,9 +649,16 @@ void Highlighter::StopDrawing()
m_visible = false;
m_leftButtonPressed = false;
m_rightButtonPressed = false;
m_spotlightPressed = false;
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
m_leftGeometry = nullptr;
m_rightGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_rightRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
m_rightGlowGeometry = nullptr;
if (m_overlay)
{
m_overlay.IsVisible(false);
@@ -478,6 +686,16 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
m_rippleMode = settings.rippleMode && !m_spotlightMode;
m_rippleSize = (settings.rippleSize > 0) ? static_cast<float>(settings.rippleSize) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
m_rippleIntensity = (settings.rippleIntensity > 0.0) ? static_cast<float>(settings.rippleIntensity) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
m_rippleDurationMs = (settings.rippleDurationMs > 0) ? settings.rippleDurationMs : MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
m_rippleShowDragTrail = settings.rippleShowDragTrail;
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
// 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;
if (m_spotlightMode)
{
@@ -548,6 +766,7 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
// If we would use a timer with a 50 ms period, there would be a flickering on the UI, as in most of the cases
// the pinned window hides our window in a few milliseconds.
case BRING_TO_FRONT_TIMER_ID:
{
static int fireCount = 0;
if (fireCount++ >= 4)
{
@@ -558,6 +777,24 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->BringToFront();
break;
}
case HOLD_RIPPLE_TIMER_LEFT:
// Button held past the threshold: show the persistent held indicator.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
if (instance->m_leftButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Left);
}
break;
case HOLD_RIPPLE_TIMER_RIGHT:
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
if (instance->m_rightButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Right);
}
break;
}
break;
}
default:
@@ -643,6 +880,548 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
}
}
// Spotlight press-down: shrink the mask radius briefly while a button is held.
void Highlighter::SpotlightAnimatePress()
{
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
m_spotlightPressed = true;
const float pressedRadius = m_radius * 0.85f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(120));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spotlight release: animate the mask back to the configured radius.
void Highlighter::SpotlightAnimateRelease()
{
m_spotlightPressed = false;
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto current = m_spotlightMaskGradient.EllipseRadius();
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, current);
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
anim.Duration(std::chrono::milliseconds(200));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spawn the press/hold ring + glow at the click point. The shapes persist
// until FadeRippleHoldDot is called (button-up). While held they can be
// re-positioned to follow the cursor (UpdateDrawingPointPosition).
void Highlighter::SpawnRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
// Resolve sizing/intensity from the ripple-specific settings so they're
// independent of the legacy "always-on dot" controls.
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
const float ringHeld = baseSize * 0.55f;
const float glowHeld = baseSize * 0.65f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Held indicator: appears once the button has been held past the hold
// threshold and sits at the held radius until release. It must NOT expand
// outward on appearance — it only FADES IN at the held size. The single
// outward "ripple" expansion happens exclusively on release
// (FadeRippleHoldDot). If this grew outward, a slow single click (release
// shortly after the threshold) would show grow-to-held + release as two
// expansions — the double-ripple bug.
auto dur = std::chrono::milliseconds(120);
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
// Glow color is the click color, lower alpha (×0.30), scaled by intensity.
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
auto glowTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Ring color uses full base alpha (alphaMul like the press recipe).
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ringTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Clean up any stray "still held" shapes for this button — guards against
// stray button-down without matching button-up (e.g. focus loss).
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (m_shape && m_shape.Shapes())
{
auto shapes = m_shape.Shapes();
uint32_t idx = 0;
if (heldRing && shapes.IndexOf(heldRing, idx))
{
shapes.RemoveAt(idx);
}
if (heldGlow && shapes.IndexOf(heldGlow, idx))
{
shapes.RemoveAt(idx);
}
}
// Glow (filled) — added first so the ring renders on top. Sits at the held
// radius and fades its alpha in (no outward size growth).
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowHeld, glowHeld });
auto glowBrush = m_compositor.CreateColorBrush(glowTransparent);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
auto glowFadeIn = m_compositor.CreateColorKeyFrameAnimation();
glowFadeIn.InsertKeyFrame(0.0f, glowTransparent);
glowFadeIn.InsertKeyFrame(1.0f, glowColor, ease);
glowFadeIn.Duration(dur);
glowBrush.StartAnimation(L"Color", glowFadeIn);
// Ring (stroked) — same: fixed at held radius, alpha fade-in only.
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringHeld, ringHeld });
auto ringBrush = m_compositor.CreateColorBrush(ringTransparent);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
auto ringFadeIn = m_compositor.CreateColorKeyFrameAnimation();
ringFadeIn.InsertKeyFrame(0.0f, ringTransparent);
ringFadeIn.InsertKeyFrame(1.0f, ringColor, ease);
ringFadeIn.Duration(dur);
ringBrush.StartAnimation(L"Color", ringFadeIn);
heldRing = ringShape;
heldGlow = glowShape;
heldGeom = ringGeom;
heldGlowGeom = glowGeom;
}
// Continue the held-ring/glow animation outward and fade both to transparent.
// For right-click, optionally spawn the expanding crosshair lines.
void Highlighter::FadeRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (!heldRing && !heldGlow)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringHeld = baseSize * 0.55f;
const float ringEnd = baseSize * 1.05f;
const float glowHeld = baseSize * 0.65f;
const float glowEnd = baseSize * 1.40f;
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Track everything spawned by this fade (and the held shapes themselves)
// so the completion callback can remove them in one pass.
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
if (heldGlow && heldGlowGeom)
{
// The held indicator has settled at the held radius; expand it outward
// from there and fade it to transparent.
heldGlowGeom.StopAnimation(L"Radius");
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowHeld, glowHeld });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
heldGlowGeom.StartAnimation(L"Radius", glowAnim);
auto brush = heldGlow.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldGlow);
}
if (heldRing && heldGeom)
{
heldGeom.StopAnimation(L"Radius");
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringHeld, ringHeld });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
heldGeom.StartAnimation(L"Radius", ringAnim);
auto brush = heldRing.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldRing);
}
// Right-click only: spawn expanding crosshair lines centered on the ring.
// Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse && heldRing)
{
const float xhairAlphaMul = 0.18f + intensity * 0.78f;
auto xhairColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * xhairAlphaMul), color.R, color.G, color.B);
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
auto center = heldRing.Offset();
const float startSpan = ringHeld * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(center.x - startSpan, center.y, center.x - startSpan * 0.30f, center.y,
center.x - endSpan, center.y, center.x - endSpan * 0.30f, center.y);
makeLine(center.x + startSpan * 0.30f, center.y, center.x + startSpan, center.y,
center.x + endSpan * 0.30f, center.y, center.x + endSpan, center.y);
// Vertical line (top half, bottom half).
makeLine(center.x, center.y - startSpan, center.x, center.y - startSpan * 0.30f,
center.x, center.y - endSpan, center.x, center.y - endSpan * 0.30f);
makeLine(center.x, center.y + startSpan * 0.30f, center.x, center.y + startSpan,
center.x, center.y + endSpan * 0.30f, center.x, center.y + endSpan);
}
// Detach our member handles BEFORE the batch completes so subsequent
// press events on this button create fresh shapes rather than racing.
heldRing = nullptr;
heldGlow = nullptr;
heldGeom = nullptr;
heldGlowGeom = nullptr;
batch.End();
if (spawned->empty())
{
return;
}
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
// Self-contained single ripple for a quick click (press + release before the
// hold threshold). Spawns a fresh ring + glow that grow from the click point
// outward and fade to transparent in one continuous animation — no held
// indicator, so a single click produces exactly one ripple. For right-click,
// optionally spawns the expanding crosshair lines too.
void Highlighter::EmitSingleRipple(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringStart = baseSize * 0.20f;
const float ringEnd = baseSize * 1.05f;
const float glowStart = baseSize * 0.30f;
const float glowEnd = baseSize * 1.40f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
// Glow (filled) — added first so the ring renders on top.
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
spawned->push_back(glowShape);
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
glowGeom.StartAnimation(L"Radius", glowAnim);
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
glowColorAnim.InsertKeyFrame(1.0f, transparent, ease);
glowColorAnim.Duration(dur);
glowBrush.StartAnimation(L"Color", glowColorAnim);
// Ring (stroked).
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(ringColor);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
spawned->push_back(ringShape);
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
ringGeom.StartAnimation(L"Radius", ringAnim);
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
ringColorAnim.InsertKeyFrame(0.0f, ringColor);
ringColorAnim.InsertKeyFrame(1.0f, transparent, ease);
ringColorAnim.Duration(dur);
ringBrush.StartAnimation(L"Color", ringColorAnim);
// Right-click only: spawn expanding crosshair lines centered on the click
// point. Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse)
{
auto xhairColor = ringColor;
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
const float startSpan = (baseSize * 0.55f) * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(fx - startSpan, fy, fx - startSpan * 0.30f, fy,
fx - endSpan, fy, fx - endSpan * 0.30f, fy);
makeLine(fx + startSpan * 0.30f, fy, fx + startSpan, fy,
fx + endSpan * 0.30f, fy, fx + endSpan, fy);
// Vertical line (top half, bottom half).
makeLine(fx, fy - startSpan, fx, fy - startSpan * 0.30f,
fx, fy - endSpan, fx, fy - endSpan * 0.30f);
makeLine(fx, fy + startSpan * 0.30f, fx, fy + startSpan,
fx, fy + endSpan * 0.30f, fx, fy + endSpan);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)

View File

@@ -4,10 +4,16 @@
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);
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
// Ripple-specific defaults (independent of the always-on circle settings above).
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE = 60;
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;
struct MouseHighlighterSettings
{
@@ -19,6 +25,12 @@ struct MouseHighlighterSettings
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
bool spotlightMode = false;
bool rippleMode = true;
int rippleSize = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE;
double rippleIntensity = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY;
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;
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -21,6 +21,12 @@ namespace
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
const wchar_t JSON_KEY_RIPPLE_SIZE[] = L"ripple_size";
const wchar_t JSON_KEY_RIPPLE_INTENSITY[] = L"ripple_intensity";
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";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -392,6 +398,90 @@ public:
{
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
}
try
{
// Parse ripple mode
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
}
try
{
// Parse ripple size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleSize = value;
}
else
{
throw std::runtime_error("Invalid ripple size value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple size from settings. Will use default value");
}
try
{
// Parse ripple intensity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_INTENSITY);
double value = jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE);
if (value > 0.0)
{
highlightSettings.rippleIntensity = value;
}
else
{
throw std::runtime_error("Invalid ripple intensity value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple intensity from settings. Will use default value");
}
try
{
// Parse ripple duration
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_DURATION_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleDurationMs = value;
}
else
{
throw std::runtime_error("Invalid ripple duration value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple duration from settings. Will use default value");
}
try
{
// Parse ripple show drag trail
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL);
highlightSettings.rippleShowDragTrail = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show drag trail from settings. Will use default value");
}
try
{
// Parse ripple show release pulse
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE);
highlightSettings.rippleShowReleasePulse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
}
}
else
{

View File

@@ -0,0 +1,821 @@
PackageName: BlackmagicDesign.DaVinciResolve
Name: DaVinci Resolve
WindowFilter: "Resolve.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Popular shortcuts
Properties:
- Name: Edit
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F5
- Name: Color
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Fairlight
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F7
- Name: Deliver
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F8
- Name: Play / Pause
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Space
- Name: Play Reverse
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- J
- Name: Stop
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Play Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- L
- Name: Import Media
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Export / Deliver
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- E
- Name: Save Project
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Cut Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Blade Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Ripple Delete
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Delete
- Name: Undo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Z
- Name: Mark In
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- I
- Name: Mark Out
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- O
- Name: Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Select All
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- A
- Name: Go to Beginning
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Home
- Name: Go to End
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Snapping
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- N
- Name: Selection Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- A
- Name: Trim Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- T
- Name: Change Clip Speed
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- SectionName: Timeline navigation
Properties:
- Name: Go to Next Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Right
- Name: Go to Previous Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Left
- Name: Jump Forward 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Right
- Name: Jump Back 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Left
- Name: Go to Next Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Up
- Name: Go to Previous Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Down
- Name: Go to Next Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Down
- Name: Go to Previous Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Up
- Name: Zoom In Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Equals
- Name: Zoom Out Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Minus
- Name: Full Screen Playback
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Space
- Name: Go to Previous Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: Go to Next Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Edit
Properties:
- Name: Delete
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Delete
- 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: Cut
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Duplicate Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Render in Place
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- Name: Add Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Append to End of Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Replace Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Move Clip Up One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Up
- Name: Move Clip Down One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Down
- Name: Split Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Link Clips
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- L
- Name: Create Compound Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- G
- SectionName: Color
Properties:
- Name: Add Serial Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Add Parallel Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- P
- Name: Add Layer Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- L
- Name: Select Node 1
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Node 2
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Node 3
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Node 4
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Node 5
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Enable/Disable Current Grade
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- D
- Name: Preview Mode
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- W
- Name: Grade All Frames in Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Keyframe Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Select Color Wheels
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Curves
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Qualifier
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Power Window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Tracking
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Reset Color Grade
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- SectionName: Fairlight
Properties:
- Name: Mute Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Solo Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- S
- Name: Automation Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F
- Name: Record Arm Selected Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Headphones Solo
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- H
- Name: Add Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Insert
- Name: Add Audio Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- A
- Name: Bounce Mix
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- SectionName: Fusion
Properties:
- Name: Switch Between Spline and Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Add Keyframe
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Shift
- Name: View Current Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: View Node Flow
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: View Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: View Spline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Merge Selected Tools
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Bypass Selected Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- SectionName: Media
Properties:
- Name: Reveal in Explorer
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Smart Bin
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- S
- Name: Rename Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Import XML / AAF
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- I
- Name: Create New Bin
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Add Clip to Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Enter
- Name: Viewer Zoom In
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Equals
- Name: Viewer Zoom Out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Minus
- SectionName: Deliver
Properties:
- Name: Add to Render Queue
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Enter
- Name: Start Render
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Enter
- Name: Select Preset
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Render Settings
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Browse Output Location
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B

View File

@@ -443,7 +443,7 @@ public sealed partial class MainListPage : DynamicListPage,
{
specialFallbacks.Add(s);
}
else
else if (s.IsEnabled)
{
commonFallbacks.Add(s);
}

View File

@@ -105,6 +105,13 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string? Homepage => _entry.Homepage;
// Validated, browser-openable homepage uri. Null when the entry has no
// homepage or it is not a web uri. NavigateUri bindings must use this
// (a Uri) rather than the raw Homepage string: x:Bind evaluates bindings
// regardless of element visibility, and converting a null/invalid string
// to Uri throws and crashes the page.
public Uri? HomepageUri => _homepageHttpUri;
public Uri IconUri { get; }
public ImageSource IconSource

View File

@@ -156,8 +156,9 @@ public sealed partial class CommandBar : UserControl,
private void ContextMenuFlyout_Opened(object sender, object e)
{
// We need to wait until our flyout is opened to try and toss focus
// at its search box. The control isn't in the UI tree before that
// Focus the filter box so the flyout captures keyboard input,
// then fire a single consolidated Narrator announcement.
ContextControl.FocusSearchBox();
ContextControl.AnnounceOpened();
}
}

View File

@@ -139,10 +139,23 @@
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<!--
Hidden element used solely for raising Narrator notifications.
It must be Content-visible in UIA but has no visual presence.
-->
<TextBlock
x:Name="NarratorAnnouncer"
Width="0"
Height="0"
AutomationProperties.AccessibilityView="Content"
AutomationProperties.LiveSetting="Assertive" />
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="0,4,0,2"
AutomationProperties.AccessibilityView="Raw"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
@@ -168,6 +181,7 @@
x:Uid="ContextFilterBox"
Margin="0"
Padding="10,7,6,8"
AutomationProperties.AccessibilityView="Raw"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8, 8, 0, 0"

View File

@@ -2,6 +2,8 @@
// 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.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Text;
@@ -11,6 +13,7 @@ using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.System;
@@ -27,6 +30,15 @@ public sealed partial class ContextMenu : UserControl,
public static readonly DependencyProperty SubscribeToCommandBarProperty =
DependencyProperty.Register(nameof(SubscribeToCommandBar), typeof(bool), typeof(ContextMenu), new PropertyMetadata(true, OnSubscribeToCommandBarChanged));
private static readonly CompositeFormat _contextMenuOpenedFormat =
CompositeFormat.Parse(ResourceLoaderInstance.GetString("ScreenReader_Announcement_ContextMenuOpened"));
/// <summary>
/// True while the context menu is transitioning from PrepareForOpen to AnnounceOpened.
/// Prevents ViewModel_PropertyChanged from triggering UIA-visible selection changes.
/// </summary>
private bool _isOpening;
public bool ShowFilterBox
{
get => (bool)GetValue(ShowFilterBoxProperty);
@@ -103,12 +115,47 @@ public sealed partial class ContextMenu : UserControl,
internal void PrepareForOpen(ContextMenuFilterLocation filterLocation)
{
_isOpening = true;
ViewModel.FilterOnTop = filterLocation == ContextMenuFilterLocation.Top;
ViewModel.ResetContextMenu();
UpdateUiForStackChange();
}
/// <summary>
/// Fires a single consolidated Narrator announcement.
/// Call this after the flyout is opened and focus has been set.
/// </summary>
internal void AnnounceOpened()
{
// Defer the announcement to the next dispatcher cycle. This ensures
// any pending FilteredItems updates have completed and the flyout
// content is fully materialized in the UIA tree.
DispatcherQueue.TryEnqueue(() =>
{
_isOpening = false;
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
var itemCount = commandItems.Count;
var selectedItem = CommandsDropdown.SelectedItem as CommandContextItemViewModel;
var selectedName = selectedItem?.Title ?? string.Empty;
var selectedIndex = selectedItem is not null ? commandItems.IndexOf(selectedItem) + 1 : 0;
var announcement = string.Format(
CultureInfo.CurrentCulture,
_contextMenuOpenedFormat,
itemCount,
selectedName,
selectedIndex);
RaiseNarratorNotification(
AutomationNotificationKind.ActionCompleted,
announcement,
"ContextMenuOpened");
});
}
public void Receive(UpdateCommandBarMessage message)
{
UpdateUiForStackChange();
@@ -197,7 +244,7 @@ public sealed partial class ContextMenu : UserControl,
{
var prop = e.PropertyName;
if (prop == nameof(ContextMenuViewModel.FilteredItems))
if (prop == nameof(ContextMenuViewModel.FilteredItems) && !_isOpening)
{
UpdateUiForStackChange();
}
@@ -255,12 +302,14 @@ public sealed partial class ContextMenu : UserControl,
if (e.Key == VirtualKey.Up)
{
NavigateUp();
AnnounceSelectedItem();
e.Handled = true;
}
else if (e.Key == VirtualKey.Down)
{
NavigateDown();
AnnounceSelectedItem();
e.Handled = true;
}
@@ -347,6 +396,46 @@ public sealed partial class ContextMenu : UserControl,
return item is SeparatorViewModel;
}
private void AnnounceSelectedItem()
{
if (CommandsDropdown.SelectedItem is not CommandContextItemViewModel selected)
{
return;
}
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
var position = commandItems.IndexOf(selected) + 1;
var total = commandItems.Count;
var announcement = $"{selected.Title}, {position} of {total}";
RaiseNarratorNotification(
AutomationNotificationKind.ItemAdded,
announcement,
"ContextMenuSelectionChanged");
}
/// <summary>
/// Raises a UIA notification via the dedicated NarratorAnnouncer element.
/// Ensures the element has a peer (forcing layout if needed on first use).
/// </summary>
private void RaiseNarratorNotification(AutomationNotificationKind kind, string announcement, string activityId)
{
// On first flyout open the announcer may not have a peer yet.
// UpdateLayout ensures the element is materialized in the UIA tree.
var peer = FrameworkElementAutomationPeer.FromElement(NarratorAnnouncer);
if (peer is null)
{
NarratorAnnouncer.UpdateLayout();
peer = FrameworkElementAutomationPeer.CreatePeerForElement(NarratorAnnouncer);
}
peer?.RaiseNotificationEvent(
kind,
AutomationNotificationProcessing.ImportantMostRecent,
announcement,
activityId);
}
private void UpdateUiForStackChange()
{
ContextFilterBox.Text = string.Empty;

View File

@@ -500,9 +500,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void ContextMenuFlyout_Opened(object sender, object e)
{
// We need to wait until our flyout is opened to try and toss focus
// at its search box. The control isn't in the UI tree before that
// Focus the filter box so the flyout captures keyboard input,
// then fire a single consolidated Narrator announcement.
ContextControl.FocusSearchBox();
ContextControl.AnnounceOpened();
}
public void Receive(CloseContextMenuMessage message)

View File

@@ -232,7 +232,7 @@
Grid.Row="3"
Padding="0"
AutomationProperties.AutomationId="CmdPal_GalleryItemPage_ViewRepository"
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
NavigateUri="{x:Bind ViewModel.HomepageUri, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.Homepage, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasHomepage), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="4">
@@ -332,7 +332,7 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="232" />
</Grid.RowDefinitions>
<TextBlock
@@ -365,6 +365,7 @@
<SolidColorBrush x:Key="ItemContainerBackgroundPressed" Color="Transparent" />
</ItemContainer.Resources>
<Border
Width="356"
Height="200"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"

View File

@@ -160,20 +160,24 @@ public sealed partial class SettingsWindow : WindowEx,
break;
}
if (pageType is not null)
if (pageType is null)
{
NavFrame.Navigate(pageType);
return;
}
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
if (NavFrame.Content?.GetType() == pageType)
{
return;
}
NavFrame.Navigate(pageType);
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
{
if (obj is NavigationViewItem item && item.Tag is string s && s == page)
{
if (obj is NavigationViewItem item)
{
if (item.Tag is string s && s == page)
{
NavView.SelectedItem = item;
}
}
NavView.SelectedItem = item;
}
}
}

View File

@@ -559,6 +559,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="ScreenReader_Announcement_NavigatedToPage0" xml:space="preserve">
<value>Navigated to {0} page</value>
</data>
<data name="ScreenReader_Announcement_ContextMenuOpened" xml:space="preserve">
<value>Menu, {0} commands. {1}, {2} of {0}.</value>
</data>
<data name="SettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Settings (Ctrl+,)</value>
</data>

View File

@@ -4,13 +4,23 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="using:Microsoft.PowerToys.Common.UI.Controls.Window"
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<controls:TransientSurface
x:Name="Surface"
MaxWidth="560"
Margin="24,24,24,16"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
HideTransition="Bottom"
ShowTransition="Bottom">
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</controls:TransientSurface>
</common:TransparentWindow>

View File

@@ -17,11 +17,14 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// CmdPal's transient toast banner. Inherits all of its chrome, click-through,
/// acrylic, and fade/slide animations from
/// <see cref="TransparentWindow"/>; adds only the bits that are bespoke to
/// CmdPal toasts: a bound message <c>TextBlock</c>, a 2.5 s auto-dismiss timer,
/// bottom-center positioning, and <see cref="QuitMessage"/> handling.
/// CmdPal's transient toast notification. It is a bare
/// <see cref="TransparentWindow"/> host whose content is a
/// <see cref="Microsoft.PowerToys.Common.UI.Controls.TransientSurface"/> — the
/// surface supplies the acrylic, border, corners, shadow, and the fade/slide
/// animation, driven automatically off the window's show/hide events. This class
/// adds only the bits bespoke to CmdPal toasts: a bound message <c>TextBlock</c>,
/// a 2.5 s auto-dismiss timer, bottom-center positioning, and
/// <see cref="QuitMessage"/> handling.
/// </summary>
public sealed partial class ToastWindow : TransparentWindow,
IRecipient<QuitMessage>
@@ -39,13 +42,10 @@ public sealed partial class ToastWindow : TransparentWindow,
AppWindow.Title = RS_.GetString("ToastWindowTitle");
this.SetWindowSize(600, 180);
// Pin the chrome card to bottom-center with the toast's classic 560-wide
// pill shape. The window itself stays 600x180 so the slide animations
// have headroom and we don't have to chase SizeToContent.
Card.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center;
Card.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom;
Card.MaxWidth = 560;
Card.Margin = new Microsoft.UI.Xaml.Thickness(24, 24, 24, 16);
// Let the surface animate itself in/out in response to this window's
// Show()/Hide(). The 600x180 window leaves the bottom-center 560-wide
// pill (positioned in XAML) room for its slide + shadow.
Surface.SubscribeTo(this);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
}

View File

@@ -119,6 +119,7 @@ public class ExtensionGalleryItemViewModelTests
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.HasAuthorUrl);
Assert.IsFalse(viewModel.HasUrlSource);
Assert.IsFalse(viewModel.HasActionableSourceDetails);
@@ -131,6 +132,32 @@ public class ExtensionGalleryItemViewModelTests
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_SetsHomepageUri_WhenHomepageIsWebUri()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = "https://example.com/extension";
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasHomepage);
Assert.AreEqual(new Uri("https://example.com/extension"), viewModel.HomepageUri);
Assert.IsTrue(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_LeavesHomepageUriNull_WhenHomepageIsMissing()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = null;
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{

View File

@@ -134,6 +134,25 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
MoreCommands = _networkPage.Commands,
};
if (isBandPage)
{
_networkUpItem = new ListItem(_networkPage)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage.Commands,
};
_networkDownItem = new ListItem(_networkPage)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage.Commands,
};
}
_networkPage.Updated += (s, e) =>
{
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
@@ -253,22 +272,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
}
else
{
_networkUpItem = new ListItem(_networkPage!)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage!.Commands,
};
_networkDownItem = new ListItem(_networkPage!)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage!.Commands,
};
return _batteryItem is not null
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -33,16 +33,40 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
return null;
}
var (workspaceEnv, machineName) = ParseVSCodeAuthority.GetWorkspaceEnvironment(authority ?? rfc3986Uri.Authority);
var isFileUri = string.Equals(
rfc3986Uri.Scheme,
Uri.UriSchemeFile,
StringComparison.OrdinalIgnoreCase);
var isUncFileUri =
isFileUri &&
string.IsNullOrEmpty(authority) &&
!string.IsNullOrEmpty(rfc3986Uri.Authority) &&
!string.Equals(
rfc3986Uri.Authority,
"localhost",
StringComparison.OrdinalIgnoreCase);
// file://server/share is a local Windows UNC path, not a VS Code remote URI.
var effectiveAuthority =
isFileUri && string.IsNullOrEmpty(authority)
? string.Empty
: authority ?? rfc3986Uri.Authority;
var (workspaceEnv, machineName) =
ParseVSCodeAuthority.GetWorkspaceEnvironment(effectiveAuthority);
if (workspaceEnv is null)
{
return null;
}
var path = rfc3986Uri.Path;
var path = isUncFileUri
? $@"\\{rfc3986Uri.Authority}{rfc3986Uri.Path.Replace('/', '\\')}"
: rfc3986Uri.Path;
// Remove preceding '/' from local (Windows) path
if (workspaceEnv == WorkspaceEnvironment.Local)
// file:///C:/... becomes C:/...
if (workspaceEnv == WorkspaceEnvironment.Local && !isUncFileUri)
{
path = path[1..];
}

View File

@@ -62,7 +62,7 @@ public static class CharacterMappings
[LetterKey.VK_M] = ["ṁ", "ᵐ", "ₘ"],
[LetterKey.VK_N] = ["ņ", "ṅ", "ⁿ", "", "№", "ₙ"],
[LetterKey.VK_O] = ["ȯ", "∅", "⌀", "ᵒ", "ₒ"],
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ"],
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ", "‰", "‱"],
[LetterKey.VK_Q] = ["", "𐞥"],
[LetterKey.VK_R] = ["ṙ", "®", "", "ʳ", "ᵣ"],
[LetterKey.VK_S] = ["ṡ", "§", "∑", "∫", "ˢ", "ₛ"],
@@ -73,10 +73,10 @@ public static class CharacterMappings
[LetterKey.VK_X] = ["ẋ", "×", "ˣ", "ₓ"],
[LetterKey.VK_Y] = ["ẏ", "ꝡ", "ʸ"],
[LetterKey.VK_Z] = ["ʒ", "ǯ", "", "ᶻ"],
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "", "√", "‟", "", "", "", "", "", "″", "‴", "⁗"], // is in VK_MINUS for other languages, but not VK_COMMA, so we add it here.
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "", "√", "‟", "", "", "", "", "", "″", "‴", "⁗"],
[LetterKey.VK_PERIOD] = ["…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C"],
[LetterKey.VK_MINUS] = ["~", "", "", "", "", "—", "―", "", "", "⸺", "⸻", "∓", "₋", "⁻"],
[LetterKey.VK_SLASH_] = ["÷", "√"],
[LetterKey.VK_SLASH_] = ["÷", "√", "‽", "⸘"],
[LetterKey.VK_DIVIDE_] = ["÷", "√"],
[LetterKey.VK_MULTIPLY_] = ["×", "⋅", "ˣ", "ₓ"],
[LetterKey.VK_PLUS] = ["≤", "≥", "≠", "≈", "≙", "⊕", "⊗", "±", "≅", "≡", "₊", "⁺", "₌", "⁼"],
@@ -368,25 +368,32 @@ public static class CharacterMappings
// a spoken language, but rather a set of symbols used across languages.
new(Language.IPA, "IPA", LanguageGroup.Special, new Dictionary<LetterKey, string[]>
{
[LetterKey.VK_A] = ["ɐ", "ɑ", "ɒ", "ǎ"],
[LetterKey.VK_B] = ["ʙ"],
[LetterKey.VK_E] = ["ɘ", ", "ə", "ɛ", "ɜ", "],
[LetterKey.VK_F] = ["ɟ", "ɸ"],
[LetterKey.VK_G] = ["ɢ", "ɣ"],
[LetterKey.VK_H] = ["ɦ", "],
[LetterKey.VK_I] = ["ɨ", "ɪ"],
[LetterKey.VK_J] = ["ʝ"],
[LetterKey.VK_L] = ["ɬ", "ɮ", "ꞎ", "ɭ", "ʎ", "ʟ", "],
[LetterKey.VK_N] = ["ɳ", "ɲ", "ŋ", "],
[LetterKey.VK_O] = ["ɤ", "ɔ", "ɶ", "ǒ"],
[LetterKey.VK_R] = ["ʁ", "ɹ", "ɻ", "ɾ", "ɽ", "],
[LetterKey.VK_S] = ["ʃ", "ʂ", "ɕ"],
[LetterKey.VK_U] = ["ʉ", "ʊ", "ǔ"],
[LetterKey.VK_V] = ["ʋ", "", "ʌ"],
[LetterKey.VK_W] = ["ɰ", "ɯ"],
[LetterKey.VK_Y] = ["ʏ"],
[LetterKey.VK_A] = ["ɑ", "æ", "ɒ", "ɐ"],
[LetterKey.VK_B] = ["β", "ʙ", "ɓ", "],
[LetterKey.VK_C] = ["ç", "χ", "ǂ"],
[LetterKey.VK_D] = ["ð", "ɗ", "ɖ", "ǀ"],
[LetterKey.VK_E] = ["ə", "ɛ", "ɚ", "ɘ", "ɜ", "ɵ", "ɞ", "æ", "],
[LetterKey.VK_F] = ["ɸ"],
[LetterKey.VK_G] = ["ɡ", "ɣ", "ɢ", "ɠ", "],
[LetterKey.VK_H] = ["ɦ", "ħ", "ɥ", "ʜ", "ɧ", "],
[LetterKey.VK_I] = ["ɪ", "],
[LetterKey.VK_J] = ["ɟ", "ʝ", "ʄ"],
[LetterKey.VK_L] = ["ɫ", "ʎ", "ɬ", "ɮ", "ɭ", ", "ɺ", "", "ǁ"],
[LetterKey.VK_M] = ["ɱ"],
[LetterKey.VK_N] = ["ŋ", ", "ɳ", "ɴ"],
[LetterKey.VK_O] = ["ɔ", "ø", "œ", ", "ɶ", "ʘ"],
[LetterKey.VK_R] = ["ɹ", "ɾ", "ʁ", ", "ɻ", "ɽ"],
[LetterKey.VK_S] = ["ʃ", ", "ʂ"],
[LetterKey.VK_T] = ["θ", "ʈ", "ǃ"],
[LetterKey.VK_U] = ["ʊ", "ʉ"],
[LetterKey.VK_V] = ["ʌ", "ʋ", "ⱱ"],
[LetterKey.VK_W] = ["ʍ", "ɯ", "ɰ"],
[LetterKey.VK_X] = ["χ"],
[LetterKey.VK_Y] = ["ʎ", "ʏ"],
[LetterKey.VK_Z] = ["ʒ", "ʐ", "ʑ"],
[LetterKey.VK_COMMA] = ["ʡ", "ʔ", "ʕ", "ʢ"],
[LetterKey.VK_COMMA] = ["ʔ", "ʕ", "ʡ", "ʢ"],
[LetterKey.VK_PERIOD] = ["ˈ", "ˌ", "ː", "ʼ", "\u031D", "\u0325", "\u031A", "\u0361", "\u035C"],
[LetterKey.VK_SLASH_] = ["ʔ"],
}),
new(Language.IT, "Italian", LanguageGroup.Language, new Dictionary<LetterKey, string[]>

View File

@@ -0,0 +1,72 @@
// 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.Json;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerDisplay.UnitTests;
/// <summary>
/// Covers the persisted shape of the mouse-wheel-increment setting on
/// <see cref="PowerDisplayProperties"/>: its default of 5 (the historical hardcoded step),
/// its snake_case JSON key, round-trip fidelity, and the forward-compatibility promise that
/// settings.json written before the feature existed deserializes to the default of 5 with no
/// migration.
/// </summary>
[TestClass]
public class MouseWheelIncrementSettingsTests
{
[TestMethod]
public void Default_IsFive()
{
var properties = new PowerDisplayProperties();
Assert.AreEqual(5, properties.MouseWheelIncrement, "Default must preserve the historical hardcoded step of 5.");
}
[TestMethod]
public void Deserialize_LegacyJsonMissingField_DefaultsToFive()
{
// A settings.json captured before this feature shipped has no mouse_wheel_increment key.
// Deserializing must fall back to the constructor default of 5, not 0. System.Text.Json
// calls the parameterless constructor (which sets MouseWheelIncrement = 5) and then fills
// only the fields present in JSON. If PowerDisplayProperties ever gains a
// [JsonConstructor]-annotated constructor, re-verify this "defaults to 5" behavior.
const string legacyJson = """
{
"monitor_refresh_delay": 5,
"restore_settings_on_startup": false,
"show_system_tray_icon": true
}
""";
var properties = JsonSerializer.Deserialize<PowerDisplayProperties>(legacyJson);
Assert.IsNotNull(properties);
Assert.AreEqual(5, properties.MouseWheelIncrement);
}
[TestMethod]
public void RoundTrip_PreservesValue()
{
var original = new PowerDisplayProperties { MouseWheelIncrement = 15 };
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<PowerDisplayProperties>(json);
Assert.IsNotNull(restored);
Assert.AreEqual(15, restored.MouseWheelIncrement);
}
[TestMethod]
public void Serialize_UsesSnakeCaseJsonKey()
{
var properties = new PowerDisplayProperties { MouseWheelIncrement = 10 };
var json = JsonSerializer.Serialize(properties);
StringAssert.Contains(json, "\"mouse_wheel_increment\":10");
}
}

View File

@@ -8,7 +8,7 @@ namespace PowerDisplay.Helpers
{
/// <summary>
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
/// <c>Microsoft.PowerToys.Common.UI.Controls.Window.FlyoutWindowHelper</c> (Common.UI.Controls).
/// </summary>
internal static partial class WindowHelper
{

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Configuration;

View File

@@ -235,7 +235,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
helpers:SliderExtensions.MouseWheelChange="{x:Bind ViewModel.MouseWheelIncrement, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -525,7 +525,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -556,7 +556,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"
@@ -586,7 +586,7 @@
Grid.Column="1"
VerticalAlignment="Center"
helpers:SliderExtensions.IsMouseWheelEnabled="True"
helpers:SliderExtensions.MouseWheelChange="5"
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Maximum="100"

View File

@@ -5,7 +5,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ManagedCommon;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Windowing;

View File

@@ -122,6 +122,7 @@ public partial class MainViewModel
foreach (var monitor in Monitors)
{
monitor.RefreshCustomVcpNames();
monitor.RefreshMouseWheelIncrement();
}
}
catch (Exception ex)

View File

@@ -93,6 +93,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
IsScanning = true;
ShowProfileSwitcher = true;
ShowIdentifyMonitorsButton = true;
MouseWheelIncrement = 5;
// Initialize settings utils
_settingsUtils = SettingsUtils.Default;
@@ -129,6 +130,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool ShowIdentifyMonitorsButton { get; set; }
/// <summary>
/// Gets or sets the per-mouse-wheel-notch step applied to every flyout slider. Loaded from
/// PowerDisplaySettings; defaults to 5 (the historical hardcoded step).
/// </summary>
[ObservableProperty]
public partial int MouseWheelIncrement { get; set; }
/// <summary>
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> so
@@ -479,6 +487,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
MouseWheelIncrement = settings.Properties.MouseWheelIncrement;
// Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this
// method runs after monitors are already discovered, the toggle hook can seed the master

View File

@@ -210,6 +210,12 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
// Property to access IsInteractionEnabled from parent ViewModel
public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true;
/// <summary>
/// Gets the shared per-mouse-wheel-notch step for this monitor's sliders, proxied from the
/// owning <see cref="MainViewModel"/>. Falls back to 5 if the owner is unavailable.
/// </summary>
public int MouseWheelIncrement => _mainViewModel?.MouseWheelIncrement ?? 5;
public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel)
{
_monitor = monitor;
@@ -669,6 +675,16 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(AvailableInputSources));
}
/// <summary>
/// Raise <see cref="PropertyChanged"/> for <see cref="MouseWheelIncrement"/> so per-monitor
/// sliders pick up a new value after the user changes it in Settings. Called from
/// <c>MainViewModel.ApplySettingsFromUI</c>.
/// </summary>
public void RefreshMouseWheelIncrement()
{
OnPropertyChanged(nameof(MouseWheelIncrement));
}
/// <summary>
/// Set input source for this monitor
/// </summary>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -7,7 +7,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.QuickAccess.Services;
using Microsoft.PowerToys.QuickAccess.ViewModels;
using Microsoft.UI.Dispatching;

View File

@@ -26,7 +26,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
AdditionalActions = new();
IsAIEnabled = false;
ShowCustomPreview = true;
ShowAIPaste = true;
CloseAfterLosingFocus = false;
EnableClipboardPreview = true;
AutoCopySelectionForCustomActionHotkey = false;
@@ -75,9 +74,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool ShowCustomPreview { get; set; }
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool ShowAIPaste { get; set; }
[JsonConverter(typeof(BoolPropertyJsonConverter))]
public bool CloseAfterLosingFocus { get; set; }
@@ -111,6 +107,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[CmdConfigureIgnoreAttribute]
public PasteAIConfiguration PasteAIConfiguration { get; set; }
[JsonPropertyName("python-scripts")]
[CmdConfigureIgnoreAttribute]
public AdvancedPastePythonScriptSettings PythonScripts { get; set; } = new();
public override string ToString()
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
}

View File

@@ -0,0 +1,298 @@
// 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.Linq;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPasteAction, ICloneable
{
private string _scriptPath = string.Empty;
private string _name = string.Empty;
private string _description = string.Empty;
private bool _isShown = true;
private bool _isEnabled = true;
private string _platform = "windows";
private string _formats = "any";
private string _requires = string.Empty;
private bool _requiresAutoDetect = true;
private HotkeySettings _shortcut = new();
private string _inputType = string.Empty;
private string _outputType = string.Empty;
[JsonPropertyName("scriptPath")]
public string ScriptPath
{
get => _scriptPath;
set => Set(ref _scriptPath, value ?? string.Empty);
}
[JsonPropertyName("name")]
public string Name
{
get => _name;
set
{
if (Set(ref _name, value ?? string.Empty))
{
OnPropertyChanged(nameof(DisplayName));
}
}
}
/// <summary>
/// Returns Name if non-empty, otherwise falls back to the script filename without extension.
/// </summary>
[JsonIgnore]
public string DisplayName =>
!string.IsNullOrWhiteSpace(_name)
? _name
: System.IO.Path.GetFileNameWithoutExtension(_scriptPath);
[JsonPropertyName("description")]
public string Description
{
get => _description;
set => Set(ref _description, value ?? string.Empty);
}
[JsonPropertyName("isShown")]
public bool IsShown
{
get => _isShown;
set => Set(ref _isShown, value);
}
[JsonPropertyName("isEnabled")]
public bool IsEnabled
{
get => _isEnabled;
set => Set(ref _isEnabled, value);
}
[JsonPropertyName("platform")]
public string Platform
{
get => _platform;
set => Set(ref _platform, value ?? "windows");
}
[JsonPropertyName("formats")]
public string Formats
{
get => _formats;
set => Set(ref _formats, value ?? "any");
}
/// <summary>
/// Space-separated requires entries, e.g. "cv2=opencv-python-headless numpy requests".
/// Only written to header when RequiresAutoDetect is false (manual mode).
/// </summary>
[JsonPropertyName("requires")]
public string Requires
{
get => _requires;
set => Set(ref _requires, value ?? string.Empty);
}
/// <summary>
/// When true, dependencies are auto-detected from import statements.
/// When false, the manual <see cref="Requires"/> value is used.
/// </summary>
[JsonPropertyName("requiresAutoDetect")]
public bool RequiresAutoDetect
{
get => _requiresAutoDetect;
set => Set(ref _requiresAutoDetect, value);
}
/// <summary>
/// Inverted view of RequiresAutoDetect for UI binding.
/// Uses a separate field to avoid circular property change notifications.
/// </summary>
[JsonIgnore]
public bool IsRequiresManual
{
get => !_requiresAutoDetect;
set
{
var newAuto = !value;
if (_requiresAutoDetect != newAuto)
{
_requiresAutoDetect = newAuto;
OnPropertyChanged(nameof(RequiresAutoDetect));
OnPropertyChanged(nameof(IsRequiresManual));
}
}
}
[JsonPropertyName("shortcut")]
public HotkeySettings Shortcut
{
get => _shortcut;
set
{
if (_shortcut != value)
{
_shortcut = value ?? new();
OnPropertyChanged();
}
}
}
/// <summary>
/// The input type declared in the function name (e.g. "text", "image", "audio").
/// Read-only display property populated during script discovery.
/// </summary>
[JsonIgnore]
public string InputType
{
get => _inputType;
set => Set(ref _inputType, value ?? string.Empty);
}
/// <summary>
/// The output type declared in the function name (e.g. "text", "image", "file").
/// Read-only display property populated during script discovery.
/// </summary>
[JsonIgnore]
public string OutputType
{
get => _outputType;
set => Set(ref _outputType, value ?? string.Empty);
}
/// <summary>
/// Human-readable conversion summary, e.g. "text → image".
/// </summary>
[JsonIgnore]
public string ConversionSummary =>
!string.IsNullOrEmpty(_inputType) && !string.IsNullOrEmpty(_outputType)
? $"{_inputType} → {_outputType}"
: string.Empty;
[JsonIgnore]
public IEnumerable<IAdvancedPasteAction> SubActions => [];
// Convenience properties for format checkboxes
[JsonIgnore]
public bool SupportsText
{
get => FormatContains("text");
set => ToggleFormat("text", value);
}
[JsonIgnore]
public bool SupportsHtml
{
get => FormatContains("html");
set => ToggleFormat("html", value);
}
[JsonIgnore]
public bool SupportsImage
{
get => FormatContains("image");
set => ToggleFormat("image", value);
}
[JsonIgnore]
public bool SupportsAudio
{
get => FormatContains("audio");
set => ToggleFormat("audio", value);
}
[JsonIgnore]
public bool SupportsVideo
{
get => FormatContains("video");
set => ToggleFormat("video", value);
}
[JsonIgnore]
public bool SupportsFiles
{
get => FormatContains("files") || FormatContains("file");
set => ToggleFormat("files", value);
}
private bool FormatContains(string format)
{
if (string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return Formats.Split(',', StringSplitOptions.TrimEntries)
.Any(f => string.Equals(f, format, StringComparison.OrdinalIgnoreCase));
}
private bool _isTogglingFormat;
private void ToggleFormat(string format, bool include)
{
if (_isTogglingFormat)
{
return;
}
_isTogglingFormat = true;
try
{
var currentFormats = string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase)
? new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(Formats.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase);
// Normalize file/files
currentFormats.Remove("file");
if (include)
{
currentFormats.Add(format);
}
else
{
currentFormats.Remove(format);
}
var allFormats = new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase);
Formats = currentFormats.SetEquals(allFormats) ? "any" : string.Join(", ", currentFormats);
OnPropertyChanged(nameof(SupportsText));
OnPropertyChanged(nameof(SupportsHtml));
OnPropertyChanged(nameof(SupportsImage));
OnPropertyChanged(nameof(SupportsAudio));
OnPropertyChanged(nameof(SupportsVideo));
OnPropertyChanged(nameof(SupportsFiles));
}
finally
{
_isTogglingFormat = false;
}
}
public object Clone()
{
return new AdvancedPastePythonScriptAction
{
ScriptPath = ScriptPath,
Name = Name,
Description = Description,
IsShown = IsShown,
IsEnabled = IsEnabled,
Platform = Platform,
Formats = Formats,
Requires = Requires,
RequiresAutoDetect = RequiresAutoDetect,
InputType = InputType,
OutputType = OutputType,
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
};
}
}

View File

@@ -0,0 +1,106 @@
// 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.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPastePythonScriptSettings
{
/// <summary>
/// Execution mode: "disabled", "windows", or "wsl".
/// </summary>
[JsonPropertyName("mode")]
public string Mode { get; set; } = "disabled";
/// <summary>
/// Settings specific to Windows-native Python execution.
/// </summary>
[JsonPropertyName("windowsSettings")]
public PythonScriptWindowsSettings WindowsSettings { get; set; } = new();
/// <summary>
/// Settings specific to WSL Python execution.
/// </summary>
[JsonPropertyName("wslSettings")]
public PythonScriptWslSettings WslSettings { get; set; } = new();
[JsonPropertyName("timeoutSeconds")]
public int TimeoutSeconds { get; set; } = 30;
[JsonPropertyName("value")]
public List<AdvancedPastePythonScriptAction> Value { get; set; } = [];
[JsonPropertyName("trustedScriptHashes")]
public Dictionary<string, string> TrustedScriptHashes { get; set; } = [];
// Legacy properties — read for migration, never written back
[JsonPropertyName("isEnabled")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? IsEnabled { get; set; }
[JsonPropertyName("useWsl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? UseWsl { get; set; }
[JsonPropertyName("scriptsFolder")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ScriptsFolder { get; set; }
[JsonPropertyName("pythonExecutablePath")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string PythonExecutablePath { get; set; }
[JsonPropertyName("wslDistribution")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string WslDistribution { get; set; }
/// <summary>
/// Migrates legacy settings (isEnabled/useWsl) to new mode format on first load.
/// </summary>
public void MigrateLegacyIfNeeded()
{
// Only migrate if Mode hasn't been set by the new UI yet
// (i.e., still at default "disabled") AND legacy fields are present.
if (IsEnabled.HasValue && string.Equals(Mode, "disabled", System.StringComparison.OrdinalIgnoreCase))
{
// Migrate from old format
if (!IsEnabled.Value)
{
Mode = "disabled";
}
else if (UseWsl == true)
{
Mode = "wsl";
}
else
{
Mode = "windows";
}
if (!string.IsNullOrEmpty(ScriptsFolder))
{
WindowsSettings.ScriptsFolder = ScriptsFolder;
}
if (!string.IsNullOrEmpty(PythonExecutablePath))
{
WindowsSettings.PythonExecutablePath = PythonExecutablePath;
}
if (!string.IsNullOrEmpty(WslDistribution))
{
WslSettings.Distribution = WslDistribution;
}
}
// Always clear legacy fields so they don't persist
IsEnabled = null;
UseWsl = null;
ScriptsFolder = null;
PythonExecutablePath = null;
WslDistribution = null;
}
}

View File

@@ -44,6 +44,24 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("spotlight_mode")]
public BoolProperty SpotlightMode { get; set; }
[JsonPropertyName("ripple_mode")]
public BoolProperty RippleMode { get; set; }
[JsonPropertyName("ripple_size")]
public IntProperty RippleSize { get; set; }
[JsonPropertyName("ripple_intensity")]
public DoubleProperty RippleIntensity { get; set; }
[JsonPropertyName("ripple_duration_ms")]
public IntProperty RippleDurationMs { get; set; }
[JsonPropertyName("ripple_show_drag_trail")]
public BoolProperty RippleShowDragTrail { get; set; }
[JsonPropertyName("ripple_show_release_pulse")]
public BoolProperty RippleShowReleasePulse { get; set; }
public MouseHighlighterProperties()
{
ActivationShortcut = DefaultActivationShortcut;
@@ -51,11 +69,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library
RightButtonClickColor = new StringProperty("#a60000FF");
AlwaysColor = new StringProperty("#00FF0000");
HighlightOpacity = new IntProperty(166); // for migration from <=1.1 to 1.2
HighlightRadius = new IntProperty(20);
HighlightFadeDelayMs = new IntProperty(500);
HighlightFadeDurationMs = new IntProperty(250);
HighlightRadius = new IntProperty(30);
HighlightFadeDelayMs = new IntProperty(400);
HighlightFadeDurationMs = new IntProperty(400);
AutoActivate = new BoolProperty(false);
SpotlightMode = new BoolProperty(false);
RippleMode = new BoolProperty(true);
RippleSize = new IntProperty(60);
RippleIntensity = new DoubleProperty(0.7);
RippleDurationMs = new IntProperty(480);
RippleShowDragTrail = new BoolProperty(true);
RippleShowReleasePulse = new BoolProperty(true);
}
}
}

View File

@@ -18,6 +18,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
{
ActivationShortcut = DefaultActivationShortcut;
MonitorRefreshDelay = 5;
MouseWheelIncrement = 5;
Monitors = new List<MonitorInfo>();
RestoreSettingsOnStartup = false;
ShowSystemTrayIcon = true;
@@ -48,6 +49,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("monitor_refresh_delay")]
public int MonitorRefreshDelay { get; set; }
/// <summary>
/// Gets or sets the amount each PowerDisplay flyout slider (brightness, contrast, volume)
/// changes per mouse-wheel notch. Defaults to 5, the historical hardcoded step.
/// </summary>
[JsonPropertyName("mouse_wheel_increment")]
public int MouseWheelIncrement { get; set; }
[JsonPropertyName("monitors")]
public List<MonitorInfo> Monitors { get; set; }

View File

@@ -0,0 +1,16 @@
// 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.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class PythonScriptWindowsSettings
{
[JsonPropertyName("scriptsFolder")]
public string ScriptsFolder { get; set; } = string.Empty;
[JsonPropertyName("pythonExecutablePath")]
public string PythonExecutablePath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
// 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.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class PythonScriptWslSettings
{
[JsonPropertyName("scriptsFolder")]
public string ScriptsFolder { get; set; } = string.Empty;
[JsonPropertyName("distribution")]
public string Distribution { get; set; } = string.Empty;
}

View File

@@ -140,6 +140,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
[JsonSerializable(typeof(AdvancedPastePythonScriptSettings))]
[JsonSerializable(typeof(AdvancedPastePythonScriptAction))]
[JsonSerializable(typeof(PythonScriptWindowsSettings))]
[JsonSerializable(typeof(PythonScriptWslSettings))]
[JsonSerializable(typeof(ImageResizerSizes))]
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
[JsonSerializable(typeof(KeyboardKeysProperty))]

View File

@@ -0,0 +1,59 @@
// 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.IO;
using System.Linq;
using System.Reflection;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ViewModelTests
{
[TestClass]
public class AdvancedPaste
{
[TestMethod]
public void CreateActionFromScript_ExtractsConversionTypes()
{
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
try
{
File.WriteAllLines(
scriptPath,
[
"# @advancedpaste:name transcribe audio",
"# @advancedpaste:desc Transcribes audio to text",
string.Empty,
"def advanced_paste_from_audio_to_text(audio_path):",
" return 'transcribed'",
]);
var createActionFromScript = typeof(AdvancedPasteViewModel)
.GetMethod("CreateActionFromScript", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(createActionFromScript);
var action = (AdvancedPastePythonScriptAction)createActionFromScript.Invoke(
null, [scriptPath, new System.Collections.Generic.List<AdvancedPastePythonScriptAction>()]);
Assert.AreEqual("transcribe audio", action.Name);
Assert.AreEqual("Transcribes audio to text", action.Description);
Assert.AreEqual("audio", action.InputType);
Assert.AreEqual("text", action.OutputType);
Assert.AreEqual("audio → text", action.ConversionSummary);
}
finally
{
if (File.Exists(scriptPath))
{
File.Delete(scriptPath);
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Windows.UI;
namespace Microsoft.PowerToys.Settings.UI.Helpers
{
/// <summary>
/// Helpers for theming the system caption buttons (minimize/maximize/close) of a window.
/// </summary>
public static class TitleBarHelper
{
/// <summary>
/// Applies the given element theme to a window's system caption buttons.
/// </summary>
/// <remarks>
/// Workaround for the AppWindow TitleBar not updating caption button colors to match the
/// app theme when the OS theme differs from the app theme or the theme changes at runtime.
/// Mirrors the helper used by the WinUI Gallery (https://github.com/microsoft/WinUI-Gallery).
/// </remarks>
public static void ApplySystemThemeToCaptionButtons(Window window, ElementTheme theme)
{
if (window?.AppWindow is null)
{
return;
}
var titleBar = window.AppWindow.TitleBar;
var foregroundColor = theme == ElementTheme.Dark ? Colors.White : Colors.Black;
titleBar.ButtonBackgroundColor = Colors.Transparent;
titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
titleBar.ButtonForegroundColor = foregroundColor;
titleBar.ButtonHoverForegroundColor = foregroundColor;
titleBar.ButtonInactiveForegroundColor = Colors.DarkGray;
titleBar.ButtonHoverBackgroundColor = theme == ElementTheme.Dark
? Color.FromArgb(24, 255, 255, 255)
: Color.FromArgb(24, 0, 0, 0);
}
}
}

View File

@@ -52,11 +52,18 @@
<tkcontrols:MarkdownTextBlock
x:Name="ReleaseNotesMarkdown"
Config="{StaticResource ReleaseNotesMarkdownConfig}"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
UseAutoLinks="True"
UseEmphasisExtras="True"
UseListExtras="True"
UsePipeTables="True"
UseTaskLists="True" />
<!-- Hidden helper used to resolve the accent brush for the active element theme (see ApplyMarkdownThemeWorkaround). -->
<TextBlock
x:Name="LinkBrushProvider"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
IsHitTestVisible="False"
Visibility="Collapsed" />
</Grid>
</Grid>
</Grid>

View File

@@ -7,10 +7,12 @@ using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using CommunityToolkit.WinUI.Controls;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Microsoft.UI.Xaml.Navigation;
@@ -19,6 +21,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
public sealed partial class ScoobeReleaseNotesPage : Page
{
private IList<PowerToysReleaseInfo> _currentReleases;
private string _releaseNotesMarkdownText;
/// <summary>
/// Initializes a new instance of the <see cref="ScoobeReleaseNotesPage"/> class.
@@ -26,6 +29,37 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
public ScoobeReleaseNotesPage()
{
this.InitializeComponent();
// Re-apply the markdown theme workaround when the theme changes at runtime so the
// headings/links stay readable after the user switches between light and dark.
this.ActualThemeChanged += OnActualThemeChanged;
this.Unloaded += OnUnloaded;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
this.ActualThemeChanged -= OnActualThemeChanged;
this.Unloaded -= OnUnloaded;
}
private void OnActualThemeChanged(FrameworkElement sender, object args)
{
RefreshMarkdownTheme();
}
private void RefreshMarkdownTheme()
{
if (string.IsNullOrEmpty(_releaseNotesMarkdownText))
{
return;
}
ApplyMarkdownThemeWorkaround();
// The MarkdownTextBlock captures heading/link brushes when it renders, so re-set the
// text to force it to rebuild with the brushes for the now-active theme.
ReleaseNotesMarkdown.Text = string.Empty;
ReleaseNotesMarkdown.Text = _releaseNotesMarkdownText;
}
/// <summary>
@@ -128,7 +162,18 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
{
LoadingProgressRing.Visibility = Visibility.Collapsed;
// Workaround: the MarkdownTextBlock control captures its heading foreground
// brushes from Application.Current.Resources when its theme config is created,
// which resolves against the OS (application) theme rather than the app's
// selected theme. When the OS is Light but PowerToys is Dark (or vice versa),
// headings render with an unreadable color. Force the control's theme and
// reapply correctly-themed heading brushes before the markdown is rendered.
// TODO: Remove once the upstream control resolves brushes against the element theme.
// Upstream fix: https://github.com/CommunityToolkit/Labs-Windows/pull/785
ApplyMarkdownThemeWorkaround();
var (releaseNotesMarkdown, heroImageUrl) = ProcessReleaseNotesMarkdown(_currentReleases);
_releaseNotesMarkdownText = releaseNotesMarkdown;
// Set the Hero image if found
if (!string.IsNullOrEmpty(heroImageUrl))
@@ -150,6 +195,46 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
}
}
/// <summary>
/// Works around the <see cref="MarkdownTextBlock"/> control pinning its heading and link
/// brushes to the OS (application) theme instead of the element's selected theme, which makes
/// titles/links unreadable when the OS and PowerToys themes differ. Pins the control's theme and
/// reassigns the heading/link brushes resolved for the selected theme before the markdown renders.
/// TODO: Remove once the upstream control resolves brushes against the element theme.
/// Upstream fix: https://github.com/CommunityToolkit/Labs-Windows/pull/785
/// </summary>
private void ApplyMarkdownThemeWorkaround()
{
var elementTheme = App.IsDarkTheme() ? ElementTheme.Dark : ElementTheme.Light;
ReleaseNotesMarkdown.RequestedTheme = elementTheme;
LinkBrushProvider.RequestedTheme = elementTheme;
if (Resources["ReleaseNotesMarkdownConfig"] is MarkdownConfig config
&& config.Themes is MarkdownThemes themes)
{
// The control's Foreground is bound to TextFillColorPrimaryBrush via ThemeResource,
// so after setting RequestedTheme it resolves to the brush for the selected theme.
// Reuse it for the heading brushes, which the control would otherwise pin to the OS theme.
if (ReleaseNotesMarkdown.Foreground is Brush headingForeground)
{
themes.H1Foreground = headingForeground;
themes.H2Foreground = headingForeground;
themes.H3Foreground = headingForeground;
themes.H4Foreground = headingForeground;
themes.H5Foreground = headingForeground;
themes.H6Foreground = headingForeground;
}
// The link brush is likewise pinned to the OS theme's accent color, which can be
// unreadable when the app theme differs from the OS theme. Reapply the accent brush
// resolved for the selected theme using the hidden helper element.
if (LinkBrushProvider.Foreground is Brush linkForeground)
{
themes.LinkForeground = linkForeground;
}
}
}
private void Page_Loaded(object sender, RoutedEventArgs e)
{
DisplayReleaseNotes();

View File

@@ -57,6 +57,16 @@ namespace Microsoft.PowerToys.Settings.UI
this.ExtendsContentIntoTitleBar = true;
this.SetTitleBar(AppTitleBar);
Title = ResourceLoaderInstance.ResourceLoader.GetString("ScoobeWindow_Title");
// The built-in WinUI TitleBar does not tint the system caption buttons (min/max/close)
// to match the app's selected theme, so they can be unreadable when the OS theme differs
// from the PowerToys theme. Drive their colors from the window content's actual theme.
if (this.Content is FrameworkElement rootElement)
{
TitleBarHelper.ApplySystemThemeToCaptionButtons(this, rootElement.ActualTheme);
rootElement.ActualThemeChanged += (s, e) =>
TitleBarHelper.ApplySystemThemeToCaptionButtons(this, s.ActualTheme);
}
}
private void Window_Activated(object sender, WindowActivatedEventArgs args)

View File

@@ -164,10 +164,10 @@
</InfoBar.IconSource>
</InfoBar>
</tkcontrols:SettingsExpander.ItemsHeader>
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard Name="AdvancedPasteEnableClipboardPreview" ContentAlignment="Left">
<CheckBox x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="AdvancedPasteAutoCopySelectionCustomAction" ContentAlignment="Left">
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_AutoCopySelectionForCustomActionHotkey" IsChecked="{x:Bind ViewModel.AutoCopySelectionForCustomActionHotkey, Mode=TwoWay}" />
@@ -178,15 +178,13 @@
IsEnabled="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=OneWay}">
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_Clipboard_History_Enabled_SettingsCard" IsChecked="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="AdvancedPasteCloseAfterLosingFocus" ContentAlignment="Left">
<CheckBox x:Uid="AdvancedPaste_CloseAfterLosingFocus" IsChecked="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="AdvancedPasteShowCustomPreviewSettingsCard" ContentAlignment="Left">
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_ShowCustomPreviewSettingsCard" IsChecked="{x:Bind ViewModel.ShowCustomPreview, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="AdvancedPasteShowAIPasteSettingsCard" ContentAlignment="Left">
<CheckBox x:Uid="AdvancedPaste_ShowAIPasteSettingsCard" IsChecked="{x:Bind ViewModel.ShowAIPaste, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
@@ -197,7 +195,7 @@
Name="PasteAsPlainTextShortcut"
x:Uid="PasteAsPlainText_Shortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xE8E9;}">
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PasteAsMarkdownShortcut"
@@ -392,6 +390,124 @@
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
Severity="Warning" />
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</tkcontrols:SettingsCard.HeaderIcon>
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.PythonScriptsModeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeOff" />
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWindows" />
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWsl" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_ScriptsFolder_SettingsCard" IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE8B7;" />
</tkcontrols:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox
x:Name="ScriptsFolderTextBox"
x:Uid="AdvancedPaste_ScriptsFolder_TextBox"
MinWidth="300"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.ScriptsFolder, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
<Button
VerticalAlignment="Bottom"
Click="BrowseScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Name="OpenScriptsFolderButton"
VerticalAlignment="Bottom"
Click="OpenScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE838;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
</StackPanel>
</tkcontrols:SettingsCard>
<!-- Windows-mode settings -->
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard" Visibility="{x:Bind ViewModel.IsWindowsMode, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</tkcontrols:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox
x:Name="PythonExecutablePathTextBox"
x:Uid="AdvancedPaste_PythonExecutablePath_TextBox"
MinWidth="300"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.PythonExecutablePath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
<Button
VerticalAlignment="Bottom"
Click="BrowsePythonExecutablePath_Click"
Content="{ui:FontIcon Glyph=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
</StackPanel>
</tkcontrols:SettingsCard>
<!-- WSL-mode settings -->
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_WslDistribution_SettingsCard" Visibility="{x:Bind ViewModel.IsWslMode, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xEC7A;" />
</tkcontrols:SettingsCard.HeaderIcon>
<ComboBox
MinWidth="200"
ItemsSource="{x:Bind ViewModel.WslDistroDisplayNames, Mode=OneWay}"
SelectedIndex="{x:Bind ViewModel.WslDistributionIndex, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<!-- Discovered Python Scripts (read-only) -->
<tkcontrols:SettingsExpander
x:Name="PythonScriptListExpander"
x:Uid="AdvancedPaste_PythonScriptList"
HeaderIcon="{ui:FontIcon Glyph=&#xE756;}"
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}"
IsExpanded="True"
ItemsSource="{x:Bind ViewModel.PythonScriptActions, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
x:Name="RefreshScriptsButton"
x:Uid="AdvancedPaste_PythonScripts_LoadScripts"
Click="RefreshPythonScripts_Click"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
<tkcontrols:SettingsExpander.ItemTemplate>
<DataTemplate x:DataType="models:AdvancedPastePythonScriptAction">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Description="{x:Bind Description, Mode=OneWay}"
Header="{x:Bind DisplayName, Mode=OneWay}"
IsActionIconVisible="False">
<StackPanel
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="12">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ConversionSummary, Mode=OneWay}" />
<Button
Click="OpenPythonScript_Click"
Content="&#xE8A7;"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Style="{StaticResource SubtleButtonStyle}"
Tag="{x:Bind}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="AdvancedPaste_PythonScripts_OpenScriptInEditor" />
</ToolTipService.ToolTip>
</Button>
</StackPanel>
</tkcontrols:SettingsCard>
</DataTemplate>
</tkcontrols:SettingsExpander.ItemTemplate>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>
@@ -430,7 +546,6 @@
</StackPanel>
</ContentDialog>
<!-- Paste AI provider dialog -->
<ContentDialog
x:Name="PasteAIProviderConfigurationDialog"
x:Uid="AdvancedPaste_EndpointDialog"

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