Compare commits

...

25 Commits

Author SHA1 Message Date
Clint Rutkas
6d4798b09c Fixing the base 2026-06-30 11:01:30 -07:00
Clint Rutkas
a0d17406ba Updating MessagePack (#49029)
Version bump on Message Pack
2026-06-30 14:26:32 +02:00
Copilot
4a27c5d5f9 New+: Fix French translation guidance (Nouveau+ not Nouveauté+) (#47225)
## Summary of the Pull Request

French translation of "New+" was rendered as "Nouveauté+" ("Novelty+")
instead of "Nouveau+" ("New+"), inconsistent with how Windows itself
translates the "New" context menu item in French. This updates
translator guidance comments in the English resource files to explicitly
call out the correct French form.

## 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

Translator-facing `<comment>` fields updated across three resource files
to explicitly state the correct French translation and flag the wrong
one:

- **`NewShellExtensionContextMenu/resources.resx`** and
**`NewShellExtensionContextMenu.win10/resources.resx`** —
`context_menu_item_new`:
> _"…e.g. Danish it would become Ny+, **French it would become Nouveau+
(not Nouveauté+)**"_

- **`Settings.UI/Strings/en-us/Resources.resw`** — five `NewPlus.*` /
`Oobe_NewPlus.*` strings:
> _"…Localize product name in accordance with Windows New. **e.g. French
would be Nouveau+ (not Nouveauté+)**"_

Actual `.lcl` translation files are managed by the CDPX localization
pipeline; these comment updates feed directly into the guidance the
localization team sees when updating those files.

## Validation Steps Performed

Comment-only changes to XML resource files; no runtime behavior
affected. Verified all targeted entries were updated and no existing
checked-in `Nouveauté` strings remain in the repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-06-30 17:06:25 +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
Clint Rutkas
a864d421fc [Keyboard Manager] Fix stuck modifiers and dropped key-to-text remaps (#48571)
## Summary
Fixes stuck modifier keys and silently-dropped remaps on Keyboard
Manager's **single key → text** path, and adds unit coverage (including
a mockable injection-failure seam).

## What this changes
1. **Insert a dummy key event before releasing held modifiers.**
Releasing a lone Win or Alt key-up otherwise triggers the Start Menu /
menu bar. The dummy key absorbs it so the release is inert. The dummy +
releases are only injected when a modifier is actually held.
2. **Accept `WM_SYSKEYDOWN` as well as `WM_KEYDOWN`.** While Alt is held
the system delivers `WM_SYSKEYDOWN`, so the previous `WM_KEYDOWN`-only
guard silently dropped the remap whenever Alt was down.
3. **Route `Helpers::SendTextInput` through `InputInterface`** instead
of calling Win32 `SendInput` directly. Besides making the path mockable,
this stops the existing unit tests from injecting real keystrokes into
the OS during a test run. Text is still flushed per character to
preserve the existing batching workaround.
4. **Never re-press released modifiers.** Once a modifier key-up is
injected, `GetAsyncKeyState` reports it as up, so re-pressing risks
leaving it stuck if the user let go during injection. Leaving it
released is always safe.

## Testing
- New `MockedInput` failure seam (`SetSendVirtualInputShouldFail`).
- `RemappedKey_ShouldPassOriginalKeyThrough_WhenInjectionFails` —
verifies the original key is passed through when injection fails (the
core stuck-key behavior, previously untestable because the mock always
succeeded).
-
`HandleSingleKeyToTextRemapEvent_ShouldFireAndReleaseAlt_WhenAltIsHeld`
— covers fix #2 by asserting the remap still fires (and releases the
held Alt) when the key arrives as `WM_SYSKEYDOWN`.
- Full Keyboard Manager engine suite: **98/98 passing**, Release x64,
against current `main`.

This is one of a small set of related "stuck key" hardening fixes; each
stands alone.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 20:10:35 -07:00
Mike Griese
3331bdf02a CmdPal: add support for compact mode (#48801)
This is a bear of a PR. Watch out.
This PR adds support for compact mode to the command palette. In compact
mode, the results of the command palette window are collapsed by
default. And the only thing that is visible is the search bar. When the
user types, the window is expanded only just enough to show the
available results[^1]



https://github.com/user-attachments/assets/fd11bbc9-1173-426f-8f44-b513baf2ac5f



This is made possible by a fairly annoyingly substantial refactoring to
how our windowing is done. Animating the size or bounds of an HWND on
Windows is not fun. With pretty much any XAML application, you're going
to get at least one frame of blackness when you resize your window. The
trick then is to create an experience where it looks like your
application is resizing, but the HWND never actually resizes.

So the main bulk of this PR is actually just refactoring our window
handling. Our `MainWindow` class now becomes a dummy holder of _some
content_, and most of the main content is moved into the
`CmdPalMainControl` class. `MainWindow`'s job then is to handle a
transparent window that hosts some XAML content inside of it, and
pretend that content is the real bounds of the window. We need to fake
our NCHITTEST results, so that the edges of the XAML content act like
they're the edge of the actual HWND. We need to hide our actual window
frame and shadow, but then also re-create them in XAML around our
content.
Previously we've done work like this using a single full screen
transparent window with XAML content inside of it. However that has the
downside of not allowing the XAML content to be movable across different
monitors. By faking out the NCHITTEST results, we allow users to resize
and move the window using the normal user32 move size loop.
Our HWND is also cropped (with SetWindowRgn to the bounds of the shadow
around our XAML content. We need to include the shadow in the hit
testable region, because if we don't, then the shadow will be visibly
cropped on the edges.

In compact mode, instead of centering our window in the middle of the
monitor, the user can set a relative height where the search box opens
on that monitor. This defaults to about 60% up from the bottom of the
monitor, so that there's room for the results to expand downwards and
feel centered within the screen. This position is a setting, so users
can customize it to whatever they like.

I've also added a developer only debug build only internal setting,
which allows you to see the actual frame of our HWND. This makes it
easier to visually debug where the bounds of the window are and
understand a little bit more about the layout of our application. This
setting and functionality is disabled in release builds.

<img width="1334" height="1164" alt="cmdpal-compact-diagram"
src="https://github.com/user-attachments/assets/cb1c273d-37cc-4cb7-8680-e1878aa20c9c"
/>

Closes #38423


[^1]: with some caveats: pages with details expand fully always.
2026-06-24 22:35:37 +00:00
Michael Jolley
9ee0c7259b CmdPal: Dock Auto-hide (#48565)
This pull request introduces a new "Auto-hide" feature for the dock,
allowing users to collapse the dock until they hover over its screen
edge. The changes include updates to the settings model, UI,
localization resources, and automated tests to support and verify this
new functionality.

**Show me:**


https://github.com/user-attachments/assets/689625e8-9050-4a54-9c4b-9e303a3da63a

**Conflicted?**

"What if I have Taskbar and Dock on the same side and both with
auto-hide turned on?"

<img width="1437" height="264" alt="Screenshot 2026-06-14 144814"
src="https://github.com/user-attachments/assets/bd037a11-0653-4b9a-bd21-625aca03b901"
/>


Closes #46239

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:39:58 -05:00
111 changed files with 6233 additions and 754 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
@@ -621,7 +623,9 @@ GETPROPERTYSTOREFLAGS
GETSCREENSAVERRUNNING
GETSECKEY
GETSTICKYKEYS
GETTASKBARPOS
GETTEXTLENGTH
GETWORKAREA
gfx
GHND
gitmodules
@@ -1230,6 +1234,8 @@ NOTSRCCOPY
NOTSRCERASE
Notupdated
notwindows
NOTXORPEN
Nouveaut
nowarn
NOZORDER
NPH
@@ -1637,6 +1643,7 @@ SETPOWEROFFACTIVE
SETRANGE
SETREDRAW
SETRULES
SETAUTOHIDEBAREX
SETSCREENSAVEACTIVE
SETSTICKYKEYS
SETTEXT
@@ -1913,6 +1920,7 @@ tracerpt
trackbar
trafficmanager
transicc
transitioning
TRAYMOUSEMESSAGE
triaging
trl
@@ -2174,6 +2182,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>
@@ -25,6 +48,26 @@
<MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Restore" Properties="RestoreInProgress=true" BuildInParallel="false" />
</Target>
<!--
The Microsoft.Web.WebView2 package's managed .targets unconditionally references the WPF
wrapper (Microsoft.Web.WebView2.Wpf.dll) for every non-WinRT .NET project. That wrapper
depends on WPF's WindowsBase, which only ships in the WPF profile of the WindowsDesktop
reference pack. WinForms-only or plain projects therefore resolve WindowsBase to the
4.0.0.0 facade from Microsoft.NETCore.App, producing an MSB3277 conflict against the
wrapper's 5.0.0.0 reference. A project that doesn't enable WPF can't use the WPF WebView2
control anyway, so drop that unused reference before RAR runs (WPF projects keep it).
WinUI/WinAppSDK projects use the CsWinRT projection and never get this reference, so this
is a no-op for them.
-->
<Target
Name="RemoveUnusedWebView2WpfReference"
BeforeTargets="ResolveAssemblyReferences"
Condition="'$(UseWPF)' != 'true'">
<ItemGroup>
<Reference Remove="@(Reference)" Condition="'%(Reference.Filename)' == 'Microsoft.Web.WebView2.Wpf'" />
</ItemGroup>
</Target>
<PropertyGroup Condition="'$(IgnoreExperimentalWarnings)' == 'true'">
<NoWarn>$(NoWarn);CS8305;SA1500;CA1852</NoWarn>
</PropertyGroup>

View File

@@ -26,7 +26,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
@@ -38,7 +38,7 @@
<PackageVersion Include="Mages" Version="3.0.0" />
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
<PackageVersion Include="MessagePack" Version="3.1.3" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
@@ -76,7 +76,7 @@
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
-->
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
@@ -151,4 +151,4 @@
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
</ItemGroup>
</Project>
</Project>

View File

@@ -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

@@ -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);
}

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

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

View File

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

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

@@ -365,6 +365,13 @@ public sealed partial class MainListPage : DynamicListPage,
public override void UpdateSearchText(string oldSearch, string newSearch)
{
var oldWasEmpty = string.IsNullOrEmpty(oldSearch);
var newWasEmpty = string.IsNullOrEmpty(newSearch);
if (oldWasEmpty != newWasEmpty)
{
WeakReferenceMessenger.Default.Send<ExpandCompactModeMessage>(new(!newWasEmpty));
}
UpdateSearchTextCore(oldSearch, newSearch, isUserInput: true);
}
@@ -436,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

@@ -0,0 +1,11 @@
// 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.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Sent when auto-hide registration fails because another app already owns the
/// auto-hide slot on the target edge. The dock falls back to pinned mode.
/// </summary>
public record DockAutoHideConflictMessage(bool IsConflict);

View File

@@ -0,0 +1,9 @@
// 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.CmdPal.UI.ViewModels.Messages;
public record ExpandCompactModeMessage(bool Expanded)
{
}

View File

@@ -24,6 +24,8 @@ public record DockSettings
public bool AlwaysOnTop { get; set; } = true;
public bool AutoHide { get; set; }
// <Theme settings>
public DockBackdrop Backdrop { get; init; } = DockBackdrop.Acrylic;

View File

@@ -16,7 +16,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// migration from legacy GDI device names (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
/// <remarks>
/// All operations are pure they return new immutable lists rather than
/// All operations are pure: they return new immutable lists rather than
/// mutating input collections.
/// </remarks>
public static class MonitorConfigReconciler
@@ -29,13 +29,6 @@ public static class MonitorConfigReconciler
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact StableId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 1.5</b>: Legacy migration — match configs with GDI-style IDs by GDI name, then rewrite to StableId.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
/// </para>
/// </summary>
public static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
@@ -73,7 +66,7 @@ public static class MonitorConfigReconciler
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Phase 1: Exact match on StableId (configs already migrated to stable paths)
// Exact match on StableId (configs already migrated to stable paths)
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
@@ -85,7 +78,7 @@ public static class MonitorConfigReconciler
}
}
// Phase 1.5: Legacy migration match configs that still have GDI-style IDs
// Legacy migration: match configs that still have GDI-style IDs
// (e.g. "\\.\DISPLAY1") by matching against the monitor's GDI DeviceId,
// then rewrite the MonitorDeviceId to the monitor's stable hardware path.
for (var mi = 0; mi < currentMonitors.Count; mi++)
@@ -110,7 +103,7 @@ public static class MonitorConfigReconciler
}
}
// Phase 2: Fuzzy match recover primary monitor config when its ID changed.
// Fuzzy match: recover primary monitor config when its ID changed.
// Windows can reassign device paths across driver updates or cable swaps.
// When the primary monitor's StableId no longer matches any saved config,
// we look for an unmatched config that was previously marked as primary and
@@ -145,9 +138,9 @@ public static class MonitorConfigReconciler
}
}
// Phase 3: Create defaults for new monitors with no matching config.
// Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start disabled with empty band lists
// upgrade path. Secondary monitors start disabled with empty band lists;
// users opt-in via Settings when they want the dock on additional displays.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
@@ -183,7 +176,7 @@ public static class MonitorConfigReconciler
}
}
// Phase 4: Retain disconnected monitor configs so settings survive reconnection.
// Retain disconnected monitor configs so settings survive reconnection.
// Prune entries not seen for longer than StaleThreshold (6 months).
for (var ci = 0; ci < existingConfigs.Count; ci++)
{

View File

@@ -42,6 +42,14 @@ public record SettingsModel
public bool AllowExternalReload { get; init; }
public bool CompactMode { get; set; } = true;
// When compact mode is on and the palette is centered on launch, this is the relative
// height from the bottom of the screen (as a percentage) at which the collapsed search
// box is vertically centered. 75 places it in the upper portion of the display. Ignored
// when compact mode is off.
public int CompactCenterHeightPercentage { get; set; } = 75;
private ImmutableDictionary<string, ProviderSettings>? _providerSettings
= ImmutableDictionary<string, ProviderSettings>.Empty;
@@ -137,6 +145,18 @@ public record SettingsModel
// </Gallery settings>
// Internal diagnostics settings
/// <summary>
/// Gets a value indicating whether the main window's HWND chrome (title bar, border,
/// system-drawn rounded corners) is visible. <strong>For internal debugging only.</strong>
/// Off by default. The setting is persisted but only honored in non-CI builds; release /
/// CI builds always force the borderless / transparent host window.
/// </summary>
public bool ShowHwndFrame { get; init; }
// </Internal diagnostics settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////

View File

@@ -14,7 +14,8 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class SettingsViewModel : INotifyPropertyChanged
public partial class SettingsViewModel : INotifyPropertyChanged,
IRecipient<DockAutoHideConflictMessage>
{
private static readonly List<TimeSpan> AutoGoHomeIntervals =
[
@@ -131,6 +132,25 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public bool CompactMode
{
get => _settingsService.Settings.CompactMode;
set
{
_settingsService.UpdateSettings(s => s with { CompactMode = value });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CompactMode)));
}
}
public double CompactCenterHeightPercentage
{
get => _settingsService.Settings.CompactCenterHeightPercentage;
set
{
_settingsService.UpdateSettings(s => s with { CompactCenterHeightPercentage = (int)value });
}
}
public bool IgnoreShortcutWhenFullscreen
{
get => _settingsService.Settings.IgnoreShortcutWhenFullscreen;
@@ -238,6 +258,30 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public bool Dock_AutoHide
{
get => _settingsService.Settings.DockSettings.AutoHide;
set
{
_settingsService.UpdateSettings(s => s with { DockSettings = s.DockSettings with { AutoHide = value } });
}
}
private bool _dockAutoHideConflict;
public bool Dock_AutoHideConflict
{
get => _dockAutoHideConflict;
private set
{
if (_dockAutoHideConflict != value)
{
_dockAutoHideConflict = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Dock_AutoHideConflict)));
}
}
}
public bool EnableDock
{
get => _settingsService.Settings.EnableDock;
@@ -328,6 +372,13 @@ public partial class SettingsViewModel : INotifyPropertyChanged
{
ApplyFallbackSort();
}
WeakReferenceMessenger.Default.Register<DockAutoHideConflictMessage>(this);
}
public void Receive(DockAutoHideConflictMessage message)
{
Dock_AutoHideConflict = message.IsConflict;
}
private IEnumerable<CommandProviderWrapper> GetCommandProviders()

View File

