Compare commits

..

21 Commits

Author SHA1 Message Date
Mike Griese
3ab6e182f8 CmdPal: fix the HWND frame on compact mode (#49184)
Admittedly, LLM discovered fix here.

The window frame for compact mode in cmdpal is very very wackadoodle.
User32 is dark and full of terrors, especially when we're trying to both
keep the original resize handles but also make them hidden from view.

TIL about
[`RedrawWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-redrawwindow),
which if you slather that about, helps make sure that the window frame
is correctly repainted by DWM as transparent.

The repro for this before was easy to see with a debug build with the
debugger attached:
* open cmdpal 
* make sure the frame is gone and the cmdpal window is focused
* click on another window
* PRESTO, window frame is back

by adding more RedrawWindow calls, we can make sure that our frame is
correctly erased even when DWM wants to put it back.

Closes: oh no one filed this?
2026-07-07 16:24:38 +00:00
Jiří Polášek
c1ef511697 CmdPal: Add keyboard shortcuts to manually expand compact mode palette (#49177)
## Summary of the Pull Request

This PR adds support for new keyboard shortcuts to expand CmdPal when in
Compact mode. Both of them are "natural"
- Down arrow key
- Tab key

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

- [x] Closes: #49112
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-07 11:07:56 -05:00
Jiří Polášek
188f6a58a8 CmdPal: Stop item action being executed when CmdPal is compact and collapsed (#49182)
## Summary of the Pull Request

This PR prevents Command Palette in collapsed compact mode executing
actions on a selected item. Since user is not aware what item is
selected or what action are
2026-07-07 16:04:53 +00:00
Michael Jolley
9449ae3686 Fix dock clock: show seconds, fix stale display, a11y (#48253)
Fixes the Command Palette Dock clock/date extension which showed time ~1
minute late and did not display seconds (unlike the taskbar clock).

The problem was that `timeExtended` was hardcoded `false` in
`NowDockBand.UpdateText()`. Fixed that by passing `true` to
`TimeAndDateHelper.GetStringFormat()`, selecting the `"T"` (long time)
format which includes seconds.

Also, changed from using `PropertyChanged` on `Title` to using
`RaiseItemsChanged` to mimic the behavior used for updating in the
Performance Monitor extension.

Fixes #46192

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Hawk <hawk@example.com>
2026-07-07 13:51:42 +02:00
Jiří Polášek
a32c75928b CmdPal: Avoid unnecessary handler runs on settings changes (part 1) (#49171)
## Summary of the Pull Request

This PR stops Command Palette subscribers from re-running their full
hot-reload path on every SettingsChanged event. Previously any settings
edit re-registered hotkeys, rebuilt the backdrop, and tore
down/recreated dock windows even when nothing the subscriber consumed
had changed.

- `MainWindow`: added `MainWindowSettingsComparer
`(IEqualityComparer<SettingsModel>); skips HotReloadSettings unless a
consumed setting changed.
- `DockWindowManager`: OnSettingsChanged bails unless
EnableDock/DockSettings changed; monitor-topology path still always
syncs.
- `DockWindow `/ `DockViewModel`: guard reloads with _settings ==
args.DockSettings.
- `DockSettings `/ `DockMonitorConfig`: list backing fields now use a
new EquatableList<T> wrapper, giving record equality content-based (not
reference-based) list comparison so guards hold after a reload rebuilds
the lists.
- Tests: EquatableListTests and DockSettingsEqualityTests cover the
wrapper and structural DockSettings equality.
- Public API and persisted JSON contract unchanged (EquatableList<T> is
backing-field only; properties still expose ImmutableList<T>).

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

- [x] Closes: #49168
- [ ] <!-- - [ ] Closes: #yyy (add separate lines for additional
resolved issues) -->
- [ ] **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-07-07 00:43:09 +00:00
Jiří Polášek
c7e53c6ad9 CmdPal: Fix issues related to deferred loading of pages in Compact mode (#49165)
## Summary of the Pull Request

This PR fixes a navigation issue in Compact mode. When Compact mode is
enabled, the frame might not always load the page right away, so the
attached behavior does not trigger.

- Search box visibility update
- Search box visibility is now reliably re-evaluated when navigating to
the home page. Until now, it was handled in the `Loaded` event handler,
but since that is deferred in Compact mode, it was not evaluated in time
and blocked the app.

- A11Y announcements after navigation
- Navigation announcements are now more consistent and reliable. As in
the previous point, the announcement was previously triggered by the
`Loaded` event.
  - Adds a note mentioning FocusState.Keyboard to Narrator annoucements.

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

- [x] Closes: #49116 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-06 17:41:22 -05:00
Jiří Polášek
52df5882a3 CmdPal: For each single-metric band, add a matching band in softdisabled state (#49162)
## Summary of the Pull Request

This PR updates Performance Monitor safe mode to add a placeholder page
for each Dock band that is available in normal mode. This way, when
Performance Monitor is soft-disabled, the user still sees the Dock band
and is informed that it is disabled.

This PR does not fix the missing battery Dock band, since that should be
moved to a separate extension soon(TM).

## Pictures? Pictures!

<img width="1022" height="134" alt="image"
src="https://github.com/user-attachments/assets/12bd33e6-041f-4f36-ad22-ed8287f19a3e"
/>


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

- [x] Related to: #49159 
- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-06 17:40:15 -05:00
Michael Jolley
0790674ddf CmdPal: Subscribe DetailsViewModel to PropChanged for live pane updates (#48070)
## Summary

Fixes #47745 — the details pane now updates live when an extension
modifies its `IDetails` properties (Title, Body, HeroImage, Metadata)
after initial display.

## Changes

### `DetailsViewModel.cs`
- Subscribe to `INotifyPropChanged` at construction (runtime check,
since `IDetails` doesn't require it)
- `Model_PropChanged` → `FetchProperty` with switch on Title, Body,
HeroImage, Metadata
- `RebuildMetadata` rebuilds the metadata list from the model
- `UnsafeCleanup` unsubscribes from PropChanged

### `ListItemViewModel.cs`
- Call `Details?.SafeCleanup()` before replacing the DetailsViewModel in
`FetchProperty("Details")`, ensuring the old subscription is cleaned up.

### `DetailsViewModelTests.cs` (new)
- 6 unit tests covering PropChanged subscription, property updates,
metadata rebuild, cleanup/unsubscribe, and non-observable IDetails
handling.

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 21:18:43 +00:00
PsychodelEKS
fb656bf040 Add Launchy to third-party Run plugins (#49080)
## Summary of the Pull Request

Adds Launchy to the third-party PowerToys Run plugins list.

Launchy is a community PowerToys Run plugin that indexes and launches
files from user-configured folders with per-folder extension, recursion
depth, and folder inclusion rules.

## PR Checklist

- [ ] Closes: #xxx
- [ ] **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
- [x] **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

This updates the existing third-party PowerToys Run plugins list with a
link to the Launchy plugin repository.

No product code is changed.

## Validation Steps Performed

- Verified the Launchy repository is public.
- Verified the plugin README includes installation, usage, settings,
screenshots, and release assets.
- Verified this PR only changes doc/thirdPartyRunPlugins.md.

---------

Co-authored-by: PsychodelEKS <konstantin.efremov@clickio.com>
2026-07-06 06:23:34 +00:00
Copilot
b329b3f243 Shortcut Guide: Add keyboard shortcut manifest for new Outlook (olk.exe) (#48821)
## Summary of the Pull Request

Shortcut Guide had no manifest for the new Outlook for Windows app, so
it showed no shortcuts when `olk.exe` was the foreground process. Adds a
new YAML manifest (`Microsoft.OutlookForWindows.en-US.yml`) with
`WindowFilter: "olk.exe"` covering email, navigation, text editing,
formatting, and calendar shortcuts.

## PR Checklist

- [ ] Closes: #48798
- [ ] **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

New file:
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/Microsoft.OutlookForWindows.en-US.yml`

- **PackageName:** `Microsoft.OutlookForWindows` (WinGet package ID)
- **WindowFilter:** `olk.exe` — process name for the new Outlook app
(distinct from classic `outlook.exe`)
- **Display name:** `Outlook (new)`
- **Shortcut categories:** Frequently used · Navigate Outlook · Text
editing · Format text · Calendar

The `.csproj` already globs `Assets\ShortcutGuide\Manifests\*.yml`, so
no project file changes are needed.

## Validation Steps Performed

- Verified the YAML structure matches existing manifests (e.g.,
`Microsoft.Outlook.en-US.yml`).
- Confirmed `olk.exe` is the correct process name for new Outlook for
Windows.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-06 10:13:38 +08:00
Copilot
b6f0a7ae91 Add Shortcut Guide manifest for the Godot editor (#48959)
## Summary of the Pull Request

Adds bundled Shortcut Guide support for the Godot editor so Godot
shortcuts can appear when `Godot.exe` is the active app.

- **Shortcut Guide manifest**
- Added `+Godot.Godot.en-US.yml` under the bundled Shortcut Guide
manifests
- Uses the no-WinGet-package Godot editor identity expected by Shortcut
Guide:
    ```yml
    PackageName: +Godot.Godot
    WindowFilter: "Godot.exe"
    ```
- **Shortcut coverage**
- Includes the reporter-provided Godot editor mappings across core
editor workflows, panels, 2D/3D editors, text editor, and project
manager
- **Schema compliance**
  - Uses spec-compliant bracketed literal digit tokens such as `"<0>"`
- Uses spec-compliant bracketed special key tokens such as `"<Tab>"`,
`"<Space>"`, `"<Insert>"`, `"<Delete>"`, `"<Escape>"`, `"<PageUp>"`, and
`"<PageDown>"`

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

## Detailed Description of the Pull Request / Additional comments

Shortcut Guide already copies all bundled manifest assets at startup and
builds its app index from those files. This change only extends that
manifest set with a Godot definition and aligns the manifest data with
the keyboard shortcuts schema; no runtime logic changed.

## Validation Steps Performed

- Parsed the new `+Godot.Godot.en-US.yml` file to verify YAML validity
and required top-level fields
- Confirmed the manifest targets `Godot.exe` and contains the expected
`+Godot.Godot` package identity
- Confirmed literal digit shortcuts use bracketed tokens like `"<0>"`
- Confirmed special keys use bracketed schema-compliant tokens where
applicable

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-06 10:13:14 +08:00
Copilot
3df0535473 Add Obsidian manifest for Shortcut Guide (#48960)
## Summary of the Pull Request

Adds built-in Shortcut Guide support for Obsidian by shipping an app
manifest keyed to `Obsidian.exe`. This lets Shortcut Guide recognize
Obsidian as the foreground app and show a curated set of common Obsidian
shortcuts.

Closes: #48596

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

- **Shortcut Guide manifest**
- Adds
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/Obsidian.Obsidian.en-US.yml`
  - Uses `PackageName: Obsidian.Obsidian`
  - Matches the app via `WindowFilter: "Obsidian.exe"`

- **Curated Obsidian shortcuts**
  - Adds common shortcuts across:
    - Navigation
    - Tabs
    - Editing
- Includes key Obsidian actions such as command palette, quick switcher,
tab navigation, reading/editing mode toggle, and core editing commands

- **No code-path changes**
- This is a data-only addition that plugs into the existing
manifest-driven Shortcut Guide app support

```yml
PackageName: Obsidian.Obsidian
Name: Obsidian
WindowFilter: "Obsidian.exe"
BackgroundProcess: false
```

## Validation Steps Performed

- Verified the new manifest is the only repository change
- Parsed the new manifest structure successfully
- Confirmed the manifest follows existing Shortcut Guide key/token
conventions used by other bundled app manifests

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-06 10:12:56 +08:00
Alex Mihaiuc
23a26428d3 Grab And Move mod click passthrough (#49121)
This lets Alt/Win + click/rclick (no drag) go through if no mouse
movement is recorded.
Now the target window goes either into the "all clicks go through" or
"all clicks are considered resizes/moves" based on whether the first
mouse action after the modifier is pressed is a click or the beginning
of a mouse drag action.

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

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-05 15:16:55 +02:00
Jiří Polášek
6da052247f CmdPal: Prevent stealing focus from other apps (#49087)
## Summary of the Pull Request

Gate search bar focus updates so they only run when the window is
visible.

The search bar can update its focus after context changes, but that
update may be dispatched after the window has already been hidden. In
that case, focusing the search box can bring the window back into focus
and steal focus from another app.

This change prevents the focus update from running when the owning
window is no longer visible.

Also as a flyby, `FocusSearchBoxMessage` is now handled by the ShellPage
instead of SearchBar, and goes through the same gate.

Regressed with the new token based params
(3bf682048e).

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

- [x] Closes: #43824
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-04 20:19:43 -05:00
Alex Mihaiuc
cccd2b7510 GrabAndMove drag maximized windows relative to the click point (#49118)
This aligns the behavior with resize.

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

When grabbing and moving a maximized icon, the old behavior was to first
restore the window with its title bar under the cursor, then proceed
with the move. Now the window is restored and moved proportionally
around the cursor, exactly like in the case of the grab and resize
behavior.

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

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

Ensure the window geometry/coordinates change exactly the same as for
grab and resize on it, when starting from a maximized state.
2026-07-04 14:09:07 +02:00
Renn
e4ef90d168 [Peek] Options for AlwaysOnTop and ShowOnTaskbar (#44645)
<!-- 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

* Add the option for Peek to allow the window to be always on top
* Add the option for Peek to allow to not show its icon on the taskbar

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

- [x] Closes: #26274 
- [x] Closes: #43093 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
- [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)
- [x] **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:
https://github.com/MicrosoftDocs/windows-dev-docs/pull/5824

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

AlwaysOnTop defaults to `false`, and ShowOnTaskbar defaults to `true` to
match the current behavior.

Peek settings after the change:
<img width="1642" height="714" alt="image"
src="https://github.com/user-attachments/assets/e9c8b390-8a8b-40aa-8990-c671b1fffd96"
/>

Peek window behavior with AlwaysOnTop set to `true` and ShowOnTaskbar
set to `false`:
<img width="1813" height="1161" alt="image"
src="https://github.com/user-attachments/assets/e5fbda14-0ba8-4a70-840c-2e8493b7d920"
/>

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

1. Start PowerToys with Peek enabled
2. In settings, turn off close window on focus loss for ease of
validation
3. Peek something
4. Turn on always on top, observe the window is now always on top
5. Turn off always on top, observe the window is no longer always on top
6. Turn off show icon on taskbar, observe the icon disappeared
immediately from the taskbar
7. Turn on show icon on taskbar, observe the icon re-appeared on the
taskbar
2026-07-04 06:02:27 +00:00
Jiří Polášek
e0fe3c48cf CmdPal: Stretch main window card vertically when in expanded mode (#49109)
## Summary of the Pull Request

This PR updates the behavior of CmdPal's main window in expanded
(non-compact) mode to match the previous behavior. The page content now
stretches across the entire window instead of collapsing to the actual
content height.

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

- [x] Closes: #48872 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-03 22:20:16 -05:00
Jiří Polášek
029dd04ce4 CmdPal: Fix compact mode not toggling the list/command bar at runtime (#49111)
<!-- 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

This PR hooks `ShellPage` to `ISettingsService` and triggers update of
expanded state when the Compact mode settings is changed.

Toggling the compact-mode setting while the palette was open
neverre-evaluated `ShellPage.ExpandedMode`, so disabling compact mode
left the palette collapsed (only the search box visible).

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

- [x] Closes: #48933 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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-07-03 22:18:32 -05:00
Copilot
3d3cef73da Add Zoom Workspace keyboard shortcut manifest for Shortcut Guide (#49062)
## Summary of the Pull Request

Adds a new Shortcut Guide manifest for Zoom Workspace so Zoom-specific
shortcuts are available in the overlay when `zoom.exe` is active.
The manifest follows existing in-repo schema and naming conventions for
app-specific keyboard shortcut files.

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

- **Manifest addition**
- Added
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/Zoom.Zoom.en-US.yml`.
  - Targets `WindowFilter: "zoom.exe"` with `PackageName: Zoom.Zoom`.

- **Shortcut coverage**
  - Added high-value Zoom shortcuts grouped by section:
    - `General`
    - `View`
    - `Meeting controls`
- Includes core actions such as mute/unmute, start/stop video, share,
full-screen, and meeting exit.

- **Schema alignment**
- Uses existing Shortcut Guide manifest structure (`SectionName` /
`Properties` / `Shortcut`) and key token conventions already used in
adjacent manifests.

```yaml
PackageName: Zoom.Zoom
Name: Zoom Workspace
WindowFilter: "zoom.exe"
BackgroundProcess: false
```

## Validation Steps Performed

- Manifest content was kept within existing Shortcut Guide manifest
schema and repository conventions for built-in app manifests.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-03 20:09:54 +02:00
Bryce Cindrich
03e5f3e837 feat(shortcut-guide): add 1Password manifest (#48793)
## Summary of the Pull Request

Adds a Shortcut Guide manifest for the **1Password** desktop app.

- **New manifest:**
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/AgileBits.1Password.en-US.yml`:
26 shortcuts for `1Password.exe`, grouped into the same four sections
1Password uses in its in-app Keyboard Shortcuts reference:
- **Basics:** View keyboard shortcuts, Show Quick Access, Lock 1Password
- **Navigation:** Find, Switch to all accounts, Switch accounts &
collections, Back, Forward, Focus next/previous row, Focus right/left
section
- **Selected item:** Copy primary field / password / one-time password,
Open & fill in web browser, Open item in new window, Edit item, Save
item, Reveal concealed fields, Archive item, Delete item
  - **View:** Show/hide sidebar, Zoom in, Zoom out, Actual size
- **No code changes.** The manifest is auto-included via the existing
`Manifests/*.yml` glob in `ShortcutGuide.Ui.csproj`, exactly like the
existing Postman, Slack, Discord, and browser manifests.
- The two literal-digit shortcuts (`Ctrl+1` switch to all accounts,
`Ctrl+0` actual size) use the `<N>` token (`<1>` / `<0>`) per the
manifest spec, and the "Switch accounts & collections" range renders as
`2 - 9`.
- **Documentation:** Added a note in `doc/specs/WinGet Manifest Keyboard
Shortcuts schema.md` documenting the existing **sentence-case** naming
convention for `Name` and `SectionName` (capitalize only the first word
plus proper nouns / product feature names), so future contributors do
not copy an application's title-case shortcut-list styling. The
1Password names in this PR follow that convention, keeping only
feature/product names capitalized (Show Quick Access, Lock 1Password).

## PR Checklist

- [x] Closes: #48792
- [ ] **Communication:** I've discussed this with core contributors
already. <!-- Filed #48792; the v0.100 announcement invites app-shortcut
contributions via PR. Follows the precedent set by #48461 (Postman). -->
- [ ] **Tests:** Added/updated and all pass <!-- N/A: data-only change,
no new code paths. The manifest was validated by deserializing it with
YamlDotNet (the same `Deserializer` used by `ManifestInterpreter`),
confirming all 26 entries and key tokens parse into `ShortcutFile`. -->
- [x] **Localization:** All end-user-facing strings can be localized
<!-- Shortcut names live in the per-language manifest (`*.en-US.yml`);
other locales fall back to en-US, consistent with every existing
manifest. -->
- [x] **Dev docs:** Added/updated <!-- Documented the sentence-case
naming convention for Name / SectionName in doc/specs/WinGet Manifest
Keyboard Shortcuts schema.md. -->
- [ ] **New binaries:** Added on the required places <!-- N/A: the new
manifest is a data asset under an already-shipped, globbed folder. No
new binaries or test projects. -->
- [ ] **Documentation updated:** <!-- N/A: user-facing docs unchanged.
-->
- [x] **Local run:** Built the Shortcut Guide projects and ran the Debug
build with 1Password focused (`Win+Shift+/`); screenshot of the rendered
guide is attached below.

## Detailed Description of the Pull Request / Additional comments

The Shortcut Guide displays per-app shortcuts from YAML manifests,
matched to the foreground window via `WindowFilter`. Adding support for
an app is purely additive: drop a `<PackageName>.<locale>.yml` file in
the `Manifests` folder and it is picked up by the existing build glob
and the index generator.

- `PackageName: AgileBits.1Password` (the WinGet package identifier) and
`WindowFilter: "1Password.exe"` (the desktop app process).
- `Name: 1Password` is the display name shown in the Shortcut Guide app
picker.
- Shortcut names follow the repo's sentence-case convention (now
documented in the schema spec). Recommended is set on the five
highest-frequency / signature actions: Show Quick Access, Lock
1Password, Copy primary field, Copy password, Copy one-time password.

## Validation Steps Performed

- **Schema/parse:** Deserialized the manifest with
`YamlDotNet.Serialization.Deserializer` (the same path
`ManifestInterpreter.YamlToShortcutList` uses). All four sections and 26
entries parse, with 5 marked Recommended. No parse errors.
- **Key rendering:** Verified every key token against `KeyVisual` and
`ShortcutDescriptionToKeysConverter`: `<Space>`/`<Delete>` strip to
their labels, `<Left>`/`<Right>`/`<Up>`/`<Down>` map to arrow glyphs,
`<1>`/`<0>` strip to the literal digit (matching the merged Postman
`<9>`/`<0>` handling), `2 - 9` renders verbatim, and `+` / `-` render as
the literal symbols (as in the bundled Windows Explorer and Shell
manifests).
- **Source fidelity:** The section grouping and every shortcut/modifier
combination match 1Password's in-app Keyboard Shortcuts reference
one-to-one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<img width="1462" height="2260" alt="image"
src="https://github.com/user-attachments/assets/e7824a38-cb56-4242-9a6a-31c7a93c03c9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:32:37 +00:00
moooyo
9039451e2f [Quick Accent] Migrate UI to WinUI 3 (#48891)
## Summary of the Pull Request

Migrates the **Quick Accent (PowerAccent)** module's UI from WPF
(`System.Windows.*`) to **WinUI 3 (Windows App SDK)**, following the
pattern used by other migrated modules (ImageResizer, PowerDisplay). The
accent selector is now a self-contained WinUI 3 app
(`PowerToys.PowerAccent.exe`) shipped under `WinUI3Apps`, and
`PowerAccent.Core` is UI-framework-agnostic.


demo:

https://github.com/user-attachments/assets/400c33ee-0fc0-491e-841b-a546438edf91


## PR Checklist

- [x] Closes: #48889
- [x] **Communication:** Tracked task (#48889) agreed with core
contributors
- [x] **Tests:** Added/updated and all pass — new
`PowerAccent.Core.UnitTests` (21 tests for the positioning / DPI math);
existing `PowerAccent.Common.UnitTests` unaffected
- [x] **Localization:** No new localizable end-user strings — new
accessibility metadata uses non-localized
`AutomationProperties.AutomationId`, and the window title is the brand
name `"Quick Accent"` (literal, matching ColorPicker)
- [x] **Dev docs:** Updated `doc/devdocs/modules/quickaccent.md`
- [x] **New binaries:** Added on the required places
- [x] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json):
the `WinUI3Apps\` PowerAccent payloads are listed in
`ESRPSigning_core.json`
- [x] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs):
no manual `Product.wxs` entry — the self-contained `WinUI3Apps` output
(exe + `.pri` + Windows App SDK runtime) is harvested by the
`WinUI3ApplicationsFiles` glob (same as ImageResizer)
- [x] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml):
no change needed — `PowerAccent.Core.UnitTests` is discovered by the
existing `**\*UnitTest*.dll` VSTest glob
- [x] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml):
covered by `ESRPSigning_core.json`; no `release.yml` change needed
- [ ] **Documentation updated:** N/A — internal UI-framework migration
with no user-facing behavior change

## Detailed Description of the Pull Request / Additional comments

The migration spans three areas (tracked in #48889):

**UI (WPF → WinUI 3)**
- `PowerAccent.UI` is now a WinUI 3 app shell (custom `Program.Main`,
`WindowsPackageType=None`, `WindowsAppSDKSelfContained=true`).
- The accent selector is a non-activating `TransparentWindow` overlay
shown with `SW_SHOWNA` (never steals focus). It is made always-on-top
only while shown — the WinUIEx `WindowEx.IsAlwaysOnTop` property is
toggled `true` on show / `false` on hide in `OnChangeDisplay` (matching
the WPF original's `Topmost = isActive`), so the dormant,
never-destroyed overlay does not pin a discrete GPU awake on
hybrid-graphics laptops (issue #34849 / PR #41044).
- The accent "pill" selection visual is reproduced with
`VisualStateManager` (WinUI 3 has no `Style.Triggers`).
- **WinUI 3 gotcha:** x:Bind on a Window-rooted XAML initializes only on
`Window.Activated`, which never fires for this `SW_SHOWNA` overlay — so
the selector calls `Bindings.Update()` after `InitializeComponent()`;
without it the `ListView` renders empty.
- **Theme:** the long-lived, never-activated process follows the system
app theme automatically — `App.xaml` leaves `Application.RequestedTheme`
unset, so WinUI re-resolves the `{ThemeResource}` brushes (and retints
the acrylic) on a live light/dark switch with no manual `ThemeListener`
needed.
- **Layout parity with the WPF original:** the bar width hugs its
content (`itemCount × 48`, clamped to the monitor width — computed, not
measured, to avoid a racy `ListView` measure), and each cell pins
`MinWidth=48` (WinUI's `ListViewItem` defaults to 88, which would
otherwise leave wide gaps).
- **Accessibility:** UIA window name + `AutomationId`s on the character
list and description.

**Dependency**
- `PowerAccent.Core` no longer depends on WPF — it raises events and
takes an injected UI-thread marshaller.
- WinForms `SendKeys` → `SendInput` (CsWin32 P/Invoke); WPF-UI (Lepo)
removed; language data moved to the UI-/WinRT-agnostic
`PowerAccent.Common`.
- MVVM via CommunityToolkit.Mvvm with `[ObservableProperty]` **partial
properties** (WinRT-correct, clears MVVMTK0045).

**CI / Build / Installer**
- Signing config, WinUI3Apps glob harvest, and the new unit-test project
— see the checklist above.

## Validation Steps Performed

- **Build:** `x64 Debug` builds with **0 warnings / 0 errors**.
- **Unit tests:** `PowerAccent.Core.UnitTests` — **21/21 pass** (9
anchor positions × DPI 1.0/1.5/2.0, the offset and negative-origin
monitors, caret centering + edge clamping + flip-below).
- **XamlStyler:** `PowerAccentXAML/MainWindow.xaml` passes the passive
format check (CI mode).
- **Manual (single monitor, Top-center, light theme):**
  - Accent popup appears with the full accent list rendered.
- Bar hugs the characters and is centered; cell spacing matches the WPF
original.
- Switching the system theme (light/dark) is followed live by the popup.
- With `show_description` enabled, the description row is wide (≥600px)
and readable, with the accent bar centered above it.
- **Remaining manual validation** (tracked in #48889): multi-monitor,
per-monitor DPI, all 9 `toolbar_position` values, and high-contrast
theme.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-03 16:58:04 +08:00
97 changed files with 5729 additions and 1378 deletions

View File

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

View File

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

View File

@@ -15,14 +15,15 @@ Quick Accent (formerly known as Power Accent) is a PowerToys module that allows
## Architecture
The Quick Accent module consists of four main components:
The Quick Accent module consists of five projects:
```
poweraccent/
├── PowerAccent.Core/ # Core component containing Language Sets
├── PowerAccent.UI/ # The character selector UI
├── PowerAccentKeyboardService/ # Keyboard Hook
── PowerAccentModuleInterface/ # DLL interface
├── PowerAccent.Common/ # Language data, character mappings, LetterKey enum
├── PowerAccent.Core/ # Accent logic, settings, positioning, usage statistics
├── PowerAccent.UI/ # WinUI 3 character selector app (PowerToys.PowerAccent.exe)
── PowerAccentKeyboardService/ # WinRT keyboard-hook component
└── PowerAccentModuleInterface/ # Native runner module DLL
```
### Module Interface (PowerAccentModuleInterface)
@@ -32,21 +33,32 @@ The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, i
- Managing module lifecycle (enable/disable/settings)
- Launching and terminating the PowerToys.PowerAccent.exe process
### Shared Data (PowerAccent.Common)
`PowerAccent.Common` holds the UI- and runtime-agnostic data the other projects share:
- The language / character-set definitions and per-letter accent mappings
- The managed `LetterKey` enum (kept in sync with the WinRT `LetterKey` in `PowerAccentKeyboardService/KeyboardListener.idl`)
It has no UI or WinRT dependencies and is unit-tested in isolation (`PowerAccent.Common.UnitTests`).
### Core Logic (PowerAccent.Core)
The Core component contains:
- Main accent character logic
- Keyboard input detection
- Character mappings for different languages
- Management of language sets and special characters (currency, math symbols, etc.)
- Usage statistics for frequently used characters
- Main accent character logic, consuming the language data from `PowerAccent.Common`
- Toolbar positioning math (9 anchor points with per-monitor DPI) and settings handling
- Management of special characters (currency, math symbols, etc.) and usage statistics
Core carries no UI-framework dependency: it raises events and accepts a UI-thread marshaller delegate instead of touching WPF/WinUI directly, and its positioning math is covered by `PowerAccent.Core.UnitTests`.
### UI Layer (PowerAccent.UI)
The UI component is responsible for:
- Displaying the toolbar with accent options
- Handling user selection of accented characters
- Managing the visual positioning of the toolbar
The UI component is a self-contained **WinUI 3 (Windows App SDK)** app, migrated from WPF.
It is responsible for:
- Displaying the accent toolbar — a non-activating, always-on-top `TransparentWindow` overlay shown with `SW_SHOWNA` so it never steals focus from the app being typed into
- Handling selection and the toolbar's sizing / positioning
- Following the system theme while the long-lived process runs
It builds to `PowerToys.PowerAccent.exe` together with its `.pri` and the bundled Windows App SDK runtime, all under the `WinUI3Apps` output folder.
### Keyboard Service (PowerAccentKeyboardService)
@@ -128,5 +140,5 @@ To directly debug the Quick Accent UI component:
5. Start debugging by pressing `F5` or clicking the "*Start*" button
6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code
**Known issue**: You may encounter approximately 78 errors during the start of debugging.<br>
**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again.
**Known issue**: A first incremental build can surface transient errors (for example from CsWinRT projection / WinUI XAML codegen ordering).<br>
**Solution**: Right-click the **PowerAccent** folder in Solution Explorer and select "*Rebuild*", then start debugging again.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,11 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsViewModel(IDetails _details, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
public partial class DetailsViewModel : ExtensionObjectViewModel
{
private readonly ExtensionObject<IDetails> _detailsModel = new(_details);
private readonly ExtensionObject<IDetails> _detailsModel;
private INotifyPropChanged? _observableDetails;
private bool _isSubscribed;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
@@ -25,6 +27,81 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
// where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator}
public List<DetailsElementViewModel> Metadata { get; private set; } = [];
public DetailsViewModel(IDetails details, WeakReference<IPageContext> context)
: base(context)
{
_detailsModel = new(details);
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
private void FetchProperty(string propertyName)
{
var model = _detailsModel.Unsafe;
if (model is null)
{
return;
}
switch (propertyName)
{
case nameof(IDetails.Title):
Title = model.Title ?? string.Empty;
UpdateProperty(nameof(Title));
break;
case nameof(IDetails.Body):
Body = model.Body ?? string.Empty;
UpdateProperty(nameof(Body));
break;
case nameof(IDetails.HeroImage):
HeroImage = new(model.HeroImage);
HeroImage.InitializeProperties();
UpdateProperty(nameof(HeroImage));
break;
case nameof(IDetails.Metadata):
RebuildMetadata(model);
UpdateProperty(nameof(Metadata));
break;
}
}
private void RebuildMetadata(IDetails model)
{
var newMetadata = new List<DetailsElementViewModel>();
var meta = model.Metadata;
if (meta is not null)
{
foreach (var element in meta)
{
DetailsElementViewModel? vm = element.Data switch
{
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
_ => null,
};
if (vm is not null)
{
vm.InitializeProperties();
newMetadata.Add(vm);
}
}
}
Metadata = newMetadata;
}
public override void InitializeProperties()
{
var model = _detailsModel.Unsafe;
@@ -33,6 +110,14 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
return;
}
// Subscribe to PropChanged if the model supports it (only subscribe once)
if (!_isSubscribed && model is INotifyPropChanged observable)
{
observable.PropChanged += Model_PropChanged;
_observableDetails = observable;
_isSubscribed = true;
}
Title = model.Title ?? string.Empty;
Body = model.Body ?? string.Empty;
HeroImage = new(model.HeroImage);
@@ -57,25 +142,18 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
UpdateProperty(nameof(Size));
var meta = model.Metadata;
if (meta is not null)
RebuildMetadata(model);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
if (_isSubscribed && _observableDetails is not null)
{
foreach (var element in meta)
{
DetailsElementViewModel? vm = element.Data switch
{
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
_ => null,
};
if (vm is not null)
{
vm.InitializeProperties();
Metadata.Add(vm);
}
}
_observableDetails.PropChanged -= Model_PropChanged;
_observableDetails = null;
_isSubscribed = false;
}
}
}

View File

@@ -83,6 +83,11 @@ public sealed partial class DockViewModel : IDisposable
return;
}
if (_settings == settings)
{
return;
}
_settings = settings;
SetupBands();
}

View File

@@ -158,11 +158,13 @@ public partial class ListItemViewModel : CommandItemViewModel
UpdateProperty(nameof(Type), nameof(IsInteractive));
break;
case nameof(Details):
var existingReference = Details;
var extensionDetails = model.Details;
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
UpdateProperty(nameof(Details), nameof(HasDetails));
UpdateShowDetailsCommand();
existingReference?.SafeCleanup();
break;
case nameof(model.MoreCommands):
AddShowDetailsCommands();

View File

@@ -48,7 +48,10 @@ public record DockSettings
public string? BackgroundImagePath { get; init; }
// </Theme settings>
private ImmutableList<DockBandSettings>? _startBands = ImmutableList.Create(
// Band lists use EquatableList backing fields so the compiler-synthesized record equality
// compares them by content, not by reference. See EquatableList<T> for why this matters.
private readonly EquatableList<DockBandSettings> _startBands = new(ImmutableList.Create(
new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
@@ -59,23 +62,23 @@ public record DockSettings
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowTitles = false,
});
}));
public ImmutableList<DockBandSettings> StartBands
{
get => _startBands ?? ImmutableList<DockBandSettings>.Empty;
init => _startBands = value;
get => _startBands.List;
init => _startBands = new(value);
}
private ImmutableList<DockBandSettings>? _centerBands = ImmutableList<DockBandSettings>.Empty;
private readonly EquatableList<DockBandSettings> _centerBands = new(ImmutableList<DockBandSettings>.Empty);
public ImmutableList<DockBandSettings> CenterBands
{
get => _centerBands ?? ImmutableList<DockBandSettings>.Empty;
init => _centerBands = value;
get => _centerBands.List;
init => _centerBands = new(value);
}
private ImmutableList<DockBandSettings>? _endBands = ImmutableList.Create(
private readonly EquatableList<DockBandSettings> _endBands = new(ImmutableList.Create(
new DockBandSettings
{
ProviderId = "PerformanceMonitor",
@@ -85,12 +88,12 @@ public record DockSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}));
public ImmutableList<DockBandSettings> EndBands
{
get => _endBands ?? ImmutableList<DockBandSettings>.Empty;
init => _endBands = value;
get => _endBands.List;
init => _endBands = new(value);
}
public bool ShowLabels { get; init; } = true;
@@ -99,12 +102,12 @@ public record DockSettings
/// Gets the per-monitor dock configurations. Each entry overrides global
/// settings for a specific display. Empty by default (all monitors use global).
/// </summary>
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
private readonly EquatableList<DockMonitorConfig> _monitorConfigs = new(ImmutableList<DockMonitorConfig>.Empty);
public ImmutableList<DockMonitorConfig> MonitorConfigs
{
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
get => _monitorConfigs.List;
init => _monitorConfigs = new(value);
}
/// <summary>
@@ -192,20 +195,41 @@ public sealed record DockMonitorConfig
/// </summary>
public bool IsCustomized { get; init; }
// Nullable EquatableList backing fields give the synthesized record equality structural
// comparison of the per-monitor bands while preserving null ("inherit global") vs. an
// explicit (possibly empty) list. See EquatableList<T>.
private readonly EquatableList<DockBandSettings>? _startBands;
/// <summary>
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? StartBands { get; init; }
public ImmutableList<DockBandSettings>? StartBands
{
get => _startBands?.List;
init => _startBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
private readonly EquatableList<DockBandSettings>? _centerBands;
/// <summary>
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
public ImmutableList<DockBandSettings>? CenterBands
{
get => _centerBands?.List;
init => _centerBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
private readonly EquatableList<DockBandSettings>? _endBands;
/// <summary>
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? EndBands { get; init; }
public ImmutableList<DockBandSettings>? EndBands
{
get => _endBands?.List;
init => _endBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
/// <summary>
/// Gets the UTC timestamp when this monitor was last seen connected. Used for

View File

@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// A thin wrapper around <see cref="ImmutableList{T}"/> that provides <em>structural</em>
/// (element-by-element) equality. <see cref="ImmutableList{T}"/> itself only implements
/// reference equality, which means a record holding one compares unequal to an otherwise
/// identical record whenever the list was rebuilt into a fresh instance (e.g. after loading
/// settings from disk).
/// </summary>
/// <remarks>
/// Used as the <em>backing field</em> type for record list properties (the public properties
/// still expose <see cref="ImmutableList{T}"/>). Because the compiler-synthesized record
/// equality compares backing fields, swapping the field type here makes that synthesized
/// equality structural — with no hand-written <c>Equals</c> to keep in sync as new properties
/// are added.
/// </remarks>
internal readonly struct EquatableList<T> : IEquatable<EquatableList<T>>
{
private readonly ImmutableList<T>? _list;
public EquatableList(ImmutableList<T>? list) => _list = list;
public ImmutableList<T> List => _list ?? ImmutableList<T>.Empty;
public bool Equals(EquatableList<T> other)
{
var a = List;
var b = other.List;
if (ReferenceEquals(a, b))
{
return true;
}
if (a.Count != b.Count)
{
return false;
}
var comparer = EqualityComparer<T>.Default;
for (var i = 0; i < a.Count; i++)
{
if (!comparer.Equals(a[i], b[i]))
{
return false;
}
}
return true;
}
public override bool Equals(object? obj) => obj is EquatableList<T> other && Equals(other);
public override int GetHashCode()
{
var hash = default(HashCode);
foreach (var item in List)
{
hash.Add(item);
}
return hash.ToHashCode();
}
}

View File

@@ -11,6 +11,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public record SettingsModel
{
// LOAD BEARNING: Some SettingsChanged subscribers react selectively (e.g.
// MainWindow.MainWindowSettingsComparer, DockWindowManager.OnSettingsChanged). If a new
// setting needs a live reaction, add it there too - otherwise it won't take effect.
///////////////////////////////////////////////////////////////////////////
// SETTINGS HERE
public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space

View File

@@ -55,6 +55,18 @@ public partial class ShellViewModel : ObservableObject,
oldValue.PropertyChanged -= CurrentPage_PropertyChanged;
value.PropertyChanged += CurrentPage_PropertyChanged;
// Re-evaluate search-box visibility for the page we're switching to.
// CurrentPage_PropertyChanged only reacts to a *change* of HasSearchBox, so
// switching to a page whose HasSearchBox already holds its final value (e.g.
// navigating back to a list from a ContentPage) would otherwise never restore
// the search box. Only force it visible here; hiding it on content pages is
// deliberately deferred (see ShellPage.FocusAfterLoaded) so focus doesn't jump
// around for screen readers.
if (value.HasSearchBox)
{
IsSearchBoxVisible = true;
}
if (oldValue is IDisposable disposable)
{
try

View File

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

View File

@@ -27,7 +27,6 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
INotifyPropertyChanged,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
IRecipient<FocusParamMessage>,
ICurrentPageAware
@@ -72,6 +71,8 @@ public sealed partial class SearchBar : UserControl,
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler? ActiveFocusTargetChanged;
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
//// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work...
@@ -102,8 +103,9 @@ public sealed partial class SearchBar : UserControl,
@this?.PropertyChanged?.Invoke(@this, new(nameof(PageType)));
@this?.PropertyChanged?.Invoke(@this, new(nameof(Parameters)));
// Attempt to focus us again, once we evaluate what input is visible
@this?.Focus();
// Let the shell decide if it's safe to restore focus after the
// SwitchPresenter swaps to a different top-bar input.
@this?.ActiveFocusTargetChanged?.Invoke(@this, EventArgs.Empty);
}
public string PageType => CurrentPageViewModel switch
@@ -120,7 +122,6 @@ public sealed partial class SearchBar : UserControl,
{
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
WeakReferenceMessenger.Default.Register<FocusParamMessage>(this);
}
@@ -497,9 +498,13 @@ public sealed partial class SearchBar : UserControl,
}
}
public void Receive(FocusSearchBoxMessage message) => Focus();
private void Focus()
/// <summary>
/// Moves focus to the inner search text box using <see cref="FocusState.Keyboard"/>, which
/// (unlike a programmatic <c>Focus</c> on the control) lets the screen reader announce the
/// box and its placeholder. Used for summon and post-navigation focus alike so both paths
/// are announced consistently.
/// </summary>
internal void FocusActiveControl()
{
this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{

View File

@@ -48,6 +48,7 @@
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
<Style.Setters>
<Setter Property="AutomationProperties.LiveSetting" Value="Off" />
<Setter Property="Background" Value="{ThemeResource DockItemBackground}" />
<Setter Property="BorderBrush" Value="{ThemeResource DockItemBorderBrush}" />
<Setter Property="Padding" Value="{StaticResource DockItemPadding}" />

View File

@@ -219,6 +219,11 @@ public sealed partial class DockWindow : WindowEx,
return;
}
if (_settings == args.DockSettings)
{
return;
}
_settings = args.DockSettings;
RefreshSideOverride();
DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread);

View File

@@ -26,6 +26,9 @@ public sealed partial class DockWindowManager : IDisposable
private bool _disposed;
private int _syncing;
private bool? _lastSyncedEnableDock;
private DockSettings? _lastSyncedDockSettings;
public DockWindowManager(
IMonitorService monitorService,
ISettingsService settingsService,
@@ -217,6 +220,14 @@ public sealed partial class DockWindowManager : IDisposable
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
if (args.EnableDock == _lastSyncedEnableDock && args.DockSettings == _lastSyncedDockSettings)
{
return;
}
_lastSyncedEnableDock = args.EnableDock;
_lastSyncedDockSettings = args.DockSettings;
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Diagnostics;
using System.Runtime.InteropServices;
using CmdPalKeyboardService;
@@ -9,7 +10,6 @@ using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;
@@ -81,6 +81,11 @@ public sealed partial class MainWindow : WindowEx,
private bool _suppressDpiChange;
private bool _themeServiceInitialized;
// The snapshot of settings last consumed by HotReloadSettings. Used to skip redundant
// hot-reloads when a SettingsChanged notification touches settings this window doesn't
// care about (theme, provider config, aliases, and so on).
private SettingsModel? _lastAppliedSettings;
// Session tracking for telemetry
private Stopwatch? _sessionStopwatch;
private int _sessionCommandsExecuted;
@@ -113,6 +118,8 @@ public sealed partial class MainWindow : WindowEx,
public bool IsVisibleToUser { get; private set; } = true;
public event EventHandler? IsVisibleToUserChanged;
public MainWindow()
{
InitializeComponent();
@@ -237,6 +244,14 @@ public sealed partial class MainWindow : WindowEx,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
{
// Only rebuild window state when a setting HotReloadSettings actually consumes has
// changed. Most SettingsChanged notifications (theme, provider config, aliases, ...)
// don't affect the host window, so hot-reloading on every one is wasteful.
if (MainWindowSettingsComparer.Instance.Equals(_lastAppliedSettings, args))
{
return;
}
DispatcherQueue.TryEnqueue(HotReloadSettings);
}
@@ -462,7 +477,11 @@ public sealed partial class MainWindow : WindowEx,
private void HotReloadSettings()
{
// NOTE: SettingsChangedHandler skips this method when nothing relevant changed, using
// MainWindowSettingsComparer. When you start consuming a new setting below, add it to
// that comparer too — otherwise changes to it won't trigger a hot-reload.
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
_lastAppliedSettings = settings;
SetupHotkey(settings);
App.Current.Services.GetService<TrayIconService>()!.SetupTrayIcon(settings.ShowSystemTrayIcon);
@@ -487,6 +506,87 @@ public sealed partial class MainWindow : WindowEx,
private static bool ShouldShowHwndFrame(SettingsModel settings) =>
!BuildInfo.IsCiBuild && settings.ShowHwndFrame;
/// <summary>
/// Compares two <see cref="SettingsModel"/> instances by only the settings that
/// <see cref="HotReloadSettings"/> (and the methods it calls) actually consume. Any change
/// to a setting outside this set is invisible to the host window, so treating such models
/// as equal lets <see cref="SettingsChangedHandler"/> skip a redundant hot-reload.
/// </summary>
/// <remarks>
/// Keep this in sync with the settings read by <see cref="HotReloadSettings"/>,
/// <see cref="SetupHotkey"/>, <see cref="ShouldShowHwndFrame"/>, and
/// <see cref="HandleExpandCompactOnUiThread"/>.
/// </remarks>
private sealed class MainWindowSettingsComparer : IEqualityComparer<SettingsModel>
{
public static MainWindowSettingsComparer Instance { get; } = new();
public bool Equals(SettingsModel? x, SettingsModel? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.UseLowLevelGlobalHotkey == y.UseLowLevelGlobalHotkey
&& x.ShowSystemTrayIcon == y.ShowSystemTrayIcon
&& x.IgnoreShortcutWhenFullscreen == y.IgnoreShortcutWhenFullscreen
&& x.IgnoreShortcutWhenBusy == y.IgnoreShortcutWhenBusy
&& x.AllowBreakthroughShortcut == y.AllowBreakthroughShortcut
&& x.AutoGoHomeInterval == y.AutoGoHomeInterval
&& x.ShowHwndFrame == y.ShowHwndFrame
&& x.CompactMode == y.CompactMode
&& x.Hotkey == y.Hotkey // HotkeySettings is a record (value equality)
&& CommandHotkeysEqual(x.CommandHotkeys, y.CommandHotkeys);
}
// TopLevelHotkey is a record (value equality); compare element-wise. ImmutableList
// itself only implements reference equality, so we can't rely on ==.
private static bool CommandHotkeysEqual(ImmutableList<TopLevelHotkey> x, ImmutableList<TopLevelHotkey> y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x.Count != y.Count)
{
return false;
}
for (var i = 0; i < x.Count; i++)
{
if (x[i] != y[i])
{
return false;
}
}
return true;
}
public int GetHashCode(SettingsModel obj)
{
var hash = default(HashCode);
hash.Add(obj.UseLowLevelGlobalHotkey);
hash.Add(obj.ShowSystemTrayIcon);
hash.Add(obj.IgnoreShortcutWhenFullscreen);
hash.Add(obj.IgnoreShortcutWhenBusy);
hash.Add(obj.AllowBreakthroughShortcut);
hash.Add(obj.AutoGoHomeInterval);
hash.Add(obj.ShowHwndFrame);
hash.Add(obj.CompactMode);
hash.Add(obj.Hotkey);
hash.Add(obj.CommandHotkeys.Count);
return hash.ToHashCode();
}
}
/// <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
@@ -562,6 +662,8 @@ public sealed partial class MainWindow : WindowEx,
var borderColor = showFrame ? DWMWA_COLOR_DEFAULT : DWMWA_COLOR_NONE;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_BORDER_COLOR, &borderColor, sizeof(uint));
}
RedrawWindow(_hwnd);
}
private void InitializeBackdropSupport()
@@ -626,7 +728,7 @@ public sealed partial class MainWindow : WindowEx,
var positionWindowForAnchor = (HWND hwnd) =>
{
PInvoke.GetWindowRect(hwnd, out var bounds);
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER;
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED;
switch (anchorCorner)
{
case AnchorPoint.TopLeft:
@@ -726,11 +828,31 @@ public sealed partial class MainWindow : WindowEx,
// 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.
// SWP_FRAMECHANGED re-sends WM_NCCALCSIZE so the OS recomputes the
// (zero-width) frame. But on this cloak->show path it doesn't reliably
// *repaint* the non-client area, so the WS_THICKFRAME border that was
// painted earlier survives on the top/left/right edges (the bottom is
// clipped away by the card region). A real resize fixes it because it
// forces that NC repaint - so we do the same explicitly below.
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);
// Force the non-client frame to actually redraw (RDW_FRAME) and the
// client to repaint over wherever it used to be. Without this the stale
// border lingers until the user resizes the window.
RedrawWindow(hwnd);
// Treat the overall show/hide lifecycle as the authoritative
// visibility transition, not the lower-level cloak/uncloak helpers.
SetIsVisibleToUser(true);
}
private static void RedrawWindow(HWND hwnd)
{
const uint RDW_INVALIDATE = 0x0001;
const uint RDW_UPDATENOW = 0x0100;
const uint RDW_ALLCHILDREN = 0x0080;
const uint RDW_FRAME = 0x0400;
_ = RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_FRAME);
}
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
@@ -915,6 +1037,10 @@ public sealed partial class MainWindow : WindowEx,
// Sure, it's not ideal, but at least it's not visible.
}
// Treat the overall show/hide lifecycle as the authoritative
// visibility transition, not the lower-level cloak/uncloak helpers.
SetIsVisibleToUser(false);
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
// Start auto-go-home timer
@@ -948,10 +1074,6 @@ public sealed partial class MainWindow : WindowEx,
{
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
}
else
{
IsVisibleToUser = false;
}
wasCloaked = hr.Succeeded;
}
@@ -965,10 +1087,20 @@ public sealed partial class MainWindow : WindowEx,
{
BOOL value = false;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
IsVisibleToUser = true;
}
}
private void SetIsVisibleToUser(bool isVisibleToUser)
{
if (IsVisibleToUser == isVisibleToUser)
{
return;
}
IsVisibleToUser = isVisibleToUser;
IsVisibleToUserChanged?.Invoke(this, EventArgs.Empty);
}
internal void MainWindow_Closed(object sender, WindowEventArgs args)
{
var serviceProvider = App.Current.Services;
@@ -1187,6 +1319,10 @@ public sealed partial class MainWindow : WindowEx,
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeleteObject(IntPtr hObject);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool RedrawWindow(IntPtr hWnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, uint flags);
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
{
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
@@ -1502,6 +1638,7 @@ public sealed partial class MainWindow : WindowEx,
// but that's the price to pay for having the HWND not light-dismiss while we're debugging.
Cloak();
this.Hide();
SetIsVisibleToUser(false);
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
return;
@@ -1552,6 +1689,17 @@ public sealed partial class MainWindow : WindowEx,
case PInvoke.WM_NCCALCSIZE when wParam.Value != 0 && _hwndFrameVisible != true:
return (LRESULT)0;
// On every activation change the OS repaints the non-client frame to
// flip between the active / inactive caption. With WS_THICKFRAME still
// present that paints the OS border back over our borderless window each
// time we lose (or gain) focus - which is the frame that reappears when
// another window steals focus, and the one that shows up shortly after
// startup (the first activation flip). Passing -1 as the update-region
// (lParam) to DefWindowProc keeps the correct activation result while
// telling it to skip the non-client repaint entirely.
case PInvoke.WM_NCACTIVATE when _hwndFrameVisible != true:
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, new LPARAM(-1));
case PInvoke.WM_HOTKEY:
{
var hotkeyIndex = (int)wParam.Value;
@@ -1759,17 +1907,26 @@ public sealed partial class MainWindow : WindowEx,
{
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))
if (!settings.CompactMode)
{
RootElement.SetCardMaxHeight(ComputeExpandedCardMaxHeightDip());
// When compact mode is off the card is always static and fills the entire window,
// regardless of how much content is currently displayed.
RootElement.SetCardStretch(true);
RootElement.SetCardMaxHeight(double.PositiveInfinity);
}
else
{
RootElement.SetCardMaxHeight(double.PositiveInfinity);
// In compact mode the card sizes itself to its content and anchors to the top.
RootElement.SetCardStretch(false);
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
var cardMaxHeight = expanded && IsCenteringSummon(settings)
? ComputeExpandedCardMaxHeightDip()
: double.PositiveInfinity;
RootElement.SetCardMaxHeight(cardMaxHeight);
}
}

View File

@@ -105,6 +105,7 @@ WM_ACTIVATE
WM_ACTIVATEAPP
WA_INACTIVE
WM_DISPLAYCHANGE
WM_NCACTIVATE
WM_SYSCOMMAND
WM_SETTINGCHANGE
WM_WINDOWPOSCHANGING

View File

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

View File

@@ -38,6 +38,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<OpenSettingsMessage>,
IRecipient<HotkeySummonMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<ShowDetailsMessage>,
IRecipient<HideDetailsMessage>,
IRecipient<ClearSearchMessage>,
@@ -67,6 +68,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly CompositeFormat _pageNavigatedAnnouncement;
private readonly ISettingsService _settingsService;
// The last compact-mode setting we reacted to. Lets us ignore hot-reloads of unrelated
// settings and only re-evaluate the layout when compact mode itself changes.
private bool _compactMode;
private SettingsWindow? _settingsWindow;
private DockWindowManager? _dockWindowManager;
@@ -79,20 +86,49 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// select-all the character the user just typed (which triggered the expand). This
// one-shot flag suppresses that select for the expand-driven load.
private bool _suppressSelectOnNextLoad;
private bool _pendingTopBarFocusRestore;
private bool _isDisposed;
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
public event PropertyChangedEventHandler? PropertyChanged;
public IHostWindow? HostWindow { get; set; }
private IHostWindow? _hostWindow;
public IHostWindow? HostWindow
{
get => _hostWindow;
set
{
if (ReferenceEquals(_hostWindow, value))
{
return;
}
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
}
_hostWindow = value;
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged += HostWindow_IsVisibleToUserChanged;
}
}
}
public bool ExpandedMode { get; set; }
// Item keybindings act on the selected item, which is hidden while collapsed — only honor them when expanded.
private bool ItemActionsAllowed => !_compactMode || ExpandedMode;
public ShellPage()
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
this.ExpandedMode = !settings.CompactMode;
_settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
_compactMode = _settingsService.Settings.CompactMode;
this.ExpandedMode = !_compactMode;
this.InitializeComponent();
@@ -100,6 +136,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
WeakReferenceMessenger.Default.Register<ShowDetailsMessage>(this);
@@ -119,6 +156,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
// The compact-mode setting can be toggled while the palette is open. React to the
// hot-reload so the expanded/collapsed layout updates immediately instead of waiting
// for the next navigation or search-text change.
_settingsService.SettingsChanged += OnSettingsChanged;
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
@@ -430,6 +472,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public void Receive(ClearSearchMessage message) => SearchBox.ClearSearch();
public void Receive(FocusSearchBoxMessage message) => RequestTopBarFocusRestore();
public void Receive(HotkeySummonMessage message) => _ = DispatcherQueue.TryEnqueue(() => SummonOnUiThread(message));
public void Receive(SettingsWindowClosedMessage message) => _settingsWindow = null;
@@ -543,9 +587,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
ViewModel.GoHome(withAnimation, focusSearch);
}
if (focusSearch)
// Only move focus when the palette is actually on screen. FocusActiveControl uses keyboard
// focus so the screen reader announces the box; doing that while the window is hidden
// would announce it prematurely.
if (focusSearch && HostWindow?.IsVisibleToUser == true)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
}
@@ -560,10 +607,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
GoBack(withAnimation, focusSearch: false);
}
// focus search box, even if we were already home
if (focusSearch)
// focus search box, even if we were already home (but only when the palette is on
// screen - see GoBack; keyboard focus while hidden announces prematurely).
if (focusSearch && HostWindow?.IsVisibleToUser == true)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
}
@@ -610,6 +658,40 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
private void SearchBox_ActiveFocusTargetChanged(object? sender, EventArgs e)
{
RequestTopBarFocusRestore();
}
private void HostWindow_IsVisibleToUserChanged(object? sender, EventArgs e)
{
if (HostWindow?.IsVisibleToUser == true &&
_pendingTopBarFocusRestore &&
ViewModel.CurrentPage?.HasSearchBox == true)
{
_pendingTopBarFocusRestore = false;
SearchBox.FocusActiveControl();
}
}
private void RequestTopBarFocusRestore()
{
if (ViewModel.CurrentPage?.HasSearchBox != true)
{
_pendingTopBarFocusRestore = false;
return;
}
if (HostWindow?.IsVisibleToUser == true)
{
_pendingTopBarFocusRestore = false;
SearchBox.FocusActiveControl();
return;
}
_pendingTopBarFocusRestore = true;
}
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
@@ -674,6 +756,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
{
// Compact mode is off: the shell always shows the full expanded UI. Set it
// explicitly (rather than trusting the constructor's initial value) so toggling
// the setting off at runtime restores the list and command bar when the palette
// was collapsed.
HandleExpandCompactOnUiThread(true);
return;
}
@@ -733,7 +820,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
return;
}
SearchBox.Focus(FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
else
@@ -865,47 +952,74 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
case VirtualKey.F when modifiers.OnlyAlt: // Alt+F: toggle filter focus
((ShellPage)sender).ToggleFilterFocus();
e.Handled = true;
break;
case VirtualKey.Down when modifiers.None:
case VirtualKey.Tab when modifiers.None:
// In a collapsed compact palette, Down/Tab reveals the top-level items so the
// user can browse and discover them. Only swallow the key when we actually
// expand; otherwise let it fall through to normal list navigation / focus move.
if (((ShellPage)sender).TryExpandCollapsedCompact())
{
e.Handled = true;
}
break;
default:
{
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
// The CommandBar handles item keybindings; skip them while collapsed so a chord can't hit the hidden selection.
if (((ShellPage)sender).ItemActionsAllowed)
{
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
}
break;
}
}
}
private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
private void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
{
var mods = KeyModifiers.GetCurrent();
if (mods.Ctrl && e.Key == VirtualKey.Enter)
if (ItemActionsAllowed && TryHandleItemAction(e))
{
// ctrl+enter
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
e.Handled = true;
return;
}
else if (e.Key == VirtualKey.Enter)
{
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
e.Handled = true;
}
else if (mods.Ctrl && e.Key == VirtualKey.K)
{
// ctrl+k
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
e.Handled = true;
}
else if (e.Key == VirtualKey.Escape)
if (e.Key == VirtualKey.Escape)
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
}
}
private static bool TryHandleItemAction(KeyRoutedEventArgs e)
{
var mods = KeyModifiers.GetCurrent();
switch (e.Key)
{
// Ctrl+Enter
case VirtualKey.Enter when mods.OnlyCtrl:
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
break;
// Enter
case VirtualKey.Enter when mods.None:
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
break;
// Ctrl+K
case VirtualKey.K when mods.OnlyCtrl:
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new(null, null, null, ContextMenuFilterLocation.Bottom));
break;
default:
return false;
}
e.Handled = true;
return true;
}
private void ShellPage_OnPointerPressed(object sender, PointerRoutedEventArgs e)
{
try
@@ -936,6 +1050,24 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
this.DispatcherQueue.TryEnqueue(UpdateCompactModeForCurrentPage);
}
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
// Only the compact-mode setting affects the expanded/collapsed layout, so ignore
// hot-reloads that leave it unchanged. Comparing and updating _compactMode on the UI
// thread keeps it single-threaded regardless of which thread raises the event.
var compactMode = args.CompactMode;
this.DispatcherQueue.TryEnqueue(() =>
{
if (compactMode == _compactMode)
{
return;
}
_compactMode = compactMode;
UpdateCompactModeForCurrentPage();
});
}
private void HandleExpandCompactOnUiThread(bool expanded)
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
@@ -953,6 +1085,23 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
}
/// <summary>
/// Expands a collapsed compact palette on demand (via Down/Tab) so the user can browse the
/// top-level items. Returns <see langword="false"/> and does nothing unless compact mode is
/// on and the palette is currently collapsed, letting the caller keep the key's normal
/// meaning (list navigation / focus traversal) in every other case.
/// </summary>
private bool TryExpandCollapsedCompact()
{
if (!_compactMode || ExpandedMode)
{
return false;
}
HandleExpandCompactOnUiThread(true);
return true;
}
/// <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.
@@ -979,6 +1128,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_isDisposed = true;
WeakReferenceMessenger.Default.UnregisterAll(this);
_settingsService.SettingsChanged -= OnSettingsChanged;
if (_hostWindow is not null)
{
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
_hostWindow = null;
}
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();

View File

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

View File

@@ -13,8 +13,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class AvailableResultsListTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class FallbackTimeDateItemTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -5,6 +5,7 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>

View File

@@ -0,0 +1,160 @@
// 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.Globalization;
using Microsoft.CmdPal.Ext.TimeDate;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposable", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed in TestCleanup")]
public class NowDockBandTests
{
private static readonly DateTime FixedTime = new DateTime(2025, 7, 1, 14, 5, 32);
private CultureInfo _originalCulture = null!;
private CultureInfo _originalUiCulture = null!;
private NowDockBand? _band;
[TestInitialize]
public void Setup()
{
_originalCulture = CultureInfo.CurrentCulture;
_originalUiCulture = CultureInfo.CurrentUICulture;
CultureInfo.CurrentCulture = new CultureInfo("en-US", false);
CultureInfo.CurrentUICulture = new CultureInfo("en-US", false);
}
[TestCleanup]
public void Cleanup()
{
_band?.Dispose();
_band = null;
CultureInfo.CurrentCulture = _originalCulture;
CultureInfo.CurrentUICulture = _originalUiCulture;
}
[TestMethod]
public void Constructor_TitleIsSetImmediately()
{
_band = new NowDockBand(clock: () => FixedTime);
Assert.AreEqual("2:05 PM", _band.Title);
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle));
}
[TestMethod]
public void UpdateText_LongTimeFormat_TitleContainsSeconds()
{
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual("2:05:32 PM", _band.Title);
}
[TestMethod]
public void UpdateText_ShortDateFormat_SubtitleIsShortDate()
{
_band = new NowDockBand(clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual("7/1/2025", _band.Subtitle);
}
[TestMethod]
public void UpdateText_FiresOnUpdatedCallback()
{
var callbackFired = false;
_band = new NowDockBand(onUpdated: () => callbackFired = true, clock: () => FixedTime);
callbackFired = false; // reset — constructor already fired it once during synchronous UpdateText()
_band.UpdateText();
Assert.IsTrue(callbackFired);
}
[TestMethod]
public void UpdateText_CallbackFiredAfterAssignments()
{
var titleAtCallback = string.Empty;
_band = new NowDockBand(
onUpdated: () => titleAtCallback = _band?.Title ?? string.Empty,
clock: () => FixedTime);
titleAtCallback = string.Empty; // reset after construction callback
_band.UpdateText();
Assert.IsFalse(string.IsNullOrEmpty(titleAtCallback), "Title should be assigned before callback fires");
}
[TestMethod]
public void UpdateText_CopyCommandsUpdated()
{
_band = new NowDockBand(clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual(_band.Title, _band.CopyTimeCommand.Text);
Assert.AreEqual(_band.Subtitle, _band.CopyDateCommand.Text);
}
[DataTestMethod]
[DataRow("de-DE")]
[DataRow("fr-FR")]
[DataRow("ar-SA")]
public void UpdateText_CultureSmoke_TitleNonEmpty(string cultureName)
{
// Culture MUST be set before construction — constructor calls UpdateText() synchronously
CultureInfo.CurrentCulture = new CultureInfo(cultureName, false);
CultureInfo.CurrentUICulture = new CultureInfo(cultureName, false);
_band = new NowDockBand(clock: () => FixedTime);
Assert.IsFalse(string.IsNullOrEmpty(_band.Title), $"Title should be non-empty for culture '{cultureName}'");
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle), $"Subtitle should be non-empty for culture '{cultureName}'");
}
[TestMethod]
public void UpdateSettings_EnablingSeconds_TitleIncludesSeconds()
{
_band = new NowDockBand(timeWithSeconds: false, clock: () => FixedTime);
Assert.AreEqual("2:05 PM", _band.Title, "Precondition: seconds hidden by default");
_band.UpdateSettings(timeWithSeconds: true);
Assert.AreEqual("2:05:32 PM", _band.Title, "Title should update live to include seconds");
}
[TestMethod]
public void UpdateSettings_DisablingSeconds_TitleDropsSeconds()
{
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
Assert.AreEqual("2:05:32 PM", _band.Title, "Precondition: seconds shown");
_band.UpdateSettings(timeWithSeconds: false);
Assert.AreEqual("2:05 PM", _band.Title, "Title should update live to drop seconds");
}
[TestMethod]
public void UpdateSettings_NoChange_FiresNoCallback()
{
var callbackCount = 0;
_band = new NowDockBand(timeWithSeconds: false, onUpdated: () => callbackCount++, clock: () => FixedTime);
callbackCount = 0; // reset after construction callback
_band.UpdateSettings(timeWithSeconds: false);
Assert.AreEqual(0, callbackCount, "A no-op settings change should not refresh the band");
}
}

View File

@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class QueryTests : CommandPaletteUnitTestBase
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -166,7 +166,7 @@ public class QueryTests : CommandPaletteUnitTestBase
Assert.IsTrue(results.Length > 0, $"Query '{query}' should return at least one result");
var firstItem = results.FirstOrDefault();
Assert.IsTrue(firstItem.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
Assert.IsTrue(firstItem!.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
}
[DataTestMethod]
@@ -201,7 +201,7 @@ public class QueryTests : CommandPaletteUnitTestBase
// Assert
Assert.IsNotNull(results);
var firstResult = results.FirstOrDefault();
Assert.IsTrue(firstResult.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
Assert.IsTrue(firstResult!.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
}
[DataTestMethod]
@@ -218,7 +218,7 @@ public class QueryTests : CommandPaletteUnitTestBase
// Assert
Assert.IsNotNull(resultsList);
var firstResult = resultsList.FirstOrDefault();
Assert.IsTrue(firstResult.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
Assert.IsTrue(firstResult!.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
}
[TestMethod]

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class ResultHelperTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -41,6 +41,8 @@ public class ResultHelperTests
{
Label = "Test Label",
Value = "Test Value",
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -55,10 +57,10 @@ public class ResultHelperTests
[TestMethod]
public void ResultHelper_CreateListItem_HandlesNullInput()
{
AvailableResult availableResult = null;
AvailableResult? availableResult = null;
// Act & Assert
Assert.ThrowsException<System.NullReferenceException>(() => availableResult.ToListItem());
Assert.ThrowsException<System.NullReferenceException>(() => availableResult!.ToListItem());
}
[TestMethod]
@@ -69,6 +71,8 @@ public class ResultHelperTests
{
Label = string.Empty,
Value = string.Empty,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -88,6 +92,7 @@ public class ResultHelperTests
{
Label = "Test Label",
Value = "Test Value",
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Date,
};
@@ -110,6 +115,8 @@ public class ResultHelperTests
{
Label = longText,
Value = longText,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -130,6 +137,8 @@ public class ResultHelperTests
{
Label = specialText,
Value = specialText,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act

View File

@@ -13,6 +13,7 @@ public class Settings : ISettingsInterface
private readonly int firstDayOfWeek;
private readonly bool enableFallbackItems;
private readonly bool timeWithSecond;
private readonly bool dockClockWithSecond;
private readonly bool dateWithWeekday;
private readonly List<string> customFormats;
@@ -21,13 +22,15 @@ public class Settings : ISettingsInterface
int firstDayOfWeek = -1,
bool enableFallbackItems = true,
bool timeWithSecond = false,
bool dockClockWithSecond = false,
bool dateWithWeekday = false,
List<string> customFormats = null)
List<string>? customFormats = null)
{
this.firstWeekOfYear = firstWeekOfYear;
this.firstDayOfWeek = firstDayOfWeek;
this.enableFallbackItems = enableFallbackItems;
this.timeWithSecond = timeWithSecond;
this.dockClockWithSecond = dockClockWithSecond;
this.dateWithWeekday = dateWithWeekday;
this.customFormats = customFormats ?? new List<string>();
}
@@ -40,6 +43,8 @@ public class Settings : ISettingsInterface
public bool TimeWithSecond => timeWithSecond;
public bool DockClockWithSecond => dockClockWithSecond;
public bool DateWithWeekday => dateWithWeekday;
public List<string> CustomFormats => customFormats;

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class StringParserTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class TimeAndDateHelperTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class TimeDateCalculatorTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -69,7 +69,7 @@ public class TimeDateCalculatorTests
var settings = new SettingsManager();
// Act
var results = TimeDateCalculator.ExecuteSearch(settings, null);
var results = TimeDateCalculator.ExecuteSearch(settings, null!);
// Assert
Assert.IsNotNull(results);

View File

@@ -11,8 +11,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
[TestClass]
public class TimeDateCommandsProviderTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -90,5 +90,27 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
// Assert
Assert.IsFalse(string.IsNullOrEmpty(displayName));
}
[TestMethod]
public void GetDockBands_ReturnsNonEmptyArray()
{
var provider = new TimeDateCommandsProvider();
var bands = provider.GetDockBands();
Assert.IsTrue(bands.Length > 0, "GetDockBands should return at least one item");
Assert.IsNotNull(bands[0], "First dock band should not be null");
}
[TestMethod]
public void GetDockBands_NotificationCenterBandDoesNotSetDockIcon()
{
var provider = new TimeDateCommandsProvider();
var bands = provider.GetDockBands();
Assert.IsTrue(bands.Length > 1, "Expected notification center band to be present");
Assert.IsNull(bands[1].Icon, "Notification center band should not set a dock icon");
}
}
}

View File

@@ -0,0 +1,137 @@
// 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.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class DetailsViewModelTests
{
private sealed class TestPageContext : IPageContext
{
public TaskScheduler Scheduler => TaskScheduler.Default;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint = null)
{
throw new AssertFailedException($"Unexpected exception from view model: {ex}");
}
}
private static WeakReference<IPageContext> CreatePageContext()
{
var ctx = new TestPageContext();
return new WeakReference<IPageContext>(ctx);
}
[TestMethod]
public void InitializeProperties_SetsBodyAndTitle()
{
var details = new Details { Title = "Hello", Body = "World" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
Assert.AreEqual("Hello", vm.Title);
Assert.AreEqual("World", vm.Body);
}
[TestMethod]
public void PropChanged_Body_UpdatesViewModelProperty()
{
var details = new Details { Title = "Initial", Body = "Initial body" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
// Act — toolkit Details raises PropChanged synchronously on set
details.Body = "Updated body";
// The property value is set synchronously in FetchProperty;
// ApplyPendingUpdates flushes the PropertyChanged notification queue.
vm.ApplyPendingUpdates();
Assert.AreEqual("Updated body", vm.Body);
}
[TestMethod]
public void PropChanged_Title_UpdatesViewModelProperty()
{
var details = new Details { Title = "Original", Body = "Text" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
details.Title = "New Title";
vm.ApplyPendingUpdates();
Assert.AreEqual("New Title", vm.Title);
}
[TestMethod]
public void PropChanged_Metadata_RebuildsList()
{
var details = new Details
{
Title = "T",
Body = "B",
Metadata = [],
};
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
Assert.AreEqual(0, vm.Metadata.Count);
// Act — update metadata with a link element
details.Metadata = [new DetailsElement { Key = "link", Data = new DetailsLink("http://example.com", "Example") }];
vm.ApplyPendingUpdates();
Assert.AreEqual(1, vm.Metadata.Count);
}
[TestMethod]
public void Cleanup_UnsubscribesFromPropChanged()
{
var details = new Details { Title = "T", Body = "Original" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
// Act — cleanup unsubscribes, then change should not propagate
vm.SafeCleanup();
details.Body = "After cleanup";
Assert.AreEqual("Original", vm.Body);
}
[TestMethod]
public void NonObservableDetails_DoesNotThrow()
{
// IDetails that does NOT implement INotifyPropChanged
var details = new NonObservableDetails();
var vm = new DetailsViewModel(details, CreatePageContext());
// Should not throw — just doesn't subscribe to anything
vm.InitializeProperties();
Assert.AreEqual("Static Title", vm.Title);
Assert.AreEqual("Static Body", vm.Body);
}
/// <summary>
/// A minimal IDetails that does NOT implement INotifyPropChanged.
/// </summary>
private sealed partial class NonObservableDetails : IDetails
{
public IIconInfo HeroImage => new IconInfo(string.Empty);
public string Title => "Static Title";
public string Body => "Static Body";
public IDetailsElement[] Metadata => [];
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that <see cref="DockSettings"/> (and its nested <see cref="DockMonitorConfig"/>)
/// compare by <em>content</em> rather than by list reference. Dock consumers guard their
/// (expensive) reloads with <c>_settings == args.DockSettings</c>, so two settings that differ
/// only by having freshly-rebuilt band lists — e.g. after loading from disk — must compare
/// equal, otherwise the reload fires needlessly.
/// </summary>
[TestClass]
public class DockSettingsEqualityTests
{
private static DockBandSettings Band(string providerId, string commandId, bool? showTitles = null) =>
new() { ProviderId = providerId, CommandId = commandId, ShowTitles = showTitles };
[TestMethod]
public void Equal_WhenBandListsHaveSameContentButDifferentInstances()
{
var a = new DockSettings
{
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
};
var b = new DockSettings
{
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
};
// Distinct ImmutableList instances — would be reference-unequal without EquatableList.
Assert.AreNotSame(a.StartBands, b.StartBands);
Assert.AreEqual(a, b);
Assert.IsTrue(a == b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void NotEqual_WhenABandDiffers()
{
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "home")) };
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "settings")) };
Assert.AreNotEqual(a, b);
Assert.IsTrue(a != b);
}
[TestMethod]
public void NotEqual_WhenBandOrderDiffers()
{
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "a"), Band("p", "b")) };
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "b"), Band("p", "a")) };
Assert.AreNotEqual(a, b);
}
[TestMethod]
public void Equal_WhenMonitorConfigsHaveSameContentIncludingPerMonitorBands()
{
var a = new DockSettings
{
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(Band("p", "home")),
}),
};
var b = new DockSettings
{
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(Band("p", "home")),
}),
};
Assert.AreEqual(a, b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void NotEqual_WhenPerMonitorBandsIsNullVersusEmpty()
{
// null means "inherit global bands"; an explicit empty list does not. The two must
// stay distinguishable so a monitor can't silently switch between the two meanings.
var inherit = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = null };
var explicitEmpty = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = ImmutableList<DockBandSettings>.Empty };
Assert.AreNotEqual(inherit, explicitEmpty);
}
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Tests for <see cref="EquatableList{T}"/> — the value-equality wrapper used as the backing
/// field for record list properties so record equality compares list contents, not references.
/// </summary>
[TestClass]
public class EquatableListTests
{
private static EquatableList<string> Of(params string[] items) =>
new(ImmutableList.Create(items));
[TestMethod]
public void Equal_WhenSameContentDifferentInstances()
{
var a = Of("x", "y", "z");
var b = Of("x", "y", "z");
Assert.AreNotSame(a.List, b.List);
Assert.IsTrue(a.Equals(b));
Assert.AreEqual(a, b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void Equal_WhenSameUnderlyingReference()
{
var shared = ImmutableList.Create("a", "b");
var a = new EquatableList<string>(shared);
var b = new EquatableList<string>(shared);
Assert.IsTrue(a.Equals(b));
}
[TestMethod]
public void NotEqual_WhenAnElementDiffers()
{
Assert.AreNotEqual(Of("a", "b"), Of("a", "c"));
}
[TestMethod]
public void NotEqual_WhenOrderDiffers()
{
Assert.AreNotEqual(Of("a", "b"), Of("b", "a"));
}
[TestMethod]
public void NotEqual_WhenCountsDiffer()
{
Assert.AreNotEqual(Of("a"), Of("a", "b"));
}
[TestMethod]
public void EmptyLists_AreEqual()
{
var fromEmpty = new EquatableList<string>(ImmutableList<string>.Empty);
var fromNull = new EquatableList<string>(null);
Assert.IsTrue(fromEmpty.Equals(fromNull));
Assert.AreEqual(fromEmpty.GetHashCode(), fromNull.GetHashCode());
}
[TestMethod]
public void Default_ExposesEmptyListAndEqualsEmpty()
{
EquatableList<string> defaulted = default;
// A default(struct) has a null inner list; List must still surface an empty list
// (never null) and compare equal to an explicitly-empty instance.
Assert.IsNotNull(defaulted.List);
Assert.AreEqual(0, defaulted.List.Count);
Assert.IsTrue(defaulted.Equals(new EquatableList<string>(ImmutableList<string>.Empty)));
}
[TestMethod]
public void List_IsNeverNull()
{
Assert.IsNotNull(new EquatableList<string>(null).List);
}
[TestMethod]
public void EqualsObject_ReturnsFalse_ForOtherTypes()
{
object other = "not an equatable list";
Assert.IsFalse(Of("a").Equals(other));
Assert.IsFalse(Of("a").Equals(null!));
}
[TestMethod]
public void EqualsObject_ReturnsTrue_ForBoxedEqualValue()
{
object boxed = Of("a", "b");
Assert.IsTrue(Of("a", "b").Equals(boxed));
}
}

View File

@@ -19,6 +19,16 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
public const string ProviderLoadGuardBlockId = ProviderIdValue + ".ProviderLoad";
public const string PageIdValue = "com.microsoft.cmdpal.performanceWidget";
private static readonly PerformanceMetricKind?[] BandMetrics =
[
null,
PerformanceMetricKind.Cpu,
PerformanceMetricKind.Memory,
PerformanceMetricKind.Network,
PerformanceMetricKind.Gpu,
PerformanceMetricKind.Battery,
];
internal static ProviderCrashSentinel CrashSentinel { get; } = new(ProviderIdValue);
private readonly Lock _stateLock = new();
@@ -109,15 +119,26 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
DisposeActivePages();
var page = new PerformanceMonitorDisabledPage(this);
var band = new PerformanceMonitorDisabledPage(this);
_bands =
[
new CommandItem(band)
// Mirror the compact form of the real bands, and keep texts short
var disabledValue = Resources.GetResource("Performance_Monitor_Disabled_Band_Title");
var bands = new List<ICommandItem>(BandMetrics.Length);
foreach (var metric in BandMetrics)
{
var icon = GetBandIcon(metric);
var item = new ListItem(page)
{
Title = Resources.GetResource("Performance_Monitor_Disabled_Band_Title"),
Subtitle = DisplayName,
},
];
Title = disabledValue,
Subtitle = GetBandSubtitle(metric),
Icon = icon,
};
bands.Add(new WrappedDockItem([item], PerformanceWidgetsPage.GetBandId(metric), GetBandDisplayTitle(metric))
{
Icon = icon,
});
}
_bands = bands.ToArray();
_commands =
[
new CommandItem(page)
@@ -129,6 +150,45 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
_softDisabled = true;
}
private string GetBandDisplayTitle(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Title"),
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Title"),
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Title"),
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Title"),
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Title"),
_ => DisplayName,
};
}
private string GetBandSubtitle(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Subtitle"),
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Subtitle"),
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Subtitle"),
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Subtitle"),
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Subtitle"),
_ => string.Empty,
};
}
private static IconInfo GetBandIcon(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Icons.CpuIcon,
PerformanceMetricKind.Memory => Icons.MemoryIcon,
PerformanceMetricKind.Network => Icons.NetworkIcon,
PerformanceMetricKind.Gpu => Icons.GpuIcon,
PerformanceMetricKind.Battery => Icons.BatteryIcon,
_ => Icons.PerformanceMonitorIcon,
};
}
private void SetEnabledState()
{
DisposeActivePages();

View File

@@ -12,9 +12,9 @@ internal sealed partial class PerformanceMonitorDisabledPage : ContentPage
{
private readonly MarkdownContent _content;
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider)
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider, string? id = null)
{
Id = PerformanceMonitorCommandsProvider.PageIdValue;
Id = id ?? PerformanceMonitorCommandsProvider.PageIdValue;
Name = Resources.GetResource("Performance_Monitor_Disabled_Title");
Title = Resources.GetResource("Performance_Monitor_Title");
Icon = Icons.PerformanceMonitorIcon;

View File

@@ -93,7 +93,7 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
{
_isBandPage = isBandPage;
_singleMetric = singleMetric;
_id = singleMetric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(singleMetric.Value)}";
_id = GetBandId(singleMetric);
if (IncludesMetric(PerformanceMetricKind.Cpu))
{
@@ -287,6 +287,11 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
_batteryPage?.Dispose();
}
internal static string GetBandId(PerformanceMetricKind? metric)
{
return metric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(metric.Value)}";
}
private bool IncludesMetric(PerformanceMetricKind metric)
{
return _singleMetric is null || _singleMetric == metric;

View File

@@ -199,7 +199,7 @@
<value>Temporarily disabled after repeated startup crashes</value>
</data>
<data name="Performance_Monitor_Disabled_Band_Title" xml:space="preserve">
<value>Perf disabled</value>
<value>Disabled</value>
</data>
<data name="Performance_Monitor_Disabled_Body" xml:space="preserve">
<value>Performance monitor was temporarily disabled after repeated crashes while initializing or reading performance counters.

View File

@@ -29,16 +29,16 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
_validOptions = new(StringComparer.OrdinalIgnoreCase)
{
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture)!,
};
}
@@ -53,7 +53,7 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
}
var availableResults = AvailableResultsList.GetList(false, _settingsManager, timestamp: _timestamp);
ListItem result = null;
ListItem? result = null;
var maxScore = 0;
foreach (var f in availableResults)

View File

@@ -10,22 +10,22 @@ internal sealed class AvailableResult
/// <summary>
/// Gets or sets the time/date value
/// </summary>
internal string Value { get; set; }
internal required string Value { get; set; }
/// <summary>
/// Gets or sets the text used for the subtitle and as search term
/// </summary>
internal string Label { get; set; }
internal required string Label { get; set; }
/// <summary>
/// Gets or sets an alternative search tag that will be evaluated if label doesn't match. For example we like to show the era on searches for 'year' too.
/// </summary>
internal string AlternativeSearchTag { get; set; }
internal required string AlternativeSearchTag { get; set; }
/// <summary>
/// Gets or sets a value indicating the type of result
/// </summary>
internal ResultIconType IconType { get; set; }
internal required ResultIconType IconType { get; set; }
/// <summary>
/// Gets or sets a value to show additional error details
@@ -37,7 +37,7 @@ internal sealed class AvailableResult
/// </summary>
/// <param name="theme">Theme</param>
/// <returns>Path</returns>
public IconInfo GetIconInfo()
public IconInfo? GetIconInfo()
{
return IconType switch
{

View File

@@ -20,6 +20,8 @@ public interface ISettingsInterface
public bool TimeWithSecond { get; }
public bool DockClockWithSecond { get; }
public bool DateWithWeekday { get; }
public List<string> CustomFormats { get; }

View File

@@ -17,7 +17,7 @@ internal static class ResultHelper
/// <param name="stringId">Id of the string. (Example: `MyString` for `MyString` and `MyStringNow`)</param>
/// <param name="stringIdNow">Optional string id for now case</param>
/// <returns>The string from the resource file, or <see cref="string.Empty"/> otherwise.</returns>
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string stringIdNow = default)
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string? stringIdNow = default)
{
return !isSystemTimeDate
? Resources.ResourceManager.GetString(stringId, CultureInfo.CurrentUICulture) ?? string.Empty

View File

@@ -87,6 +87,12 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Resources.Microsoft_plugin_timedate_SettingTimeWithSeconds_Description,
false); // TODO -- double check default value
private readonly ToggleSetting _dockClockWithSeconds = new(
Namespaced(nameof(DockClockWithSecond)),
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds,
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description,
false);
private readonly ToggleSetting _dateWithWeekday = new(
Namespaced(nameof(DateWithWeekday)),
Resources.Microsoft_plugin_timedate_SettingDateWithWeekday,
@@ -143,9 +149,11 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool TimeWithSecond => _timeWithSeconds.Value;
public bool DockClockWithSecond => _dockClockWithSeconds.Value;
public bool DateWithWeekday => _dateWithWeekday.Value;
public List<string> CustomFormats => _customFormats.Value.Split(TEXTBOXNEWLINE).ToList();
public List<string> CustomFormats => (_customFormats.Value ?? string.Empty).Split(TEXTBOXNEWLINE).ToList();
internal static string SettingsJsonPath()
{
@@ -161,6 +169,7 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Settings.Add(_enableFallbackItems);
Settings.Add(_timeWithSeconds);
Settings.Add(_dockClockWithSeconds);
Settings.Add(_dateWithWeekday);
Settings.Add(_firstWeekOfYear);
Settings.Add(_firstDayOfWeek);

View File

@@ -5,6 +5,7 @@
<PropertyGroup>
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate</RootNamespace>
<Nullable>enable</Nullable>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>

View File

@@ -0,0 +1,134 @@
// 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.Globalization;
using Microsoft.CmdPal.Ext.TimeDate.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.TimeDate;
internal sealed partial class NowDockBand : ListItem, IDisposable
{
private static readonly TimeSpan PerSecondUpdateInterval = TimeSpan.FromSeconds(1);
private static readonly TimeSpan PerMinuteUpdateInterval = TimeSpan.FromMinutes(1);
private readonly System.Timers.Timer _timer;
private readonly Action? _onUpdated;
private readonly Func<DateTime> _clock;
private bool _timeWithSeconds;
private CopyTextCommand _copyTimeCommand;
private CopyTextCommand _copyDateCommand;
internal CopyTextCommand CopyTimeCommand => _copyTimeCommand;
internal CopyTextCommand CopyDateCommand => _copyDateCommand;
internal NowDockBand(bool timeWithSeconds = false, Action? onUpdated = null, Func<DateTime>? clock = null)
{
_timeWithSeconds = timeWithSeconds;
_onUpdated = onUpdated;
_clock = clock ?? (() => DateTime.Now);
Command = new OpenUrlCommand("ms-actioncenter:")
{
Id = "com.microsoft.cmdpal.timedate.dockBand",
Name = Resources.timedate_show_notification_center_command_name,
Result = CommandResult.Dismiss(),
};
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
MoreCommands =
[
new CommandContextItem(_copyTimeCommand),
new CommandContextItem(_copyDateCommand),
];
UpdateText();
_timer = new System.Timers.Timer() { AutoReset = true };
ConfigureTimer();
}
// Reads the current "show seconds" preference and, if it changed, reconfigures
// the timer cadence and refreshes the displayed text. Safe to call at any time
// (e.g. from a settings-changed handler) so the dock clock stays in sync without
// requiring the app to restart.
internal void UpdateSettings(bool timeWithSeconds)
{
if (_timeWithSeconds == timeWithSeconds)
{
return;
}
_timeWithSeconds = timeWithSeconds;
ConfigureTimer();
UpdateText();
}
private void ConfigureTimer()
{
_timer.Stop();
_timer.Elapsed -= Timer_Elapsed;
_timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
if (_timeWithSeconds)
{
_timer.Interval = PerSecondUpdateInterval.TotalMilliseconds;
_timer.Elapsed += Timer_Elapsed;
}
else
{
// Align the first tick to the next minute boundary so the clock flips
// exactly when the system clock does, then fall back to a per-minute cadence.
var now = _clock();
_timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
_timer.Elapsed += Timer_ElapsedFirstMinuteTick;
}
_timer.Start();
}
private void Timer_ElapsedFirstMinuteTick(object? sender, System.Timers.ElapsedEventArgs e)
{
if (sender is System.Timers.Timer timer)
{
timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds;
timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
timer.Elapsed += Timer_Elapsed;
}
Timer_Elapsed(sender, e);
}
private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
UpdateText();
}
internal void UpdateText()
{
var now = _clock();
var timeString = now.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, _timeWithSeconds, false),
CultureInfo.CurrentCulture);
var dateString = now.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, false, false),
CultureInfo.CurrentCulture);
Title = timeString;
Subtitle = dateString;
_copyTimeCommand.Text = timeString;
_copyDateCommand.Text = dateString;
_onUpdated?.Invoke(); // Must remain last — ViewModel reads Title/Subtitle via GetItems() on callback
}
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
}
}

View File

@@ -834,6 +834,24 @@ namespace Microsoft.CmdPal.Ext.TimeDate {
}
}
/// <summary>
/// Looks up a localized string similar to Show seconds on the dock clock.
/// </summary>
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds {
get {
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This setting applies to the clock shown on the Command Palette dock..
/// </summary>
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description {
get {
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Select for more details..
/// </summary>

View File

@@ -267,6 +267,12 @@
<data name="Microsoft_plugin_timedate_SettingTimeWithSeconds_Description" xml:space="preserve">
<value>This setting applies to the 'Time' and 'Now' result.</value>
</data>
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds" xml:space="preserve">
<value>Show seconds on the dock clock</value>
</data>
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description" xml:space="preserve">
<value>This setting applies to the clock shown on the Command Palette dock.</value>
</data>
<data name="Microsoft_plugin_timedate_SubTitleNote" xml:space="preserve">
<value>Select or press Ctrl+C to copy</value>
<comment>'Ctrl+C' is a shortcut</comment>

View File

@@ -20,8 +20,11 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
private static readonly TimeDateExtensionPage _timeDateExtensionPage = new(_settingsManager);
private readonly FallbackTimeDateItem _fallbackTimeDateItem = new(_settingsManager);
private readonly ListItem _bandItem;
private readonly ListItem _notificationCenterBandItem;
private readonly WrappedDockItem _bandItem;
private readonly WrappedDockItem _notificationCenterBandItem;
// Keep a reference to the band so we can dispose it when the provider is disposed.
private NowDockBand? _nowDockBand;
public TimeDateCommandsProvider()
{
@@ -37,8 +40,40 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
Icon = _timeDateExtensionPage.Icon;
Settings = _settingsManager.Settings;
_bandItem = new NowDockBand();
_notificationCenterBandItem = new NotificationCenterDockBand();
WrappedDockItem? wrappedBand = null;
// During NowDockBand construction, UpdateText() runs synchronously.
// At that point wrappedBand is still null so the callback is a no-op.
// On subsequent timer ticks, wrappedBand is non-null and SetItems fires
// RaiseItemsChanged - the framework marshals to the UI thread in
// DockBandViewModel.InitializeFromList via DoOnUiThread.
_nowDockBand = new NowDockBand(_settingsManager.DockClockWithSecond, onUpdated: () =>
{
if (wrappedBand is not null)
{
wrappedBand.Items = [_nowDockBand!];
}
});
// Re-read the dock clock preference whenever settings change so the band updates
// live (no app restart required). The band ignores no-op changes internally.
_settingsManager.Settings.SettingsChanged += OnSettingsChanged;
wrappedBand = new WrappedDockItem(
[_nowDockBand],
"com.microsoft.cmdpal.timedate.dockBand",
Resources.Microsoft_plugin_timedate_dock_band_title)
{
Icon = Icons.TimeDateExtIcon,
};
_bandItem = wrappedBand;
var notificationCenterBand = new NotificationCenterDockBand();
_notificationCenterBandItem = new WrappedDockItem(
[notificationCenterBand],
"com.microsoft.cmdpal.timedate.notificationCenterBand",
Resources.timedate_notification_center_band_title);
}
private string GetTranslatedPluginDescription()
@@ -56,97 +91,26 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
public override ICommandItem[] GetDockBands()
{
var clockBand = new WrappedDockItem(
[_bandItem],
"com.microsoft.cmdpal.timedate.dockBand",
Resources.Microsoft_plugin_timedate_dock_band_title)
{
Icon = Icons.TimeDateExtIcon,
};
return [_bandItem, _notificationCenterBandItem];
}
var notificationBand = new WrappedDockItem(
[_notificationCenterBandItem],
"com.microsoft.cmdpal.timedate.notificationCenterBand",
Resources.timedate_notification_center_band_title)
{
Icon = Icons.NotificationCenterIcon,
};
private void OnSettingsChanged(object sender, Settings args)
{
_nowDockBand?.UpdateSettings(_settingsManager.DockClockWithSecond);
}
return new ICommandItem[] { clockBand, notificationBand };
public override void Dispose()
{
_settingsManager.Settings.SettingsChanged -= OnSettingsChanged;
_nowDockBand?.Dispose();
_nowDockBand = null;
GC.SuppressFinalize(this);
base.Dispose();
}
}
#pragma warning disable SA1402 // File may only contain a single type
internal sealed partial class NowDockBand : ListItem
{
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(60);
private CopyTextCommand _copyTimeCommand;
private CopyTextCommand _copyDateCommand;
public NowDockBand()
{
// Open Notification Center on click
Command = new OpenUrlCommand("ms-actioncenter:") { Id = "com.microsoft.cmdpal.timedate.dockBand", Name = Resources.timedate_show_notification_center_command_name, Result = CommandResult.Dismiss(), Icon = null };
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
MoreCommands = [
new CommandContextItem(_copyTimeCommand),
new CommandContextItem(_copyDateCommand)
];
UpdateText();
// Create a timer to update the time every minute
System.Timers.Timer timer = new(UpdateInterval.TotalMilliseconds);
// but we want it to tick on the minute, so calculate the initial delay
var now = DateTime.Now;
timer.Interval = UpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
// then after the first tick, set it to 60 seconds
timer.Elapsed += Timer_ElapsedFirst;
timer.Start();
}
private void Timer_ElapsedFirst(object sender, System.Timers.ElapsedEventArgs e)
{
// After the first tick, set the interval to 60 seconds
if (sender is System.Timers.Timer timer)
{
timer.Interval = UpdateInterval.TotalMilliseconds;
timer.Elapsed -= Timer_ElapsedFirst;
timer.Elapsed += Timer_Elapsed;
// Still call the callback, so that we update the clock
Timer_Elapsed(sender, e);
}
}
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
UpdateText();
}
private void UpdateText()
{
var timeExtended = false; // timeLongFormat ?? settings.TimeWithSecond;
var dateExtended = false; // dateLongFormat ?? settings.DateWithWeekday;
var dateTimeNow = DateTime.Now;
var timeString = dateTimeNow.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, timeExtended, dateExtended),
CultureInfo.CurrentCulture);
var dateString = dateTimeNow.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, timeExtended, dateExtended),
CultureInfo.CurrentCulture);
Title = timeString;
Subtitle = dateString;
_copyDateCommand.Text = dateString;
_copyTimeCommand.Text = timeString;
}
}
internal sealed partial class NotificationCenterDockBand : ListItem
{
public NotificationCenterDockBand()

View File

@@ -0,0 +1,103 @@
// 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.Globalization;
using System.Timers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
#nullable enable
namespace SamplePagesExtension;
internal sealed partial class SampleLiveDetailsPage : ListPage, IDisposable
{
private readonly Details _clockDetails = new()
{
Title = "Current Time",
Body = "Loading...",
};
private readonly Details _counterDetails = new()
{
Title = "Count: 0",
Body = "Elapsed: 0 seconds",
};
private readonly Details _staticDetails = new()
{
Title = "Static Details",
Body = "This item does not update. Select the items above to see live updates in the details pane.",
};
private readonly ListItem[] _items;
private Timer? _timer;
private int _counter;
private bool _disposed;
public SampleLiveDetailsPage()
{
Icon = new IconInfo("\uE916"); // Refresh
Name = Title = "Live Updating Details";
ShowDetails = true;
_items = [
new ListItem(new NoOpCommand())
{
Title = "Live Clock",
Subtitle = "Details pane shows current time, updating every second",
Details = _clockDetails,
},
new ListItem(new NoOpCommand())
{
Title = "Counter",
Subtitle = "Details pane increments a counter every second",
Details = _counterDetails,
},
new ListItem(new NoOpCommand())
{
Title = "Static Item",
Subtitle = "This item's details do not change",
Details = _staticDetails,
},
];
}
public override IListItem[] GetItems()
{
if (_timer is null)
{
_timer = new Timer(1000);
_timer.Elapsed += Timer_Elapsed;
_timer.AutoReset = true;
_timer.Enabled = true;
}
return _items;
}
private void Timer_Elapsed(object? sender, ElapsedEventArgs e)
{
_counter++;
// Updating Details properties fires INotifyPropChanged automatically
// (Details extends BaseObservable). DetailsViewModel picks up the change
// live without requiring the user to reselect the item.
_clockDetails.Body = DateTime.Now.ToString("HH:mm:ss", CultureInfo.CurrentCulture);
_counterDetails.Title = $"Count: {_counter}";
_counterDetails.Body = $"Elapsed: {_counter} second{(_counter == 1 ? string.Empty : "s")}";
}
public void Dispose()
{
if (!_disposed)
{
_timer?.Dispose();
_timer = null;
_disposed = true;
}
}
}

View File

@@ -29,6 +29,11 @@ public partial class SamplesListPage : ListPage
Title = "List Page With Details",
Subtitle = "A list of items, each with additional details to display",
},
new ListItem(new SampleLiveDetailsPage())
{
Title = "Live Updating Details",
Subtitle = "Details pane updates in real time without reselecting",
},
new ListItem(new SectionsIndexPage())
{
Title = "List Pages With Sections",

View File

@@ -1,283 +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 Microsoft.VisualStudio.TestTools.UnitTesting;
using Peek.Common.Helpers;
namespace Peek.Common.UnitTests
{
[TestClass]
public class MathHelperTests
{
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies standard modulo for positive numbers (7 mod 3 = 1)
/// Why: Baseline correctness — ensures the positive path matches C# % operator
/// </summary>
[TestMethod]
public void Modulo_PositiveNumbers_ShouldReturnStandardModulo()
{
Assert.AreEqual(1, MathHelper.Modulo(7, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that 0 mod n = 0
/// Why: Zero dividend is a common boundary — must not throw or return non-zero
/// </summary>
[TestMethod]
public void Modulo_ZeroDividend_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(0, 5));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that exact division yields zero remainder
/// Why: Guards against off-by-one in the modulo formula
/// </summary>
[TestMethod]
public void Modulo_ExactDivision_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(6, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend path
/// What: Verifies that -1 mod 3 = 2 (mathematical modulo, not C# remainder)
/// Why: C# % returns -1 for negative dividends; Modulo wraps to positive range
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_ShouldReturnPositiveResult()
{
Assert.AreEqual(2, MathHelper.Modulo(-1, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend with larger magnitude
/// What: Verifies -7 mod 3 = 2 (wraps correctly for larger negative values)
/// Why: Tests the full formula: ((-7 % 3) + 3) % 3 = (-1 + 3) % 3 = 2
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_LargerMagnitude_ShouldWrapCorrectly()
{
Assert.AreEqual(2, MathHelper.Modulo(-7, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies -6 mod 3 = 0 (exact negative multiple)
/// Why: Edge case where negative dividend is an exact multiple of divisor
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_ExactMultiple_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(-6, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies n mod 1 = 0 for positive n
/// Why: Divisor of 1 always yields 0 — guards against divide-by-zero edge case
/// </summary>
[TestMethod]
public void Modulo_PositiveDividend_DivisorOne_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(5, 1));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies -n mod 1 = 0 for negative n
/// Why: Tests the negative path with divisor=1 (simplest negative case)
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_DivisorOne_ShouldReturnZero()
{
Assert.AreEqual(0, MathHelper.Modulo(-5, 1));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies modulo works with large numbers (1000001 mod 1000000 = 1)
/// Why: Tests that no integer overflow occurs in the formula for large operands
/// </summary>
[TestMethod]
public void Modulo_LargePositiveNumbers_ShouldWork()
{
Assert.AreEqual(1, MathHelper.Modulo(1000001, 1000000));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int)
/// What: Verifies that dividend less than divisor returns the dividend itself
/// Why: 2 mod 5 = 2 — no division happens, just returns the dividend
/// </summary>
[TestMethod]
public void Modulo_DividendLessThanDivisor_ShouldReturnDividend()
{
Assert.AreEqual(2, MathHelper.Modulo(2, 5));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — negative dividend
/// What: Verifies -2 mod 3 = 1 (mathematical modulo)
/// Why: Tests another negative wrap case: ((-2 % 3) + 3) % 3 = (-2 + 3) % 3 = 1
/// </summary>
[TestMethod]
public void Modulo_NegativeDividend_MinusTwo_ModThree_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.Modulo(-2, 3));
}
/// <summary>
/// Product code: MathHelper.Modulo(int, int) — zero divisor
/// What: Documents that dividing by zero throws DivideByZeroException
/// Why: Edge case — callers must handle zero divisor; the method does not guard against it
/// </summary>
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Modulo_ZeroDivisor_ShouldThrow()
{
MathHelper.Modulo(5, 0);
}
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void Modulo_NegativeDivisor_ShouldThrow()
{
MathHelper.Modulo(-1, -3);
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies that 0 has 1 digit
/// Why: Zero is a special case — Math.Abs(0).ToString() = "0" which has length 1
/// </summary>
[TestMethod]
public void NumberOfDigits_Zero_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(0));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies single-digit number returns 1
/// Why: Baseline case for the simplest positive input
/// </summary>
[TestMethod]
public void NumberOfDigits_SingleDigit_ShouldReturnOne()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(5));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies two-digit number returns 2
/// Why: Tests the transition from single to multi-digit
/// </summary>
[TestMethod]
public void NumberOfDigits_TwoDigits_ShouldReturnTwo()
{
Assert.AreEqual(2, MathHelper.NumberOfDigits(42));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int)
/// What: Verifies three-digit number (100) returns 3
/// Why: Tests exact power of 10 boundary (100 vs 99)
/// </summary>
[TestMethod]
public void NumberOfDigits_ThreeDigits_ShouldReturnThree()
{
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — negative number
/// What: Verifies that sign is ignored via Math.Abs
/// Why: Negative sign is not a digit — only magnitude matters
/// </summary>
[TestMethod]
public void NumberOfDigits_NegativeNumber_ShouldIgnoreSign()
{
Assert.AreEqual(3, MathHelper.NumberOfDigits(-123));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — int.MaxValue
/// What: Verifies int.MaxValue (2147483647) returns 10 digits
/// Why: Upper boundary of int range — ensures no overflow in Math.Abs path
/// </summary>
[TestMethod]
public void NumberOfDigits_MaxValue_ShouldReturnTenDigits()
{
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MaxValue));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — near int.MinValue
/// What: Verifies int.MinValue + 1 (-2147483647) returns 10 digits
/// Why: int.MinValue itself overflows Math.Abs — this tests the safe boundary
/// </summary>
[TestMethod]
public void NumberOfDigits_MinValue_ShouldHandleAbsoluteValue()
{
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MinValue + 1));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — int.MinValue
/// What: Documents that int.MinValue causes OverflowException due to Math.Abs
/// Why: Edge case — Math.Abs(-2147483648) has no positive int representation
/// </summary>
[TestMethod]
[ExpectedException(typeof(OverflowException))]
public void NumberOfDigits_MinValue_ShouldThrowOverflow()
{
MathHelper.NumberOfDigits(int.MinValue);
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — powers of 10
/// What: Verifies digit count at each power-of-10 boundary (1, 10, 100, 1000, 10000)
/// Why: Powers of 10 are the exact transition points — off-by-one bugs surface here
/// </summary>
[TestMethod]
public void NumberOfDigits_PowerOfTen_ShouldReturnCorrectCount()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(1));
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
Assert.AreEqual(4, MathHelper.NumberOfDigits(1000));
Assert.AreEqual(5, MathHelper.NumberOfDigits(10000));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — 9→10 boundary
/// What: Verifies that 9 returns 1 digit and 10 returns 2 digits
/// Why: Tests the first single-to-double digit transition
/// </summary>
[TestMethod]
public void NumberOfDigits_BoundaryValues_NineAndTen()
{
Assert.AreEqual(1, MathHelper.NumberOfDigits(9));
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
}
/// <summary>
/// Product code: MathHelper.NumberOfDigits(int) — 99→100 boundary
/// What: Verifies that 99 returns 2 digits and 100 returns 3 digits
/// Why: Tests the double-to-triple digit transition
/// </summary>
[TestMethod]
public void NumberOfDigits_BoundaryValues_NinetyNineAndHundred()
{
Assert.AreEqual(2, MathHelper.NumberOfDigits(99));
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
}
}
}

View File

@@ -1,156 +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.VisualStudio.TestTools.UnitTesting;
using Peek.Common.Helpers;
namespace Peek.Common.UnitTests
{
[TestClass]
public class PathHelperTests
{
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a standard UNC path (\\server\share) is recognized
/// Why: Baseline positive case — the most common UNC format
/// </summary>
[TestMethod]
public void IsUncPath_StandardUncPath_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with nested subfolders and file is recognized
/// Why: Real-world UNC paths include subfolders — must not fail on deeper paths
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithSubfolder_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\folder\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with dotted server name (FQDN) is recognized
/// Why: Enterprise environments use FQDN server names (e.g., server.corp.com)
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithDottedServer_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server.domain.com\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a UNC path with IP address is recognized
/// Why: Some network shares use IP addresses instead of hostnames
/// </summary>
[TestMethod]
public void IsUncPath_UncPathWithIPAddress_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\192.168.1.1\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a local drive path (C:\...) is not classified as UNC
/// Why: Drive-letter paths are local — confusing them with UNC would break file access
/// </summary>
[TestMethod]
public void IsUncPath_LocalDrivePath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"C:\Users\test\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a bare drive root (D:\) is not classified as UNC
/// Why: Drive roots are local paths — shortest possible local absolute path
/// </summary>
[TestMethod]
public void IsUncPath_LocalRootPath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"D:\"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a relative path is not classified as UNC
/// Why: Relative paths have no server component — Uri.TryCreate fails for them
/// </summary>
[TestMethod]
public void IsUncPath_RelativePath_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"folder\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies an empty string returns false without throwing
/// Why: Empty input is a common edge case — must not crash
/// </summary>
[TestMethod]
public void IsUncPath_EmptyString_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(string.Empty));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies an HTTP URL is not classified as UNC
/// Why: HTTP URLs are not file paths — Uri.IsUnc must return false for them
/// </summary>
[TestMethod]
public void IsUncPath_HttpUrl_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath("http://example.com/path"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a file:/// URI (local file) is not classified as UNC
/// Why: file:///C:/... is a local file URI, not a network UNC path
/// </summary>
[TestMethod]
public void IsUncPath_FileUri_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath("file:///C:/Users/test/file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a standard UNC path with a file component (\\server\share\file.txt)
/// Why: Tests UNC with a trailing file name — distinct from share-only paths
/// </summary>
[TestMethod]
public void IsUncPath_StandardUncWithFile_ShouldReturnTrue()
{
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\file.txt"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies a single-backslash prefix is not classified as UNC
/// Why: UNC requires exactly two leading backslashes — one backslash is not UNC
/// </summary>
[TestMethod]
public void IsUncPath_SingleBackslash_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(@"\server\share"));
}
/// <summary>
/// Product code: PathHelper.IsUncPath(string)
/// What: Verifies null input returns false without throwing
/// Why: Null is a common edge case — Uri.TryCreate handles null gracefully
/// </summary>
[TestMethod]
public void IsUncPath_NullInput_ShouldReturnFalse()
{
Assert.IsFalse(PathHelper.IsUncPath(null));
}
}
}

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Peek.Common.Tests\</OutputPath>
<RootNamespace>Peek.Common.UnitTests</RootNamespace>
<AssemblyName>PowerToys.Peek.Common.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
<!-- Pull in the WindowsDesktop shared framework so that runtime DLLs (System.CodeDom,
Microsoft.VisualBasic, WindowsBase, etc.) resolve to the same version as all other
projects in the solution. Without this, transitive NuGet packages provide older
versions that fail the verifyDepsJsonLibraryVersions CI check. -->
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Peek.Common\Peek.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -11,11 +11,6 @@ namespace Peek.Common.Helpers
{
public static int Modulo(int a, int b)
{
if (b <= 0)
{
throw new ArgumentOutOfRangeException(nameof(b), b, "Divisor must be positive.");
}
return a < 0 ? ((a % b) + b) % b : a % b;
}

View File

@@ -34,6 +34,7 @@ namespace Peek.UI
public MainWindowViewModel ViewModel { get; }
private readonly ThemeListener? themeListener;
private readonly IUserSettings userSettings;
/// <summary>
/// Whether the delete confirmation dialog is currently open. Used to ensure only one
@@ -66,6 +67,19 @@ namespace Peek.UI
AppWindow.SetIcon("Assets/Peek/Icon.ico");
AppWindow.Closing += AppWindow_Closing;
userSettings = Application.Current.GetService<IUserSettings>();
userSettings.Changed += UpdateWindowBySettings;
UpdateWindowBySettings(null, EventArgs.Empty);
}
private void UpdateWindowBySettings(object? sender, EventArgs e)
{
DispatcherQueue.TryEnqueue(() =>
{
IsAlwaysOnTop = userSettings.AlwaysOnTop;
IsShownInSwitchers = userSettings.ShowTaskbarIcon;
});
}
private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
@@ -88,7 +102,7 @@ namespace Peek.UI
{
_isDeleteInProgress = true;
if (Application.Current.GetService<IUserSettings>().ConfirmFileDelete)
if (userSettings.ConfirmFileDelete)
{
if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary)
{
@@ -341,6 +355,7 @@ namespace Peek.UI
public void Dispose()
{
themeListener?.Dispose();
userSettings.Changed -= UpdateWindowBySettings;
}
/// <summary>

View File

@@ -2,14 +2,22 @@
// 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 Peek.UI
{
public interface IUserSettings
{
public bool AlwaysOnTop { get; }
public bool ShowTaskbarIcon { get; }
public bool CloseAfterLosingFocus { get; }
public bool ConfirmFileDelete { get; set; }
public bool ShowFilePreviewTooltip { get; }
public event EventHandler? Changed;
}
}

View File

@@ -36,13 +36,29 @@ namespace Peek.UI
lock (_settingsLock)
{
_settings = value;
AlwaysOnTop = _settings.Properties.AlwaysOnTop.Value;
ShowTaskbarIcon = _settings.Properties.ShowTaskbarIcon.Value;
CloseAfterLosingFocus = _settings.Properties.CloseAfterLosingFocus.Value;
ConfirmFileDelete = _settings.Properties.ConfirmFileDelete.Value;
ShowFilePreviewTooltip = _settings.Properties.ShowFilePreviewTooltip.Value;
}
Changed?.Invoke(this, EventArgs.Empty);
}
}
public event EventHandler? Changed;
/// <summary>
/// Gets a value indicating whether Peek shows its window on the top of the stack.
/// </summary>
public bool AlwaysOnTop { get; private set; }
/// <summary>
/// Gets a value indicating whether Peek shows its icon on the taskbar when activated.
/// </summary>
public bool ShowTaskbarIcon { get; private set; }
/// <summary>
/// Gets a value indicating whether Peek closes automatically when the window loses focus.
/// </summary>

View File

@@ -1,5 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<!-- Currently hard-coded, as this project does not target WinRT.
@@ -8,6 +9,10 @@
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<!-- Required by the CsWinRT AOT optimizer: marshaling generic collections (e.g. the
Dictionary<Language, LanguageInfo> in CharacterMappings) across the WinRT ABI emits
unsafe code. Matches the sibling AOT-compatible library PowerDisplay.Lib. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,148 @@
// 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.VisualStudio.TestTools.UnitTesting;
using PowerAccent.Core;
using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
namespace PowerAccent.Core.UnitTests;
/// <summary>
/// Exercises the pure anchor / DPI geometry in <see cref="Calculation"/>. These are the math that
/// the WinUI 3 Selector feeds into AppWindow.Move/Resize, so a regression here silently mis-places
/// the accent popup (the classic high-DPI / multi-monitor "double scaling" failure mode).
/// </summary>
[TestClass]
public sealed class CalculationTests
{
// offset baked into Calculation: the gap from the screen edge for the edge anchors.
private const int Offset = 24;
// A 1920x1080 primary monitor rooted at the virtual-desktop origin.
private static readonly Rect PrimaryScreen = new(0, 0, 1920, 1080);
// A one-row accent bar, in DIP.
private static readonly Size Window = new(200, 52);
// At 100% scaling (dpi = 1.0) the physical window size equals the DIP size, so each of the nine
// anchors lands at an easily hand-checkable coordinate.
[DataTestMethod]
[DataRow(Position.TopLeft, 24.0, 24.0)]
[DataRow(Position.Top, 860.0, 24.0)]
[DataRow(Position.TopRight, 1696.0, 24.0)]
[DataRow(Position.Left, 24.0, 514.0)]
[DataRow(Position.Center, 860.0, 514.0)]
[DataRow(Position.Right, 1696.0, 514.0)]
[DataRow(Position.BottomLeft, 24.0, 1004.0)]
[DataRow(Position.Bottom, 860.0, 1004.0)]
[DataRow(Position.BottomRight, 1696.0, 1004.0)]
public void GetRawCoordinatesFromPosition_AtDpi1_PlacesEachAnchor(Position position, double expectedX, double expectedY)
{
var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.0);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// At 150% scaling the physical window is 300x78. The centered anchors must subtract HALF of the
// scaled size (not the DIP size) and the right/bottom anchors must subtract the FULL scaled size
// plus the offset - this is exactly where a missing/extra dpi factor shows up.
[DataTestMethod]
[DataRow(Position.TopLeft, 24.0, 24.0)]
[DataRow(Position.Center, 810.0, 501.0)]
[DataRow(Position.BottomRight, 1596.0, 978.0)]
public void GetRawCoordinatesFromPosition_AtDpi150Percent_ScalesWindowFootprint(Position position, double expectedX, double expectedY)
{
var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.5);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// A secondary 2560x1440 monitor to the right of the primary at 200% scaling. Verifies the screen
// origin (screen.X / screen.Y) is honored for every anchor, not just the primary-at-origin case.
[DataTestMethod]
[DataRow(Position.TopLeft, 1944.0, 24.0)]
[DataRow(Position.Center, 3000.0, 668.0)]
[DataRow(Position.BottomRight, 4056.0, 1312.0)]
public void GetRawCoordinatesFromPosition_OnOffsetMonitor_HonorsScreenOrigin(Position position, double expectedX, double expectedY)
{
var secondaryScreen = new Rect(1920, 0, 2560, 1440);
var point = Calculation.GetRawCoordinatesFromPosition(position, secondaryScreen, Window, dpi: 2.0);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// A monitor positioned to the LEFT of the primary has a negative virtual-desktop X origin. The
// edge anchors must still be offset relative to that negative origin.
[TestMethod]
public void GetRawCoordinatesFromPosition_OnNegativeOriginMonitor_OffsetsFromScreenEdge()
{
var leftScreen = new Rect(-1920, 0, 1920, 1080);
var topLeft = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, leftScreen, Window, dpi: 1.0);
Assert.AreEqual(-1920 + Offset, topLeft.X);
Assert.AreEqual(Offset, topLeft.Y);
var bottomRight = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, leftScreen, Window, dpi: 1.0);
Assert.AreEqual(-1920 + 1920 - (Window.Width + Offset), bottomRight.X);
Assert.AreEqual(1080 - (Window.Height + Offset), bottomRight.Y);
}
[TestMethod]
public void GetRawCoordinatesFromPosition_UnknownPosition_Throws()
{
Assert.ThrowsException<NotImplementedException>(
() => Calculation.GetRawCoordinatesFromPosition((Position)999, PrimaryScreen, Window, dpi: 1.0));
}
// Caret-relative placement centers the window horizontally on the caret and sits it 20px above.
[TestMethod]
public void GetRawCoordinatesFromCaret_WithRoom_CentersAboveCaret()
{
var caret = new Point(960, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(960 - (Window.Width / 2), point.X); // 860
Assert.AreEqual(540 - Window.Height - 20, point.Y); // 468
}
// Near the left edge the window would overflow off-screen, so X clamps to the screen's left edge.
[TestMethod]
public void GetRawCoordinatesFromCaret_NearLeftEdge_ClampsToScreenLeft()
{
var caret = new Point(50, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(PrimaryScreen.X, point.X);
}
// Near the right edge X clamps so the window's right side sits on the screen's right edge.
[TestMethod]
public void GetRawCoordinatesFromCaret_NearRightEdge_ClampsToScreenRight()
{
var caret = new Point(1900, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(PrimaryScreen.X + PrimaryScreen.Width - Window.Width, point.X); // 1720
}
// When there is no room above the caret (top would land off-screen) the window flips to 20px
// BELOW the caret instead of being clipped at the top.
[TestMethod]
public void GetRawCoordinatesFromCaret_NoRoomAbove_FlipsBelowCaret()
{
var caret = new Point(960, 10);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(caret.Y + 20, point.Y); // 30
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<AssemblyName>PowerToys.PowerAccent.Core.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerAccent.Core.UnitTests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<!--
Do NOT set CsWinRTIncludes here. PowerAccent.Core already projects PowerToys.GPOWrapper and
PowerToys.PowerAccentKeyboardService, and those managed projections arrive transitively through
the PowerAccent.Core project reference. Listing either here generates a SECOND copy and breaks
the build with CS0436 (matches PowerAccent.UI, which references Core the same way).
-->
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,12 +6,6 @@ namespace PowerAccent.Core;
public struct Point
{
public Point()
{
X = 0;
Y = 0;
}
public Point(double x, double y)
{
X = x;
@@ -24,35 +18,7 @@ public struct Point
Y = y;
}
public Point(System.Drawing.Point point)
{
X = point.X;
Y = point.Y;
}
public double X { get; init; }
public double Y { get; init; }
public static implicit operator Point(System.Drawing.Point point) => new Point(point.X, point.Y);
public static Point operator /(Point point, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Point(point.X / divider, point.Y / divider);
}
public static Point operator /(Point point, Point divider)
{
if (divider.X == 0 || divider.Y == 0)
{
throw new DivideByZeroException();
}
return new Point(point.X / divider.X, point.Y / divider.Y);
}
}

View File

@@ -6,14 +6,6 @@ namespace PowerAccent.Core;
public struct Rect
{
public Rect()
{
X = 0;
Y = 0;
Width = 0;
Height = 0;
}
public Rect(int x, int y, int width, int height)
{
X = x;
@@ -22,14 +14,6 @@ public struct Rect
Height = height;
}
public Rect(double x, double y, double width, double height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
public Rect(Point coord, Size size)
{
X = coord.X;
@@ -45,24 +29,4 @@ public struct Rect
public double Width { get; init; }
public double Height { get; init; }
public static Rect operator /(Rect rect, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Rect(rect.X / divider, rect.Y / divider, rect.Width / divider, rect.Height / divider);
}
public static Rect operator /(Rect rect, Rect divider)
{
if (divider.X == 0 || divider.Y == 0)
{
throw new DivideByZeroException();
}
return new Rect(rect.X / divider.X, rect.Y / divider.Y, rect.Width / divider.Width, rect.Height / divider.Height);
}
}

View File

@@ -6,12 +6,6 @@ namespace PowerAccent.Core;
public struct Size
{
public Size()
{
Width = 0;
Height = 0;
}
public Size(double width, double height)
{
Width = width;
@@ -27,26 +21,4 @@ public struct Size
public double Width { get; init; }
public double Height { get; init; }
public static implicit operator Size(System.Drawing.Size size) => new Size(size.Width, size.Height);
public static Size operator /(Size size, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Size(size.Width / divider, size.Height / divider);
}
public static Size operator /(Size size, Size divider)
{
if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}
return new Size(size.Width / divider.Width, size.Height / divider.Height);
}
}

View File

@@ -8,8 +8,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<PropertyGroup>
@@ -26,6 +24,13 @@
<PackageReference Include="UnicodeInformation" />
</ItemGroup>
<ItemGroup>
<!-- Expose internal helpers (e.g. Tools.Calculation) to the unit-test assembly. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PowerToys.PowerAccent.Core.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />

View File

@@ -45,8 +45,12 @@ public partial class PowerAccent : IDisposable
private readonly CharactersUsageInfo _usageInfo;
public PowerAccent()
private readonly Action<Action> _runOnUiThread;
public PowerAccent(Action<Action> runOnUiThread)
{
_runOnUiThread = runOnUiThread ?? throw new ArgumentNullException(nameof(runOnUiThread));
Logger.InitializeLogger("\\QuickAccent\\Logs");
LoadUnicodeInfoCache();
@@ -68,7 +72,7 @@ public partial class PowerAccent : IDisposable
{
_keyboardListener.SetShowToolbarEvent(new PowerToys.PowerAccentKeyboardService.ShowToolbar((LetterKey letterKey) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
ShowToolbar(letterKey);
});
@@ -76,7 +80,7 @@ public partial class PowerAccent : IDisposable
_keyboardListener.SetHideToolbarEvent(new PowerToys.PowerAccentKeyboardService.HideToolbar((InputType inputType) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
SendInputAndHideToolbar(inputType);
});
@@ -84,7 +88,7 @@ public partial class PowerAccent : IDisposable
_keyboardListener.SetNextCharEvent(new PowerToys.PowerAccentKeyboardService.NextChar((TriggerKey triggerKey, bool shiftPressed) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
ProcessNextChar(triggerKey, shiftPressed);
});
@@ -236,13 +240,13 @@ public partial class PowerAccent : IDisposable
case InputType.Right:
{
SendKeys.SendWait("{RIGHT}");
WindowsFunctions.SendArrowKey(left: false);
break;
}
case InputType.Left:
{
SendKeys.SendWait("{LEFT}");
WindowsFunctions.SendArrowKey(left: true);
break;
}
@@ -391,14 +395,13 @@ public partial class PowerAccent : IDisposable
/// Gets the maximum width for the toolbar display based on the active screen
/// dimensions.
/// </summary>
/// <returns>The maximum width in logical pixels, accounting for screen padding.
/// </returns>
/// <returns>The maximum width in DIPs (device-independent pixels), accounting for
/// screen padding.</returns>
public double GetDisplayMaxWidth()
{
// Note: activeDisplay.Size.Width is in raw physical pixels.
// We divide by DPI to convert to WPF logical pixels (Device-Independent Pixels),
// because ScreenMinPadding is a logical pixel value and WPF MaxWidth expects
// logical pixels.
// activeDisplay.Size.Width is in raw physical pixels; divide by the DPI scale to
// convert to DIPs (device-independent pixels), since ScreenMinPadding and the
// consuming window width are both expressed in DIPs.
var activeDisplay = WindowsFunctions.GetActiveDisplay();
return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding;
}

View File

@@ -88,6 +88,40 @@ internal static class WindowsFunctions
}
}
public static void SendArrowKey(bool left)
{
var key = left ? VIRTUAL_KEY.VK_LEFT : VIRTUAL_KEY.VK_RIGHT;
var inputs = new INPUT[]
{
new INPUT
{
type = INPUT_TYPE.INPUT_KEYBOARD,
Anonymous = new INPUT._Anonymous_e__Union
{
ki = new KEYBDINPUT
{
wVk = key,
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY,
},
},
},
new INPUT
{
type = INPUT_TYPE.INPUT_KEYBOARD,
Anonymous = new INPUT._Anonymous_e__Union
{
ki = new KEYBDINPUT
{
wVk = key,
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY | KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP,
},
},
},
};
_ = PInvoke.SendInput(inputs, Marshal.SizeOf<INPUT>());
}
public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
{
GUITHREADINFO guiInfo = default;
@@ -107,7 +141,8 @@ internal static class WindowsFunctions
double dpi = dpiRaw / 96d;
var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top);
return (location, monitorInfo.rcWork.Size, dpi);
var size = new Size(monitorInfo.rcWork.right - monitorInfo.rcWork.left, monitorInfo.rcWork.bottom - monitorInfo.rcWork.top);
return (location, size, dpi);
}
public static bool IsCapsLockState()

View File

@@ -1,6 +0,0 @@
<Application
x:Class="PowerAccent.UI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Selector.xaml"
ThemeMode="System" />

View File

@@ -1,64 +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.Threading;
using System.Windows;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
namespace PowerAccent.UI
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application, IDisposable
{
private static Mutex _mutex;
private bool _disposed;
private ETWTrace _etwTrace = new ETWTrace();
protected override void OnStartup(StartupEventArgs e)
{
_mutex = new Mutex(true, "QuickAccent", out bool createdNew);
if (!createdNew)
{
Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent");
Application.Current.Shutdown();
}
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
_mutex?.ReleaseMutex();
base.OnExit(e);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_mutex?.Dispose();
_etwTrace?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,10 +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.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located (used if a resource is not found in the page, or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly) // where the generic resource dictionary is locate (used if a resource is not found in the page, app, or any theme specific resource dictionaries)
]

View File

@@ -1,2 +0,0 @@
SetWindowPos
GetSystemMetrics

View File

@@ -2,39 +2,110 @@
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<Nullable>disable</Nullable>
<UseWPF>true</UseWPF>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<ApplicationIcon>icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>PowerToys.PowerAccent</AssemblyName>
<XamlDebuggingInformation>True</XamlDebuggingInformation>
<StartupObject>PowerAccent.UI.Program</StartupObject>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" />
<ItemGroup>
<Resource Include="icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>PowerAccent.UI</RootNamespace>
<AssemblyName>PowerToys.PowerAccent</AssemblyName>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- Required so CommunityToolkit.Mvvm's source generator emits the WinRT-correct partial
property implementations for [ObservableProperty] (avoids MVVMTK0045 / CS9248).
Matches the sibling WinUI 3 module PowerDisplay. -->
<LangVersion>preview</LangVersion>
<UseWinUI>true</UseWinUI>
<!--
App.xaml and the windows live under PowerAccentXAML\ (not the project root). Nesting the XAML
in a named subfolder is the repo convention for WinUI 3 apps that share the WinUI3Apps output
folder (see Peek's PeekXAML\, PowerDisplay's PowerDisplayXAML\): it keeps the compiled .xbf out
of the WinUI3Apps root, so the "Audit WinAppSDK applications path asset conflicts" pipeline step
passes. Disable the default ApplicationDefinition glob so the explicit
<ApplicationDefinition Include="PowerAccentXAML\App.xaml" /> below is the single one.
-->
<EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<ApplicationIcon>icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<StartupObject>PowerAccent.UI.Program</StartupObject>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<ProjectPriFileName>PowerToys.PowerAccent.pri</ProjectPriFileName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- Native AOT Configuration. Mirrors the sibling WinUI 3 module PowerDisplay so the app is
compiled with ILC on publish, surfacing trim/AOT problems that the analyzers alone miss. -->
<PropertyGroup>
<PublishSingleFile>false</PublishSingleFile>
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
<PublishAot>true</PublishAot>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<PropertyGroup>
<!--
Do NOT set CsWinRTIncludes here. Both WinRT components the UI touches - PowerToys.GPOWrapper
(called directly from Program.cs) and PowerToys.PowerAccentKeyboardService (used by Core) -
are already projected by PowerAccent.Core, which the UI references, so their managed
projections arrive transitively. Listing either here generates a SECOND copy of the same
types and breaks the build with CS0436 (e.g. GpoRuleConfigured defined both in this project's
generated files and in PowerAccent.Core). This matches the original WPF UI, which had no
CsWinRTIncludes at all.
-->
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<Resource Include="icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<Target Name="CopyPRIFileToOutputDir" AfterTargets="Build">
<ItemGroup>
<PRIFile Include="$(OutDir)**\PowerToys.PowerAccent.pri" />
</ItemGroup>
<Copy SourceFiles="@(PRIFile)" DestinationFolder="$(OutDir)" />
</Target>
<ItemGroup>
<Page Remove="PowerAccentXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="PowerAccentXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.WinUI.Animations" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI.Controls\Common.UI.Controls.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="PowerAccent.UI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,60 @@
// 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 ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
namespace PowerAccent.UI;
public partial class App : Application, IDisposable
{
private readonly ETWTrace _etwTrace = new ETWTrace();
private bool _disposed;
public static new App Current => (App)Application.Current;
public DispatcherQueue DispatcherQueueForApp { get; private set; }
public static MainWindow Window { get; private set; }
public App()
{
InitializeComponent();
UnhandledException += (s, e) => Logger.LogError("Unhandled exception", e.Exception);
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
DispatcherQueueForApp = DispatcherQueue.GetForCurrentThread();
Window = new MainWindow();
// Quick Accent has no visible main window until summoned by the keyboard hook;
// the accent selector keeps itself hidden (TransparentWindow hides its AppWindow on init).
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_etwTrace?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<common:TransparentWindow
x:Class="PowerAccent.UI.MainWindow"
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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:PowerAccent.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<!--
The content lives in a UserControl (SelectorControl) rather than inline here so its x:Bind
bindings initialize on the control's Loading pass - which fires when this SW_SHOWNA overlay is
first laid out - instead of on Window.Activated, which never fires for a window shown without
activation. That removes the need to call Bindings.Update() by hand.
-->
<local:SelectorControl x:Name="Selector" />
</common:TransparentWindow>

View File

@@ -0,0 +1,177 @@
// 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 Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using CoreSize = PowerAccent.Core.Size;
namespace PowerAccent.UI;
public sealed partial class MainWindow : TransparentWindow, IDisposable
{
// Accent-bar geometry (DIP). Width is derived from the item count (count * ItemWidthDip), not
// measured from the ListView: its DesiredSize (wrapped in a ScrollViewer) is racy while item
// containers realize and intermittently reports 0, yielding a blank/clipped bar. The one-row bar
// hugs its content like the WPF original, capped at the monitor width; beyond that it scrolls
// and ScrollIntoView reveals the selected glyph.
private const double RowHeightDip = 92; // one row of accent pills (item Height=48 + card border)
private const double DescriptionHeightDip = 36; // extra row shown when the Unicode description is on
private const double ItemWidthDip = 48; // one accent cell (ListViewItem Grid MinWidth=48)
private const double DescriptionMinWidthDip = 648; // min bar width while the description row shows (WPF parity)
private readonly Core.PowerAccent _powerAccent;
private int _selectedIndex = -1;
private bool _active;
// The view model lives on the SelectorControl (the x:Bind target); expose it here for the
// PowerAccent event handlers that populate the accent list and description.
private SelectorViewModel ViewModel => Selector.ViewModel;
public MainWindow()
{
InitializeComponent();
// Give the overlay a stable UIA identity (window name) for accessibility tools (Narrator,
// Accessibility Insights) and the release-verification harness. "Quick Accent" is the
// user-facing feature name.
AppWindow.Title = "Quick Accent";
// The accent popup is shown/hidden instantly (no slide/fade) for typing-aid
// responsiveness. TransientSurface defaults to Transition.None (no animation);
// SubscribeSurfaceTo forwards to the inner surface so it follows this window's Show/Hide.
Selector.SubscribeSurfaceTo(this);
_powerAccent = new Core.PowerAccent(RunOnUiThread);
_powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay;
_powerAccent.OnSelectCharacter += PowerAccent_OnSelectCharacter;
// No manual theme handling: App.xaml leaves RequestedTheme unset, so WinUI follows the system
// theme and re-resolves the {ThemeResource} brushes (and retints the acrylic) on a live
// light/dark switch, even for this never-activated SW_SHOWNA overlay.
}
// Marshal keyboard-hook callbacks (ShowToolbar / HideToolbar / NextChar) onto the UI thread. The
// hook runs on this UI thread, so callbacks arrive here already; run them inline (not via
// TryEnqueue, which would defer) so the accent injection stays ordered before the hook returns
// and the trigger key-up propagates. Fall back to enqueueing if ever called off-thread.
private void RunOnUiThread(Action action)
{
if (DispatcherQueue.HasThreadAccess)
{
action();
}
else
{
DispatcherQueue.TryEnqueue(() => action());
}
}
private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars)
{
if (!isActive)
{
_active = false;
// Release always-on-top before hiding so the dormant overlay does not keep a discrete
// GPU awake on hybrid-graphics laptops (issue #34849 / PR #41044). IsAlwaysOnTop is the
// WinUIEx WindowEx property (same as the sibling PowerDisplay).
IsAlwaysOnTop = false;
Hide();
ViewModel.Characters.Clear();
_selectedIndex = -1;
return;
}
_active = true;
ViewModel.ShowDescription = _powerAccent.ShowUnicodeDescription;
ViewModel.Characters.Clear();
foreach (var c in chars)
{
ViewModel.Characters.Add(c);
}
Selector.SetSelectedIndex(_selectedIndex);
ViewModel.Description = (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length)
? _powerAccent.CharacterDescriptions[_selectedIndex]
: string.Empty;
// Always-on-top only while shown, so the overlay sits above the foreground app (Show uses
// SW_SHOWNA and never activates it); released on hide (see above). Then size and show.
IsAlwaysOnTop = true;
SizeAndPosition();
Show();
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
if (_active)
{
Selector.ScrollSelectedIntoView(_selectedIndex);
}
});
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Core.Telemetry.PowerAccentShowAccentMenuEvent());
}
private void PowerAccent_OnSelectCharacter(int index, string character)
{
_selectedIndex = index;
Selector.SetSelectedIndex(index);
if (index >= 0 && index < _powerAccent.CharacterDescriptions.Length)
{
ViewModel.Description = _powerAccent.CharacterDescriptions[index];
}
Selector.ScrollSelectedIntoView(index);
}
private void SizeAndPosition()
{
// Width hugs the content: item count * ItemWidthDip (see the class-level note on why the
// ListView is not measured), capped at the monitor's max usable width so long lists scroll.
double maxWidthDip = _powerAccent.GetDisplayMaxWidth();
double contentWidthDip = ViewModel.Characters.Count * ItemWidthDip;
// The Unicode description row needs room for a readable line; the WPF original gave it a
// 600px MinWidth. Widen a short accent bar to match when the row is shown (the accent bar
// itself stays centered within the wider window).
if (ViewModel.ShowDescription)
{
contentWidthDip = Math.Max(contentWidthDip, DescriptionMinWidthDip);
}
double widthDip = Math.Clamp(contentWidthDip, ItemWidthDip, maxWidthDip);
double heightDip = RowHeightDip + (ViewModel.ShowDescription ? DescriptionHeightDip : 0);
// Calculation works in physical pixels; GetDisplayCoordinates multiplies the DIP size by
// the active monitor's DPI internally and returns the physical top-left for the anchor.
var coordinates = _powerAccent.GetDisplayCoordinates(new CoreSize(widthDip, heightDip));
var display = DisplayArea.GetFromPoint(
new PointInt32((int)Math.Round(coordinates.X), (int)Math.Round(coordinates.Y)),
DisplayAreaFallback.Nearest);
double dpiScale = FlyoutWindowHelper.GetDpiScale(display);
var rect = new RectInt32(
(int)Math.Round(coordinates.X),
(int)Math.Round(coordinates.Y),
(int)Math.Ceiling(widthDip * dpiScale),
(int)Math.Ceiling(heightDip * dpiScale));
FlyoutWindowHelper.MoveAndResizeOnDisplay(this, display, rect);
}
public void Dispose()
{
_powerAccent.SaveUsageInfo();
_powerAccent.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="PowerAccent.UI.SelectorControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<controls:TransientSurface x:Name="Surface" Margin="24,24,24,16">
<Grid x:Name="RootContent">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}">
<ListView
x:Name="CharactersList"
Padding="0"
HorizontalAlignment="Center"
AutomationProperties.AutomationId="QuickAccentCharacterList"
IsHitTestVisible="False"
IsItemClickEnabled="False"
ItemsSource="{x:Bind ViewModel.Characters, Mode=OneWay}"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.HorizontalScrollMode="Enabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="Single">
<!--
Disable default ListView item animations: the bar is rebuilt on every keystroke,
and the built-in slide/fade transitions read as lag on a typing aid.
-->
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<!--
Custom container template reproducing the WPF accent "pill": an inset, rounded,
accent-filled rectangle shown only on selection (via VisualStateManager, since
WinUI 3 has no Style/ControlTemplate triggers), with the glyph turning on-accent.
-->
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<!--
WinUI's ListViewItem defaults MinWidth to 88; without pinning it to the
48px cell width each glyph is padded out, leaving wide gaps between accents.
-->
<Setter Property="MinWidth" Value="48" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Grid
Height="48"
MinWidth="48"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Border
x:Name="SelectionIndicator"
Margin="7"
Background="{ThemeResource AccentFillColorDefaultBrush}"
BorderBrush="{ThemeResource AccentControlElevationBorderBrush}"
BorderThickness="1"
CornerRadius="4"
Opacity="0" />
<ContentPresenter
x:Name="ContentPresenter"
Margin="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Selected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOverSelected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PressedSelected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Text="{x:Bind Mode=OneTime}"
TextAlignment="Center" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
<Grid Grid.Row="1" Visibility="{x:Bind ViewModel.DescriptionVisibility, Mode=OneWay}">
<TextBlock
x:Name="CharacterName"
MaxHeight="36"
Margin="8"
AutomationProperties.AutomationId="QuickAccentDescription"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.Description, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<Rectangle
Height="1"
VerticalAlignment="Top"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</controls:TransientSurface>
</UserControl>

View File

@@ -0,0 +1,41 @@
// 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.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Xaml.Controls;
namespace PowerAccent.UI;
/// <summary>
/// The accent selector content. Hosting it in a UserControl (rather than directly in the
/// TransparentWindow) lets x:Bind initialize on the control's Loading pass - which fires when the
/// SW_SHOWNA overlay is first laid out - instead of on Window.Activated (which never fires for a
/// never-activated overlay). That removes the need to call Bindings.Update() by hand.
/// </summary>
public sealed partial class SelectorControl : UserControl
{
public SelectorViewModel ViewModel { get; } = new();
public SelectorControl()
{
InitializeComponent();
}
// Number of items currently in the accent bar (mirrors the bound ObservableCollection).
public int ItemCount => CharactersList.Items.Count;
// Wire the inner TransientSurface to the hosting window's Show/Hide so it animates in/out.
// TransientSurface.SubscribeTo explicitly supports being "placed within" the window content.
public void SubscribeSurfaceTo(TransparentWindow host) => Surface.SubscribeTo(host);
public void SetSelectedIndex(int index) => CharactersList.SelectedIndex = index;
public void ScrollSelectedIntoView(int index)
{
if (index >= 0 && index < CharactersList.Items.Count)
{
CharactersList.ScrollIntoView(CharactersList.Items[index]);
}
}
}

View File

@@ -1,14 +1,13 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using PowerToys.Interop;
namespace PowerAccent.UI;
@@ -16,13 +15,14 @@ namespace PowerAccent.UI;
internal static class Program
{
private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource();
private static App _application;
private static Mutex _mutex;
private static int _powerToysRunnerPid;
[STAThread]
public static void Main(string[] args)
{
Logger.InitializeLogger("\\QuickAccent\\Logs");
WinRT.ComWrappersSupport.InitializeComWrappers();
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredQuickAccentEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
@@ -30,21 +30,32 @@ internal static class Program
return;
}
_mutex = new Mutex(true, "QuickAccent", out bool createdNew);
if (!createdNew)
{
Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent");
return;
}
Arguments(args);
InitExitListener();
InitEvents();
Microsoft.UI.Xaml.Application.Start((p) =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
_ = new App();
});
_application = new App();
_application.InitializeComponent();
_application.Run();
_mutex?.ReleaseMutex();
}
private static void InitEvents()
private static void InitExitListener()
{
Task.Run(
() =>
{
EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent());
using EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent());
if (eventHandle.WaitOne())
{
Terminate();
@@ -55,39 +66,41 @@ internal static class Program
private static void Arguments(string[] args)
{
if (args?.Length > 0)
if (args?.Length > 0 && int.TryParse(args[0], out _powerToysRunnerPid))
{
try
Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}");
RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () =>
{
if (int.TryParse(args[0], out _powerToysRunnerPid))
{
Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}");
RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () =>
{
Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent");
Terminate();
});
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
}
Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent");
Terminate();
});
}
else
{
Logger.LogInfo($"QuickAccent started detached from PowerToys Runner.");
Logger.LogInfo("QuickAccent started detached from PowerToys Runner.");
_powerToysRunnerPid = -1;
}
}
private static void Terminate()
{
Application.Current.Dispatcher.BeginInvoke(() =>
var app = App.Current;
var queue = app?.DispatcherQueueForApp;
// If the exit signal arrives during the brief startup window before OnLaunched has set
// DispatcherQueueForApp (e.g. the runner dies, or disable() is called, right after launch),
// or the queue is already draining, TryEnqueue can't run our cleanup. Fall back to a hard
// exit so we never orphan the process with the low-level keyboard hook still installed. The
// OS releases the hook on process termination; usage stats are simply not saved on this path.
if (queue is null || !queue.TryEnqueue(() =>
{
_tokenSource.Cancel();
Application.Current.Shutdown();
});
App.Window?.Dispose(); // MainWindow.SaveUsageInfo + Core.PowerAccent.Dispose on the UI thread
app.Dispose(); // disposes ETWTrace (idempotent via _disposed guard)
app.Exit();
}))
{
Environment.Exit(0);
}
}
}

View File

@@ -1,129 +0,0 @@
<Window
x:Class="PowerAccent.UI.Selector"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="MainWindow"
MinWidth="0"
MinHeight="0"
AllowsTransparency="True"
Background="Transparent"
DataContext="{Binding RelativeSource={RelativeSource Self}}"
ResizeMode="NoResize"
ShowInTaskbar="False"
SizeChanged="Window_SizeChanged"
SizeToContent="WidthAndHeight"
Visibility="Collapsed"
WindowStyle="None"
mc:Ignorable="d">
<Window.Resources>
<DataTemplate x:Key="DefaultKeyTemplate">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding}"
TextAlignment="Center" />
</DataTemplate>
<DataTemplate x:Key="SelectedKeyTemplate">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="{Binding}"
TextAlignment="Center" />
</DataTemplate>
</Window.Resources>
<Border
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
BorderBrush="{DynamicResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListBox
x:Name="characters"
HorizontalAlignment="Center"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
Background="Transparent"
Focusable="False"
IsHitTestVisible="False"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Focusable" Value="False" />
<Setter Property="ContentTemplate" Value="{StaticResource DefaultKeyTemplate}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Grid
Height="48"
MinWidth="48"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
SnapsToDevicePixels="true">
<Rectangle
x:Name="SelectionIndicator"
Margin="7"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="{DynamicResource AccentFillColorDefaultBrush}"
RadiusX="4"
RadiusY="4"
Stroke="{DynamicResource AccentControlElevationBorderBrush}"
StrokeThickness="1"
Visibility="Collapsed" />
<ContentPresenter Margin="12" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="true">
<Setter TargetName="SelectionIndicator" Property="Visibility" Value="Visible" />
<Setter Property="ContentTemplate" Value="{StaticResource SelectedKeyTemplate}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
<Grid
Grid.Row="1"
MinWidth="600"
MaxWidth="{Binding ActualWidth, ElementName=characters}"
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
Visibility="{Binding CharacterNameVisibility, UpdateSourceTrigger=PropertyChanged}">
<TextBlock
x:Name="characterName"
MaxHeight="36"
Margin="8"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="(U+0000) A COOL LETTER NAME COMES HERE"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Fill="{DynamicResource DividerStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -1,185 +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.ComponentModel;
using System.Windows;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
using Point = PowerAccent.Core.Point;
using Size = PowerAccent.Core.Size;
namespace PowerAccent.UI;
public partial class Selector : Window, IDisposable, INotifyPropertyChanged
{
// When setting the position for the selector window, we do not alter the z-order,
// activation status, or size.
private const SET_WINDOW_POS_FLAGS WindowPosFlags =
SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE;
private readonly Core.PowerAccent _powerAccent = new();
private Visibility _characterNameVisibility = Visibility.Visible;
private int _selectedIndex = -1;
public event PropertyChangedEventHandler PropertyChanged;
public Visibility CharacterNameVisibility
{
get
{
return _characterNameVisibility;
}
set
{
_characterNameVisibility = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CharacterNameVisibility)));
}
}
public Selector()
{
InitializeComponent();
Application.Current.MainWindow.ShowActivated = false;
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
_powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay;
_powerAccent.OnSelectCharacter += PowerAccent_OnSelectionCharacter;
this.Visibility = Visibility.Hidden;
}
private void PowerAccent_OnSelectionCharacter(int index, string character)
{
_selectedIndex = index;
characters.SelectedIndex = _selectedIndex;
if (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length)
{
characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex];
}
if (characters.Items.Count > _selectedIndex && _selectedIndex >= 0)
{
characters.ScrollIntoView(characters.Items[_selectedIndex]);
}
}
private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars)
{
// Topmost is conditionally set here to address hybrid graphics issues on laptops.
this.Topmost = isActive;
CharacterNameVisibility = _powerAccent.ShowUnicodeDescription ? Visibility.Visible : Visibility.Collapsed;
if (isActive)
{
int offscreenX = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_XVIRTUALSCREEN) - 1000;
int offscreenY = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_YVIRTUALSCREEN) - 1000;
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
if (hwnd != IntPtr.Zero)
{
// Move off-screen to avoid flicker on previous monitor before Show() and
// UpdateLayout().
PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, offscreenX, offscreenY, 0, 0, WindowPosFlags);
}
else
{
this.Left = offscreenX;
this.Top = offscreenY;
}
Show();
SetWindowsSize();
characters.ItemsSource = chars;
characters.SelectedIndex = -1; // Reset before setting dynamically to avoid flashing
this.UpdateLayout(); // Required for filling the actual width/height before positioning.
characters.SelectedIndex = _selectedIndex;
if (_selectedIndex >= 0 && _selectedIndex < chars.Length)
{
characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex];
characters.ScrollIntoView(characters.Items[_selectedIndex]);
this.UpdateLayout(); // Re-layout after scrolling
}
else
{
characterName.Text = string.Empty;
}
SetWindowPosition();
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new PowerAccent.Core.Telemetry.PowerAccentShowAccentMenuEvent());
}
else
{
Hide();
characters.ItemsSource = null;
_selectedIndex = -1;
}
}
private void MenuExit_Click(object sender, RoutedEventArgs e)
{
Application.Current.Shutdown();
}
private void SetWindowPosition()
{
Size windowSize = new(((FrameworkElement)Application.Current.MainWindow.Content).ActualWidth, ((FrameworkElement)Application.Current.MainWindow.Content).ActualHeight);
Point physicalPosition = _powerAccent.GetDisplayCoordinates(windowSize);
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
if (hwnd != IntPtr.Zero)
{
PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, (int)Math.Round(physicalPosition.X), (int)Math.Round(physicalPosition.Y), 0, 0, WindowPosFlags);
}
}
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
{
base.OnDpiChanged(oldDpi, newDpi);
if (this.Visibility == Visibility.Visible)
{
SetWindowsSize();
SetWindowPosition();
}
}
private void SetWindowsSize()
{
double maxWidth = _powerAccent.GetDisplayMaxWidth();
this.characters.MaxWidth = maxWidth;
this.MaxWidth = maxWidth;
}
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
{
if (this.Visibility == Visibility.Visible)
{
SetWindowPosition();
}
}
protected override void OnClosed(EventArgs e)
{
_powerAccent.SaveUsageInfo();
_powerAccent.Dispose();
base.OnClosed(e);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml;
namespace PowerAccent.UI;
public partial class SelectorViewModel : ObservableObject
{
// Partial properties (not [ObservableProperty] fields): the CsWinRT generators need partial
// properties to emit correct WinRT marshalling for a WinUI 3 app (otherwise MVVMTK0045).
// Partial properties cannot carry field initializers, so initial values are set in the ctor.
[ObservableProperty]
public partial ObservableCollection<string> Characters { get; set; }
[ObservableProperty]
public partial string Description { get; set; }
// Exposed directly as a Visibility (rather than binding the bool through a
// BoolToVisibilityConverter) so the description row's visibility needs no converter resource.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DescriptionVisibility))]
public partial bool ShowDescription { get; set; }
public SelectorViewModel()
{
Characters = new ObservableCollection<string>();
Description = string.Empty;
}
public Visibility DescriptionVisibility => ShowDescription ? Visibility.Visible : Visibility.Collapsed;
}

View File

@@ -46,7 +46,7 @@
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<TargetName>PowerToys.PowerAccentKeyboardService</TargetName>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>

View File

@@ -59,7 +59,7 @@ private:
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L"" + std::to_wstring(powertoys_pid);
std::wstring application_path = L"PowerToys.PowerAccent.exe";
std::wstring application_path = L"WinUI3Apps\\PowerToys.PowerAccent.exe";
std::wstring full_command_path = application_path + L" " + executable_args.data();
Logger::trace(L"PowerToys QuickAccent launching: " + full_command_path);

View File

@@ -9,7 +9,6 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Management;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;

View File

@@ -17,6 +17,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library
{
ActivationShortcut = DefaultActivationShortcut;
AlwaysRunNotElevated = new BoolProperty(true);
AlwaysOnTop = new BoolProperty(false);
ShowTaskbarIcon = new BoolProperty(true);
CloseAfterLosingFocus = new BoolProperty(false);
ConfirmFileDelete = new BoolProperty(true);
EnableSpaceToActivate = new BoolProperty(true); // Toggle is ON by default for new users. No impact on existing users.
@@ -27,6 +29,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
public BoolProperty AlwaysRunNotElevated { get; set; }
public BoolProperty AlwaysOnTop { get; set; }
public BoolProperty ShowTaskbarIcon { get; set; }
public BoolProperty CloseAfterLosingFocus { get; set; }
public BoolProperty ConfirmFileDelete { get; set; }

View File

@@ -46,6 +46,18 @@
HeaderIcon="{ui:FontIcon Glyph=&#xE7EF;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AlwaysRunNotElevated, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekAlwaysOnTop"
x:Uid="Peek_AlwaysOnTop"
HeaderIcon="{ui:FontIcon Glyph=&#xE7C4;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AlwaysOnTop, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekShowTaskbarIcon"
x:Uid="Peek_ShowTaskbarIcon"
HeaderIcon="{ui:FontIcon Glyph=&#xE8F9;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowTaskbarIcon, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekCloseAfterLosingFocus"
x:Uid="Peek_CloseAfterLosingFocus"

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@@ -3156,6 +3156,14 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Runs Peek without admin permissions to improve access to network shares. To apply this change, you must disable and re-enable Peek.</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_AlwaysOnTop.Header" xml:space="preserve">
<value>Peek window is always on top</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_ShowTaskbarIcon.Header" xml:space="preserve">
<value>Show Peek icon on the taskbar</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_CloseAfterLosingFocus.Header" xml:space="preserve">
<value>Automatically close the Peek window after it loses focus</value>
<comment>Peek is a product name, do not loc</comment>

View File

@@ -197,6 +197,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool AlwaysOnTop
{
get => _peekSettings.Properties.AlwaysOnTop.Value;
set
{
if (_peekSettings.Properties.AlwaysOnTop.Value != value)
{
_peekSettings.Properties.AlwaysOnTop.Value = value;
OnPropertyChanged(nameof(AlwaysOnTop));
NotifySettingsChanged();
}
}
}
public bool ShowTaskbarIcon
{
get => _peekSettings.Properties.ShowTaskbarIcon.Value;
set
{
if (_peekSettings.Properties.ShowTaskbarIcon.Value != value)
{
_peekSettings.Properties.ShowTaskbarIcon.Value = value;
OnPropertyChanged(nameof(ShowTaskbarIcon));
NotifySettingsChanged();
}
}
}
public bool CloseAfterLosingFocus
{
get => _peekSettings.Properties.CloseAfterLosingFocus.Value;

View File

@@ -432,12 +432,9 @@ function Test-CoreFiles {
'PowerToys.MouseWithoutBordersHelper.dll',
'PowerToys.MouseWithoutBordersHelper.exe',
# PowerAccent
'PowerAccent.Core.dll',
'PowerToys.PowerAccent.dll',
'PowerToys.PowerAccent.exe',
# PowerAccent - only the runner-loaded module interface ships in the install root.
# The app, core, common and keyboard-service binaries moved to WinUI3Apps (see $winUI3SignedFiles).
'PowerToys.PowerAccentModuleInterface.dll',
'PowerToys.PowerAccentKeyboardService.dll',
# Workspaces
'PowerToys.WorkspacesSnapshotTool.exe',
@@ -500,7 +497,14 @@ function Test-CoreFiles {
'PowerToys.RegistryPreviewExt.dll',
'PowerToys.RegistryPreviewUILib.dll',
'PowerToys.RegistryPreview.dll',
'PowerToys.RegistryPreview.exe'
'PowerToys.RegistryPreview.exe',
# PowerAccent (Quick Accent) - moved from the install root to WinUI3Apps
'PowerAccent.Core.dll',
'PowerAccent.Common.dll',
'PowerToys.PowerAccent.dll',
'PowerToys.PowerAccent.exe',
'PowerToys.PowerAccentKeyboardService.dll'
)
# Tools signed files (in Tools subdirectory)