@@ -85,6 +85,8 @@ public partial class ShellViewModel : ObservableObject,
public bool IsNested => _isNested && !_currentlyTransient;
public bool IsTransient => _currentlyTransient;
public PageViewModel NullPage { get; private set; }
public ShellViewModel(

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.CmdPalMainControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Background="Transparent"
IsTabStop="False"
mc:Ignorable="d">
<UserControl.Resources>
<ThemeShadow x:Key="CardShadow" />
</UserControl.Resources>
<!-- Outer transparent host. Padding leaves room for the drop shadow. -->
<Grid x:Name="ShadowHost" Padding="{x:Bind ShadowPadding, Mode=OneWay}">
<!--
The "card" — this is what looks like the cmdpal window.
Border draws the 1px stroke and clips children to the rounded shape.
ThemeShadow + a Translation on Z casts the drop shadow outside the card.
-->
<Border
x:Name="CardBorder"
VerticalAlignment="Top"
Background="Transparent"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{x:Bind CardCornerRadius, Mode=OneWay}"
Shadow="{StaticResource CardShadow}"
Translation="0,0,32">
<Grid x:Name="CardContent">
<!-- System backdrop (Mica / Acrylic / etc.) drawn only behind the card -->
<controls:SystemBackdropElement x:Name="BackdropElement" CornerRadius="{x:Bind CardCornerRadius, Mode=OneWay}" />
<!-- Optional background image (sits between backdrop and content) -->
<ContentPresenter
x:Name="BackgroundLayerPresenter"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Content="{x:Bind BackgroundLayer, Mode=OneWay}"
IsHitTestVisible="False" />
<!-- Main UI content (e.g. ShellPage) -->
<ContentPresenter
x:Name="MainContentPresenter"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Content="{x:Bind MainContent, Mode=OneWay}" />
</Grid>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,221 @@
// 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 ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.UI;
using WinUIEx;
namespace Microsoft.CmdPal.UI.Controls;
/// <summary>
/// The visible "card" of the Command Palette — a control that renders the rounded
/// corners, border, shadow and system backdrop. The HWND that hosts it is borderless
/// and transparent, so all the chrome lives here instead of in window non-client area.
/// </summary>
public sealed partial class CmdPalMainControl : UserControl
{
public static readonly DependencyProperty MainContentProperty =
DependencyProperty.Register(
nameof(MainContent),
typeof(object),
typeof(CmdPalMainControl),
new PropertyMetadata(null));
public static readonly DependencyProperty BackgroundLayerProperty =
DependencyProperty.Register(
nameof(BackgroundLayer),
typeof(object),
typeof(CmdPalMainControl),
new PropertyMetadata(null));
public static readonly DependencyProperty ShadowPaddingProperty =
DependencyProperty.Register(
nameof(ShadowPadding),
typeof(Thickness),
typeof(CmdPalMainControl),
new PropertyMetadata(new Thickness(16)));
public static readonly DependencyProperty CardCornerRadiusProperty =
DependencyProperty.Register(
nameof(CardCornerRadius),
typeof(CornerRadius),
typeof(CmdPalMainControl),
new PropertyMetadata(new CornerRadius(8)));
/// <summary>
/// Gets or sets the main UI content hosted inside the card (e.g. the ShellPage).
/// </summary>
public object? MainContent
{
get => GetValue(MainContentProperty);
set => SetValue(MainContentProperty, value);
}
/// <summary>
/// Gets or sets a background layer rendered between the backdrop and the main content
/// (e.g. the BlurImageControl). Hit-testing is disabled on this layer.
/// </summary>
public object? BackgroundLayer
{
get => GetValue(BackgroundLayerProperty);
set => SetValue(BackgroundLayerProperty, value);
}
/// <summary>
/// Gets or sets the amount of transparent padding around the card. The drop shadow
/// is rendered into this padded area.
/// </summary>
public Thickness ShadowPadding
{
get => (Thickness)GetValue(ShadowPaddingProperty);
set => SetValue(ShadowPaddingProperty, value);
}
/// <summary>
/// Gets or sets the corner radius of the card. Applied to both the clipping border
/// and the backdrop element.
/// </summary>
public CornerRadius CardCornerRadius
{
get => (CornerRadius)GetValue(CardCornerRadiusProperty);
set => SetValue(CardCornerRadiusProperty, value);
}
/// <summary>
/// Gets the visible card border. Drag regions should be computed against this element
/// so they line up with what the user sees, not the (larger, transparent) HWND.
/// </summary>
public FrameworkElement CardElement => CardBorder;
/// <summary>
/// Gets the panel inside the card that hosts the backdrop, background layer, and main
/// content. Overlay UI (e.g. the dev ribbon) can be added to this panel so it draws
/// inside the rounded card.
/// </summary>
public Panel CardContentPanel => CardContent;
public CmdPalMainControl()
{
this.InitializeComponent();
}
/// <summary>
/// Clamps the maximum height of the visible card (in DIPs). Use this to keep an expanded
/// compact card from growing past the bottom of the display. Pass
/// <see cref="double.PositiveInfinity"/> to remove the clamp.
/// </summary>
public void SetCardMaxHeight(double maxHeightDip)
{
CardBorder.MaxHeight = maxHeightDip;
}
/// <summary>
/// Returns the current height of the visible card (in DIPs). When the card is in its
/// compact layout this is the height of just the search box, which callers use to center
/// the collapsed card on screen.
/// </summary>
public double GetCardHeight()
{
CardBorder.UpdateLayout();
return CardBorder.ActualHeight;
}
/// <summary>
/// Forwards the host window's activation state to the current backdrop so the system can
/// render its active / inactive appearance correctly.
/// </summary>
public void SetIsInputActive(bool isActive)
{
if (BackdropElement.SystemBackdrop is TintedControllerBackdrop tinted)
{
tinted.IsInputActive = isActive;
}
}
/// <summary>
/// Detaches any backdrop from the embedded element. Used during shutdown to release the
/// underlying controller eagerly.
/// </summary>
public void ClearBackdrop()
{
BackdropElement.SystemBackdrop = null;
}
/// <summary>
/// Applies a backdrop configuration to the embedded <see cref="SystemBackdropElement"/>.
/// </summary>
/// <param name="backdrop">Tint / opacity / fallback parameters from the theme service.</param>
/// <param name="kind">The controller kind selected by the user's backdrop style.</param>
/// <param name="isImageMode">When true, the background image control draws the tint, so no tint is applied to the backdrop itself.</param>
/// <param name="hasColorization">When true, custom tint properties are applied to Mica backdrops.</param>
public void ApplyBackdrop(BackdropParameters backdrop, BackdropControllerKind kind, bool isImageMode, bool hasColorization)
{
try
{
BackdropElement.SystemBackdrop = CreateBackdrop(backdrop, kind, isImageMode, hasColorization);
}
catch (Exception ex)
{
Logger.LogError("Failed to apply backdrop to CmdPalMainControl", ex);
}
}
private static Microsoft.UI.Xaml.Media.SystemBackdrop? CreateBackdrop(BackdropParameters backdrop, BackdropControllerKind kind, bool isImageMode, bool hasColorization)
{
// Image mode: don't tint here, BlurImageControl handles it (avoids double-tinting).
var effectiveTintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
switch (kind)
{
case BackdropControllerKind.Solid:
var solidTint = Color.FromArgb(
(byte)(backdrop.EffectiveOpacity * 255),
backdrop.TintColor.R,
backdrop.TintColor.G,
backdrop.TintColor.B);
return new TransparentTintBackdrop { TintColor = solidTint };
case BackdropControllerKind.Mica:
case BackdropControllerKind.MicaAlt:
if (!MicaController.IsSupported())
{
return new TransparentTintBackdrop { TintColor = backdrop.FallbackColor };
}
return new TintedMicaBackdrop
{
Kind = kind == BackdropControllerKind.MicaAlt ? MicaKind.BaseAlt : MicaKind.Base,
ApplyTint = hasColorization || isImageMode,
TintColor = backdrop.TintColor,
TintOpacity = effectiveTintOpacity,
FallbackColor = backdrop.FallbackColor,
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
};
case BackdropControllerKind.Acrylic:
case BackdropControllerKind.AcrylicThin:
default:
if (!DesktopAcrylicController.IsSupported())
{
return new TransparentTintBackdrop { TintColor = backdrop.FallbackColor };
}
return new TintedDesktopAcrylicBackdrop
{
Kind = kind == BackdropControllerKind.AcrylicThin
? DesktopAcrylicKind.Thin
: DesktopAcrylicKind.Default,
TintColor = backdrop.TintColor,
TintOpacity = effectiveTintOpacity,
FallbackColor = backdrop.FallbackColor,
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
};
}
}
}

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

@@ -0,0 +1,90 @@
// 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.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
/// <summary>
/// Base class for tinted backdrops that wrap a controller from
/// <see cref="Microsoft.UI.Composition.SystemBackdrops"/> so they can be applied
/// to a single control via <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
/// </summary>
/// <remarks>
/// The stock <see cref="MicaBackdrop"/> / <see cref="DesktopAcrylicBackdrop"/> classes
/// don't expose tint color / opacity / luminosity customization. This base type plugs
/// the lower-level controllers into the new <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>
/// extensibility surface so we can keep all of CmdPal's theme-driven tinting.
/// </remarks>
internal abstract partial class TintedControllerBackdrop : SystemBackdrop
{
private SystemBackdropConfiguration? _config;
public Color TintColor { get; init; }
public float TintOpacity { get; init; }
public Color FallbackColor { get; init; }
public float LuminosityOpacity { get; init; }
/// <summary>
/// Gets a value indicating whether tint properties should be applied. Mica without
/// colorization wants the system defaults; in that case set this to false.
/// </summary>
public bool ApplyTint { get; init; } = true;
/// <summary>
/// Gets or sets a value indicating whether the host window is currently activated. The
/// system uses this to decide between the active and inactive backdrop appearance.
/// </summary>
public bool IsInputActive
{
get => _config?.IsInputActive ?? true;
set
{
if (_config is not null)
{
_config.IsInputActive = value;
}
}
}
protected SystemBackdropConfiguration? Configuration => _config;
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
_config = new SystemBackdropConfiguration
{
IsInputActive = true,
Theme = xamlRoot.Content is FrameworkElement fe
? ToBackdropTheme(fe.ActualTheme)
: SystemBackdropTheme.Default,
};
AttachController(connectedTarget, xamlRoot);
}
protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget)
{
DetachController(disconnectedTarget);
_config = null;
base.OnTargetDisconnected(disconnectedTarget);
}
protected abstract void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot);
protected abstract void DetachController(ICompositionSupportsSystemBackdrop target);
private static SystemBackdropTheme ToBackdropTheme(ElementTheme theme) => theme switch
{
ElementTheme.Dark => SystemBackdropTheme.Dark,
ElementTheme.Light => SystemBackdropTheme.Light,
_ => SystemBackdropTheme.Default,
};
}

View File

@@ -0,0 +1,57 @@
// 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.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
namespace Microsoft.CmdPal.UI.Controls;
/// <summary>
/// A tinted <see cref="DesktopAcrylicController"/> exposed as a <see cref="SystemBackdrop"/>
/// so it can be hosted by <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
/// </summary>
internal sealed partial class TintedDesktopAcrylicBackdrop : TintedControllerBackdrop, IDisposable
{
private DesktopAcrylicController? _controller;
public DesktopAcrylicKind Kind { get; init; } = DesktopAcrylicKind.Default;
protected override void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot)
{
if (!DesktopAcrylicController.IsSupported())
{
return;
}
_controller = new DesktopAcrylicController
{
Kind = Kind,
TintColor = TintColor,
TintOpacity = TintOpacity,
FallbackColor = FallbackColor,
LuminosityOpacity = LuminosityOpacity,
};
_controller.AddSystemBackdropTarget(target);
_controller.SetSystemBackdropConfiguration(Configuration);
}
protected override void DetachController(ICompositionSupportsSystemBackdrop target)
{
if (_controller is not null)
{
_controller.RemoveSystemBackdropTarget(target);
_controller.Dispose();
_controller = null;
}
}
public void Dispose()
{
_controller?.Dispose();
_controller = null;
}
}

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 Microsoft.UI.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
/// <summary>
/// A tinted <see cref="MicaController"/> exposed as a <see cref="SystemBackdrop"/>
/// so it can be hosted by <see cref="Microsoft.UI.Xaml.Controls.SystemBackdropElement"/>.
/// </summary>
internal sealed partial class TintedMicaBackdrop : TintedControllerBackdrop, IDisposable
{
private MicaController? _controller;
public MicaKind Kind { get; init; } = MicaKind.Base;
protected override void AttachController(ICompositionSupportsSystemBackdrop target, XamlRoot xamlRoot)
{
if (!MicaController.IsSupported())
{
return;
}
_controller = new MicaController { Kind = Kind };
// Only set tint properties when colorization is active.
// Otherwise let the system handle light/dark theme defaults automatically.
if (ApplyTint)
{
_controller.TintColor = TintColor;
_controller.TintOpacity = TintOpacity;
_controller.FallbackColor = FallbackColor;
_controller.LuminosityOpacity = LuminosityOpacity;
}
_controller.AddSystemBackdropTarget(target);
_controller.SetSystemBackdropConfiguration(Configuration);
}
protected override void DetachController(ICompositionSupportsSystemBackdrop target)
{
if (_controller is not null)
{
_controller.RemoveSystemBackdropTarget(target);
_controller.Dispose();
_controller = null;
}
}
public void Dispose()
{
_controller?.Dispose();
_controller = null;
}
}

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 Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// Converts a boolean to a <see cref="GridLength"/>: <c>true</c> yields a star (*) row that
/// fills the available space, while <c>false</c> yields an Auto row that sizes to its content.
/// This lets the expandable content row collapse to zero in compact mode so the card can
/// shrink to just the search box (a star row would otherwise reserve space during measure
/// even when its only child is collapsed).
/// </summary>
public partial class BoolToStarOrAutoGridLengthConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
var expanded = value is bool b && b;
return expanded ? new GridLength(1, GridUnitType.Star) : GridLength.Auto;
}
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
}

View File

@@ -39,6 +39,14 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
/// </summary>
internal IntPtr OwnerHwnd { get; set; }
internal bool HasOpenTransientUi =>
ContextMenuFlyout.IsOpen ||
AddBandFlyout.IsOpen ||
EditModeContextMenu.IsOpen ||
EditButtonsTeachingTip.IsOpen;
internal bool IsDragOperationActive => _draggedBand is not null;
public static readonly DependencyProperty ItemsOrientationProperty =
DependencyProperty.Register(nameof(ItemsOrientation), typeof(Orientation), typeof(DockControl), new PropertyMetadata(Orientation.Horizontal));
@@ -492,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

@@ -24,17 +24,24 @@ using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
using Windows.Win32.UI.Accessibility;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.Shell;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT;
using WinRT.Interop;
using WinUIEx;
using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo;
using POINT = Microsoft.PowerToys.Settings.UI.Helpers.POINT;
using RECT = Windows.Win32.Foundation.RECT;
namespace Microsoft.CmdPal.UI.Dock;
#pragma warning disable SA1402 // File may only contain a single type
/// <summary>
/// The main window for the dock feature. Uses the Windows AppBar API to reserve
/// screen work area and position itself at the edge of the display.
/// </summary>
public sealed partial class DockWindow : WindowEx,
IRecipient<BringToTopMessage>,
IRecipient<RequestShowPaletteAtMessage>,
@@ -42,6 +49,13 @@ public sealed partial class DockWindow : WindowEx,
IRecipient<QuitMessage>,
IDisposable
{
private enum DockAppBarMode
{
None,
Pinned,
AutoHide,
}
#pragma warning disable SA1306 // Field names should begin with lower-case letter
#pragma warning disable SA1310 // Field names should not contain underscore
private readonly uint WM_TASKBAR_RESTART;
@@ -69,6 +83,27 @@ public sealed partial class DockWindow : WindowEx,
private BackdropParameters? _lastAppliedAcrylicBackdrop;
private DockSize _lastSize;
private bool _isDisposed;
private DockAppBarMode _appBarMode;
private bool _autoHideRegistrationSucceeded;
private bool _isDockRevealed = true;
private bool _trackingMouseLeave;
private RECT _revealedRect;
private RECT _collapsedRect;
private DispatcherQueueTimer? _collapseTimer;
private DispatcherQueueTimer? _revealPollTimer;
private DispatcherQueueTimer? _slideTimer;
private RECT _slideFromRect;
private RECT _slideToRect;
private bool _slideIsRevealing;
private System.Diagnostics.Stopwatch? _slideStopwatch;
private bool _paletteOpenedFromDock;
private const int AutoHideCollapsedThicknessDips = 0;
private const int RevealHitTestMarginPixels = 1;
private static readonly TimeSpan AutoHideCollapseDelay = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan RevealPollInterval = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan SlideRevealDuration = TimeSpan.FromMilliseconds(200);
private static readonly TimeSpan SlideCollapseDuration = TimeSpan.FromMilliseconds(150);
private static readonly TimeSpan SlideFrameInterval = TimeSpan.FromMilliseconds(8);
/// <summary>
/// The monitor this dock window is displayed on. Null means primary monitor (legacy behavior).
@@ -111,7 +146,7 @@ public sealed partial class DockWindow : WindowEx,
_settingsService.SettingsChanged += SettingsChangedHandler;
_monitorService = serviceProvider.GetRequiredService<IMonitorService>();
_settings = mainSettings.DockSettings;
_lastSize = EffectiveDockSize(_settings);
_lastSize = EffectiveDockSize(_settings, EffectiveSide);
viewModel = dockViewModel;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
@@ -230,13 +265,22 @@ public sealed partial class DockWindow : WindowEx,
_dock.UpdateSettings(_settings, EffectiveSide);
var side = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
var desiredMode = GetDesiredAppBarMode();
var effectiveSize = EffectiveDockSize(_settings, EffectiveSide);
if (_appBarData.hWnd != IntPtr.Zero)
{
var sameEdge = _appBarData.uEdge == side;
var sameSize = _lastSize == EffectiveDockSize(_settings);
if (sameEdge && sameSize)
var sameSize = _lastSize == effectiveSize;
var sameMode = _appBarMode == desiredMode;
if (sameEdge && sameSize && sameMode)
{
if (_appBarMode == DockAppBarMode.AutoHide)
{
UpdateWindowPosition();
}
UpdateTopmostState();
return;
}
@@ -378,6 +422,11 @@ public sealed partial class DockWindow : WindowEx,
});
}
/// <summary>
/// Registers this window as a Windows AppBar. In pinned mode, the dock
/// reserves work area so maximized windows do not overlap it. In auto-hide
/// mode, <c>ABM_SETAUTOHIDEBAR</c> is used and no work area is reserved.
/// </summary>
private void CreateAppBar(HWND hwnd)
{
_appBarData = new APPBARDATA
@@ -387,20 +436,71 @@ public sealed partial class DockWindow : WindowEx,
uCallbackMessage = _callbackMessageId,
};
// Register this window as an app bar
PInvoke.SHAppBarMessage(PInvoke.ABM_NEW, ref _appBarData);
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_NEW, ref _appBarData);
// Stash the last size we created the bar at, so we know when to hot-
// reload it
_lastSize = EffectiveDockSize(_settings);
_appBarMode = DockAppBarMode.None;
_autoHideRegistrationSucceeded = false;
if (GetDesiredAppBarMode() == DockAppBarMode.AutoHide
&& !IsTaskbarAutoHideOnSameEdge(EffectiveSide)
&& TryRegisterAutoHideAppBar())
{
_appBarMode = DockAppBarMode.AutoHide;
_autoHideRegistrationSucceeded = true;
WeakReferenceMessenger.Default.Send(new ViewModels.Messages.DockAutoHideConflictMessage(false));
}
else
{
_appBarMode = DockAppBarMode.Pinned;
if (_settings.AutoHide)
{
var reason = IsTaskbarAutoHideOnSameEdge(EffectiveSide)
? "taskbar auto-hide conflict"
: "registration rejected";
Logger.LogWarning($"Dock auto-hide unavailable ({reason}) on edge {EffectiveSide} for monitor {MonitorForLogs()}. Falling back to pinned mode.");
WeakReferenceMessenger.Default.Send(new ViewModels.Messages.DockAutoHideConflictMessage(true));
}
}
_lastSize = EffectiveDockSize(_settings, EffectiveSide);
UpdateWindowPosition();
if (_appBarMode == DockAppBarMode.AutoHide)
{
// Briefly show the dock at the new position so users can confirm
// the move, then schedule a collapse after the standard delay.
_isDockRevealed = true;
ApplyAutoHideRect(_revealedRect);
ScheduleCollapseAutoHideDock();
}
}
private void DestroyAppBar(HWND hwnd)
{
PInvoke.SHAppBarMessage(PInvoke.ABM_REMOVE, ref _appBarData);
StopCollapseTimer();
StopRevealPollTimer();
StopSlideAnimation();
// If the window was hidden via SW_HIDE (auto-hide collapsed state),
// make it visible again before transitioning to a new mode.
if (_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed)
{
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
}
if (_appBarMode == DockAppBarMode.AutoHide && _autoHideRegistrationSucceeded)
{
_ = TrySetAutoHideRegistration(register: false);
_autoHideRegistrationSucceeded = false;
}
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_REMOVE, ref _appBarData);
_appBarData = default;
_appBarMode = DockAppBarMode.None;
_trackingMouseLeave = false;
_isDockRevealed = true;
_revealedRect = default;
_collapsedRect = default;
}
private void UpdateTopmostState(bool bringToFront = false)
@@ -440,23 +540,27 @@ public sealed partial class DockWindow : WindowEx,
private void UpdateWindowPosition()
{
Logger.LogDebug("UpdateWindowPosition");
Logger.LogDebug($"UpdateWindowPosition mode={_appBarMode} autoHideRequested={_settings.AutoHide} monitor={MonitorForLogs()}");
var dpi = PInvoke.GetDpiForWindow(_hwnd);
var scaleFactor = dpi / 96.0;
var effectiveSize = EffectiveDockSize(_settings);
var effectiveSize = EffectiveDockSize(_settings, EffectiveSide);
if (_appBarMode == DockAppBarMode.AutoHide)
{
UpdateAutoHideWindowPosition(effectiveSize, scaleFactor);
return;
}
UpdatePinnedWindowPosition(effectiveSize, scaleFactor);
}
private void UpdatePinnedWindowPosition(DockSize effectiveSize, double scaleFactor)
{
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
// ABM_QUERYPOS adjusts our rect so we don't overlap other app bars,
// but it may have shifted our anchored edge without updating the
// opposite edge. We need to re-apply our desired thickness so the
// bar keeps its correct size. Without this, a second bar docked to
// the same side would get a zero-height/width rect and fail to
// reserve work-area space.
switch (EffectiveSide)
{
case DockSide.Top:
@@ -473,18 +577,8 @@ public sealed partial class DockWindow : WindowEx,
break;
}
PInvoke.SHAppBarMessage(PInvoke.ABM_SETPOS, ref _appBarData);
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_SETPOS, ref _appBarData);
// TODO: investigate ABS_AUTOHIDE and auto hide bars.
// I think it's something like this, but I don't totally know
// _appBarData.lParam = ABS_ALWAYSONTOP;
// _appBarData.lParam = (LPARAM)(int)PInvoke.ABS_AUTOHIDE;
// PInvoke.SHAppBarMessage(ABM_SETSTATE, ref _appBarData);
// PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
// The dock window is borderless (SetBorderAndTitleBar(false, false),
// IsResizable = false) so no frame compensation is needed — the
// app bar rect matches the window rect exactly.
PInvoke.MoveWindow(
_hwnd,
_appBarData.rc.left,
@@ -492,6 +586,8 @@ public sealed partial class DockWindow : WindowEx,
_appBarData.rc.right - _appBarData.rc.left,
_appBarData.rc.bottom - _appBarData.rc.top,
true);
_isDockRevealed = true;
}
/// <summary>
@@ -530,19 +626,550 @@ public sealed partial class DockWindow : WindowEx,
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
/// </summary>
private static DockSize EffectiveDockSize(DockSettings settings)
private static DockSize EffectiveDockSize(DockSettings settings, DockSide side)
{
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var isHorizontal = side == DockSide.Top || side == DockSide.Bottom;
return isHorizontal ? settings.DockSize : DockSize.Default;
}
private DockAppBarMode GetDesiredAppBarMode()
{
return _settings.AutoHide ? DockAppBarMode.AutoHide : DockAppBarMode.Pinned;
}
/// <summary>
/// Checks whether the Windows taskbar is set to auto-hide on the same
/// edge as the dock. When true, the dock should not use auto-hide mode
/// to avoid competing for the same screen-edge reveal zone.
/// </summary>
private bool IsTaskbarAutoHideOnSameEdge(DockSide side)
{
var stateAbd = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
var state = PInvoke.SHAppBarMessage(PInvoke.ABM_GETSTATE, ref stateAbd);
if ((state & PInvoke.ABS_AUTOHIDE) == 0)
{
return false;
}
// Taskbar is auto-hiding; check which edge
var posAbd = new APPBARDATA { cbSize = (uint)Marshal.SizeOf<APPBARDATA>() };
_ = PInvoke.SHAppBarMessage(PInvoke.ABM_GETTASKBARPOS, ref posAbd);
var taskbarEdge = posAbd.uEdge;
var dockEdge = DockSettingsToViews.GetAppBarEdge(side);
return taskbarEdge == dockEdge;
}
private bool TryRegisterAutoHideAppBar()
{
return TrySetAutoHideRegistration(register: true);
}
private bool TrySetAutoHideRegistration(bool register)
{
_appBarData.rc = GetMonitorBoundsRect();
_appBarData.uEdge = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
_appBarData.lParam = register ? new LPARAM(1) : new LPARAM(0);
if (_targetMonitor is null)
{
var result = PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
return result != 0;
}
var exResult = PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAREX, ref _appBarData);
return exResult != 0;
}
private string MonitorForLogs()
{
return _targetMonitor?.StableId ?? "primary";
}
private RECT GetMonitorBoundsRect()
{
if (_targetMonitor is not null)
{
return new RECT
{
left = _targetMonitor.Bounds.Left,
top = _targetMonitor.Bounds.Top,
right = _targetMonitor.Bounds.Right,
bottom = _targetMonitor.Bounds.Bottom,
};
}
return new RECT
{
left = 0,
top = 0,
right = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN),
bottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN),
};
}
private void UpdateAutoHideWindowPosition(DockSize effectiveSize, double scaleFactor)
{
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
_revealedRect = _appBarData.rc;
_collapsedRect = BuildCollapsedRect(_revealedRect, EffectiveSide, scaleFactor);
ApplyAutoHideRect(_isDockRevealed ? _revealedRect : _collapsedRect);
if (_isDockRevealed)
{
EnsureMouseLeaveTracking();
}
}
private RECT BuildCollapsedRect(RECT revealedRect, DockSide side, double scaleFactor)
{
var collapsedRect = revealedRect;
var thickness = (int)Math.Round(AutoHideCollapsedThicknessDips * scaleFactor);
switch (side)
{
case DockSide.Top:
collapsedRect.bottom = collapsedRect.top + thickness;
break;
case DockSide.Bottom:
collapsedRect.top = collapsedRect.bottom - thickness;
break;
case DockSide.Left:
collapsedRect.right = collapsedRect.left + thickness;
break;
case DockSide.Right:
collapsedRect.left = collapsedRect.right - thickness;
break;
}
return collapsedRect;
}
private void ApplyAutoHideRect(RECT rect)
{
var width = rect.right - rect.left;
var height = rect.bottom - rect.top;
if (width <= 0 || height <= 0)
{
// Window is fully collapsed - hide it
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
return;
}
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
PInvoke.MoveWindow(
_hwnd,
rect.left,
rect.top,
width,
height,
true);
}
/// <summary>
/// Positions the window without forcing a synchronous repaint.
/// Used during animation frames for smoother sliding.
/// </summary>
private void ApplyAutoHideRectNoRepaint(RECT rect)
{
var width = rect.right - rect.left;
var height = rect.bottom - rect.top;
if (width <= 0 || height <= 0)
{
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
return;
}
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
const SET_WINDOW_POS_FLAGS flags = SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOCOPYBITS;
PInvoke.SetWindowPos(_hwnd, HWND.Null, rect.left, rect.top, width, height, flags);
}
private void RevealAutoHideDock(bool immediate = false)
{
if (_appBarMode != DockAppBarMode.AutoHide || _isDockRevealed)
{
return;
}
StopCollapseTimer();
StopRevealPollTimer();
_isDockRevealed = true;
if (immediate)
{
StopSlideAnimation();
ApplyAutoHideRect(_revealedRect);
UpdateTopmostState(bringToFront: true);
}
else
{
StartSlideAnimation(_collapsedRect, _revealedRect, isRevealing: true);
UpdateTopmostState(bringToFront: true);
}
EnsureMouseLeaveTracking();
}
private void CollapseAutoHideDock(bool immediate = false)
{
if (_appBarMode != DockAppBarMode.AutoHide || !_isDockRevealed)
{
return;
}
if (!immediate && !CanCollapseAutoHideDock())
{
// Cursor is still over the dock or a blocking condition exists.
// Reschedule so we retry when conditions change.
ScheduleCollapseAutoHideDock();
return;
}
StopCollapseTimer();
_isDockRevealed = false;
if (immediate)
{
StopSlideAnimation();
ApplyAutoHideRect(_collapsedRect);
}
else
{
StartSlideAnimation(_revealedRect, _collapsedRect, isRevealing: false);
}
// Only start the reveal poll timer if no fullscreen app is blocking this monitor
if (!_isFullScreenAppOpen)
{
StartRevealPollTimer();
}
}
private bool CanCollapseAutoHideDock()
{
if (_dock.IsEditMode || _dock.HasOpenTransientUi || _dock.IsDragOperationActive)
{
return false;
}
if (_paletteOpenedFromDock)
{
return false;
}
if (!PInvoke.GetCursorPos(out var cursor))
{
return true;
}
// Only block collapse if cursor is over our actual window (not just in the same screen area)
var cursorPoint = new POINT(cursor.X, cursor.Y);
if (IsPointInRect(_revealedRect, cursorPoint))
{
var windowUnderCursor = PInvoke.WindowFromPoint(new System.Drawing.Point(cursor.X, cursor.Y));
// WindowFromPoint may return a child control (button, panel, etc.)
// inside the dock. Walk up to the top-level window to compare.
var rootWindow = PInvoke.GetAncestor(windowUnderCursor, GET_ANCESTOR_FLAGS.GA_ROOT);
if (rootWindow == _hwnd)
{
return false;
}
}
return true;
}
private static bool IsPointInRect(RECT rect, POINT point)
{
return point.X >= rect.left && point.X < rect.right && point.Y >= rect.top && point.Y < rect.bottom;
}
private void ScheduleCollapseAutoHideDock()
{
if (_appBarMode != DockAppBarMode.AutoHide || !_isDockRevealed)
{
return;
}
_collapseTimer ??= CreateCollapseTimer();
_collapseTimer.Stop();
_collapseTimer.Start();
}
private DispatcherQueueTimer CreateCollapseTimer()
{
var timer = DispatcherQueue.CreateTimer();
timer.Interval = AutoHideCollapseDelay;
timer.IsRepeating = false;
timer.Tick += (sender, _) =>
{
sender.Stop();
CollapseAutoHideDock();
};
return timer;
}
private void StopCollapseTimer()
{
_collapseTimer?.Stop();
}
private void StartRevealPollTimer()
{
_revealPollTimer ??= CreateRevealPollTimer();
_revealPollTimer.Start();
}
private void StopRevealPollTimer()
{
_revealPollTimer?.Stop();
}
private DispatcherQueueTimer CreateRevealPollTimer()
{
var timer = DispatcherQueue.CreateTimer();
timer.Interval = RevealPollInterval;
timer.IsRepeating = true;
timer.Tick += (_, _) => PollCursorForReveal();
return timer;
}
private void PollCursorForReveal()
{
if (_appBarMode != DockAppBarMode.AutoHide || _isDockRevealed)
{
StopRevealPollTimer();
return;
}
if (!PInvoke.GetCursorPos(out var cursor))
{
return;
}
if (IsCursorAtDockEdge(new POINT(cursor.X, cursor.Y)))
{
StopRevealPollTimer();
RevealAutoHideDock(immediate: false);
}
}
private bool IsCursorAtDockEdge(POINT cursor)
{
// Use the revealed rect's edge position for detection. This already
// accounts for work area offsets (e.g., dock positioned above taskbar).
switch (EffectiveSide)
{
case DockSide.Top:
return cursor.Y <= _revealedRect.top + RevealHitTestMarginPixels
&& cursor.X >= _revealedRect.left && cursor.X < _revealedRect.right;
case DockSide.Bottom:
return cursor.Y >= _revealedRect.bottom - RevealHitTestMarginPixels
&& cursor.X >= _revealedRect.left && cursor.X < _revealedRect.right;
case DockSide.Left:
return cursor.X <= _revealedRect.left + RevealHitTestMarginPixels
&& cursor.Y >= _revealedRect.top && cursor.Y < _revealedRect.bottom;
case DockSide.Right:
return cursor.X >= _revealedRect.right - RevealHitTestMarginPixels
&& cursor.Y >= _revealedRect.top && cursor.Y < _revealedRect.bottom;
default:
return false;
}
}
private void StartSlideAnimation(RECT from, RECT to, bool isRevealing)
{
StopSlideAnimation();
_slideFromRect = from;
_slideToRect = to;
_slideIsRevealing = isRevealing;
// Ensure window is visible at start of reveal animation
if (isRevealing)
{
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNOACTIVATE);
}
_slideStopwatch = System.Diagnostics.Stopwatch.StartNew();
_slideTimer ??= CreateSlideTimer();
_slideTimer.Start();
}
private void StopSlideAnimation()
{
_slideTimer?.Stop();
_slideStopwatch?.Stop();
}
private DispatcherQueueTimer CreateSlideTimer()
{
var timer = DispatcherQueue.CreateTimer();
timer.Interval = SlideFrameInterval;
timer.IsRepeating = true;
timer.Tick += (_, _) => OnSlideTimerTick();
return timer;
}
private void OnSlideTimerTick()
{
if (_slideStopwatch is null)
{
StopSlideAnimation();
return;
}
var duration = _slideIsRevealing ? SlideRevealDuration : SlideCollapseDuration;
var elapsed = _slideStopwatch.Elapsed.TotalMilliseconds;
var progress = Math.Min(1.0, elapsed / duration.TotalMilliseconds);
var easedProgress = _slideIsRevealing
? EaseOutCubic(progress)
: EaseInCubic(progress);
var currentRect = LerpRect(_slideFromRect, _slideToRect, easedProgress);
if (progress >= 1.0)
{
StopSlideAnimation();
// Final frame: apply exact position with full repaint
ApplyAutoHideRect(_slideToRect);
}
else
{
// Intermediate frames: move without synchronous repaint for smoothness
ApplyAutoHideRectNoRepaint(currentRect);
}
}
private static double EaseOutCubic(double t) => 1.0 - Math.Pow(1.0 - t, 3);
private static double EaseInCubic(double t) => t * t * t;
private static RECT LerpRect(RECT a, RECT b, double t)
{
return new RECT
{
left = Lerp(a.left, b.left, t),
top = Lerp(a.top, b.top, t),
right = Lerp(a.right, b.right, t),
bottom = Lerp(a.bottom, b.bottom, t),
};
}
private static int Lerp(int a, int b, double t) => (int)Math.Round(double.Lerp(a, b, t));
private void EnsureMouseLeaveTracking()
{
if (_trackingMouseLeave)
{
return;
}
var track = new TRACKMOUSEEVENT
{
cbSize = (uint)Marshal.SizeOf<TRACKMOUSEEVENT>(),
dwFlags = TRACKMOUSEEVENT_FLAGS.TME_LEAVE,
hwndTrack = _hwnd,
dwHoverTime = 0,
};
if (PInvoke.TrackMouseEvent(ref track))
{
_trackingMouseLeave = true;
}
}
private void HandleWorkAreaChanged()
{
if (_isDisposed)
{
return;
}
var desiredMode = GetDesiredAppBarMode();
var taskbarConflict = desiredMode == DockAppBarMode.AutoHide
&& IsTaskbarAutoHideOnSameEdge(EffectiveSide);
if (taskbarConflict && _appBarMode == DockAppBarMode.AutoHide)
{
// Taskbar started auto-hiding on our edge, switch to pinned
DestroyAppBar(_hwnd);
CreateAppBar(_hwnd);
}
else if (!taskbarConflict && _appBarMode == DockAppBarMode.Pinned && desiredMode == DockAppBarMode.AutoHide)
{
// Taskbar stopped auto-hiding on our edge, try auto-hide again
DestroyAppBar(_hwnd);
CreateAppBar(_hwnd);
}
else
{
UpdateWindowPosition();
}
}
private void HandleMouseMoveForAutoHide()
{
if (_appBarMode != DockAppBarMode.AutoHide)
{
return;
}
// The poll timer handles reveal when the dock is collapsed/hidden.
// WM_MOUSEMOVE only arrives when the dock is visible (revealed),
// so we just reset the collapse timer to keep it open while active.
if (!_isDockRevealed)
{
return;
}
StopCollapseTimer();
EnsureMouseLeaveTracking();
}
private void HandleMouseLeaveForAutoHide()
{
_trackingMouseLeave = false;
if (_appBarMode != DockAppBarMode.AutoHide)
{
return;
}
ScheduleCollapseAutoHideDock();
}
private void HandleDeactivationForAutoHide()
{
if (_appBarMode != DockAppBarMode.AutoHide)
{
return;
}
// When the dock loses activation, the palette (if opened from dock) is closing
_paletteOpenedFromDock = false;
ScheduleCollapseAutoHideDock();
}
private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleFactor)
{
Logger.LogDebug("UpdateAppBarDataForEdge");
var horizontalHeightDips = DockSettingsToViews.HeightForSize(size);
var verticalWidthDips = DockSettingsToViews.WidthForSize(size);
// Use monitor-specific bounds when available; fall back to primary screen metrics
// Use monitor-specific bounds when available; fall back to primary screen metrics.
// In auto-hide mode, use work area on the dock's edge so the dock
// positions inward of the taskbar (if the taskbar is on the same edge).
int monLeft, monTop, monRight, monBottom;
if (_targetMonitor is not null)
{
@@ -550,6 +1177,27 @@ public sealed partial class DockWindow : WindowEx,
monTop = _targetMonitor.Bounds.Top;
monRight = _targetMonitor.Bounds.Right;
monBottom = _targetMonitor.Bounds.Bottom;
if (_appBarMode == DockAppBarMode.AutoHide || GetDesiredAppBarMode() == DockAppBarMode.AutoHide)
{
// Use work area edge only on the side where the dock is positioned.
// This keeps the dock inward of the taskbar on the shared edge.
switch (side)
{
case DockSide.Top:
monTop = _targetMonitor.WorkArea.Top;
break;
case DockSide.Bottom:
monBottom = _targetMonitor.WorkArea.Bottom;
break;
case DockSide.Left:
monLeft = _targetMonitor.WorkArea.Left;
break;
case DockSide.Right:
monRight = _targetMonitor.WorkArea.Right;
break;
}
}
}
else
{
@@ -557,6 +1205,33 @@ public sealed partial class DockWindow : WindowEx,
monTop = 0;
monRight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
monBottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
if (_appBarMode == DockAppBarMode.AutoHide || GetDesiredAppBarMode() == DockAppBarMode.AutoHide)
{
// For primary monitor without MonitorInfo, use the system work area
unsafe
{
RECT workArea = default;
if (PInvoke.SystemParametersInfo(SYSTEM_PARAMETERS_INFO_ACTION.SPI_GETWORKAREA, 0, &workArea, 0))
{
switch (side)
{
case DockSide.Top:
monTop = workArea.top;
break;
case DockSide.Bottom:
monBottom = workArea.bottom;
break;
case DockSide.Left:
monLeft = workArea.left;
break;
case DockSide.Right:
monRight = workArea.right;
break;
}
}
}
}
}
if (side == DockSide.Top)
@@ -612,8 +1287,9 @@ public sealed partial class DockWindow : WindowEx,
{
Logger.LogDebug($"WM_SETTINGCHANGE(SPI_SETWORKAREA)");
// Use debounced call to throttle rapid successive calls
DispatcherQueue.TryEnqueue(() => UpdateWindowPosition());
// Work area changed - taskbar may have toggled auto-hide or moved.
// Re-evaluate whether our auto-hide mode is still valid.
DispatcherQueue.TryEnqueue(HandleWorkAreaChanged);
}
}
else if (msg == PInvoke.WM_DISPLAYCHANGE)
@@ -639,9 +1315,38 @@ public sealed partial class DockWindow : WindowEx,
}
RefreshTargetMonitor();
UpdateWindowPosition();
if (_appBarData.hWnd != IntPtr.Zero)
{
// The Shell caches the monitor coordinates from the original
// ABM_NEW registration, so after a topology change the stale
// AppBar rect cannot be repositioned correctly. Destroy and
// recreate to re-register with the new monitor geometry.
DestroyAppBar(_hwnd);
CreateAppBar(_hwnd);
}
else
{
UpdateWindowPosition();
}
});
}
else if (msg == PInvoke.WM_MOUSEMOVE)
{
HandleMouseMoveForAutoHide();
}
else if (msg == PInvoke.WM_MOUSELEAVE)
{
HandleMouseLeaveForAutoHide();
}
else if (msg == PInvoke.WM_ACTIVATEAPP && wParam.Value == 0)
{
HandleDeactivationForAutoHide();
}
else if (msg == PInvoke.WM_ACTIVATE && (wParam.Value & 0xFFFF) == PInvoke.WA_INACTIVE)
{
HandleDeactivationForAutoHide();
}
// Intercept WM_SYSCOMMAND to prevent minimize and maximize
else if (msg == PInvoke.WM_SYSCOMMAND)
@@ -661,8 +1366,10 @@ public sealed partial class DockWindow : WindowEx,
{
var pWindowPos = (WINDOWPOS*)lParam.Value;
// Check if the window is being hidden (minimized) or if flags suggest minimize/maximize
if ((pWindowPos->flags & SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW) != 0)
// Check if the window is being hidden (minimized) or if flags suggest minimize/maximize.
// Allow hiding when auto-hide is collapsing the dock intentionally.
if ((pWindowPos->flags & SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW) != 0
&& !(_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed))
{
// Prevent hiding the window (minimize)
pWindowPos->flags &= ~SET_WINDOW_POS_FLAGS.SWP_HIDEWINDOW;
@@ -694,9 +1401,9 @@ public sealed partial class DockWindow : WindowEx,
else if (msg == PInvoke.WM_SHOWWINDOW)
{
var isBeingShown = wParam.Value != 0;
if (!isBeingShown)
if (!isBeingShown && !(_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed))
{
// Prevent hiding the window
// Prevent hiding the window (unless auto-hide is intentionally collapsing)
return new LRESULT(0);
}
}
@@ -741,6 +1448,17 @@ public sealed partial class DockWindow : WindowEx,
else if (wParam.Value == PInvoke.ABN_FULLSCREENAPP)
{
_isFullScreenAppOpen = lParam != 0;
if (_isFullScreenAppOpen)
{
StopRevealPollTimer();
CollapseAutoHideDock(immediate: true);
}
else if (_appBarMode == DockAppBarMode.AutoHide && !_isDockRevealed)
{
// Fullscreen app exited - restart edge detection
StartRevealPollTimer();
}
UpdateTopmostState();
}
}
@@ -748,7 +1466,15 @@ public sealed partial class DockWindow : WindowEx,
{
Logger.LogDebug("WM_TASKBAR_RESTART");
DispatcherQueue.TryEnqueue(() => CreateAppBar(_hwnd));
DispatcherQueue.TryEnqueue(() =>
{
if (_appBarData.hWnd != IntPtr.Zero)
{
DestroyAppBar(_hwnd);
}
CreateAppBar(_hwnd);
});
WeakReferenceMessenger.Default.Send<BringToTopMessage>(new(false));
}
@@ -761,6 +1487,11 @@ public sealed partial class DockWindow : WindowEx,
{
DispatcherQueue.TryEnqueue(() =>
{
if (message.BringToFront)
{
RevealAutoHideDock(immediate: true);
}
UpdateTopmostState(message.BringToFront);
});
}
@@ -782,7 +1513,12 @@ public sealed partial class DockWindow : WindowEx,
return;
}
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => RequestShowPaletteOnUiThread(message.PosDips));
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
_paletteOpenedFromDock = true;
RevealAutoHideDock(immediate: true);
RequestShowPaletteOnUiThread(message.PosDips);
});
}
void IRecipient<ShowDockMonitorLabelsMessage>.Receive(ShowDockMonitorLabelsMessage message)
@@ -965,6 +1701,7 @@ public sealed partial class DockWindow : WindowEx,
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
WeakReferenceMessenger.Default.UnregisterAll(this);
StopCollapseTimer();
DisposeAcrylic();
_windowViewModel.Dispose();

View File

@@ -15,22 +15,32 @@
Activated="MainWindow_Activated"
Closed="MainWindow_Closed"
mc:Ignorable="d">
<Grid x:Name="RootElement">
<controls:BlurImageControl
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}"
ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}"
ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}"
ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}"
ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}"
IsHitTestVisible="False"
IsHoldingEnabled="False"
TintColor="{x:Bind ViewModel.BackgroundImageTint, Mode=OneWay}"
TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}"
Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" />
<pages:ShellPage HostWindow="{x:Bind}" />
</Grid>
<!--
The whole window is borderless and transparent (see MainWindow.xaml.cs).
CmdPalMainControl is the visible "card" — it draws the rounded corners,
border, drop shadow, and hosts the SystemBackdropElement that paints
Mica / Acrylic / etc. behind the content.
-->
<controls:CmdPalMainControl x:Name="RootElement">
<controls:CmdPalMainControl.BackgroundLayer>
<controls:BlurImageControl
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}"
ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}"
ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}"
ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}"
ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}"
IsHitTestVisible="False"
IsHoldingEnabled="False"
TintColor="{x:Bind ViewModel.BackgroundImageTint, Mode=OneWay}"
TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}"
Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" />
</controls:CmdPalMainControl.BackgroundLayer>
<controls:CmdPalMainControl.MainContent>
<pages:ShellPage HostWindow="{x:Bind}" />
</controls:CmdPalMainControl.MainContent>
</controls:CmdPalMainControl>
</winuiex:WindowEx>

View File

@@ -15,6 +15,7 @@ using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Pages;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -22,8 +23,7 @@ using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
@@ -32,13 +32,11 @@ using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Graphics;
using Windows.System;
using Windows.UI;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT;
using WinUIEx;
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
@@ -58,6 +56,7 @@ public sealed partial class MainWindow : WindowEx,
IRecipient<DragCompletedMessage>,
IRecipient<ToggleDevRibbonMessage>,
IRecipient<GetHwndMessage>,
IRecipient<ExpandCompactModeMessage>,
IDisposable,
IHostWindow
{
@@ -90,12 +89,19 @@ public sealed partial class MainWindow : WindowEx,
private int _sessionMaxNavigationDepth;
private int _sessionErrorCount;
private DesktopAcrylicController? _acrylicController;
private MicaController? _micaController;
private SystemBackdropConfiguration? _configurationSource;
private bool _isUpdatingBackdrop;
private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan;
// Tracks the chrome mode currently applied to the HWND. Nullable so the first
// call to ApplyHwndFrameMode always runs, regardless of which mode we land in.
private bool? _hwndFrameVisible;
// Thickness (in DIPs) of the resize grip around the visible card's border. Shared
// by the InputNonClientPointerSource region registration (so WM_NCHITTEST actually
// fires over the border) and the WM_NCHITTEST handler (so it returns resize codes
// over the same band). These MUST match or the two disagree about where resizing is.
private const int ResizeBorderThicknessDip = 8;
private WindowPosition _currentWindowPosition = new();
private bool _preventHideWhenDeactivated;
@@ -127,7 +133,13 @@ public sealed partial class MainWindow : WindowEx,
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
}
InitializeBackdropSupport();
// The HWND itself is borderless / transparent — the visible card lives inside
// RootElement (CmdPalMainControl) and draws its own corners, border, shadow, and
// backdrop via the SystemBackdropElement. The frame can be re-enabled via an
// internal-only setting (hot-reloaded through HotReloadSettings) to make the
// HWND bounds visible while debugging.
var initialSettings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
ApplyHwndFrameMode(ShouldShowHwndFrame(initialSettings));
_hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached);
@@ -164,6 +176,7 @@ public sealed partial class MainWindow : WindowEx,
WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this);
WeakReferenceMessenger.Default.Register<ToggleDevRibbonMessage>(this);
WeakReferenceMessenger.Default.Register<GetHwndMessage>(this);
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
// Hide our titlebar.
// We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed
@@ -232,16 +245,24 @@ public sealed partial class MainWindow : WindowEx,
// the DIP size won't trigger it, leaving drag regions at the old physical coordinates.
RootElement.XamlRoot.Changed += XamlRoot_Changed;
// Add dev ribbon if enabled
// The visible card resizes inside the fixed-size HWND (e.g. compact <-> expanded),
// which does not raise WindowSizeChanged. Recompute the drag regions and the HWND
// clip region whenever the card's own size changes so they keep tracking it.
RootElement.CardElement.SizeChanged += CardElement_SizeChanged;
// Add dev ribbon if enabled. The ribbon lives inside the visible card so it
// doesn't draw into the transparent shadow area outside the rounded border.
if (!BuildInfo.IsCiBuild)
{
_devRibbon = new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) };
RootElement.Children.Add(_devRibbon);
RootElement.CardContentPanel.Children.Add(_devRibbon);
}
}
private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
private void CardElement_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateRegionsForCustomTitleBar();
private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
private void PositionCentered()
@@ -275,10 +296,77 @@ public sealed partial class MainWindow : WindowEx,
if (rect is not null)
{
MoveAndResizeDpiAware(rect.Value);
var finalRect = rect.Value;
// In compact mode, center the *visible collapsed card* (the search box) on the
// display, not the much larger transparent HWND. The card is anchored to the top
// of the HWND, so we offset the HWND upward by the card's center so that growing
// the card downward (when results appear) keeps the search box where it was.
if (TryGetCompactCardCenterOffsetPhysical(windowDpi, out var cardCenterFromHwndTop))
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
var workArea = displayArea.WorkArea;
// The setting is the relative height measured from the *bottom* of the screen,
// so a larger percentage places the search box higher up the display.
var fractionFromTop = GetCompactCenterFractionFromTop(settings);
var desiredCardCenterY = workArea.Y + (int)Math.Round(workArea.Height * fractionFromTop);
finalRect.Y = desiredCardCenterY - cardCenterFromHwndTop;
if (finalRect.Y < workArea.Y)
{
finalRect.Y = workArea.Y;
}
}
MoveAndResizeDpiAware(finalRect);
}
}
/// <summary>
/// When the palette is in compact mode and is being centered on launch, computes the
/// distance (in physical pixels) from the top of the HWND to the vertical center of the
/// collapsed card, so the caller can position the HWND such that the card is centered.
/// Returns false when the card should not be re-centered (compact mode off, or a summon
/// behavior that restores the last position).
/// </summary>
private bool TryGetCompactCardCenterOffsetPhysical(int windowDpi, out int cardCenterFromHwndTop)
{
cardCenterFromHwndTop = 0;
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode || !IsCenteringSummon(settings))
{
return false;
}
// Make sure the card is actually collapsed before we measure it.
(RootElement.MainContent as ShellPage)?.EnsureCompactLayout();
var cardHeightDip = RootElement.GetCardHeight();
if (cardHeightDip <= 0)
{
return false;
}
var scale = windowDpi / 96.0;
var cardTopDip = RootElement.ShadowPadding.Top;
cardCenterFromHwndTop = (int)Math.Round((cardTopDip + (cardHeightDip / 2.0)) * scale);
return true;
}
// Every summon behavior except ToLast centers the window on its target display.
private static bool IsCenteringSummon(SettingsModel settings) => settings.SummonOn != MonitorBehavior.ToLast;
// Converts the "center height" setting (a percentage measured up from the bottom of the
// screen) into the fraction of the work area, measured from the top, at which the
// collapsed search box should be centered.
private static double GetCompactCenterFractionFromTop(SettingsModel settings)
{
var pct = Math.Clamp(settings.CompactCenterHeightPercentage, 0, 100);
return 1.0 - (pct / 100.0);
}
private void RestoreWindowPosition(WindowPosition? savedPosition)
{
if (savedPosition?.IsSizeValid != true)
@@ -380,17 +468,104 @@ public sealed partial class MainWindow : WindowEx,
_autoGoHomeInterval = settings.AutoGoHomeInterval;
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
ApplyHwndFrameMode(ShouldShowHwndFrame(settings));
// Start collapsed: the card shrinks to just the search box until there is a query.
HandleExpandCompactOnUiThread(false);
}
/// <summary>
/// Returns true if the user has opted in to seeing the OS-drawn HWND chrome (an internal
/// debugging setting). Always false in CI / release builds.
/// </summary>
private static bool ShouldShowHwndFrame(SettingsModel settings) =>
!BuildInfo.IsCiBuild && settings.ShowHwndFrame;
/// <summary>
/// Configures the HWND for the borderless / transparent main-window mode and (when
/// the internal debug toggle is enabled) overlays the OS-drawn chrome so the HWND's
/// real bounds are easy to spot. Hit testing is always handled by
/// <see cref="HitTestForCardResize"/> — the frame flag is purely visual.
/// </summary>
private void ApplyHwndFrameMode(bool showFrame)
{
if (_hwndFrameVisible == showFrame)
{
return;
}
_hwndFrameVisible = showFrame;
// The HWND itself never paints — the card draws the backdrop. Re-applying this
// each toggle is safe (it just reassigns SystemBackdrop) and guards against the
// OS replacing it when chrome changes.
InitializeBackdropSupport();
if (AppWindow.Presenter is OverlappedPresenter overlappedPresenter)
{
// When the debug flag is off we hide the OS chrome (no title bar, no border).
// When on we let the OS draw both so the HWND outline is obvious.
// This must actually be applied (not just relied on via WM_NCCALCSIZE): now
// that the HWND is clipped to the card region, the OS-drawn title bar / frame
// is no longer covered by our full-window transparent content, so DWM would
// otherwise repaint it (most visibly the inactive caption) behind the card
// when the window loses focus.
overlappedPresenter.SetBorderAndTitleBar(showFrame, showFrame);
// IsResizable must stay true so WS_THICKFRAME is present. The OS only honors
// resize-style WM_NCHITTEST results (HTLEFT, HTRIGHT, HT{TOP,BOTTOM}{,LEFT,RIGHT})
// when the window has a sizing frame, even though we drive the resize from a
// custom NCHITTEST handler. Setting it after SetBorderAndTitleBar makes sure a
// borderless window still keeps its sizing frame.
overlappedPresenter.IsResizable = true;
}
ApplyHwndBorderAttributes(showFrame);
// Drag regions are computed relative to the visible card; the chrome change can
// shift its on-screen position, so refresh.
UpdateRegionsForCustomTitleBar();
}
/// <summary>
/// Applies the DWM corner and border attributes for the current frame mode. This is
/// split out from <see cref="ApplyHwndFrameMode"/> because the DWM border color does
/// not reliably "take" when first set during window construction (before the HWND has
/// been shown on a cold process start) — leaving the faint OS outline visible until
/// the chrome is toggled. Re-applying it each time the window is shown guarantees the
/// borderless look on a cold start.
/// </summary>
private void ApplyHwndBorderAttributes(bool showFrame)
{
unsafe
{
// Rounded corners: let the OS pick when the debug frame is on, suppress
// otherwise so the card's CornerRadius isn't doubled by an OS rounding.
var corner = (uint)(showFrame
? DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT
: DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DONOTROUND);
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(uint));
// DWMWA_BORDER_COLOR: 0xFFFFFFFE = DWMWA_COLOR_NONE (no border drawn);
// 0xFFFFFFFF = DWMWA_COLOR_DEFAULT (system default). With WS_THICKFRAME still
// on, DWM otherwise draws a faint 1px outline around the HWND — which the
// user sees as the "frame still appears around the sides" even when our
// ShowHwndFrame setting is off. Setting COLOR_NONE removes it.
const uint DWMWA_COLOR_NONE = 0xFFFFFFFEu;
const uint DWMWA_COLOR_DEFAULT = 0xFFFFFFFFu;
var borderColor = showFrame ? DWMWA_COLOR_DEFAULT : DWMWA_COLOR_NONE;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_BORDER_COLOR, &borderColor, sizeof(uint));
}
}
private void InitializeBackdropSupport()
{
if (DesktopAcrylicController.IsSupported() || MicaController.IsSupported())
{
_configurationSource = new SystemBackdropConfiguration
{
IsInputActive = true,
};
}
// The window itself paints nothing (it's transparent). All actual backdrop
// rendering lives on the SystemBackdropElement inside CmdPalMainControl, so the
// mica/acrylic only fills the rounded card instead of the whole HWND. The empty
// tint here keeps the HWND fully transparent.
SystemBackdrop = new TransparentTintBackdrop { TintColor = Colors.Transparent };
}
private void UpdateBackdrop()
@@ -403,35 +578,14 @@ public sealed partial class MainWindow : WindowEx,
_isUpdatingBackdrop = true;
var backdrop = _themeService.Current.BackdropParameters;
var isImageMode = ViewModel.ShowBackgroundImage;
var config = BackdropStyles.Get(backdrop.Style);
try
{
switch (config.ControllerKind)
{
case BackdropControllerKind.Solid:
CleanupBackdropControllers();
var tintColor = Color.FromArgb(
(byte)(backdrop.EffectiveOpacity * 255),
backdrop.TintColor.R,
backdrop.TintColor.G,
backdrop.TintColor.B);
SetupTransparentBackdrop(tintColor);
break;
var backdrop = _themeService.Current.BackdropParameters;
var isImageMode = ViewModel.ShowBackgroundImage;
var config = BackdropStyles.Get(backdrop.Style);
var hasColorization = _themeService.Current.HasColorization;
case BackdropControllerKind.Mica:
case BackdropControllerKind.MicaAlt:
SetupMica(backdrop, isImageMode, config.ControllerKind);
break;
case BackdropControllerKind.Acrylic:
case BackdropControllerKind.AcrylicThin:
default:
SetupDesktopAcrylic(backdrop, isImageMode, config.ControllerKind);
break;
}
RootElement.ApplyBackdrop(backdrop, config.ControllerKind, isImageMode, hasColorization);
}
catch (Exception ex)
{
@@ -443,111 +597,6 @@ public sealed partial class MainWindow : WindowEx,
}
}
private void SetupTransparentBackdrop(Color tintColor)
{
if (SystemBackdrop is TransparentTintBackdrop existingBackdrop)
{
existingBackdrop.TintColor = tintColor;
}
else
{
SystemBackdrop = new TransparentTintBackdrop { TintColor = tintColor };
}
}
private void CleanupBackdropControllers()
{
if (_acrylicController is not null)
{
_acrylicController.RemoveAllSystemBackdropTargets();
_acrylicController.Dispose();
_acrylicController = null;
}
if (_micaController is not null)
{
_micaController.RemoveAllSystemBackdropTargets();
_micaController.Dispose();
_micaController = null;
}
}
private void SetupDesktopAcrylic(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
{
CleanupBackdropControllers();
// Fall back to solid color if acrylic not supported
if (_configurationSource is null || !DesktopAcrylicController.IsSupported())
{
SetupTransparentBackdrop(backdrop.FallbackColor);
return;
}
// DesktopAcrylicController and SystemBackdrop can't be active simultaneously
SystemBackdrop = null;
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
var effectiveTintOpacity = isImageMode
? 0.0f
: backdrop.EffectiveOpacity;
_acrylicController = new DesktopAcrylicController
{
Kind = kind == BackdropControllerKind.AcrylicThin
? DesktopAcrylicKind.Thin
: DesktopAcrylicKind.Default,
TintColor = backdrop.TintColor,
TintOpacity = effectiveTintOpacity,
FallbackColor = backdrop.FallbackColor,
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
};
// Requires "using WinRT;" for Window.As<>()
_acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
_acrylicController.SetSystemBackdropConfiguration(_configurationSource);
}
private void SetupMica(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
{
CleanupBackdropControllers();
// Fall back to solid color if Mica not supported
if (_configurationSource is null || !MicaController.IsSupported())
{
SetupTransparentBackdrop(backdrop.FallbackColor);
return;
}
// MicaController and SystemBackdrop can't be active simultaneously
SystemBackdrop = null;
_configurationSource.Theme = _themeService.Current.Theme == ElementTheme.Dark
? SystemBackdropTheme.Dark
: SystemBackdropTheme.Light;
var hasColorization = _themeService.Current.HasColorization || isImageMode;
_micaController = new MicaController
{
Kind = kind == BackdropControllerKind.MicaAlt
? MicaKind.BaseAlt
: MicaKind.Base,
};
// Only set tint properties when colorization is active
// Otherwise let system handle light/dark theme defaults automatically
if (hasColorization)
{
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
_micaController.TintColor = backdrop.TintColor;
_micaController.TintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
_micaController.FallbackColor = backdrop.FallbackColor;
_micaController.LuminosityOpacity = backdrop.EffectiveLuminosityOpacity;
}
_micaController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
_micaController.SetSystemBackdropConfiguration(_configurationSource);
}
private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target)
{
var positionWindowForTargetMonitor = (HWND hwnd) =>
@@ -654,15 +703,29 @@ public sealed partial class MainWindow : WindowEx,
// Just to be sure, SHOW our hwnd.
PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW);
// Re-apply the borderless DWM attributes now that the window is
// actually shown. On a cold launch these are first set during
// construction before the HWND has ever been displayed, and DWM doesn't
// reliably honor the border color until the window exists on-screen —
// which left the faint OS outline visible until the chrome was toggled.
ApplyHwndBorderAttributes(_hwndFrameVisible ?? false);
// Once we're done, uncloak to avoid all animations
Uncloak();
PInvoke.SetForegroundWindow(hwnd);
PInvoke.SetActiveWindow(hwnd);
// Push our window to the top of the Z-order and make it the topmost, so that it appears above all other windows.
// We want to remove the topmost status when we hide the window (because we cloak it instead of hiding it).
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
// Push our window to the top of the Z-order and make it the topmost, so
// that it appears above all other windows. We want to remove the
// topmost status when we hide the window (because we cloak it instead
// of hiding it).
//
// SWP_FRAMECHANGED is load-bearing for the borderless look on a cold
// start. Asking for SWP_FRAMECHANGED here re-sends WM_NCCALCSIZE and
// forces the NC repaint every time we show, so the frame is gone from
// the very first summon.
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED);
}
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
@@ -942,8 +1005,17 @@ public sealed partial class MainWindow : WindowEx,
private void DisposeAcrylic()
{
CleanupBackdropControllers();
_configurationSource = null!;
// The backdrop controllers now live on the SystemBackdropElement inside
// CmdPalMainControl. Clearing its SystemBackdrop fires OnTargetDisconnected on the
// current backdrop, which removes targets and disposes the underlying controller.
try
{
RootElement?.ClearBackdrop();
}
catch
{
// Best-effort cleanup; ignore errors during shutdown.
}
}
// Updates our window s.t. the top of the window is draggable.
@@ -958,31 +1030,134 @@ public sealed partial class MainWindow : WindowEx,
// Specify the interactive regions of the title bar.
var scaleAdjustment = xamlRoot.RasterizationScale;
// Get the rectangle around our XAML content. We're going to mark this
// rectangle as "Passthrough", so that the normal window operations
// (resizing, dragging) don't apply in this space.
var transform = RootElement.TransformToVisual(null);
// Reserve 16px of space at the top for dragging.
var topHeight = 16;
var bounds = transform.TransformBounds(new Rect(
0,
topHeight,
RootElement.ActualWidth,
RootElement.ActualHeight));
var contentRect = GetRect(bounds, scaleAdjustment);
var rectArray = new RectInt32[] { contentRect };
var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id);
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray);
// Add a drag-able region on top
var w = RootElement.ActualWidth;
_ = RootElement.ActualHeight;
var dragSides = new RectInt32[]
// Drag/passthrough regions are computed against the visible card (the rounded
// border inside CmdPalMainControl), not the whole HWND. The HWND extends beyond
// the card to make room for the drop shadow, and we don't want that transparent
// shadow area to be draggable.
var card = RootElement.CardElement;
if (card.ActualWidth <= 0 || card.ActualHeight <= 0)
{
GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall
return;
}
// All coordinates below are in the card's own (DIP) space: (0,0) is the
// top-left of the visible card, (w,h) is the bottom-right. GetRect transforms
// them into the physical-pixel client coordinates that
// InputNonClientPointerSource expects.
var transform = card.TransformToVisual(null);
var w = card.ActualWidth;
var h = card.ActualHeight;
RectInt32 CardRect(double x, double y, double rw, double rh) =>
GetRect(transform.TransformBounds(new Rect(x, y, rw, rh)), scaleAdjustment);
// Reserve some space at the top for dragging the window (caption).
const double dragHeight = 16;
// The resize grip straddles each card edge by `grip` DIPs on either side so the
// affordance reaches a little into the drop-shadow padding too.
const double grip = ResizeBorderThicknessDip;
var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id);
// Mark the card's border ring + top drag bar as Caption. Only regions
// registered here generate WM_NCHITTEST on our wndproc - without the
// side/bottom strips the XAML island swallows the pointer and we never
// get a resize. HotKeyPrc's WM_NCHITTEST then picks drag vs. resize
// per-pixel.
var caption = new RectInt32[]
{
CardRect(0, 0, w, dragHeight), // top drag bar
CardRect(-grip, -grip, 2 * grip, h + (2 * grip)), // left edge
CardRect(w - grip, -grip, 2 * grip, h + (2 * grip)), // right edge
CardRect(-grip, -grip, w + (2 * grip), 2 * grip), // top edge
CardRect(-grip, h - grip, w + (2 * grip), 2 * grip), // bottom edge
};
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, dragSides);
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, caption);
// Everything inside the border ring (and below the drag bar) is
// interactive content. Marking it Passthrough keeps the search box,
// list, etc. clickable and explicitly carves it out of the caption
// regions above.
var interiorWidth = Math.Max(0, w - (2 * grip));
var interiorHeight = Math.Max(0, h - dragHeight - grip);
var passthrough = new RectInt32[]
{
CardRect(grip, dragHeight, interiorWidth, interiorHeight),
};
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, passthrough);
// Clip the HWND to the card + its shadow. Card is inset by
// ShadowPadding on each side, so card + full padding lands flush with
// the HWND edge - and a region flush with the edge makes WS_THICKFRAME
// draw its border there. So inset 1px on every side to hide that.
// Bottom is clamped to 1px inside the HWND so the border doesn't
// reappear as the card grows tall.
var shadowPadding = RootElement.ShadowPadding;
var cardPhysical = CardRect(0, 0, w, h);
const int EdgeInsetPx = 1;
var windowWidthPx = AppWindow.Size.Width;
var windowHeightPx = AppWindow.Size.Height;
var clipLeft = EdgeInsetPx;
var clipTop = EdgeInsetPx;
var clipRight = windowWidthPx - EdgeInsetPx;
var bottomShadowPx = (int)Math.Round(shadowPadding.Bottom * scaleAdjustment);
var clipBottom = Math.Min(
cardPhysical.Y + cardPhysical.Height + bottomShadowPx,
windowHeightPx - EdgeInsetPx);
ApplyCardWindowRegion(new RectInt32(
clipLeft,
clipTop,
Math.Max(0, clipRight - clipLeft),
Math.Max(0, clipBottom - clipTop)));
}
/// <summary>
/// Restricts the HWND's visible / hit-testable area to the supplied
/// rectangle (in physical client pixels), which covers the visible card and
/// its drop-shadow margin. Everything outside — the empty transparent area
/// of the (larger) HWND — becomes click-through and is excluded from the
/// window region. When the debug HWND frame is enabled the clip is removed
/// so the full window stays visible.
/// </summary>
private void ApplyCardWindowRegion(RectInt32 regionPhysical)
{
nint hwnd;
unsafe
{
hwnd = (nint)_hwnd.Value;
}
// Debug frame mode: keep the whole window visible / interactive, no clip.
if (_hwndFrameVisible == true)
{
_ = SetWindowRgn(hwnd, IntPtr.Zero, true);
return;
}
// CreateRectRgn coordinates are relative to the window's top-left. For this
// borderless popup the client origin coincides with the window origin, so the
// region's client-space physical rect maps directly into window space.
var region = CreateRectRgn(
regionPhysical.X,
regionPhysical.Y,
regionPhysical.X + regionPhysical.Width,
regionPhysical.Y + regionPhysical.Height);
if (region == IntPtr.Zero)
{
return;
}
// On success SetWindowRgn takes ownership of the region (the OS frees it), so we
// only delete it ourselves if the call failed.
if (SetWindowRgn(hwnd, region, true) == 0)
{
_ = DeleteObject(region);
}
}
private static RectInt32 GetRect(Rect bounds, double scale)
@@ -994,6 +1169,19 @@ public sealed partial class MainWindow : WindowEx,
_Height: (int)Math.Round(bounds.Height * scale));
}
// Raw interop for the window-region clip. Declared here (rather than via CsWin32)
// because SetWindowRgn transfers ownership of the HRGN to the OS on success, which is
// awkward to express through CsWin32's SafeHandle-returning region creator.
[DllImport("user32.dll", SetLastError = true)]
private static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, [MarshalAs(UnmanagedType.Bool)] bool bRedraw);
[DllImport("gdi32.dll")]
private static extern IntPtr CreateRectRgn(int nLeftRect, int nTopRect, int nRightRect, int nBottomRect);
[DllImport("gdi32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeleteObject(IntPtr hObject);
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
{
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
@@ -1046,9 +1234,9 @@ public sealed partial class MainWindow : WindowEx,
PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus());
}
if (_configurationSource is not null)
if (RootElement is not null)
{
_configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated;
RootElement.SetIsInputActive(args.WindowActivationState != WindowActivationState.Deactivated);
}
}
@@ -1336,6 +1524,29 @@ public sealed partial class MainWindow : WindowEx,
case PInvoke.WM_DPICHANGED when _suppressDpiChange:
return (LRESULT)IntPtr.Zero;
case PInvoke.WM_NCHITTEST:
{
var ht = HitTestForCardResize(lParam);
if (ht != 0)
{
return (LRESULT)(nint)ht;
}
break;
}
// Borderless mode: claim the entire window rectangle as client area.
// A resizable window has WS_THICKFRAME, which makes the OS reserve a
// non-client sizing frame *and* gives the window a DWM drop shadow / a thin
// frame line along the top. We keep WS_THICKFRAME (so our custom WM_NCHITTEST
// can still drive resizing) but tell the OS the whole window is client by
// returning 0 from WM_NCCALCSIZE — which removes that frame and its shadow.
// The visible card draws its own border + shadow inside the transparent HWND.
// When the debug frame is on we fall through to the default handling so the
// real OS chrome appears.
case PInvoke.WM_NCCALCSIZE when wParam.Value != 0 && _hwndFrameVisible != true:
return (LRESULT)0;
case PInvoke.WM_HOTKEY:
{
var hotkeyIndex = (int)wParam.Value;
@@ -1360,6 +1571,116 @@ public sealed partial class MainWindow : WindowEx,
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
/// <summary>
/// Custom WM_NCHITTEST handler that turns the visible card's border (the rounded
/// stroke drawn by <see cref="CmdPalMainControl"/>) into the window's resize handles.
/// Without this the borderless / transparent HWND has no visible resize affordance,
/// even though the OS still allows resizing along the (invisible) HWND edges.
/// </summary>
/// <returns>
/// A non-zero HT* value to override the system hit test, or 0 to fall through to
/// the default WndProc (which lets the InputNonClientPointerSource Caption /
/// Passthrough regions decide caption vs. client behavior inside the card).
/// </returns>
private uint HitTestForCardResize(LPARAM lParam)
{
// NB: We intentionally do *not* short-circuit when the debug frame is showing.
// The HWND frame toggle is purely a visual diagnostic; resize hit-testing
// remains ours in both modes so the card's border is always the grab area.
if (RootElement is null || RootElement.XamlRoot is null)
{
return 0;
}
// LPARAM packs the screen-space pointer position: low word = x, high word = y,
// both as signed 16-bit ints.
var ptX = (short)(lParam.Value & 0xFFFF);
var ptY = (short)((lParam.Value >> 16) & 0xFFFF);
if (!PInvoke.GetWindowRect(_hwnd, out var windowRect))
{
return 0;
}
// Convert the card's ShadowPadding (DIPs) into screen pixels so we can locate
// the visible card rect within the (larger, transparent) HWND.
var dpi = PInvoke.GetDpiForWindow(_hwnd);
var scale = dpi / 96.0;
var padding = RootElement.ShadowPadding;
var cardLeft = windowRect.left + (int)Math.Round(padding.Left * scale);
var cardTop = windowRect.top + (int)Math.Round(padding.Top * scale);
var cardRight = windowRect.right - (int)Math.Round(padding.Right * scale);
var cardBottom = windowRect.bottom - (int)Math.Round(padding.Bottom * scale);
// Width of the resize grip around the card's visible border, in screen pixels.
// Shared with the InputNonClientPointerSource region registration in
// UpdateRegionsForCustomTitleBar so the band where WM_NCHITTEST fires lines up
// exactly with the band where we return resize codes.
var grip = (int)Math.Round(ResizeBorderThicknessDip * scale);
var onLeftEdge = ptX >= cardLeft - grip && ptX < cardLeft + grip;
var onRightEdge = ptX > cardRight - grip && ptX <= cardRight + grip;
var onTopEdge = ptY >= cardTop - grip && ptY < cardTop + grip;
var onBottomEdge = ptY > cardBottom - grip && ptY <= cardBottom + grip;
// Corners get priority over edges.
if (onTopEdge && onLeftEdge)
{
return PInvoke.HTTOPLEFT;
}
if (onTopEdge && onRightEdge)
{
return PInvoke.HTTOPRIGHT;
}
if (onBottomEdge && onLeftEdge)
{
return PInvoke.HTBOTTOMLEFT;
}
if (onBottomEdge && onRightEdge)
{
return PInvoke.HTBOTTOMRIGHT;
}
var withinHorizontalSpan = ptX >= cardLeft - grip && ptX <= cardRight + grip;
var withinVerticalSpan = ptY >= cardTop - grip && ptY <= cardBottom + grip;
if (onTopEdge && withinHorizontalSpan)
{
return PInvoke.HTTOP;
}
if (onBottomEdge && withinHorizontalSpan)
{
return PInvoke.HTBOTTOM;
}
if (onLeftEdge && withinVerticalSpan)
{
return PInvoke.HTLEFT;
}
if (onRightEdge && withinVerticalSpan)
{
return PInvoke.HTRIGHT;
}
// Pointer is inside the card but away from the border: defer to the default
// hit test so the InputNonClientPointerSource Caption/Passthrough regions take
// effect for dragging vs. normal input.
if (ptX >= cardLeft && ptX <= cardRight && ptY >= cardTop && ptY <= cardBottom)
{
return 0;
}
// Pointer is in the transparent shadow padding around the card. Make that area
// click-through so the window behind us receives the mouse input.
return unchecked((uint)PInvoke.HTTRANSPARENT);
}
public void Dispose()
{
_localKeyboardListener.Dispose();
@@ -1418,4 +1739,51 @@ public sealed partial class MainWindow : WindowEx,
{
message.Hwnd = this.GetWindowHandle();
}
public void Receive(ExpandCompactModeMessage message)
{
this.DispatcherQueue.TryEnqueue(() => HandleExpandCompactOnUiThread(message.Expanded));
}
// The HWND is already as large as it will ever need to be (and it's transparent), so
// instead of resizing the window we simply shrink or grow the visible card inside it.
private void HandleExpandCompactOnUiThread(bool expanded)
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
if (expanded && settings.CompactMode && IsCenteringSummon(settings))
{
RootElement.SetCardMaxHeight(ComputeExpandedCardMaxHeightDip());
}
else
{
RootElement.SetCardMaxHeight(double.PositiveInfinity);
}
}
// Computes how tall (in DIPs) the visible card may grow before it would extend past the
// bottom of the work area, given the card's current top on screen.
private double ComputeExpandedCardMaxHeightDip()
{
var dpi = (int)this.GetDpiForWindow();
var scale = dpi / 96.0;
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest);
var workArea = displayArea.WorkArea;
var padding = RootElement.ShadowPadding;
var cardTopPhysical = AppWindow.Position.Y + (padding.Top * scale);
var availablePhysical = (workArea.Y + workArea.Height) - cardTopPhysical - (padding.Bottom * scale);
if (availablePhysical <= 0)
{
return double.PositiveInfinity;
}
return availablePhysical / scale;
}
}

View File

@@ -5,6 +5,9 @@ GetForegroundWindow
SetForegroundWindow
GetWindowRect
GetCursorPos
WindowFromPoint
GetAncestor
GET_ANCESTOR_FLAGS
SetWindowPos
HWND_TOPMOST
HWND_BOTTOM
@@ -24,6 +27,8 @@ GetMonitorInfo
GetDpiForMonitor
WM_HOTKEY
WM_NCLBUTTONDBLCLK
SetWindowRgn
CreateRectRgn
Shell_NotifyIcon
LoadIcon
@@ -70,10 +75,13 @@ MoveWindow
GetSystemMetrics
SHAppBarMessage
ABM_NEW
ABM_GETSTATE
ABM_GETTASKBARPOS
ABM_QUERYPOS
ABM_SETPOS
ABM_REMOVE
ABM_SETAUTOHIDEBAR
ABM_SETAUTOHIDEBAREX
ABS_AUTOHIDE
ABN_POSCHANGED
ABN_FULLSCREENAPP
@@ -86,7 +94,16 @@ SYSTEM_METRICS_INDEX
GetDpiForWindow
SHQueryUserNotificationState
SYSTEM_PARAMETERS_INFO_ACTION
SystemParametersInfo
WINDOWPOS
WM_MOUSEMOVE
WM_MOUSELEAVE
TrackMouseEvent
TRACKMOUSEEVENT
TRACKMOUSEEVENT_FLAGS
WM_ACTIVATE
WM_ACTIVATEAPP
WA_INACTIVE
WM_DISPLAYCHANGE
WM_SYSCOMMAND
WM_SETTINGCHANGE
@@ -105,6 +122,19 @@ SIZE_MINIMIZED
HWND_NOTOPMOST
HWND_TOP
HTCAPTION
HTCLIENT
HTTRANSPARENT
HTNOWHERE
HTLEFT
HTRIGHT
HTTOP
HTBOTTOM
HTTOPLEFT
HTTOPRIGHT
HTBOTTOMLEFT
HTBOTTOMRIGHT
WM_NCHITTEST
WM_NCCALCSIZE
GetClassName
EVENT_SYSTEM_FOREGROUND
WINEVENT_OUTOFCONTEXT

View File

@@ -28,6 +28,7 @@
<cmdpalUI:DetailsSizeToGridLengthConverter x:Key="SizeToWidthConverter" />
<cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" />
<cmdpalUI:BoolToStarOrAutoGridLengthConverter x:Key="ExpandedModeToRowHeightConverter" />
<cmdpalUI:DetailsDataTemplateSelector
x:Key="DetailsDataTemplateSelector"
@@ -183,15 +184,19 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<!--
In compact mode this row collapses to Auto so the card can shrink to just the
search box. A star row would otherwise reserve space during measure even when
its only child (the collapsed content) is hidden.
-->
<RowDefinition Height="{x:Bind ExpandedMode, Mode=OneWay, Converter={StaticResource ExpandedModeToRowHeightConverter}}" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Back button and search box -->
@@ -383,6 +388,18 @@
</animations:Implicit.HideAnimations>
</ProgressBar>
</Grid>
<Grid
Grid.Row="1"
Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"
Visibility="{x:Bind ExpandedMode, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid x:Name="ContentGrid" Grid.Row="1">
<Grid.ColumnDefinitions>
@@ -516,12 +533,13 @@
See https://github.com/microsoft/microsoft-ui-xaml/issues/5741
-->
<StackPanel
Grid.Row="0"
Grid.Row="1"
Margin="16,8,16,8"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
CornerRadius="{ThemeResource ControlCornerRadius}">
CornerRadius="{ThemeResource ControlCornerRadius}"
Visibility="Collapsed">
<InfoBar
CornerRadius="{ThemeResource ControlCornerRadius}"
IsOpen="{x:Bind ViewModel.CurrentPage.HasStatusMessage, Mode=OneWay}"
@@ -540,10 +558,11 @@
</StackPanel>
<Grid
Grid.Row="1"
Grid.Row="2"
Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}"
BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}"
BorderThickness="0,1,0,0">
BorderThickness="0,1,0,0"
Visibility="{x:Bind ExpandedMode, Mode=OneWay}">
<cpcontrols:CommandBar CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
</Grid>

View File

@@ -50,6 +50,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateToPageMessage>,
IRecipient<ShowHideDockMessage>,
IRecipient<ShowPinToDockDialogMessage>,
IRecipient<ExpandCompactModeMessage>,
INotifyPropertyChanged,
IDisposable
{
@@ -71,6 +72,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private CancellationTokenSource? _focusAfterLoadedCts;
private WeakReference<Page>? _lastNavigatedPageRef;
// When the shell goes from compact (collapsed) to expanded, the content frame's page
// — which was collapsed and therefore never laid out — finally fires its Loaded event.
// That late Loaded would otherwise run the post-navigation focus/select logic and
// select-all the character the user just typed (which triggered the expand). This
// one-shot flag suppresses that select for the expand-driven load.
private bool _suppressSelectOnNextLoad;
private bool _isDisposed;
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
@@ -79,8 +87,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public IHostWindow? HostWindow { get; set; }
public bool ExpandedMode { get; set; }
public ShellPage()
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
this.ExpandedMode = !settings.CompactMode;
this.InitializeComponent();
// how we are doing navigation around
@@ -104,6 +117,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ShowHideDockMessage>(this);
WeakReferenceMessenger.Default.Register<ShowPinToDockDialogMessage>(this);
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
@@ -482,6 +497,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
// When re-showing the palette, the previous session's query may still be present
// (e.g. after a light dismiss with HighlightSearchOnActivate). Recompute the
// compact/expanded state so a retained query restores the expanded results instead
// of being stuck in the collapsed search-only layout.
UpdateCompactModeForCurrentPage();
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
@@ -593,6 +614,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
{
// A real navigation always loads a fresh page that we do want to focus/select, so
// clear any stale suppression left over from a prior compact expand. (If this
// navigation itself expands compact mode, UpdateCompactModeForCurrentPage below
// will re-arm the flag for the page that's about to load.)
_suppressSelectOnNextLoad = false;
// This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter.
// This is currently used for both forward and backward navigation.
// As when we go back that we restore ourselves to the proper state within our VM
@@ -629,6 +656,42 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_lastNavigatedPageRef = new WeakReference<Page>(element);
element.Loaded += FocusAfterLoaded;
}
UpdateCompactModeForCurrentPage();
}
/// <summary>
/// Updates the compact/expanded state after a navigation. On any nested (sub) page we
/// always show the full expanded UI; on the root page the search box drives the state,
/// so we collapse to the compact search box only when the query is empty. Driving this
/// from navigation (rather than only from search-text changes) makes alias-based
/// navigation expand correctly — an alias clears the search box before navigating, so
/// the search-text transition alone would otherwise leave the palette collapsed.
/// Transient pages always show the expanded UI, ignoring the compact setting entirely.
/// </summary>
private void UpdateCompactModeForCurrentPage()
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
{
return;
}
// Transient pages ignore compact mode and always present as expanded.
if (ViewModel.IsTransient)
{
HandleExpandCompactOnUiThread(true);
return;
}
// The ShellViewModel's IsNested flag is only updated on forward navigation and is
// never cleared when navigating back to the root page. Gate it on the current
// page's own root-ness so a stale IsNested can't keep the home page expanded after
// returning to it (e.g. after following a 1-character alias and going back).
var isRootPage = ViewModel.CurrentPage?.IsRootPage ?? false;
var nested = ViewModel.IsNested && !isRootPage;
var hasQuery = !string.IsNullOrEmpty(ViewModel.CurrentPage?.SearchTextBox);
HandleExpandCompactOnUiThread(nested || hasQuery);
}
private void FocusAfterLoaded(object sender, RoutedEventArgs e)
@@ -661,6 +724,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
return;
}
// This Loaded can fire late when expanding out of compact mode (the page was
// collapsed and never laid out). In that case the user is mid-typing in the
// already-focused search box, so don't steal focus / select-all their input.
if (_suppressSelectOnNextLoad)
{
_suppressSelectOnNextLoad = false;
return;
}
SearchBox.Focus(FocusState.Programmatic);
SearchBox.SelectSearch();
}
@@ -854,6 +926,50 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
public void Receive(ExpandCompactModeMessage message)
{
// Re-evaluate from the current authoritative page state rather than applying the
// message's snapshot directly. The message can race with navigation: following a
// 1-character alias clears the home search (sending a "collapse") right as we
// navigate to a nested page that must stay expanded. Recomputing here keeps the
// final state consistent regardless of message/navigation ordering.
this.DispatcherQueue.TryEnqueue(UpdateCompactModeForCurrentPage);
}
private void HandleExpandCompactOnUiThread(bool expanded)
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
var newExpanded = settings.CompactMode ? expanded : true;
// Going from collapsed to expanded realizes the (previously collapsed) content
// page for the first time, which fires its deferred Loaded event. Suppress the
// resulting focus/select so we don't select-all the character the user just typed.
if (!this.ExpandedMode && newExpanded)
{
_suppressSelectOnNextLoad = true;
}
this.ExpandedMode = newExpanded;
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
}
/// <summary>
/// Forces the shell into its compact (collapsed) layout and flushes layout so the host can
/// read the resulting card height. Only has an effect when compact mode is enabled.
/// </summary>
public void EnsureCompactLayout()
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
{
return;
}
this.ExpandedMode = false;
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
this.UpdateLayout();
}
public void Dispose()
{
if (_isDisposed)

View File

@@ -249,6 +249,16 @@
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_DockSettingsPage_AlwaysOnTop" IsOn="{x:Bind ViewModel.Dock_AlwaysOnTop, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard x:Uid="DockBehavior_AutoHide_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE70A;}">
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_DockSettingsPage_AutoHide" IsOn="{x:Bind ViewModel.Dock_AutoHide, Mode=TwoWay}" />
</controls:SettingsCard>
<InfoBar
x:Uid="DockBehavior_AutoHideConflict_InfoBar"
IsClosable="True"
IsOpen="{x:Bind ViewModel.Dock_AutoHideConflict, Mode=OneWay}"
Severity="Warning" />
<!-- Monitors Section -->
<TextBlock x:Uid="DockMonitors_Header" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

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

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.Settings.GeneralPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -119,6 +119,29 @@
</ComboBox>
</controls:SettingsCard>
<controls:SettingsExpander x:Uid="Settings_GeneralPage_CompactMode_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE73F;}">
<ToggleSwitch AutomationProperties.AutomationId="CmdPal_GeneralPage_CompactMode" IsOn="{x:Bind viewModel.CompactMode, Mode=TwoWay}" />
<controls:SettingsExpander.Items>
<controls:SettingsCard
x:Uid="Settings_GeneralPage_CompactCenterHeight_SettingsCard"
HeaderIcon="{ui:FontIcon Glyph=&#xE74A;}"
IsEnabled="{x:Bind viewModel.CompactMode, Mode=OneWay}">
<Slider
Width="100"
Height="100"
MinWidth="{StaticResource SettingActionControlMinWidth}"
AutomationProperties.AutomationId="CmdPal_GeneralPage_CompactCenterHeight"
Maximum="100"
Minimum="0"
Orientation="Vertical"
StepFrequency="5"
TickFrequency="25"
TickPlacement="Outside"
Value="{x:Bind viewModel.CompactCenterHeightPercentage, Mode=TwoWay}" />
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<!-- 'Behavior' section -->
<TextBlock x:Uid="BehaviorSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

View File

@@ -82,6 +82,17 @@
Click="ToggleDevRibbonClicked"
Content="Toggle dev ribbon" />
</controls:SettingsCard>
<controls:SettingsCard
x:Name="ShowHwndFrameSettingsCard"
Description="Shows the OS-drawn title bar, border, and rounded corners on the Command Palette's HWND so its actual bounds are visible. Always off in CI / release builds."
Header="Show HWND frame"
HeaderIcon="{ui:FontIcon Glyph=&#xE737;}">
<ToggleSwitch
x:Name="ShowHwndFrameToggle"
AutomationProperties.AutomationId="CmdPal_InternalPage_ShowHwndFrame"
IsOn="{x:Bind ShowHwndFrame, Mode=OneTime}"
Toggled="ShowHwndFrameToggle_Toggled" />
</controls:SettingsCard>
<!-- Gallery Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Extension Gallery" />

View File

@@ -26,6 +26,8 @@ public sealed partial class InternalPage : Page
public string GalleryFeedUrl => _settingsService.Settings.GalleryFeedUrl ?? string.Empty;
public bool ShowHwndFrame => _settingsService.Settings.ShowHwndFrame;
public InternalPage()
{
InitializeComponent();
@@ -120,4 +122,16 @@ public sealed partial class InternalPage : Page
{
WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage());
}
private void ShowHwndFrameToggle_Toggled(object sender, RoutedEventArgs e)
{
if (sender is ToggleSwitch toggle)
{
var newValue = toggle.IsOn;
if (newValue != _settingsService.Settings.ShowHwndFrame)
{
_settingsService.UpdateSettings(s => s with { ShowHwndFrame = newValue });
}
}
}
}

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

@@ -380,6 +380,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_GeneralPage_HighlightSearch_SettingsCard.Description" xml:space="preserve">
<value>Selects the previous search text at launch</value>
</data>
<data name="Settings_GeneralPage_CompactMode_SettingsCard.Header" xml:space="preserve">
<value>Compact mode</value>
</data>
<data name="Settings_GeneralPage_CompactMode_SettingsCard.Description" xml:space="preserve">
<value>Shrinks the palette to just the search box until you start typing</value>
</data>
<data name="Settings_GeneralPage_CompactCenterHeight_SettingsCard.Header" xml:space="preserve">
<value>Search box position</value>
</data>
<data name="Settings_GeneralPage_CompactCenterHeight_SettingsCard.Description" xml:space="preserve">
<value>Relative height from the bottom of the screen where the collapsed search box is centered. Only applies in compact mode.</value>
</data>
<data name="Settings_GeneralPage_KeepPreviousQuery_SettingsCard.Header" xml:space="preserve">
<value>Keep previous query</value>
</data>
@@ -476,6 +488,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="DockBehavior_AlwaysOnTop_SettingsCard.Description" xml:space="preserve">
<value>Keep the dock above other windows, except while an app is fullscreen</value>
</data>
<data name="DockBehavior_AutoHide_SettingsCard.Header" xml:space="preserve">
<value>Auto-hide dock</value>
</data>
<data name="DockBehavior_AutoHide_SettingsCard.Description" xml:space="preserve">
<value>Collapse the dock until you hover over its screen edge</value>
</data>
<data name="DockBehavior_AutoHideConflict_InfoBar.Title" xml:space="preserve">
<value>Auto-hide unavailable</value>
</data>
<data name="DockBehavior_AutoHideConflict_InfoBar.Message" xml:space="preserve">
<value>The taskbar or another application is using auto-hide on this edge. The dock is using pinned mode instead.</value>
</data>
<data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Back</value>
</data>
@@ -535,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

@@ -324,6 +324,53 @@ public class DockMultiMonitorTests
Assert.AreEqual("c1", deserialized.StartBands![0].CommandId);
}
[TestMethod]
public void DockSettings_AutoHide_DefaultsToFalse()
{
var settings = CreateMinimalDockSettings();
Assert.IsFalse(settings.AutoHide);
}
[TestMethod]
public void DockSettings_AutoHide_JsonRoundTrip_PreservesValue()
{
var settings = CreateMinimalDockSettings() with
{
AutoHide = true,
MonitorConfigs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
};
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.DockSettings);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings);
Assert.IsNotNull(deserialized);
Assert.IsTrue(deserialized!.AutoHide);
Assert.AreEqual(2, deserialized.MonitorConfigs.Count);
}
[TestMethod]
public void DockSettings_WithUpdatedMonitorConfigs_PreservesAutoHide()
{
var settings = CreateMinimalDockSettings() with
{
AutoHide = true,
MonitorConfigs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
};
var updated = settings with
{
MonitorConfigs = settings.MonitorConfigs.SetItem(1, settings.MonitorConfigs[1] with { Enabled = true }),
};
Assert.IsTrue(updated.AutoHide);
Assert.IsTrue(updated.MonitorConfigs[1].Enabled);
}
[TestMethod]
public void DockSettings_MonitorConfigs_JsonRoundTrip()
{
@@ -719,7 +766,7 @@ public class DockMultiMonitorTests
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Phase 1.5 should detect GDI-style names and rewrite to stable IDs
// Legacy migration should detect GDI-style names and rewrite to stable IDs
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId, "Primary should be migrated to stable ID");
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId, "Secondary should be migrated to stable ID");

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

@@ -126,4 +126,28 @@ public class BasicTests : CommandPaletteTestBase
Assert.IsNotNull(this.Find<NavigationViewItem>("Put computer to sleep"));
}
[TestMethod]
public void DockSettingsAutoHideToggleTest()
{
OpenSettingsWindow();
NavigateToDockSettings();
var autoHideToggle = FindDockAutoHideToggle();
Assert.IsNotNull(autoHideToggle);
var initialState = autoHideToggle.IsOn;
autoHideToggle.Toggle(!initialState);
Assert.AreEqual(!initialState, autoHideToggle.IsOn);
this.Find<NavigationViewItem>("General").Click();
NavigateToDockSettings();
autoHideToggle = FindDockAutoHideToggle();
Assert.IsNotNull(autoHideToggle);
Assert.AreEqual(!initialState, autoHideToggle.IsOn);
autoHideToggle.Toggle(initialState);
Assert.AreEqual(initialState, autoHideToggle.IsOn);
}
}

View File

@@ -27,6 +27,18 @@ public class CommandPaletteTestBase : UITestBase
protected void SetTimeAndDaterExtensionSearchBox(string text) => SetSearchBoxText(text);
protected void OpenSettingsWindow()
{
this.Find<Button>(By.AccessibilityId("SettingsIconButton")).Click();
}
protected void NavigateToDockSettings()
{
this.Find<NavigationViewItem>("Dock (Preview)").Click();
}
protected ToggleSwitch FindDockAutoHideToggle() => this.Find<ToggleSwitch>(By.AccessibilityId("CmdPal_DockSettingsPage_AutoHide"));
private void SetSearchBoxText(string text)
{
Assert.AreEqual(this.Find<TextBox>(By.AccessibilityId("MainSearchBox")).SetText(text, true).Text, text);

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

@@ -122,6 +122,21 @@ namespace KeyboardEventHandlers
key_count = std::get<Shortcut>(it->second).Size();
}
const DWORD sourceKey = data->lParam->vkCode;
const bool isKeyUp = (data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP);
// If the matching key-down injection was blocked earlier, we passed the
// original key-down through to the foreground app to keep the key alive.
// The corresponding key-up must be passed through as well; otherwise the
// physical key is stranded DOWN (its down reached the app, but its up would
// be swallowed by the remap). Key-down and key-up arrive as separate hook
// events, so this is the cross-invocation counterpart of the key-down
// passthrough handled below.
if (isKeyUp && state.ConsumeSingleKeyRemapInjectionFailed(sourceKey))
{
return 0;
}
std::vector<INPUT> keyEventList;
// Handle remaps to VK_WIN_BOTH
@@ -177,7 +192,25 @@ namespace KeyboardEventHandlers
}
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
// Injection was blocked (e.g. by UIPI). Return 0 so the ORIGINAL key is
// passed through instead of being swallowed, leaving no dead key. For a
// key-down, remember that we passed it through so the matching key-up is
// passed through too (handled above), preventing a key stranded DOWN.
if (!isKeyUp)
{
state.SetSingleKeyRemapInjectionFailed(sourceKey, true);
}
return 0;
}
// Injection succeeded; drop any stale passthrough marker for this key so its
// key-up follows the normal (suppressed) path.
if (!isKeyUp)
{
state.SetSingleKeyRemapInjectionFailed(sourceKey, false);
}
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
{
@@ -552,9 +585,12 @@ namespace KeyboardEventHandlers
// Send modifier release events first, then send text directly
// (SendTextInput handles multiline by flushing between chunks)
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
keyEventList.clear();
Helpers::SendTextInput(remapping);
Helpers::SendTextInput(remapping, ii);
}
it->second.isShortcutInvoked = true;
@@ -566,7 +602,10 @@ namespace KeyboardEventHandlers
Logger::trace(L"ChordKeyboardHandler:keyEventList.size:{}", keyEventList.size());
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
if (activatedApp.has_value())
{
if (remapToKey)
@@ -705,7 +744,10 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
return 1;
}
@@ -735,12 +777,14 @@ namespace KeyboardEventHandlers
else if (remapToText)
{
auto& remapping = std::get<std::wstring>(it->second.targetShortcut);
ii.SendVirtualInput(keyEventList);
Helpers::SendTextInput(remapping);
Helpers::SendTextInput(remapping, ii);
return 1;
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
return 1;
}
@@ -827,7 +871,10 @@ namespace KeyboardEventHandlers
}
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
return 1;
}
@@ -952,7 +999,10 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
return 1;
}
else
@@ -1021,7 +1071,10 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
ii.SendVirtualInput(keyEventList);
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
return 1;
}
else
@@ -1799,8 +1852,9 @@ namespace KeyboardEventHandlers
return 0;
}
// Only send the text on keydown event
if (data->wParam != WM_KEYDOWN)
// Only send the text on key-down events. WM_SYSKEYDOWN is sent instead of
// WM_KEYDOWN while Alt is held, so accept it too or the remap silently drops.
if (data->wParam != WM_KEYDOWN && data->wParam != WM_SYSKEYDOWN)
{
return 0;
}
@@ -1811,7 +1865,43 @@ namespace KeyboardEventHandlers
return 0;
}
Helpers::SendTextInput(*remapping);
// Release held modifiers before text injection to prevent Ctrl+text corruption
constexpr int modifierKeys[] = { VK_LCONTROL, VK_RCONTROL, VK_LSHIFT, VK_RSHIFT, VK_LMENU, VK_RMENU, VK_LWIN, VK_RWIN };
std::vector<INPUT> releaseEvents;
// A dummy key event must precede the modifier releases so that releasing a
// held Win (Start Menu) or Alt (menu bar) does not trigger its lone-press
// action when we inject the modifier key-up.
Helpers::SetDummyKeyEvent(releaseEvents, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
bool anyModifierHeld = false;
for (int vk : modifierKeys)
{
if (ii.GetVirtualKeyState(vk))
{
Helpers::SetKeyEvent(releaseEvents, INPUT_KEYBOARD, static_cast<WORD>(vk), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
anyModifierHeld = true;
}
}
// Only inject the dummy + modifier releases when a modifier was actually held.
if (anyModifierHeld)
{
if (!ii.SendVirtualInput(releaseEvents))
{
return 0;
}
}
Helpers::SendTextInput(*remapping, ii);
// Intentionally do NOT re-press the released modifiers. Once we inject a
// KEYUP for a modifier, GetAsyncKeyState (and therefore GetVirtualKeyState)
// reports it as up, so there is no reliable way to tell whether the user is
// still physically holding the key or has released it. Re-pressing
// unconditionally would risk leaving a modifier stuck down if the user let
// go during injection — the exact failure this change set prevents. Leaving
// the modifier released is always safe: the user taps it again to re-engage.
return 1;
}

View File

@@ -73,3 +73,20 @@ std::wstring State::GetActivatedApp()
{
return activatedAppSpecificShortcutTarget;
}
void State::SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed)
{
if (failed)
{
singleKeyRemapInjectionFailedKeys.insert(sourceKey);
}
else
{
singleKeyRemapInjectionFailedKeys.erase(sourceKey);
}
}
bool State::ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey)
{
return singleKeyRemapInjectionFailedKeys.erase(sourceKey) > 0;
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <keyboardmanager/common/MappingConfiguration.h>
#include <unordered_set>
class State : public MappingConfiguration
{
@@ -7,6 +8,12 @@ private:
// Stores the activated target application in app-specific shortcut
std::wstring activatedAppSpecificShortcutTarget;
// Source keys whose single-key remap key-down injection was blocked, so the original
// key-down was passed through to the foreground app. The matching key-up must be
// passed through too; otherwise the physical key is stranded DOWN. Only accessed from
// the (serialized) low-level keyboard hook thread.
std::unordered_set<DWORD> singleKeyRemapInjectionFailedKeys;
public:
// Function to get the iterator of a single key remap given the source key. Returns nullopt if it isn't remapped
std::optional<SingleKeyRemapTable::iterator> GetSingleKeyRemap(const DWORD& originalKey);
@@ -26,4 +33,14 @@ public:
// Gets the activated target application in app-specific shortcut
std::wstring GetActivatedApp();
// Records (failed == true) or clears (failed == false) that the single-key remap
// key-down injection for sourceKey was blocked and the original key-down was passed
// through to the foreground app.
void SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed);
// Returns true and clears the marker if sourceKey's single-key remap key-down
// injection was previously blocked, indicating that its key-up should be passed
// through as well.
bool ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey);
};

View File

@@ -10,8 +10,14 @@ void MockedInput::SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> ho
}
// Function to simulate keyboard input - arguments and return value based on SendInput function (https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-sendinput)
void MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
bool MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
{
// Simulate an injection failure (e.g. SendInput blocked) when configured.
if (sendVirtualInputShouldFail != nullptr && sendVirtualInputShouldFail(inputs))
{
return false;
}
// Iterate over inputs
for (const INPUT& input : inputs)
{
@@ -107,6 +113,7 @@ void MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
}
}
}
return true;
}
// Function to simulate keyboard hook behavior
@@ -129,6 +136,12 @@ bool MockedInput::GetVirtualKeyState(int key)
return keyboardState[key];
}
// Function to set the state of a particular key for test setup
void MockedInput::SetKeyboardState(int key, bool state)
{
keyboardState[key] = state;
}
// Function to reset the mocked keyboard state
void MockedInput::ResetKeyboardState()
{
@@ -142,6 +155,12 @@ void MockedInput::SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyb
sendVirtualInputCallCondition = condition;
}
// Function to force SendVirtualInput to fail for calls matching a predicate
void MockedInput::SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition)
{
sendVirtualInputShouldFail = condition;
}
// Function to get SendVirtualInput call count
int MockedInput::GetSendVirtualInputCallCount()
{

View File

@@ -22,6 +22,10 @@ namespace KeyboardManagerInput
int sendVirtualInputCallCount = 0;
std::function<bool(LowlevelKeyboardEvent*)> sendVirtualInputCallCondition;
// Optional predicate; when set and it returns true for a SendVirtualInput
// call, that call fails (returns false) to simulate a SendInput failure.
std::function<bool(const std::vector<INPUT>&)> sendVirtualInputShouldFail;
std::wstring currentProcess;
public:
@@ -34,7 +38,7 @@ namespace KeyboardManagerInput
void SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> hookProcedure);
// Function to simulate keyboard input
void SendVirtualInput(const std::vector<INPUT>& inputs);
bool SendVirtualInput(const std::vector<INPUT>& inputs);
// Function to simulate keyboard hook behavior
intptr_t MockedKeyboardHook(LowlevelKeyboardEvent* data);
@@ -42,12 +46,18 @@ namespace KeyboardManagerInput
// Function to get the state of a particular key
bool GetVirtualKeyState(int key);
// Function to set the state of a particular key for test setup
void SetKeyboardState(int key, bool state);
// Function to reset the mocked keyboard state
void ResetKeyboardState();
// Function to set SendVirtualInput call count condition
void SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyboardEvent*)> condition);
// Function to force SendVirtualInput to fail for calls matching a predicate
void SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition);
// Function to get SendVirtualInput call count
int GetSendVirtualInputCallCount();

View File

@@ -63,6 +63,84 @@ namespace RemappingLogicTests
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x42), false);
}
// When injecting the remapped key fails (e.g. SendInput is blocked by UIPI or
// another hook), the handler must let the ORIGINAL key through instead of
// silently swallowing it, so the user is never left with a dead key. This
// exercises the stuck-key hardening that checks SendVirtualInput's return value.
TEST_METHOD (RemappedKey_ShouldPassOriginalKeyThrough_WhenInjectionFails)
{
// Remap A to B
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
// Fail only KBM-injected events (tagged with a non-zero dwExtraInfo),
// leaving the test's own driving input (dwExtraInfo == 0) untouched.
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
for (const auto& input : inputs)
{
if (input.ki.dwExtraInfo != 0)
{
return true;
}
}
return false;
});
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
};
// Send A keydown - injection of B fails, so A must pass through
mockedInputHandler.SendVirtualInput(inputs);
// The original A is let through (state true); B was never injected (false)
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
}
// When the remapped key-DOWN injection is blocked but the later key-UP injection
// would succeed, the handler must still let the ORIGINAL key-up through. The
// key-down was passed through to the app (key is physically DOWN), so swallowing
// the key-up would strand the physical key DOWN. This guards the asymmetric
// injection-failure stuck-key edge case, where key-down and key-up arrive as
// separate hook events.
TEST_METHOD (RemappedKey_ShouldReleaseOriginalKey_WhenKeyDownInjectionFailedButKeyUpSucceeds)
{
// Remap A to B
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
// Fail only KBM-injected key-DOWN events; allow injected key-ups (and the
// test's own driving input, which has dwExtraInfo == 0) through.
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
for (const auto& input : inputs)
{
if (input.ki.dwExtraInfo != 0 && (input.ki.dwFlags & KEYEVENTF_KEYUP) == 0)
{
return true;
}
}
return false;
});
std::vector<INPUT> keyDown{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
};
// Send A keydown - injection of B fails, so A passes through and is now DOWN
mockedInputHandler.SendVirtualInput(keyDown);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
std::vector<INPUT> keyUp{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A', .dwFlags = KEYEVENTF_KEYUP } },
};
// Send A keyup - even though injecting B's key-up would succeed, the original A
// key-up must pass through so the physical A key is released, not stranded down
mockedInputHandler.SendVirtualInput(keyUp);
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
}
// Test if key is suppressed if a key is disabled by single key remap
TEST_METHOD (RemappedKeyDisabled_ShouldNotChangeKeyState_OnKeyEvent)
{
@@ -350,4 +428,148 @@ namespace RemappingLogicTests
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x56), false);
}
};
// Tests for single key to text remap modifier release logic
TEST_CLASS (SingleKeyToTextRemapModifierTests)
{
private:
KeyboardManagerInput::MockedInput mockedInputHandler;
State testState;
public:
TEST_METHOD_INITIALIZE(InitializeTestEnv)
{
TestHelpers::ResetTestEnv(mockedInputHandler, testState);
// Set HandleSingleKeyToTextRemapEvent as the hook procedure
std::function<intptr_t(LowlevelKeyboardEvent*)> currentHookProc = std::bind(&KeyboardEventHandlers::HandleSingleKeyToTextRemapEvent, std::ref(mockedInputHandler), std::placeholders::_1, std::ref(testState));
mockedInputHandler.SetHookProc(currentHookProc);
}
// A held Win key must be released before the text is injected and then left
// released — never re-pressed — so it can never be left stuck down.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseWinKeyAndNotRestore_WhenWinKeyIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LWin being held down
mockedInputHandler.SetKeyboardState(VK_LWIN, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown — handler releases LWin before the text and does not restore it
mockedInputHandler.SendVirtualInput(inputs);
// LWin must be left released so it can never be stuck down
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
}
// A held Ctrl must be released before the text and left released afterwards.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseCtrlAndNotRestore_WhenCtrlIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl being held down
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// LCtrl must be left released so it can never be stuck down
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
}
// Every modifier that was held should be released, and none re-pressed.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseAllHeldModifiers_AndNotRestore)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl and LShift being held down together
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
mockedInputHandler.SetKeyboardState(VK_LSHIFT, true);
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// Both modifiers must be left released
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LSHIFT));
}
// The handler must never inject a modifier key-down (re-press) event. Doing
// so could leave a modifier stuck down if the user released it during text
// injection, since GetAsyncKeyState cannot distinguish a still-held key from
// one we just released ourselves.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldNeverRePressModifier_WhenModifierIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl being held down
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
// Count any modifier key-down events the handler injects (i.e. a re-press)
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* keyEvent) {
const DWORD vk = keyEvent->lParam->vkCode;
const bool isModifier = (vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LMENU || vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN);
return isModifier && keyEvent->wParam == WM_KEYDOWN;
});
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// No modifier re-press should ever be injected
Assert::AreEqual(0, mockedInputHandler.GetSendVirtualInputCallCount());
}
// A key-to-text remap must still fire while Alt is held. Windows delivers a
// key pressed with Alt down as WM_SYSKEYDOWN rather than WM_KEYDOWN, so a
// handler that only accepted WM_KEYDOWN would silently drop the remap. Alt
// being held also drives the modifier-release path, so the proof that the
// WM_SYSKEYDOWN event was accepted and processed is that the held Alt ends
// up released. If WM_SYSKEYDOWN were rejected the handler would return
// before the release loop and Alt would remain down.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldFireAndReleaseAlt_WhenAltIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate Left Alt being held. VK_MENU makes the mock deliver the key
// as WM_SYSKEYDOWN (as the OS does while Alt is down); VK_LMENU is the
// physical key the handler sees as held and must release.
mockedInputHandler.SetKeyboardState(VK_MENU, true);
mockedInputHandler.SetKeyboardState(VK_LMENU, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown — arrives as WM_SYSKEYDOWN because Alt is held
mockedInputHandler.SendVirtualInput(inputs);
// The remap fired: the held Alt was released and never re-pressed, so it
// can never be left stuck down.
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
}
};
}

View File

@@ -11,10 +11,12 @@ namespace TestHelpers
input.ResetKeyboardState();
input.SetHookProc(nullptr);
input.SetSendVirtualInputTestHandler(nullptr);
input.SetSendVirtualInputShouldFail(nullptr);
input.SetForegroundProcess(L"");
state.ClearSingleKeyRemaps();
state.ClearOSLevelShortcuts();
state.ClearAppSpecificShortcuts();
state.ClearSingleKeyToTextRemaps();
// Allocate memory for the keyboardManagerState activatedApp member to avoid CRT assert errors
std::wstring maxLengthString;

View File

@@ -6,6 +6,7 @@
#include <common/utils/process_path.h>
#include "KeyboardManagerConstants.h"
#include "InputInterface.h"
namespace Helpers
{
@@ -313,7 +314,7 @@ namespace Helpers
// Shift+Enter. Each character is sent individually to avoid a synchronization
// error across key-down and key-up events that causes repeated or dropped characters
// when large batches of KEYEVENTF_UNICODE events are sent at once.
void SendTextInput(const std::wstring& text)
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii)
{
for (size_t i = 0; i < text.size(); ++i)
{
@@ -359,7 +360,7 @@ namespace Helpers
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
SendInput(ARRAYSIZE(returnInputs), returnInputs, sizeof(INPUT));
ii.SendVirtualInput(std::vector<INPUT>(returnInputs, returnInputs + ARRAYSIZE(returnInputs)));
continue;
}
@@ -374,7 +375,7 @@ namespace Helpers
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[1].ki.wScan = c;
SendInput(ARRAYSIZE(charInputs), charInputs, sizeof(INPUT));
ii.SendVirtualInput(std::vector<INPUT>(charInputs, charInputs + ARRAYSIZE(charInputs)));
}
}

View File

@@ -4,6 +4,11 @@
class LayoutMap;
namespace KeyboardManagerInput
{
class InputInterface;
}
namespace Helpers
{
// Type to distinguish between keys
@@ -41,7 +46,7 @@ namespace Helpers
// Function to send text input directly, with multiline support.
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
// as separate SendInput calls to avoid mixing event types.
void SendTextInput(const std::wstring& text);
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii);
// Function to return window handle for a full screen UWP app
HWND GetFullscreenUWPWindowHandle();

View File

@@ -11,17 +11,41 @@ namespace KeyboardManagerInput
class Input : public InputInterface
{
public:
// Function to simulate input
void SendVirtualInput(const std::vector<INPUT>& inputs)
// Function to simulate input. Returns false only when nothing could be injected
// (the call was fully blocked); returns true on full or partial success. A partial
// injection means some remap events already reached the system, so passing the
// original key through on top of them would corrupt the input stream (e.g. leave a
// modifier stuck). In that rare case we suppress the original and log a warning.
bool SendVirtualInput(const std::vector<INPUT>& inputs)
{
if (inputs.empty())
{
return true;
}
std::vector<INPUT> copy = inputs;
UINT eventCount = SendInput(static_cast<UINT>(copy.size()), copy.data(), sizeof(INPUT));
if (eventCount != copy.size())
if (eventCount == 0)
{
// Nothing was injected (e.g. blocked by UIPI). The caller passes the
// original key through so the user is never left with a dead key.
Logger::error(
L"Failed to send input events. {}",
get_last_error_or_default(GetLastError()));
return false;
}
if (eventCount != copy.size())
{
// Partial injection: SendInput stopped after some events. Report success so
// the caller suppresses the original event rather than layering it on top of
// a half-applied remap, which could strand a key or modifier down.
Logger::warn(
L"Partially sent input events ({} of {}). {}",
eventCount,
static_cast<UINT>(copy.size()),
get_last_error_or_default(GetLastError()));
}
return true;
}
// Function to get the state of a particular key

View File

@@ -10,8 +10,9 @@ namespace KeyboardManagerInput
class InputInterface
{
public:
// Function to simulate input
virtual void SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
// Function to simulate input. Returns false only when nothing could be injected
// (the call was fully blocked); returns true on full or partial success.
virtual bool SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
// Function to get the state of a particular key
virtual bool GetVirtualKeyState(int key) = 0;

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;

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