Compare commits

..

11 Commits

Author SHA1 Message Date
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
Clint Rutkas
af45c3ec7c Add press-and-hold activation mode to Quick Accent (#48937)
## Summary of the Pull Request

Adds an opt-in **press-and-hold** activation mode to Quick Accent, like
iOS / macOS: hold an accent-capable letter (e.g. `a`) and after a short,
configurable delay the accent picker opens automatically — no separate
trigger key (Space/arrows) required.

This is purely additive. The existing trigger-key modes (`Left/Right
arrow`, `Space`, `Both`) and all serialized settings values are
unchanged.


https://github.com/user-attachments/assets/faec298c-e42c-4fd1-84bd-6e74d1b481a0


### What it does

- Holding a letter types the base letter immediately, then arms the
picker. After the **Hold duration** (default **500 ms**) the toolbar
appears.
- Navigate the options with the arrow keys / Space, then **release the
letter** to insert the selected accent (it replaces the base letter).
- A quick tap types just the letter. Holding and releasing without
selecting leaves the base letter as-is.
- `Ctrl` / `Alt` / `AltGr` / `Win` + letter combinations are left
untouched, so shortcuts like `Ctrl+A` still work.

## PR Checklist

- [ ] **Closes:** N/A — feature enhancement (happy to link a tracking
issue if one is preferred)
- [x] **Communication:** Discussed direction with maintainers;
coordinated with #48891 (see below)
- [ ] **Tests:** No automated tests added — the activation decision
lives in the C++ low-level keyboard hook and isn't reachable from the
existing managed unit-test project. Validated manually (steps below).
Open to guidance on the preferred test surface.
- [x] **Localization:** All new end-user strings are in
`Settings.UI/Strings/en-us/Resources.resw` with translator comments.
- [x] **Dev docs:** `doc/devdocs/modules/quickaccent.md` updated with
the new mode.
- [x] **New binaries:** None.
- [x] **Documentation updated:** Dev docs updated; public Learn docs can
follow once shipped.

## Detailed Description of the Pull Request / Additional comments

- **`PowerAccentKeyboardService` (C++ hook):**
- Append `PressAndHold` to the internal `PowerAccentActivationKey` enum
(value `3`, appended to preserve serialized `0/1/2`).
- Add a `holdDuration` setting and `UpdateHoldDuration(Int32)` to the
WinRT projection (`.idl`).
- In `OnKeyDown`, arm the picker on the held letter itself; the base
letter still types on first press and auto-repeat is swallowed (reuses
the existing `m_toolbarVisible` repeat guard from #36853).
- In `OnKeyUp`, use the hold duration as the minimum-hold release
threshold for this mode (trigger modes keep using `inputTime`).
  - Modifier guard: Ctrl/Alt/AltGr/Win do not arm the mode.
- **Settings model (`Settings.UI.Library`):** append `PressAndHold` to
`PowerAccentActivationKey`; add `hold_duration_ms` (`IntProperty`,
default 500). Existing `settings.json` without the field falls back to
the 500 ms default.
- **`PowerAccent.Core`:** read and forward the hold duration to the
hook, and use it as the popup delay when `PressAndHold` is active.
- **Settings UI:** add the **"Press and hold the letter"** activation
option and a **"Hold duration (ms)"** control that is shown only when
that mode is selected.

### Enum sync note

`PowerAccentActivationKey` exists in both the C++ hook and the managed
settings library and is kept in sync by integer value. `PressAndHold`
was **appended** (never reordered) so existing serialized settings
(`0/1/2`) keep their meaning.

### Coordination with #48891 (Quick Accent WinUI migration)

This lands as its own atomic change on `main`. The overlap with the
in-progress WinUI migration (#48891) is tiny: only `PowerAccent.cs`'s
mode-aware popup delay (a single `Task.Delay` line). The C++ hook,
settings enum/model, and Settings UI are not touched by #48891, so it
can rebase onto this with minimal effort.

## Validation Steps Performed

- Built the full chain in `Debug|x64`:
- `PowerAccent.UI.csproj` → rebuilds the C++
`PowerAccentKeyboardService` projection (incl. `UpdateHoldDuration`) +
`PowerAccent.Core` + `Settings.UI.Library`. **0 errors.**
- `PowerToys.Settings.csproj` → Settings UI XAML / ViewModel / `.resw`
(XamlIndexBuilder search index regenerated). **0 errors.**
- Manual trial of the running module (`PowerToys.PowerAccent.exe`) with
`activation_key = 3`:
- Hold `a` → base letter types immediately; picker opens after ~500 ms;
arrows/Space navigate; releasing inserts the accent (replacing the base
letter).
- Quick tap → base letter only. Hold + release without selecting → base
letter remains.
  - `Ctrl+A` / `Alt`+letter unaffected.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-03 03:43:37 +00:00
Clint Rutkas
de4859454c [PowerToys] Guard TitleBar windows against an empty window title (startup fault) (#49069)
## Summary

Guard PowerToys' WinUI windows against an empty native window title, so
the WinUI `TitleBar` control can't read an empty title during startup
and fault the process. This fixes a class of bugs like
https://github.com/microsoft/PowerToys/issues/48547

## Background

Spotted while reading through the Environment Variables `MainWindow`
startup path. The WinUI `TitleBar` control (used with
`ExtendsContentIntoTitleBar`) reads the owning window's
`AppWindow.Title` during a deferred layout pass (`OnApplyTemplate` →
`UpdateTitle`). When the native window title is empty at that instant,
the windowing layer can fault while resolving the title and terminate
the process during startup.

The native title ends up empty in two ways:
1. The title is computed from `ResourceLoader.GetString(...)`, which
returns an **empty string** (it doesn't throw) when the resource map
can't be resolved at runtime.
2. The window sets `AppWindow.Title` only *later*, not before the title
bar's first layout.

## Windows fixed

Every PowerToys window that hosts the `TitleBar` control:

| Window | Fix |
|---|---|
| Environment Variables | Non-empty fallback for the resource-based
title |
| Hosts | Non-empty fallback for the resource-based title |
| File Locksmith | Non-empty fallback for the resource-based title |
| Shortcut Guide | Non-empty fallback for the resource-based title |
| Settings — shortcut-conflict window | Non-empty fallback for the
resource-based title |
| Registry Preview | Set `AppWindow.Title` to the app name in the
constructor (previously only set later in `UpdateWindowTitle`) |
| Keyboard Manager Editor | No change — already sets a hardcoded
non-empty `Title` |

## Risk

Very low. The only behavior change is that a previously-empty title
becomes a non-empty fallback; the normal (resource-resolved) paths are
unchanged.

## Validation

Each affected project builds clean (`x64 | Release`):
EnvironmentVariables, Hosts, FileLocksmithUI, ShortcutGuide.Ui,
RegistryPreview, PowerToys.Settings.

## Related

Root cause write-up (windowing/WinUI side):
microsoft/microsoft-ui-xaml#11214.

---

ADO:
https://microsoft.visualstudio.com/DefaultCollection/OS/_workitems/edit/62685601/

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:12:52 +02:00
Niels Laute
93669df118 TransparentWindow: opt-in Esc / focus-lost dismiss + multi-surface docs (#48950)
## Summary

Hardens the shared `TransparentWindow` (in
`src/common/Common.UI.Controls/`) with two **opt-in** dismissal
behaviors and documents multi-surface hosting. No consumer changes;
everything defaults off.

### 1. `DismissOnEscape` (default `false`)
Pressing <kbd>Esc</kbd> while the window content has keyboard focus
calls `Hide()`. Input is hooked lazily at show time because `Content` is
assigned by the consumer after construction.

### 2. `DismissOnFocusLost` (default `false`)
Light dismiss: hides when the window is deactivated. Guarded by a "seen
activated" flag (reset on each `Show()`) so the transient deactivation
that can occur during the show sequence doesn't dismiss prematurely.
This mirrors the guards that **PowerDisplay** (`_isShowingWindow`) and
**Quick Access** (`_hasSeenInteractiveActivation`) currently hand-roll —
they can drop their bespoke logic once they derive from
`TransparentWindow`.

> Both properties are no-ops unless the consumer activates the window
(it shows no-activate / `SW_SHOWNA`).

### 3. Multi-surface hosting (docs only — already works)
Added class `<remarks>` documenting that multiple `TransientSurface`s
can `SubscribeTo` one window: `HidingEventArgs` aggregates deferrals so
the window hides only after **all** surfaces finish animating out, and
plain `Show()` lets each surface play its own configured transition.

## Why no DWM "full-bleed" hardening here
The extra DWM hardening Shortcut Guide uses (NCRENDERING disabled,
`DwmExtendFrameIntoClientArea(-1)`, etc.) is only needed for
**full-monitor, edge-to-edge** overlays. Content-sized surfaces (Quick
Accent, CmdPal Toast) inset their acrylic card behind transparent
padding, so any phantom border sits in the transparent margin and is
invisible. Applying it universally would add compositing risk for zero
benefit, so it's deferred to the Shortcut Guide refactor (where it's
actually exercised) as an opt-in.

## Testing
- `Common.UI.Controls` builds clean (Debug/x64).
- Behavior is opt-in and off by default, so existing consumers are
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:58:43 +08:00
Noraa Junker
56fabda79c [Chore] Remove outdated clean up tool (#48992)
<!-- 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

Remove outdated clean up tool and script

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

- [x] Closes: #48991
<!-- - [ ] 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
- [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
2026-07-02 12:52:27 +02:00
Clint Rutkas
70555459ab [AlwaysOnTop] Guard m_frameDrawer in WindowBorder::UpdateBorderPosition (#48412)
## Summary

`WindowBorder::UpdateBorderPosition()` dereferences `m_frameDrawer`
without a null check when `GetFrameRect` fails on the tracked window:

```cpp
auto rectOpt = GetFrameRect(m_trackingWindow);
if (!rectOpt.has_value())
{
    m_frameDrawer->Hide();   // <-- AV if m_frameDrawer == nullptr
    return;
}
```

The sibling routine `WindowBorder::UpdateBorderProperties()` already
guards both `m_trackingWindow` and `m_frameDrawer` before doing any
work:

```cpp
if (!m_trackingWindow || !m_frameDrawer)
{
    return;
}
```

`UpdateBorderPosition` was the only call site that didn't match that
pattern. This PR brings it in line, and also adds a guard for `m_window`
for symmetry (the subsequent `SetWindowPos(m_window, ...)` would
otherwise fail silently with `ERROR_INVALID_WINDOW_HANDLE` but it's
cleaner to early-return).

## Why `m_frameDrawer` can be null when `UpdateBorderPosition` runs

`WindowBorder` registers itself as a `SettingsObserver` in its
base-class constructor — *before* `Init()` finishes. `Init()` is what
actually allocates `m_frameDrawer` (via `FrameDrawer::Create`). So
between those two steps there is a window where the object is alive and
observable but `m_frameDrawer == nullptr`.

The destructor has a similar shape:

```cpp
WindowBorder::~WindowBorder()
{
    if (m_frameDrawer)
    {
        m_frameDrawer->Hide();
        m_frameDrawer = nullptr;
    }

    if (m_window)
    {
        SetWindowLongPtrW(m_window, GWLP_USERDATA, 0);
        DestroyWindow(m_window);
    }
}
```

`m_frameDrawer` is nulled before `GWLP_USERDATA` is cleared and before
`DestroyWindow` is called. Any `WM_TIMER` that fires through `s_WndProc`
in that window dispatches to `WndProc → UpdateBorderPosition()` on an
instance whose `m_frameDrawer` has already been released — same null
deref, same access violation.

`UpdateBorderPosition` is also invoked from
`EVENT_OBJECT_LOCATIONCHANGE` / `EVENT_SYSTEM_MOVESIZEEND` in
`AlwaysOnTop.cpp` and from `WM_TIMER` in `WndProc`; both run on the
message-loop thread and either path can land here while the object is in
a transient half-constructed or half-destructed state.

## Change

Add `!m_frameDrawer` (and `!m_window`) to the early-return guard at the
top of `UpdateBorderPosition`. Three-line patch.

## How this was found

While reviewing the `WindowBorder` lifecycle for an unrelated
AlwaysOnTop tweak, the asymmetry between `UpdateBorderPosition` and
`UpdateBorderProperties` jumped out — the former dereferences
`m_frameDrawer` without the null check the latter performs.

## Tests

`src/modules/alwaysontop` has no test project today — `WindowBorder` is
tightly bound to Win32 (`HWND`, `DwmGetWindowAttribute`, `SetWindowPos`)
and `FrameDrawer` (Direct2D / D3D), with no abstraction layer to mock.
Adding meaningful native unit tests here would require a substantial
refactor that's well out of scope for this fix.

Manual validation:
- Build clean: `MSBuild
src\modules\alwaysontop\AlwaysOnTop\AlwaysOnTop.vcxproj
/p:Configuration=Release /p:Platform=x64` produces
`PowerToys.AlwaysOnTop.exe` with no warnings.
- Behavioural: with the guard added, the early-return path for "tracked
window has no valid extended frame bounds" simply skips the `Hide()`
call instead of crashing — same end-state for the user (border position
is left as-is until the next timer tick recovers it).

If we want a runtime regression net here, the right level is a UI /
lifecycle test that creates and pins/unpins windows under churn; that's
a separate piece of work and not gated on this fix.

## Risk

Three guarded conditions on a path that was already guarding one of
them. The behavioural delta only fires when the dereference *would* have
crashed — anywhere else the function is unchanged.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:08:10 +08:00
168 changed files with 1816 additions and 10622 deletions

View File

@@ -185,7 +185,6 @@ CAPTURECHANGED
CARETBLINKING
carlos
Carlseibert
caseinsensitive
caub
CBN
cch
@@ -434,7 +433,6 @@ downsampling
downscale
DPICHANGED
DPIs
dpm
DPMS
DPSAPI
DQTAT
@@ -504,7 +502,6 @@ EREOF
EResize
ERRORIMAGE
ERRORTITLE
esac
esrp
etd
ETDT
@@ -680,7 +677,6 @@ hcursor
hcwhite
hdc
HDEVNOTIFY
hdmi
hdr
HDROP
hdwwiz
@@ -842,6 +838,7 @@ INTRESOURCE
INVALIDARG
invalidoperatioexception
invokecommand
iOS
ipcmanager
ipreviewhandlervisualssetfont
IPTC
@@ -1253,7 +1250,6 @@ NTSTATUS
NTSYSAPI
nullability
NULLCURSOR
nullid
nullonfailure
nullref
numberbox
@@ -1316,7 +1312,6 @@ PARENTRELATIVEFORADDRESSBAR
PARENTRELATIVEFORUI
PARENTRELATIVEPARSING
parray
parseable
PARTIALCONFIRMATIONDIALOGTITLE
PATCOPY
PATHMUSTEXIST
@@ -1554,7 +1549,6 @@ Removelnk
renamable
RENAMEONCOLLISION
RENDERFULLCONTENT
renumbers
reparented
reparenting
reportfileaccesses
@@ -1568,7 +1562,6 @@ RESIZETOFIT
resmimetype
RESOURCEID
RESTORETOMAXIMIZED
resx
RETURNONLYFSDIRS
Revalidates
RGBQUAD
@@ -1965,7 +1958,6 @@ ums
uncompilable
UNCPRIORITY
UNDNAME
unescaped
ungroup
UNICODETEXT
unins
@@ -1978,7 +1970,6 @@ unittests
UNLEN
UNORM
unparsable
unparseable
unremapped
Unsend
Unsubscribes

1
.gitignore vendored
View File

@@ -381,4 +381,3 @@ deps/vcpkg/
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
docs/superpowers/
.superpowers/

View File

@@ -211,19 +211,16 @@
"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",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.exe",
"WinUI3Apps\\PowerToys.PowerDisplay.Cli.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.Contracts.dll",
"PowerDisplay.Lib.dll",
"PowerDisplay.Models.dll",

View File

@@ -722,10 +722,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts/PowerDisplay.Contracts.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
@@ -734,10 +730,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli/PowerDisplay.Cli.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
</Folder>
<Folder Name="/modules/PowerDisplay/Tests/">
@@ -745,18 +737,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Cli.UnitTests/PowerDisplay.Cli.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Contracts.UnitTests/PowerDisplay.Contracts.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Ipc.UnitTests/PowerDisplay.Ipc.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/MeasureTool/">
<Project Path="src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj" Id="54a93af7-60c7-4f6c-99d2-fbb1f75f853a">
@@ -826,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

@@ -69,24 +69,10 @@ Reference implementations:
### Exit Codes
Use `0` for success and a non-zero code for failure. A minimal CLI can use:
- `0`: Success
- `1`: General error (parsing, validation, runtime)
- `2`: Invalid arguments (optional)
Modules **MAY** define a richer, module-specific exit-code scheme when scripts benefit from
distinguishing failure kinds (e.g. not-found vs. out-of-range vs. hardware failure). When you do:
- Keep the code→meaning mapping in one place (a single source of truth) so an error's code and its
exit code cannot drift.
- **Document it in the module's own docs** — do not assume the minimal `1`/`2` meanings above carry
over. In a richer scheme `2` may mean something else (e.g. "out of range"), so a consumer must read
the module's table, not this baseline.
For a worked example see the PowerDisplay CLI ([`modules/powerdisplay/cli.md`](modules/powerdisplay/cli.md)),
which maps ten distinct error codes to exit codes `1``10`.
### Exception Handling
- Always wrap `Main()` in try-catch for unhandled exceptions.

View File

@@ -1,132 +0,0 @@
# PowerDisplay CLI
`PowerToys.PowerDisplay.Cli.exe` is a headless command-line front end for controlling monitor
settings (brightness, contrast, volume, color temperature, input source, power state, orientation)
and applying saved profiles.
The examples below use `powerdisplay` as shorthand — that is the name the tool uses for itself in
its `--help` output and error hints. There is no separate `powerdisplay` shim today; invoke the
executable by its real name (`PowerToys.PowerDisplay.Cli.exe`) or via your own alias.
## How it works
The CLI is a thin client. It does **not** talk to the hardware directly: it connects to the running
PowerDisplay app over a per-session named pipe (`PipeNames.CliServer()`), sends one JSON request,
and renders the one JSON response the app returns.
- **The PowerDisplay module must be enabled and running.** If it is not, the CLI exits with `10`
(`PROVIDER_UNAVAILABLE`) after a short connect timeout.
- The pipe is ACL'd to the current user's SID, so a non-elevated CLI can drive a same-user elevated
app (and other users are denied). See `PowerDisplay/Ipc/CliPipeServer.cs`.
- One invocation is bounded by an overall deadline (`Program.OperationTimeout`, 5s); the connect
phase is bounded separately and shorter (`Program.ConnectTimeout`, 2s) so a not-running app fails
fast and correctly as `PROVIDER_UNAVAILABLE` rather than `TIMEOUT`.
Human-readable text goes to **stdout** (success) and **stderr** (warnings/errors). Scripts should
branch on the **process exit code** (below), which is the stable machine contract.
## Commands
Canonical names live in `PowerDisplay.Contracts/Requests/CliCommandNames.cs`.
| Command | Purpose | Selector |
|---|---|---|
| `list` | Discover attached monitors (number, id, name, transport). | none |
| `get` | Read the current value of one or all settings. | optional (omit = all monitors) |
| `set` | Apply exactly one setting to a monitor. | required |
| `up` / `down` | Raise / lower one continuous setting relative to its current value. | required |
| `capabilities` | Print the monitor's advertised VCP capabilities. | required |
| `profiles` | List saved profiles (name, monitor count, last modified). | none |
| `apply-profile <name>` | Apply a saved profile's per-monitor settings. | none |
### Selecting a monitor
- `-n`, `--monitor-number <n>` — 1-based index from `list`.
- `-i`, `--monitor-id <id>` — stable id from `list`. **Wins** if both are supplied (the CLI prints a
note that `-n` was ignored).
### Settings
Names live in `PowerDisplay.Contracts/CliSettingNames.cs`.
| Setting | `set` flag | Kind | Value |
|---|---|---|---|
| brightness | `--brightness <0-100>` | continuous | percent |
| contrast | `--contrast <0-100>` | continuous | percent |
| volume | `--volume <0-100>` | continuous | percent |
| color-temperature | `--color-temperature <0xNN>` | discrete | hex VCP value |
| input-source | `--input-source <0xNN>` | discrete | hex VCP value |
| power-state | `--power-state <0xNN>` | discrete | hex VCP value |
| orientation | `--orientation <0\|90\|180\|270>` | GDI | degrees |
- Discrete values are **hex only** (e.g. `0x05`); friendly names are not accepted because the generic
VCP name table can disagree with a specific panel. Run `capabilities --setting <name>` to list the
values a monitor actually advertises.
- `set` requires **exactly one** setting flag.
- `up`/`down` accept one of `--brightness` / `--contrast` / `--volume` as a **no-value presence flag**,
plus optional `--step <n>` (defaults to the PowerDisplay `mouse_wheel_increment` setting).
- Applying a `--power-state` that blanks the panel requires `--confirm-power-off`.
### Global options
- `--quiet` — suppress warning messages on stderr.
## Exit codes
Single source of truth: `PowerDisplay.Contracts/CliExitCodes.cs` (paired 1:1 with the `error.code`
strings in `CliErrorCodes.cs`). **This scheme extends the baseline in
[`../../cli-conventions.md`](../../cli-conventions.md); exit code `2` here means "out of range", not
"invalid arguments".**
| Exit | `error.code` | Meaning |
|---|---|---|
| 0 | — | Success |
| 1 | `MONITOR_NOT_FOUND` | The selected monitor number/id was not found. |
| 2 | `OUT_OF_RANGE` | A continuous value was outside `[0, 100]`. |
| 3 | `INVALID_DISCRETE_VALUE` | A discrete or orientation value was invalid, or not in the monitor's advertised set. |
| 4 | `UNSUPPORTED_FEATURE` | The monitor does not support the requested setting. |
| 5 | `HARDWARE_FAILURE` | The DDC/CI or GDI write failed. |
| 6 | `SELECTOR_MISSING` | A command that needs a monitor was given none. |
| 7 | `ARGUMENT_ERROR` | Invalid arguments (unknown setting, bad combination, parse error). |
| 8 | `TIMEOUT` | The operation exceeded the deadline or was cancelled (Ctrl+C). |
| 9 | `INTERNAL_ERROR` | Unexpected failure. |
| 10 | `PROVIDER_UNAVAILABLE` | The PowerDisplay app is not running / unreachable. |
For `apply-profile`, the exit code is the **worst** per-setting outcome across all monitors
(`HARDWARE_FAILURE` > `INVALID_DISCRETE_VALUE` > `OUT_OF_RANGE` > success); `unsupported` settings do
not fail the command.
## Examples
```pwsh
# List monitors
powerdisplay list
# Read everything for monitor 1
powerdisplay get -n 1
# Read just brightness for a specific monitor id
powerdisplay get -i "\\?\DISPLAY#..." --setting brightness
# Set brightness to 60% on monitor 2
powerdisplay set -n 2 --brightness 60
# Nudge volume down by 5
powerdisplay down -n 1 --volume --step 5
# Discover the color-temperature values a monitor advertises, then set one
powerdisplay capabilities -n 1 --setting color-temperature
powerdisplay set -n 1 --color-temperature 0x05
# Power the panel off (requires explicit confirmation)
powerdisplay set -n 1 --power-state 0x04 --confirm-power-off
# Apply a saved profile
powerdisplay apply-profile "Night"
```
## Related source
- CLI client: `src/modules/powerdisplay/PowerDisplay.Cli/`
- Shared contracts / DTOs: `src/modules/powerdisplay/PowerDisplay.Contracts/`
- App-side IPC (pipe server, executors, projectors): `src/modules/powerdisplay/PowerDisplay/Ipc/`

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)
@@ -59,13 +71,26 @@ This component:
### Activation Mechanism
The Quick Accent is activated when:
Quick Accent supports two activation styles, selected by the **Activation key** setting.
**Trigger-key modes** (`Left/Right arrow`, `Space`, or `Both` — the default):
1. A user presses and holds a character key (e.g., 'a')
2. User presses the trigger key
3. After a brief delay (around 300ms per setting), the accent toolbar appears
4. The user can select an accented variant using the trigger key
5. Upon releasing the keys, the selected accented character is inserted
**Press-and-hold mode** (`Press and hold the letter`, iOS/macOS style, opt-in):
1. A user presses and holds an accent-capable character key (e.g., 'a'); the base
letter is typed immediately
2. After the configured **Hold duration** (around 500ms per setting), the accent
toolbar appears automatically — no separate trigger key is required
3. The user navigates the options with the arrow keys or Space
4. Upon releasing the letter, the selected accent replaces the base letter; if no
option was selected, the base letter that was already typed simply remains
5. A quick tap (shorter than the Hold duration) types the base letter only, and
modifier combinations (Ctrl/Alt/AltGr/Win + letter) are left untouched
### Character Sets
The module includes multiple language-specific character sets and special character sets:
@@ -115,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

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

View File

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

View File

@@ -109,7 +109,7 @@ Per Application/Package one or more Keyboard manifests can be declared. Every ma
<details>
<summary><b>SectionName</b> - Name of the category of shortcuts</summary>
Name of the section of shortcuts.
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

@@ -367,12 +367,6 @@
</RegistryKey>
<File Id="CmdPalExtPowerToys_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\Microsoft.CmdPal.Ext.PowerToys.resources.dll" />
</Component>
<Component Id="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Guid="$(var.CompGUIDPrefix)24">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="PowerDisplayCli_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="PowerDisplayCli_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\PowerToys.PowerDisplay.Cli.resources.dll" />
</Component>
<?undef IdSafeLanguage?>
<?undef CompGUIDPrefix?>
<?endforeach?>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

@@ -1759,17 +1759,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

@@ -67,6 +67,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;
@@ -91,8 +97,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
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();
@@ -119,6 +126,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);
@@ -674,6 +686,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;
}
@@ -936,6 +953,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;
@@ -979,6 +1014,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_isDisposed = true;
WeakReferenceMessenger.Default.UnregisterAll(this);
_settingsService.SettingsChanged -= OnSettingsChanged;
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();

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

@@ -11,6 +11,8 @@ using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
using PowerToys.PowerAccentKeyboardService;
using PowerAccentActivationKey = Microsoft.PowerToys.Settings.UI.Library.Enumerations.PowerAccentActivationKey;
namespace PowerAccent.Core;
public partial class PowerAccent : IDisposable
@@ -43,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();
@@ -66,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);
});
@@ -74,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);
});
@@ -82,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);
});
@@ -96,28 +102,49 @@ public partial class PowerAccent : IDisposable
private void ShowToolbar(LetterKey letterKey)
{
_initialShiftState = WindowsFunctions.IsShiftState();
_visible = true;
bool isPressAndHold = _settingService.ActivationKey == PowerAccentActivationKey.PressAndHold;
// Each summon gets a generation id so a delayed render queued by an earlier
// press can't fire for a newer one (or after the toolbar was hidden).
int generation = ++_showGeneration;
_characters = GetCharacters(letterKey);
_characterDescriptions = GetCharacterDescriptions(_characters);
_showUnicodeDescription = _settingService.ShowUnicodeDescription;
// Trigger modes navigate the instant the toolbar is summoned, so the character data must
// be ready synchronously. Press-and-hold can't navigate until the popup is actually shown,
// so defer the (relatively expensive) character/description build to the delayed render and
// keep quick taps off the keystroke hot path.
if (!isPressAndHold)
{
PrepareCharacters(letterKey);
}
Task.Delay(_settingService.InputTime).ContinueWith(
int displayDelay = isPressAndHold ? _settingService.HoldDuration : _settingService.InputTime;
Task.Delay(displayDelay).ContinueWith(
t =>
{
if (_visible && generation == _showGeneration)
{
if (isPressAndHold)
{
PrepareCharacters(letterKey);
}
OnChangeDisplay?.Invoke(true, _characters);
}
},
TaskScheduler.FromCurrentSynchronizationContext());
}
private void PrepareCharacters(LetterKey letterKey)
{
_initialShiftState = WindowsFunctions.IsShiftState();
_characters = GetCharacters(letterKey);
_characterDescriptions = GetCharacterDescriptions(_characters);
_showUnicodeDescription = _settingService.ShowUnicodeDescription;
}
private string[] GetCharacters(LetterKey letterKey)
{
var characters = CharacterMappings.GetCharacters(letterKey, _settingService.SelectedLang);
@@ -213,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;
}
@@ -247,6 +274,13 @@ public partial class PowerAccent : IDisposable
private void ProcessNextChar(TriggerKey triggerKey, bool shiftPressed)
{
// Press-and-hold builds its character set lazily when the popup renders; ignore any
// navigation that races ahead of it (there is nothing to select yet).
if (_characters.Length == 0)
{
return;
}
// Use an async hardware check as a fallback in case the keyboard hook misses a
// quick Shift press. If the popup was opened while holding Shift (e.g., typing a
// capital letter), ignore the hardware check so we don't accidentally trigger a
@@ -361,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

@@ -59,6 +59,9 @@ public class SettingsService
InputTime = settings.Properties.InputTime.Value;
_keyboardListener.UpdateInputTime(InputTime);
HoldDuration = settings.Properties.HoldDuration.Value;
_keyboardListener.UpdateHoldDuration(HoldDuration);
ExcludedApps = settings.Properties.ExcludedApps.Value;
_keyboardListener.UpdateExcludedApps(ExcludedApps);
@@ -196,6 +199,8 @@ public class SettingsService
}
}
public int HoldDuration { get; set; } = PowerAccentSettings.DefaultHoldDurationMs;
private string _excludedApps;
public string ExcludedApps

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

@@ -96,6 +96,11 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_settings.inputTime = std::chrono::milliseconds(inputTime);
}
void KeyboardListener::UpdateHoldDuration(int32_t holdDuration)
{
m_settings.holdDuration = std::chrono::milliseconds(holdDuration);
}
void KeyboardListener::UpdateExcludedApps(std::wstring_view excludedAppsView)
{
std::vector<std::wstring> excludedApps;
@@ -123,6 +128,17 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
return m_settings.doNotActivateOnGameMode && detect_game_mode();
}
bool KeyboardListener::IsBlockingModifierDown()
{
// Ctrl / Alt (including AltGr = Ctrl+Alt) / Win turn a held letter into a shortcut,
// so they must not trigger press-and-hold. Shift is intentionally allowed so that
// uppercase accents still work.
return (GetAsyncKeyState(VK_CONTROL) & 0x8000) ||
(GetAsyncKeyState(VK_MENU) & 0x8000) ||
(GetAsyncKeyState(VK_LWIN) & 0x8000) ||
(GetAsyncKeyState(VK_RWIN) & 0x8000);
}
bool KeyboardListener::IsForegroundAppExcluded()
{
std::lock_guard<std::mutex> lock(m_mutex_excluded_apps);
@@ -181,6 +197,25 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
letterPressed = letterKey;
}
// Press-and-hold activation: the held letter itself opens the toolbar after the hold
// duration. The base letter still types on first press; auto-repeats are swallowed above.
if (m_settings.activationKey == PowerAccentActivationKey::PressAndHold &&
!m_toolbarVisible &&
letterPressed != LetterKey::None &&
letterKey == letterPressed &&
!IsBlockingModifierDown() &&
!IsSuppressedByGameMode() &&
!IsForegroundAppExcluded())
{
Logger::debug(L"Show toolbar (press-and-hold). Letter: {}", letterPressed);
m_triggeredWithSpace = false;
m_triggeredWithLeftArrow = false;
m_triggeredWithRightArrow = false;
m_toolbarVisible = true;
m_showToolbarCb(letterPressed);
return false;
}
UINT triggerPressed = 0;
if (letterPressed != LetterKey::None)
{
@@ -199,7 +234,9 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
}
}
if (!m_toolbarVisible && letterPressed != LetterKey::None && triggerPressed && !IsSuppressedByGameMode() && !IsForegroundAppExcluded())
// Trigger-key activation (letter + Space/arrow) is exclusive to the non-hold modes.
if (m_settings.activationKey != PowerAccentActivationKey::PressAndHold &&
!m_toolbarVisible && letterPressed != LetterKey::None && triggerPressed && !IsSuppressedByGameMode() && !IsForegroundAppExcluded())
{
Logger::debug(L"Show toolbar. Letter: {}, Trigger: {}", letterPressed, triggerPressed);
@@ -211,7 +248,14 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_showToolbarCb(letterPressed);
}
if (m_toolbarVisible && triggerPressed)
// In press-and-hold the popup only appears once the hold duration elapses, so Space/arrow
// must pass through until then; treat the picker as interactive only once it is shown.
const bool pickerInteractive =
m_toolbarVisible &&
(m_settings.activationKey != PowerAccentActivationKey::PressAndHold ||
m_stopwatch.elapsed() >= m_settings.holdDuration);
if (pickerInteractive && triggerPressed)
{
if (triggerPressed == VK_LEFT)
{
@@ -247,13 +291,27 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_rightShiftPressed = false;
}
if (std::find(std::begin(letters), end(letters), static_cast<LetterKey>(info.vkCode)) != end(letters) && m_isLanguageLetterCb(static_cast<LetterKey>(info.vkCode)))
const auto releasedLetter = static_cast<LetterKey>(info.vkCode);
if (std::find(std::begin(letters), end(letters), releasedLetter) != end(letters) && m_isLanguageLetterCb(releasedLetter))
{
// Only react to the key-up of the letter that owns the toolbar, so releasing a
// different held letter can't cancel or commit the active picker.
if (letterPressed != releasedLetter)
{
return false;
}
letterPressed = LetterKey::None;
if (m_toolbarVisible)
{
if (m_stopwatch.elapsed() < m_settings.inputTime)
// Press-and-hold uses its own (typically longer) hold duration as the
// minimum-hold threshold; the trigger-key modes use inputTime.
const auto activationThreshold =
m_settings.activationKey == PowerAccentActivationKey::PressAndHold
? m_settings.holdDuration
: m_settings.inputTime;
if (m_stopwatch.elapsed() < activationThreshold)
{
Logger::debug(L"Activation too fast. Do nothing.");
@@ -275,7 +333,11 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_hideToolbarCb(InputType::None);
}
m_toolbarVisible = false;
return true;
// In press-and-hold the base letter already typed on key-down and no trigger
// key was consumed, so let this key-up pass through to avoid a stuck-key
// perception. Trigger modes keep swallowing it as before.
return m_settings.activationKey != PowerAccentActivationKey::PressAndHold;
}
Logger::debug(L"Hide toolbar event and input char");

View File

@@ -11,6 +11,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
LeftRightArrow,
Space,
Both,
PressAndHold,
};
struct PowerAccentSettings
@@ -18,6 +19,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
PowerAccentActivationKey activationKey{ PowerAccentActivationKey::Both };
bool doNotActivateOnGameMode{ true };
std::chrono::milliseconds inputTime{ 300 }; // Should match with UI.Library.PowerAccentSettings.DefaultInputTimeMs
std::chrono::milliseconds holdDuration{ 500 }; // Should match with UI.Library.PowerAccentSettings.DefaultHoldDurationMs
std::vector<std::wstring> excludedApps;
};
@@ -39,6 +41,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
void UpdateActivationKey(int32_t activationKey);
void UpdateDoNotActivateOnGameMode(bool doNotActivateOnGameMode);
void UpdateInputTime(int32_t inputTime);
void UpdateHoldDuration(int32_t holdDuration);
void UpdateExcludedApps(std::wstring_view excludedApps);
static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam);
@@ -48,6 +51,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
bool OnKeyUp(KBDLLHOOKSTRUCT info) noexcept;
bool IsSuppressedByGameMode();
bool IsForegroundAppExcluded();
bool IsBlockingModifierDown();
static inline KeyboardListener* s_instance;
HHOOK s_llKeyboardHook = nullptr;

View File

@@ -83,6 +83,7 @@ namespace PowerToys
void UpdateActivationKey(Int32 activationKey);
void UpdateDoNotActivateOnGameMode(Boolean doNotActivateOnGameMode);
void UpdateInputTime(Int32 inputTime);
void UpdateHoldDuration(Int32 holdDuration);
void UpdateExcludedApps(String excludedApps);
}
}

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

@@ -1,21 +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 PowerDisplay.Cli.Commands;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class AdjustCommandInputsTests
{
[TestMethod]
public void CountSelectedSettings_CountsAcrossThresholds()
{
Assert.AreEqual(0, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs()));
Assert.AreEqual(1, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true }));
Assert.AreEqual(2, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true, Volume = true }));
Assert.AreEqual(3, AdjustCommand.CountSelectedSettings(new AdjustCommandInputs { Brightness = true, Contrast = true, Volume = true }));
}
}

View File

@@ -1,149 +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.IO;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Output;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for <see cref="CliErrorLocalizer"/> (the app-Code/MessageId -> localized text mapping) and
/// the <see cref="TextCliOutput.WriteError"/> rendering that consumes it. The app sends only ids +
/// structured data; these pin that the CLI composes the human text from them, and falls back to the
/// app's English message for an unrecognized id.
/// </summary>
[TestClass]
public class CliErrorLocalizerTests
{
[TestMethod]
public void Localize_OutOfRange_SubstitutesValueAndSetting()
{
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
Code = CliErrorCodes.OutOfRange,
MessageId = CliMessageIds.OutOfRange,
Value = "150",
Setting = "brightness",
});
Assert.AreEqual("150 is out of range for brightness", message);
Assert.IsNull(hint);
}
[TestMethod]
public void Localize_Unsupported_UsesSettingName()
{
var (message, _) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.Unsupported,
Setting = "volume",
});
Assert.AreEqual("volume is not supported", message);
}
[TestMethod]
public void Localize_UnknownSetting_ProducesCliGeneratedHint()
{
// The hint's valid-settings list is CLI-known data, generated here (not sent by the app).
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.UnknownSetting,
Value = "foo",
});
Assert.AreEqual("unknown setting foo", message);
Assert.IsNotNull(hint);
StringAssert.Contains(hint, "brightness");
}
[TestMethod]
public void Localize_HardwareFailure_MessageIsFixed_DetailRenderedSeparately()
{
// The driver string travels in Detail (rendered on its own line), not folded into the message.
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = CliMessageIds.HardwareFailure,
Detail = "DDC write timed out",
});
Assert.AreEqual("hardware write failed", message);
Assert.IsNull(hint);
}
[TestMethod]
public void Localize_UnknownMessageId_FallsBackToAppMessageAndHint()
{
// Version-skew safety: an id the CLI does not recognize degrades to the app's English prose.
var (message, hint) = CliErrorLocalizer.Localize(new CliError
{
MessageId = "an-id-a-future-app-added",
Message = "english fallback",
Hint = "english hint",
});
Assert.AreEqual("english fallback", message);
Assert.AreEqual("english hint", hint);
}
[TestMethod]
public void Localize_EmptyMessageId_FallsBackToAppMessage()
{
// CLI-side errors (parse/validation) already carry a localized Message and no MessageId.
var (message, _) = CliErrorLocalizer.Localize(new CliError
{
Message = "already-localized cli-side message",
});
Assert.AreEqual("already-localized cli-side message", message);
}
[TestMethod]
public void WriteError_OutOfRange_RendersMessageExpectedAndLabels()
{
var stderr = new StringWriter();
var output = new TextCliOutput(new StringWriter(), stderr, quiet: false);
output.WriteError(new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.OutOfRange,
MessageId = CliMessageIds.OutOfRange,
Value = "150",
Setting = "brightness",
ExpectedRange = "[0, 100]",
},
});
var text = stderr.ToString();
StringAssert.Contains(text, "150 is out of range for brightness");
StringAssert.Contains(text, "[0, 100]");
}
[TestMethod]
public void WriteError_HardwareFailure_RendersDetailLine()
{
var stderr = new StringWriter();
var output = new TextCliOutput(new StringWriter(), stderr, quiet: false);
output.WriteError(new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.HardwareFailure,
MessageId = CliMessageIds.HardwareFailure,
Detail = "DDC write timed out",
},
});
var text = stderr.ToString();
StringAssert.Contains(text, "hardware write failed");
StringAssert.Contains(text, "DDC write timed out");
}
}

View File

@@ -1,106 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests for <see cref="CliPipeClient"/>.
/// </summary>
[TestClass]
public class CliPipeClientTests
{
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(200);
// ── Happy-path: in-proc fake server ──────────────────────────────────────
[TestMethod]
[Timeout(10_000)]
public async Task SendAsync_WithFakeServer_ReturnsCannedResponse()
{
const string RequestJson = @"{""command"":""list""}";
const string ResponseJson = @"{""monitors"":[]}";
// Start a one-shot in-proc server on the same pipe name
using var serverReady = new SemaphoreSlim(0, 1);
var serverTask = Task.Run(async () =>
{
using var server = new NamedPipeServerStream(
PipeNames.CliServer(),
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
serverReady.Release(); // signal: server is now listening
await server.WaitForConnectionAsync();
// Mirror the server protocol: BOM-less UTF-16 LE (same as CliPipeClient / CliPipeServer).
// Use the shared pipe encoding/buffer so the fake server stays byte-compatible with the client.
using var reader = new StreamReader(server, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
using var writer = new StreamWriter(server, CliPipeProtocol.PipeEncoding, CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
var line = await reader.ReadLineAsync();
// Echo back the canned response regardless of what was sent
await writer.WriteLineAsync(ResponseJson);
});
// Wait until the server is listening before connecting
await serverReady.WaitAsync(TimeSpan.FromSeconds(5));
var client = new CliPipeClient();
var result = await client.SendAsync(RequestJson, ConnectTimeout, CancellationToken.None);
await serverTask; // ensure the server task completes cleanly
Assert.AreEqual(ResponseJson, result);
}
// ── No-server path: returns null within short timeout ────────────────────
[TestMethod]
[Timeout(5_000)]
public async Task SendAsync_NoServer_ReturnsNullWithinShortTimeout()
{
// There is no server listening on this pipe, so ConnectAsync will throw TimeoutException.
// We use ShortTimeout (200 ms) to keep the test fast.
var client = new CliPipeClient();
var result = await client.SendAsync(@"{""command"":""list""}", ShortTimeout, CancellationToken.None);
Assert.IsNull(result, "Expected null when no pipe server is running");
}
// ── Cancellation propagates ───────────────────────────────────────────────
[TestMethod]
[Timeout(5_000)]
public async Task SendAsync_CancelledToken_ThrowsOperationCanceledException()
{
using var cts = new CancellationTokenSource();
cts.Cancel(); // pre-cancelled
var client = new CliPipeClient();
// Assert.ThrowsExceptionAsync<T> matches the exact type, so TaskCanceledException
// (which derives from OperationCanceledException) would fail it. Use a manual
// try/catch so any subclass of OperationCanceledException is accepted.
try
{
await client.SendAsync(@"{""command"":""list""}", ConnectTimeout, cts.Token);
Assert.Fail("Expected the operation to be cancelled.");
}
catch (OperationCanceledException)
{
// expected (TaskCanceledException derives from OperationCanceledException)
}
}
}

View File

@@ -1,320 +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.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Cli.Output;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
/// <summary>
/// Tests the IPC dispatch path: provider-unavailable (null response) → exit 10,
/// success response → rendered and exit 0, and error response → rendered and
/// correct exit code.
/// </summary>
[TestClass]
public class IpcDispatchTests
{
private static readonly TimeSpan AnyTimeout = TimeSpan.FromSeconds(30);
// ── helpers ──────────────────────────────────────────────────────────────
private sealed class CaptureOutput : ICliOutput, IDisposable
{
private readonly List<string> stdoutLines = new();
private readonly List<string> stderrLines = new();
private readonly StringWriter stdout = new();
private readonly StringWriter stderr = new();
public IReadOnlyList<string> StdoutLines => this.stdoutLines;
public IReadOnlyList<string> StderrLines => this.stderrLines;
public void WriteListResult(CliListResult r) => this.stdoutLines.Add("list:" + r.Command);
public void WriteSetResult(CliSetResult r) => this.stdoutLines.Add("set:" + r.Setting);
public void WriteGetResult(CliGetResult r) => this.stdoutLines.Add("get");
public void WriteCapabilitiesResult(CliCapabilitiesResult r) => this.stdoutLines.Add("capabilities");
public void WriteProfileListResult(CliProfileListResult r) => this.stdoutLines.Add("profiles");
public void WriteApplyProfileResult(CliApplyProfileResult r) => this.stdoutLines.Add("apply-profile:" + r.ExitCode);
public void WriteError(CliErrorResult r) => this.stderrLines.Add("error:" + r.Error.Code + ":" + r.Error.ExitCode);
public void WriteWarning(string message) => this.stderrLines.Add("warn:" + message);
public void Dispose()
{
this.stdout.Dispose();
this.stderr.Dispose();
}
}
private static IpcDispatcher MakeDispatcher(string? stubResponse, CaptureOutput output)
{
Task<string?> StubSend(string requestJson, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(stubResponse);
return new IpcDispatcher(StubSend, output, AnyTimeout);
}
private static string SerializeSuccess<T>(T obj, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo)
=> JsonSerializer.Serialize(obj, typeInfo);
private static string SerializeError(CliErrorResult err)
=> JsonSerializer.Serialize(err, ContractsJsonContext.Default.CliErrorResult);
// ── ProviderUnavailable (null) ────────────────────────────────────────────
[TestMethod]
public async Task When_provider_unavailable_list_exits_10()
{
var output = new CaptureOutput();
var dispatcher = MakeDispatcher(null, output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.ProviderUnavailable, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.ProviderUnavailable);
StringAssert.Contains(output.StderrLines[0], "10");
}
// ── Success responses rendered, exit 0 ───────────────────────────────────
[TestMethod]
public async Task Success_set_renders_result_exits_0()
{
var output = new CaptureOutput();
var responseJson = SerializeSuccess(
new CliSetResult { Setting = "brightness", Monitor = new CliMonitorRef { Number = 1, Id = "x", Name = "N" }, AfterDisplay = "80%" },
ContractsJsonContext.Default.CliSetResult);
var dispatcher = MakeDispatcher(responseJson, output);
var inputs = new SetCommandInputs { Brightness = 80 };
var exit = await dispatcher.SendSetAsync(CliRequestBuilder.BuildSet(inputs), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit);
Assert.AreEqual(1, output.StdoutLines.Count);
StringAssert.Contains(output.StdoutLines[0], "brightness");
}
// ── Error responses rendered, correct exit code ───────────────────────────
[TestMethod]
public async Task Error_response_renders_error_and_returns_its_exit_code()
{
var output = new CaptureOutput();
var errorResponse = new CliErrorResult
{
Command = "list",
Error = new CliError
{
Code = CliErrorCodes.MonitorNotFound,
Message = "Monitor not found.",
},
};
var responseJson = SerializeError(errorResponse);
var dispatcher = MakeDispatcher(responseJson, output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.MonitorNotFound, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.MonitorNotFound);
// An error envelope (isError=true) routes through the error renderer (stderr) only and must
// never leak to the success path (stdout).
Assert.AreEqual(0, output.StdoutLines.Count, "error envelope must not render via the success path");
}
// ── apply-profile exit-code carried through IPC ───────────────────────────
/// <summary>
/// Verifies that when the app returns a canned CliApplyProfileResult with
/// ExitCode=2 (OutOfRange), the CLI dispatcher returns exit 2, NOT the old hardcoded 5
/// (HardwareFailure). This is the regression test for the apply-profile exit-code bug.
/// </summary>
[TestMethod]
public async Task ApplyProfile_OutOfRange_partial_failure_exits_2()
{
var output = new CaptureOutput();
var responseJson = SerializeSuccess(
new CliApplyProfileResult
{
ExitCode = CliExitCodes.OutOfRange,
Profile = "Night",
Monitors = new List<CliProfileMonitorOutcome>
{
new CliProfileMonitorOutcome
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
Connected = true,
Changes = new List<CliProfileChange>
{
new CliProfileChange { Setting = "brightness", Value = 110, Status = CliProfileChange.StatusOutOfRange },
},
},
},
},
ContractsJsonContext.Default.CliApplyProfileResult);
var dispatcher = MakeDispatcher(responseJson, output);
var exit = await dispatcher.SendApplyProfileAsync(CliRequestBuilder.BuildApplyProfile("Night"), CancellationToken.None);
Assert.AreEqual(CliExitCodes.OutOfRange, exit, "OutOfRange partial failure must return exit 2, not hardcoded HardwareFailure(5)");
// A partial-failure apply-profile result is a SUCCESS envelope (isError=false): it must route
// through the success renderer (stdout) and never WriteError — purely on the explicit discriminator.
Assert.AreEqual(1, output.StdoutLines.Count, "rendered via the success path");
Assert.AreEqual(0, output.StderrLines.Count, "must not go through WriteError");
}
[TestMethod]
public async Task ApplyProfile_full_success_exits_0()
{
var output = new CaptureOutput();
var responseJson = SerializeSuccess(
new CliApplyProfileResult
{
ExitCode = CliExitCodes.Ok,
Profile = "Work",
Monitors = new List<CliProfileMonitorOutcome>(),
},
ContractsJsonContext.Default.CliApplyProfileResult);
var dispatcher = MakeDispatcher(responseJson, output);
var exit = await dispatcher.SendApplyProfileAsync(CliRequestBuilder.BuildApplyProfile("Work"), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit);
}
// ── schema-mismatch / undeserializable response → InternalError (9) ────────
[TestMethod]
public async Task Malformed_json_response_exits_internal_error()
{
var output = new CaptureOutput();
var dispatcher = MakeDispatcher("{ this is not valid json", output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.InternalError, exit);
Assert.AreEqual(1, output.StderrLines.Count);
StringAssert.Contains(output.StderrLines[0], CliErrorCodes.InternalError);
}
[TestMethod]
public async Task Wrong_shape_response_exits_internal_error()
{
// Valid JSON with isError:false, but the success payload cannot deserialize as the expected
// type (monitors is a string, not an array) — the version-skew fallback path.
var output = new CaptureOutput();
var dispatcher = MakeDispatcher("{\"isError\":false,\"monitors\":\"oops\"}", output);
var exit = await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), CancellationToken.None);
Assert.AreEqual(CliExitCodes.InternalError, exit);
}
// ── CliRequestBuilder round-trips ────────────────────────────────────────
[TestMethod]
public void BuildSet_Brightness_MapsCorrectly()
{
var inputs = new SetCommandInputs { Brightness = 75, MonitorNumber = 2 };
var envelope = CliRequestBuilder.BuildSet(inputs);
Assert.AreEqual(CliCommandNames.Set, envelope.Command);
Assert.IsNotNull(envelope.Set);
Assert.AreEqual("brightness", envelope.Set!.Setting);
Assert.AreEqual("75", envelope.Set.RawValue);
Assert.AreEqual(2, envelope.Set.MonitorNumber);
}
[TestMethod]
public void BuildSet_PowerState_MapsCorrectly()
{
var inputs = new SetCommandInputs { PowerState = "Standby", ConfirmPowerOff = true };
var envelope = CliRequestBuilder.BuildSet(inputs);
Assert.AreEqual("power-state", envelope.Set!.Setting);
Assert.AreEqual("Standby", envelope.Set.RawValue);
Assert.IsTrue(envelope.Set.ConfirmPowerOff);
}
[TestMethod]
public void BuildSet_NoSetting_Throws()
{
var inputs = new SetCommandInputs();
Assert.ThrowsException<InvalidOperationException>(() => CliRequestBuilder.BuildSet(inputs));
}
[TestMethod]
public void BuildGet_Maps_MonitorSelectors_And_Filter()
{
var envelope = CliRequestBuilder.BuildGet(3, "myId", "brightness");
Assert.AreEqual(CliCommandNames.Get, envelope.Command);
Assert.AreEqual(3, envelope.Get!.MonitorNumber);
Assert.AreEqual("myId", envelope.Get.MonitorId);
Assert.AreEqual("brightness", envelope.Get.SettingFilter);
}
[TestMethod]
public void BuildApplyProfile_Maps_ProfileName()
{
var envelope = CliRequestBuilder.BuildApplyProfile("Night");
Assert.AreEqual(CliCommandNames.ApplyProfile, envelope.Command);
Assert.AreEqual("Night", envelope.ApplyProfile!.ProfileName);
}
// ── BuildAdjust round-trips ──────────────────────────────────────────────
[TestMethod]
public void BuildAdjust_Up_Brightness_MapsCommandSettingAndStep()
{
var inputs = new AdjustCommandInputs { Brightness = true, Step = 10, MonitorNumber = 2 };
var envelope = CliRequestBuilder.BuildAdjust(CliCommandNames.Up, inputs);
Assert.AreEqual(CliCommandNames.Up, envelope.Command);
Assert.IsNotNull(envelope.Adjust);
Assert.AreEqual("brightness", envelope.Adjust!.Setting);
Assert.AreEqual(10, envelope.Adjust.Step);
Assert.AreEqual(2, envelope.Adjust.MonitorNumber);
}
[TestMethod]
public void BuildAdjust_Down_Contrast_NullStep()
{
var inputs = new AdjustCommandInputs { Contrast = true, Step = null };
var envelope = CliRequestBuilder.BuildAdjust(CliCommandNames.Down, inputs);
Assert.AreEqual(CliCommandNames.Down, envelope.Command);
Assert.AreEqual("contrast", envelope.Adjust!.Setting);
Assert.IsNull(envelope.Adjust.Step);
}
[TestMethod]
public void BuildAdjust_NoSetting_Throws()
{
Assert.ThrowsException<InvalidOperationException>(
() => CliRequestBuilder.BuildAdjust(CliCommandNames.Up, new AdjustCommandInputs()));
}
// ── SendAdjustAsync renders via the set renderer, exits 0 ─────────────────
[TestMethod]
public async Task Success_adjust_renders_result_exits_0()
{
var output = new CaptureOutput();
var responseJson = SerializeSuccess(
new CliSetResult { Command = "up", Setting = "brightness", Monitor = new CliMonitorRef { Number = 1, Id = "x", Name = "N" }, AfterDisplay = "60%" },
ContractsJsonContext.Default.CliSetResult);
var dispatcher = MakeDispatcher(responseJson, output);
var inputs = new AdjustCommandInputs { Brightness = true, Step = 10 };
var exit = await dispatcher.SendAdjustAsync(CliRequestBuilder.BuildAdjust(CliCommandNames.Up, inputs), CancellationToken.None);
Assert.AreEqual(CliExitCodes.Ok, exit);
Assert.AreEqual(1, output.StdoutLines.Count);
StringAssert.Contains(output.StdoutLines[0], "brightness");
}
}

View File

@@ -1,37 +0,0 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerDisplay.Cli.UnitTests</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Cli.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.CodeDom">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerDisplay.Cli\PowerDisplay.Cli.csproj" />
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,164 +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.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Cli;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Options;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class ProgramTokenTests
{
private static ParseResult Parse(params string[] args)
=> new Parser(new PowerDisplayRootCommand()).Parse(args);
[TestMethod]
public void HelpFlag_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("--help")));
[TestMethod]
public void HelpUnderSubcommand_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("get", "--help")));
[TestMethod]
public void HelpValueOfOption_IsNotTreatedAsHelp()
=> Assert.IsFalse(Program.HasHelpToken(Parse("set", "-i", "-h", "--brightness", "50")));
[TestMethod]
public void HelpUnderApplyProfile_IsDetected()
=> Assert.IsTrue(Program.HasHelpToken(Parse("apply-profile", "--help")));
[TestMethod]
public void ApplyProfileWithRealName_IsNotHelp()
=> Assert.IsFalse(Program.HasHelpToken(Parse("apply-profile", "Night")));
[TestMethod]
public void VersionFlag_IsDetected()
=> Assert.IsTrue(Program.HasVersionToken(Parse("--version")));
[TestMethod]
public void VersionFlag_DetectedAlongsideValidOptions()
=> Assert.IsTrue(Program.HasVersionToken(Parse("set", "-n", "1", "--version")));
[TestMethod]
public void VersionValueOfOption_IsNotTreatedAsVersion()
=> Assert.IsFalse(Program.HasVersionToken(Parse("set", "-i", "--version", "--brightness", "50")));
[TestMethod]
public void IsVersionRequest_BareVersion_True()
=> Assert.IsTrue(Program.IsVersionRequest(Parse("--version")));
[TestMethod]
public void IsVersionRequest_VersionAfterSubcommand_False()
=> Assert.IsFalse(Program.IsVersionRequest(Parse("set", "-n", "1", "--version")));
[TestMethod]
public void IsVersionRequest_VersionUnderApplyProfile_True()
{
// `apply-profile <name>` greedily binds "--version" as the profile name, so it never reaches
// UnmatchedTokens. It must still be treated as a version request (mirrors the --help carve-out)
// rather than dispatched as "apply a profile literally named --version".
Assert.IsTrue(Program.IsVersionRequest(Parse("apply-profile", "--version")));
}
[TestMethod]
public void ApplyProfileWithRealName_IsNotVersion()
=> Assert.IsFalse(Program.IsVersionRequest(Parse("apply-profile", "Night")));
[TestMethod]
public void BuildParseErrorResult_CollapsesMultipleMessagesIntoOneEnvelope()
{
// System.CommandLine can report several errors for one bad invocation; they must be
// collapsed into a single envelope so consumers receive one parseable object.
var messages = new[] { "first problem", "second problem" };
var result = Program.BuildParseErrorResult("set", messages);
Assert.AreEqual("set", result.Command);
Assert.AreEqual(CliErrorCodes.ArgumentError, result.Error.Code);
Assert.AreEqual(CliExitCodes.ArgumentError, result.Error.ExitCode);
StringAssert.Contains(result.Error.Message, "first problem");
StringAssert.Contains(result.Error.Message, "second problem");
}
[TestMethod]
public void BuildParseErrorResult_EmptyMessages_FallsBackToGenericMessage()
{
var blanks = new[] { string.Empty, " " };
var result = Program.BuildParseErrorResult("get", blanks);
Assert.AreEqual("invalid arguments", result.Error.Message);
}
[TestMethod]
public void Step_Negative_ProducesParseError()
{
var parsed = Parse("up", "--brightness", "--step", "-5");
Assert.IsTrue(parsed.Errors.Count > 0, "a negative --step must be a parse error");
}
[TestMethod]
public void Step_Zero_IsAccepted()
{
var parsed = Parse("up", "--brightness", "--step", "0");
Assert.AreEqual(0, parsed.Errors.Count, "--step 0 is a valid no-op and must not error");
}
[TestMethod]
public void Up_BrightnessFlag_ParsesWithoutValue()
{
var parsed = Parse("up", "--brightness");
Assert.AreEqual(0, parsed.Errors.Count);
Assert.IsTrue(parsed.GetValueForOption(CliOptions.BrightnessFlag));
}
[TestMethod]
public void Up_BrightnessFlag_RejectsAttachedValue()
{
// The up/down setting flags are pure presence flags (ArgumentArity.Zero). A following
// bareword like "false" must NOT be swallowed as the flag's value (which would silently make
// the flag false and yield a misleading "no setting specified"); it is an unrecognized token.
var parsed = Parse("up", "--brightness", "false");
Assert.IsTrue(parsed.Errors.Count > 0, "an attached value on a no-value flag must be a parse error");
}
[TestMethod]
public void Quiet_DoesNotSwallowFollowingProfileName()
{
// Regression: --quiet is a global Option<bool>. With ArgumentArity.Zero it must NOT swallow a
// following bareword that parses as a bool, so `apply-profile --quiet true` binds "true" as the
// profile name (not as --quiet's value, which would leave apply-profile with no name).
var parsed = Parse("apply-profile", "--quiet", "true");
Assert.AreEqual(0, parsed.Errors.Count, "--quiet must not consume the profile name");
Assert.AreEqual("true", parsed.GetValueForArgument(CliOptions.ProfileName));
Assert.IsTrue(parsed.GetValueForOption(CliOptions.Quiet), "a bare --quiet resolves to true");
}
[TestMethod]
public void ConfirmPowerOff_ResolvesToTrueWhenPresent()
{
// --confirm-power-off is a pure presence flag (ArgumentArity.Zero): present -> true, and it
// does not swallow the following power-state value.
var parsed = Parse("set", "--power-state", "0x04", "--confirm-power-off");
Assert.AreEqual(0, parsed.Errors.Count);
Assert.IsTrue(parsed.GetValueForOption(CliOptions.ConfirmPowerOff));
Assert.AreEqual("0x04", parsed.GetValueForOption(CliOptions.PowerState));
}
[TestMethod]
public void ConnectTimeout_IsStrictlyShorterThanOperationTimeout()
{
// Guards the connect-timeout fix: the pipe-connect bound must stay strictly below the overall
// deadline, or a not-running app is misreported as TIMEOUT (exit 8) after the full deadline
// instead of a fast PROVIDER_UNAVAILABLE (exit 10). See Program.ConnectTimeout / OperationTimeout.
Assert.IsTrue(
Program.ConnectTimeout < Program.OperationTimeout,
$"ConnectTimeout ({Program.ConnectTimeout}) must be < OperationTimeout ({Program.OperationTimeout})");
}
}

View File

@@ -1,37 +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 PowerDisplay.Cli.Properties;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class ResourcesTests
{
[TestMethod]
public void SafeFormat_PlaceholderIndexOutOfRange_DoesNotThrow_ReturnsTemplate()
{
// A translation that renumbers a placeholder ({0} -> {1}) leaves an index with no argument;
// the guarantee is "degrade to the template, never throw".
Assert.AreEqual("value {1}", Resources.SafeFormat("value {1}", "x"));
}
[TestMethod]
public void SafeFormat_UnescapedBrace_DoesNotThrow_ReturnsTemplate()
{
// A translation with an unescaped brace is also a malformed format string.
Assert.AreEqual("oops {", Resources.SafeFormat("oops {", "x"));
}
[TestMethod]
public void SafeFormat_WellFormedTemplate_SubstitutesArgument()
{
// The success path must actually substitute — without this, a regression to `return template;`
// would silently drop every {0}/{1} from localized messages while the malformed-template tests
// above stayed green (a malformed template returns unchanged either way).
Assert.AreEqual("value x", Resources.SafeFormat("value {0}", "x"));
Assert.AreEqual("a then b", Resources.SafeFormat("{0} then {1}", "a", "b"));
}
}

View File

@@ -1,38 +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 PowerDisplay.Cli.Commands;
namespace PowerDisplay.Cli.UnitTests;
[TestClass]
public class SetCommandInputsTests
{
// The count drives the "exactly one setting" validation in Program: 0 -> NoSetting error,
// 1 -> proceed, >1 -> OnlyOneSetting error. Exercise the 0/1/2 thresholds in one place.
[TestMethod]
public void CountSelectedSettings_CountsAcrossThresholds()
{
Assert.AreEqual(0, SetCommand.CountSelectedSettings(new SetCommandInputs()));
Assert.AreEqual(1, SetCommand.CountSelectedSettings(new SetCommandInputs { Brightness = 50 }));
Assert.AreEqual(2, SetCommand.CountSelectedSettings(new SetCommandInputs { Brightness = 50, Contrast = 70 }));
}
[TestMethod]
public void CountSelectedSettings_AllSeven()
{
var inputs = new SetCommandInputs
{
Brightness = 0,
Contrast = 0,
Volume = 0,
ColorTemperature = "x",
InputSource = "x",
PowerState = "x",
Orientation = "x",
};
Assert.AreEqual(7, SetCommand.CountSelectedSettings(inputs));
}
}

View File

@@ -1,21 +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.Linq;
namespace PowerDisplay.Cli.Commands;
public static class AdjustCommand
{
/// <summary>
/// Counts how many continuous-setting flags are set in <paramref name="inputs"/>.
/// Exactly one must be true for a valid <c>up</c>/<c>down</c> invocation.
/// </summary>
public static int CountSelectedSettings(AdjustCommandInputs inputs)
{
// Mirror SetCommand.CountSelectedSettings: list the candidate flags, then Count the selected.
bool[] flags = [inputs.Brightness, inputs.Contrast, inputs.Volume];
return flags.Count(f => f);
}
}

View File

@@ -1,24 +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.
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Inputs collected from the parsed <c>up</c>/<c>down</c> subcommand. Exactly one of the three
/// continuous-setting flags must be true. <see cref="Step"/> is null when <c>--step</c> is omitted.
/// </summary>
public sealed class AdjustCommandInputs
{
public int? MonitorNumber { get; init; }
public string? MonitorId { get; init; }
public bool Brightness { get; init; }
public bool Contrast { get; init; }
public bool Volume { get; init; }
public int? Step { get; init; }
}

View File

@@ -1,109 +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.CommandLine;
using PowerDisplay.Cli.Options;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Builds the <c>powerdisplay</c> root command and its subcommands. <see cref="Program"/>
/// dispatches on <c>parseResult.CommandResult.Command.Name</c> against the
/// <see cref="CliCommandNames"/> constants.
/// </summary>
// 'partial' is required by the CsWinRT analyzer (CsWinRT1028) for AOT/WinRT-ABI compatibility,
// even though there is only one declaration.
public sealed partial class PowerDisplayRootCommand : RootCommand
{
public PowerDisplayRootCommand()
: base("PowerToys PowerDisplay - control monitor settings from the command line.")
{
AddGlobalOption(CliOptions.Quiet);
AddCommand(BuildList());
AddCommand(BuildCapabilities());
AddCommand(BuildGet());
AddCommand(BuildSet());
AddCommand(BuildProfiles());
AddCommand(BuildApplyProfile());
AddCommand(BuildUp());
AddCommand(BuildDown());
}
private static Command BuildList()
{
return new Command(CliCommandNames.List, "Discover attached monitors and print their number, stable id, name, and transport.");
}
private static Command BuildCapabilities()
{
var cmd = new Command(CliCommandNames.Capabilities, "Print the VCP capabilities advertised by the monitor. Use --setting to restrict to one discrete setting (color-temperature, input-source, power-state).");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.SettingFilter);
return cmd;
}
private static Command BuildGet()
{
var cmd = new Command(CliCommandNames.Get, "Read the current value of one or all settings for a monitor.");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.SettingFilter);
return cmd;
}
private static Command BuildSet()
{
var cmd = new Command(CliCommandNames.Set, "Apply a single setting to a monitor. Exactly one --<setting> flag must be provided.");
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.Brightness);
cmd.AddOption(CliOptions.Contrast);
cmd.AddOption(CliOptions.Volume);
cmd.AddOption(CliOptions.ColorTemperature);
cmd.AddOption(CliOptions.InputSource);
cmd.AddOption(CliOptions.PowerState);
cmd.AddOption(CliOptions.Orientation);
cmd.AddOption(CliOptions.ConfirmPowerOff);
return cmd;
}
private static Command BuildProfiles()
{
return new Command(CliCommandNames.Profiles, "List the saved PowerDisplay profiles (name, monitor count, last modified).");
}
private static Command BuildApplyProfile()
{
var cmd = new Command(CliCommandNames.ApplyProfile, "Apply a saved profile's per-monitor settings to the connected monitors.");
cmd.AddArgument(CliOptions.ProfileName);
return cmd;
}
private static Command BuildUp()
{
var cmd = new Command(CliCommandNames.Up, "Raise a continuous setting (brightness, contrast, or volume) relative to its current value. Exactly one --<setting> flag must be provided.");
AddAdjustOptions(cmd);
return cmd;
}
private static Command BuildDown()
{
var cmd = new Command(CliCommandNames.Down, "Lower a continuous setting (brightness, contrast, or volume) relative to its current value. Exactly one --<setting> flag must be provided.");
AddAdjustOptions(cmd);
return cmd;
}
private static void AddAdjustOptions(Command cmd)
{
cmd.AddOption(CliOptions.MonitorNumber);
cmd.AddOption(CliOptions.MonitorId);
cmd.AddOption(CliOptions.BrightnessFlag);
cmd.AddOption(CliOptions.ContrastFlag);
cmd.AddOption(CliOptions.VolumeFlag);
cmd.AddOption(CliOptions.Step);
}
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Linq;
namespace PowerDisplay.Cli.Commands;
public static class SetCommand
{
/// <summary>
/// Counts how many settings are specified in <paramref name="inputs"/>.
/// Exactly one must be non-null for a valid <c>set</c> invocation.
/// </summary>
public static int CountSelectedSettings(SetCommandInputs inputs)
{
// A continuous int? of 0 still boxes to a non-null object, so zero-valued
// settings are counted just like the discrete string settings.
object?[] settings =
[
inputs.Brightness,
inputs.Contrast,
inputs.Volume,
inputs.ColorTemperature,
inputs.InputSource,
inputs.PowerState,
inputs.Orientation,
];
return settings.Count(s => s is not null);
}
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace PowerDisplay.Cli.Commands;
/// <summary>
/// Inputs collected from the parsed <c>set</c> subcommand. Exactly one of the
/// setting fields must be non-null.
/// </summary>
public sealed class SetCommandInputs
{
public int? MonitorNumber { get; init; }
public string? MonitorId { get; init; }
public int? Brightness { get; init; }
public int? Contrast { get; init; }
public int? Volume { get; init; }
public string? ColorTemperature { get; init; }
public string? InputSource { get; init; }
public string? PowerState { get; init; }
public string? Orientation { get; init; }
public bool ConfirmPowerOff { get; init; }
}

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.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// CLI-side named-pipe client that connects to the running PowerDisplay app, sends one request
/// line, reads one response line, and returns <see langword="null"/> on connect failure or timeout.
/// <para>
/// <b>Protocol:</b> BOM-less UTF-16 LE encoding, <c>'\n'</c>-delimited lines, one request → one response.
/// Mirrors the app-side <c>CliPipeServer</c> in <c>PowerDisplay/Ipc/CliPipeServer.cs</c>.
/// </para>
/// </summary>
public sealed class CliPipeClient
{
/// <summary>
/// Connects to the PowerDisplay named-pipe server, sends <paramref name="requestJson"/>,
/// and returns the response JSON line.
/// </summary>
/// <param name="requestJson">The JSON-encoded request to send.</param>
/// <param name="connectTimeout">How long to wait for the pipe server to accept the connection.</param>
/// <param name="ct">Cancellation token; <see cref="OperationCanceledException"/> propagates to the caller.</param>
/// <returns>
/// The response JSON line on success; <see langword="null"/> when the app is not running,
/// the pipe is unavailable, or the connection timed out.
/// </returns>
public async Task<string?> SendAsync(string requestJson, TimeSpan connectTimeout, CancellationToken ct)
{
try
{
using var client = new NamedPipeClientStream(".", PipeNames.CliServer(), PipeDirection.InOut, PipeOptions.Asynchronous);
await client.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
using var writer = new StreamWriter(client, CliPipeProtocol.PipeEncoding, CliPipeProtocol.BufferSize, leaveOpen: true) { AutoFlush = true };
using var reader = new StreamReader(client, CliPipeProtocol.PipeEncoding, false, CliPipeProtocol.BufferSize, leaveOpen: true);
await writer.WriteLineAsync(requestJson.AsMemory(), ct);
return await reader.ReadLineAsync(ct);
}
catch (TimeoutException)
{
return null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
// OperationCanceledException is intentionally NOT caught here — it propagates to the
// caller, which treats Ctrl+C / timeout-token cancellation as user cancellation.
}
}

View File

@@ -1,119 +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 PowerDisplay.Cli.Commands;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// Maps parsed CLI arguments into a <see cref="CliRequestEnvelope"/> ready for IPC serialization.
/// One static factory method per command. Syntactic validation (exactly one setting, valid setting
/// name) is intentionally NOT performed here — it lives in <see cref="Program"/> before this
/// builder is called.
/// </summary>
public static class CliRequestBuilder
{
/// <summary>Builds a <c>list</c> request envelope.</summary>
public static CliRequestEnvelope BuildList() => new()
{
Command = CliCommandNames.List,
};
/// <summary>Builds a <c>get</c> request envelope.</summary>
public static CliRequestEnvelope BuildGet(int? monitorNumber, string? monitorId, string? settingFilter) => new()
{
Command = CliCommandNames.Get,
Get = new GetRequest
{
MonitorNumber = monitorNumber,
MonitorId = monitorId,
SettingFilter = settingFilter,
},
};
/// <summary>Builds a <c>set</c> request envelope from the already-validated inputs.
/// Exactly one setting field in <paramref name="inputs"/> must be non-null.</summary>
public static CliRequestEnvelope BuildSet(SetCommandInputs inputs)
{
// Derive the canonical setting name and raw value from the first non-null field.
var (settingName, rawValue) = inputs switch
{
{ Brightness: { } v } => (CliSettingNames.Brightness, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ Contrast: { } v } => (CliSettingNames.Contrast, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ Volume: { } v } => (CliSettingNames.Volume, v.ToString(System.Globalization.CultureInfo.InvariantCulture)),
{ ColorTemperature: { } v } => (CliSettingNames.ColorTemperature, v),
{ InputSource: { } v } => (CliSettingNames.InputSource, v),
{ PowerState: { } v } => (CliSettingNames.PowerState, v),
{ Orientation: { } v } => (CliSettingNames.Orientation, v),
_ => throw new System.InvalidOperationException(
"BuildSet called without any setting; callers must validate CountSelectedSettings == 1 first."),
};
return new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest
{
MonitorNumber = inputs.MonitorNumber,
MonitorId = inputs.MonitorId,
Setting = settingName,
RawValue = rawValue,
ConfirmPowerOff = inputs.ConfirmPowerOff,
},
};
}
/// <summary>Builds an <c>up</c>/<c>down</c> request envelope from the already-validated inputs.
/// Exactly one continuous-setting flag in <paramref name="inputs"/> must be true.
/// <paramref name="command"/> is the subcommand name (<c>up</c> or <c>down</c>).</summary>
public static CliRequestEnvelope BuildAdjust(string command, AdjustCommandInputs inputs)
{
var settingName = inputs switch
{
{ Brightness: true } => CliSettingNames.Brightness,
{ Contrast: true } => CliSettingNames.Contrast,
{ Volume: true } => CliSettingNames.Volume,
_ => throw new System.InvalidOperationException(
"BuildAdjust called without any setting; callers must validate CountSelectedSettings == 1 first."),
};
return new CliRequestEnvelope
{
Command = command,
Adjust = new AdjustRequest
{
MonitorNumber = inputs.MonitorNumber,
MonitorId = inputs.MonitorId,
Setting = settingName,
Step = inputs.Step,
},
};
}
/// <summary>Builds a <c>capabilities</c> request envelope.</summary>
public static CliRequestEnvelope BuildCapabilities(int? monitorNumber, string? monitorId, string? settingFilter) => new()
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest
{
MonitorNumber = monitorNumber,
MonitorId = monitorId,
SettingFilter = settingFilter,
},
};
/// <summary>Builds a <c>profiles</c> request envelope.</summary>
public static CliRequestEnvelope BuildProfiles() => new()
{
Command = CliCommandNames.Profiles,
};
/// <summary>Builds an <c>apply-profile</c> request envelope.</summary>
public static CliRequestEnvelope BuildApplyProfile(string profileName) => new()
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileName = profileName },
};
}

View File

@@ -1,175 +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.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using PowerDisplay.Cli.Output;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Ipc;
/// <summary>
/// Encapsulates the common IPC dispatch flow: serialize envelope → send → check
/// provider-unavailable → deserialize response → render → return exit code.
/// <para>
/// The <see cref="SendAsync"/> delegate is injected so the dispatch core can be unit-tested
/// with a stub without standing up a real named-pipe server.
/// </para>
/// </summary>
public sealed class IpcDispatcher
{
/// <summary>
/// Signature that matches <see cref="CliPipeClient.SendAsync"/>. Inject a stub in tests.
/// </summary>
public delegate Task<string?> SendDelegate(string requestJson, TimeSpan connectTimeout, CancellationToken ct);
private readonly SendDelegate _send;
private readonly ICliOutput _output;
private readonly TimeSpan _connectTimeout;
public IpcDispatcher(SendDelegate send, ICliOutput output, TimeSpan connectTimeout)
{
_send = send;
_output = output;
_connectTimeout = connectTimeout;
}
/// <summary>
/// Convenience constructor that uses a real <see cref="CliPipeClient"/> instance.
/// </summary>
public IpcDispatcher(ICliOutput output, TimeSpan connectTimeout)
: this(new CliPipeClient().SendAsync, output, connectTimeout)
{
}
// ── per-command dispatch helpers ─────────────────────────────────────────
public Task<int> SendListAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliListResult, _output.WriteListResult, ct);
public Task<int> SendGetAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliGetResult, _output.WriteGetResult, ct);
public Task<int> SendSetAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliSetResult, _output.WriteSetResult, ct);
public Task<int> SendCapabilitiesAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliCapabilitiesResult, _output.WriteCapabilitiesResult, ct);
public Task<int> SendProfilesAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliProfileListResult, _output.WriteProfileListResult, ct);
// up/down reuse the set response shape (CliSetResult before/after) and the set renderer.
public Task<int> SendAdjustAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAsync(envelope, ContractsJsonContext.Default.CliSetResult, _output.WriteSetResult, ct);
// apply-profile is the one success envelope whose exit code is data-driven: it returns the
// worst-outcome code carried by the DTO (0=Ok, 2=OutOfRange, 3=InvalidDiscreteValue,
// 5=HardwareFailure) instead of a constant Ok, so partial failures are not lost.
public Task<int> SendApplyProfileAsync(CliRequestEnvelope envelope, CancellationToken ct)
=> SendAndRenderAsync(envelope, ContractsJsonContext.Default.CliApplyProfileResult, _output.WriteApplyProfileResult, result => result.ExitCode, ct);
// Most success envelopes map to exit 0; SendApplyProfileAsync above is the only data-driven one.
private Task<int> SendAsync<T>(CliRequestEnvelope envelope, JsonTypeInfo<T> typeInfo, Action<T> write, CancellationToken ct)
where T : class
=> SendAndRenderAsync(envelope, typeInfo, write, static _ => CliExitCodes.Ok, ct);
// ── core flow ────────────────────────────────────────────────────────────
private async Task<int> SendAndRenderAsync<T>(
CliRequestEnvelope envelope,
JsonTypeInfo<T> typeInfo,
Action<T> write,
Func<T, int> exitCode,
CancellationToken ct)
where T : class
{
var requestJson = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var respJson = await _send(requestJson, _connectTimeout, ct);
if (respJson is null)
{
return WriteProviderUnavailable(envelope.Command);
}
// The app stamps an explicit IsError discriminator on every response (see CliResponseHeader):
// error envelopes set it true; all success DTOs set it false — including apply-profile partial
// failures, which are still success envelopes and report their outcome via ExitCode. Read the
// flag first, then deserialize as the matching concrete type.
var header = TryReadHeader(respJson);
if (header is { IsError: true })
{
try
{
var error = JsonSerializer.Deserialize(respJson, ContractsJsonContext.Default.CliErrorResult);
if (error is not null)
{
_output.WriteError(error);
return error.Error.ExitCode;
}
}
catch (JsonException)
{
}
// Flagged as an error but the envelope did not deserialize — treat as a schema mismatch.
_output.WriteError(BuildInternalError(envelope.Command, Resources.Error_DeserializeMismatch));
return CliExitCodes.InternalError;
}
try
{
var result = JsonSerializer.Deserialize(respJson, typeInfo)
?? throw new JsonException($"Deserialized {typeof(T).Name} was null.");
write(result);
return exitCode(result);
}
catch (JsonException)
{
// A non-error response that failed to deserialize as the expected success type — likely a
// schema mismatch between CLI and app versions.
_output.WriteError(BuildInternalError(envelope.Command, Resources.Error_DeserializeMismatch));
return CliExitCodes.InternalError;
}
}
private static CliResponseHeader? TryReadHeader(string respJson)
{
try
{
return JsonSerializer.Deserialize(respJson, ContractsJsonContext.Default.CliResponseHeader);
}
catch (JsonException)
{
return null;
}
}
private int WriteProviderUnavailable(string command)
{
_output.WriteError(new CliErrorResult
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.ProviderUnavailable,
Message = Resources.Error_ProviderUnavailable,
},
});
return CliExitCodes.ProviderUnavailable;
}
private static CliErrorResult BuildInternalError(string command, string message) => new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.InternalError,
Message = message,
},
};
}

View File

@@ -1,172 +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.CommandLine;
using System.Globalization;
using PowerDisplay.Cli.Properties;
namespace PowerDisplay.Cli.Options;
/// <summary>
/// Shared option instances. Same <see cref="Option{T}"/> instance is reused across
/// subcommands so <c>parseResult.GetValueForOption</c> in dispatch code can rely on
/// reference identity.
/// </summary>
public static class CliOptions
{
public static readonly Option<int?> MonitorNumber = new(
["--monitor-number", "-n"],
"Index of the monitor (1-based). Run 'powerdisplay list' to discover.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> MonitorId = new(
["--monitor-id", "-i"],
"Stable monitor ID (DevicePath-derived). Wins if --monitor-number is also provided.")
{
Arity = ArgumentArity.ZeroOrOne,
};
public static readonly Option<string?> SettingFilter = new(
["--setting"],
"Restrict 'get' to a single setting name (e.g. brightness, input-source).")
{
Arity = ArgumentArity.ZeroOrOne,
};
// --- set: continuous ---
public static readonly Option<int?> Brightness = new(
["--brightness"],
"Brightness percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<int?> Contrast = new(
["--contrast"],
"Contrast percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<int?> Volume = new(
["--volume"],
"Volume percentage in [0, 100].")
{
Arity = ArgumentArity.ExactlyOne,
};
// --- up/down: no-value setting flags (exactly one) ---
// These intentionally reuse the same alias strings (--brightness/--contrast/--volume) as the
// set-command Option<int?> instances above. There is no conflict: each Option instance is added
// only to its own subcommand (set gets the int? options; up/down get these bool flags), and
// System.CommandLine scopes alias resolution per command. Do NOT add both variants to one command.
//
// Arity is Zero (a pure presence flag), not ZeroOrOne: ZeroOrOne lets the option greedily swallow
// a following bareword, so `up --brightness false` would bind "false" as the flag value and then
// report "no setting specified" — contradicting the documented "no value" contract. Zero rejects
// any attached value while `up --brightness` still resolves to true.
public static readonly Option<bool> BrightnessFlag = new(
["--brightness"],
"Adjust brightness (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<bool> ContrastFlag = new(
["--contrast"],
"Adjust contrast (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<bool> VolumeFlag = new(
["--volume"],
"Adjust volume (no value; the amount comes from --step or the mouse_wheel_increment setting).")
{
Arity = ArgumentArity.Zero,
};
public static readonly Option<int?> Step = new(
["--step"],
"Amount to raise/lower by. Defaults to the PowerDisplay mouse_wheel_increment setting. Must be >= 0.")
{
Arity = ArgumentArity.ExactlyOne,
};
// --- set: discrete ---
public static readonly Option<string?> ColorTemperature = new(
["--color-temperature"],
"Hex VCP value (e.g. 0x05). Run 'powerdisplay capabilities --setting color-temperature' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> InputSource = new(
["--input-source"],
"Hex VCP value (e.g. 0x11). Run 'powerdisplay capabilities --setting input-source' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> PowerState = new(
["--power-state"],
"Hex VCP value (e.g. 0x01=On, 0x04=Off (DPM)). Run 'powerdisplay capabilities --setting power-state' to list supported values.")
{
Arity = ArgumentArity.ExactlyOne,
};
public static readonly Option<string?> Orientation = new(
["--orientation"],
"Rotation in degrees: 0, 90, 180, or 270.")
{
Arity = ArgumentArity.ExactlyOne,
};
// Arity is Zero (a pure presence flag), not ZeroOrOne: a ZeroOrOne bool greedily swallows a
// following bareword that parses as a bool. Since --quiet is a global option, `apply-profile
// --quiet true` would otherwise bind "true" as the flag value and leave apply-profile with no
// name (a misleading "Required argument missing"), so a profile literally named "true"/"false"
// could not be applied. Zero rejects any attached value while a bare --quiet still resolves to
// true. Mirrors the up/down setting flags above.
public static readonly Option<bool> Quiet = new(
["--quiet"],
"Suppress warning messages on stderr.")
{
Arity = ArgumentArity.Zero,
};
// Arity is Zero (a pure presence flag), not ZeroOrOne: same greedy-swallow reasoning as --quiet
// and the up/down setting flags. A bare --confirm-power-off resolves to true.
public static readonly Option<bool> ConfirmPowerOff = new(
["--confirm-power-off"],
"Required to apply a power-state that powers the display off or puts it to sleep (Standby/Suspend/Off).")
{
Arity = ArgumentArity.Zero,
};
// --- apply-profile ---
public static readonly Argument<string> ProfileName = new(
"name",
"Name of the profile to apply (case-insensitive). Run 'powerdisplay profiles' to list them.")
{
Arity = ArgumentArity.ExactlyOne,
};
static CliOptions()
{
// Reject a negative --step at parse time so it flows through the single ArgumentError
// envelope instead of an unfriendly framework message. 0 is allowed (a no-op adjust).
Step.AddValidator(result =>
{
if (result.Tokens.Count != 0
&& int.TryParse(result.Tokens[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var step)
&& step < 0)
{
result.ErrorMessage = Resources.Error_NegativeStep;
}
});
}
}

View File

@@ -1,62 +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 PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Maps an app-produced <see cref="CliError"/> to its localized (message, hint) pair, keyed by
/// <see cref="CliError.MessageId"/> and filled from the error's structured fields (Setting, Value).
/// The app sends only ids + data (no prose); this is the single place the CLI owns the human text.
/// <para>
/// Hints are generated here — the CLI already knows the valid setting lists, so the app need not
/// send them. An unrecognized or empty <see cref="CliError.MessageId"/> falls back to the app's
/// English <see cref="CliError.Message"/> / <see cref="CliError.Hint"/> (version-skew safety).
/// </para>
/// </summary>
internal static class CliErrorLocalizer
{
private static readonly string AllSettings = string.Join(", ", CliSettingNames.All);
private static readonly string DiscreteSettings = string.Join(
", ", CliSettingNames.ColorTemperature, CliSettingNames.InputSource, CliSettingNames.PowerState);
private static readonly string ContinuousSettings = string.Join(
", ", CliSettingNames.Brightness, CliSettingNames.Contrast, CliSettingNames.Volume);
/// <summary>Returns the localized message and optional hint for <paramref name="e"/>.</summary>
public static (string Message, string? Hint) Localize(CliError e)
{
var value = e.Value ?? string.Empty;
var setting = e.Setting ?? string.Empty;
return e.MessageId switch
{
CliMessageIds.OutOfRange => (Resources.ErrMsg_OutOfRange(value, setting), null),
CliMessageIds.InvalidInteger => (Resources.ErrMsg_InvalidInteger(value, setting), null),
CliMessageIds.InvalidDiscrete => (Resources.ErrMsg_InvalidDiscrete(value, setting), Resources.Hint_UseHexVcp),
CliMessageIds.DiscreteNotInSet => (Resources.ErrMsg_DiscreteNotInSet(value, setting), Resources.Hint_UseHexVcp),
CliMessageIds.InvalidOrientation => (Resources.ErrMsg_InvalidOrientation(value), Resources.Hint_Orientation),
CliMessageIds.Unsupported => (Resources.ErrMsg_Unsupported(setting), null),
CliMessageIds.PowerBlankingConfirm => (Resources.ErrMsg_PowerBlankingConfirm, Resources.Hint_ConfirmPowerOff),
CliMessageIds.HardwareFailure => (Resources.ErrMsg_HardwareFailure, null),
CliMessageIds.UnknownSetting => (Resources.ErrMsg_UnknownSetting(value), Resources.Hint_ValidSettings(AllSettings)),
CliMessageIds.NotDiscreteSetting => (Resources.ErrMsg_NotDiscreteSetting(value), Resources.Hint_ValidDiscreteSettings(DiscreteSettings)),
CliMessageIds.SelectorMissing => (Resources.ErrMsg_SelectorMissing, Resources.Hint_SelectorMissing),
CliMessageIds.MonitorNotFoundNumber => (Resources.ErrMsg_MonitorNotFoundNumber(value), Resources.Hint_RunList),
CliMessageIds.MonitorNotFoundId => (Resources.ErrMsg_MonitorNotFoundId(value), Resources.Hint_RunList),
CliMessageIds.UnknownSettingAdjust => (Resources.ErrMsg_UnknownSetting(value), Resources.Hint_AdjustSettings(ContinuousSettings)),
CliMessageIds.NotAdjustable => (Resources.ErrMsg_NotAdjustable(setting), Resources.Hint_AdjustSettings(ContinuousSettings)),
CliMessageIds.AdjustValueUnknown => (Resources.ErrMsg_AdjustValueUnknown(setting), Resources.Hint_UseSetForAbsolute),
CliMessageIds.ProfileNotFound => (Resources.ErrMsg_ProfileNotFound(value), Resources.Hint_RunProfiles),
CliMessageIds.UnknownCommand => (Resources.ErrMsg_UnknownCommand(value), null),
CliMessageIds.InternalError => (Resources.ErrMsg_InternalError, null),
// Unknown/empty id: fall back to whatever English prose the app supplied.
_ => (e.Message, e.Hint),
};
}
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Abstraction over CLI output rendering (today only <see cref="TextCliOutput"/>; the seam also
/// lets tests capture output). Each command builds the typed result record and hands it to one of
/// these methods. Errors are routed through <see cref="WriteError"/> regardless of which command
/// produced them.
/// </summary>
public interface ICliOutput
{
void WriteListResult(CliListResult result);
void WriteSetResult(CliSetResult result);
void WriteGetResult(CliGetResult result);
void WriteCapabilitiesResult(CliCapabilitiesResult result);
void WriteProfileListResult(CliProfileListResult result);
void WriteApplyProfileResult(CliApplyProfileResult result);
void WriteError(CliErrorResult result);
void WriteWarning(string message);
}

View File

@@ -1,236 +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.IO;
using System.Linq;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli.Output;
/// <summary>
/// Human-readable text output. Success lines go to stdout; warnings and errors go
/// to stderr so scripts that capture only stdout receive a clean stream.
/// </summary>
public sealed class TextCliOutput : ICliOutput
{
private readonly TextWriter _stdout;
private readonly TextWriter _stderr;
private readonly bool _quiet;
public TextCliOutput(bool quiet = false)
: this(Console.Out, Console.Error, quiet)
{
}
public TextCliOutput(TextWriter stdout, TextWriter stderr, bool quiet = false)
{
_stdout = stdout;
_stderr = stderr;
_quiet = quiet;
}
public void WriteListResult(CliListResult result)
{
if (result.Monitors.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoMonitorsDiscovered);
return;
}
_stdout.WriteLine($"{"#",-3} {"Name",-22} {"Method",-7} {"Monitor ID"}");
foreach (var m in result.Monitors)
{
var name = Truncate(m.Name, 22);
_stdout.WriteLine($"{m.Number,-3} {name,-22} {m.Method,-7} {m.Id}");
}
}
public void WriteSetResult(CliSetResult result)
{
var via = string.IsNullOrEmpty(result.Monitor.Method)
? string.Empty
: $" [{result.Monitor.Method}]";
var monitor = $"{MonitorLabel(result.Monitor)}{via}";
var before = result.BeforeDisplay ?? "?";
_stdout.WriteLine($"{monitor}: {result.Setting} {before} → {result.AfterDisplay}");
}
public void WriteGetResult(CliGetResult result)
{
if (result.Monitors.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoMonitorsDiscovered);
return;
}
for (int i = 0; i < result.Monitors.Count; i++)
{
var entry = result.Monitors[i];
if (i > 0)
{
_stdout.WriteLine();
}
_stdout.WriteLine(MonitorLabel(entry.Monitor));
_stdout.WriteLine($" protocol {entry.Monitor.Method}");
_stdout.WriteLine($" id {entry.Monitor.Id}");
foreach (var s in entry.Settings)
{
// Three honest states: the monitor can't do it, it can but discovery couldn't read
// it, or here's the value.
var rendered = !s.Supported ? Resources.Text_NotSupported
: s.Display ?? Resources.Text_Unknown;
_stdout.WriteLine($" {s.Setting,-18} {rendered}");
}
}
}
public void WriteCapabilitiesResult(CliCapabilitiesResult result)
{
var monitor = MonitorLabel(result.Monitor);
_stdout.WriteLine($"{monitor} via {result.CommunicationMethod}");
if (!string.IsNullOrEmpty(result.Model))
{
_stdout.WriteLine($" Model: {result.Model}");
}
if (!string.IsNullOrEmpty(result.MccsVersion))
{
_stdout.WriteLine($" MCCS: {result.MccsVersion}");
}
if (result.VcpCodes.Count == 0)
{
_stdout.WriteLine($" {Resources.Text_NoVcpCapabilities}");
}
else
{
_stdout.WriteLine(" VCP codes:");
foreach (var code in result.VcpCodes)
{
if (code.Continuous)
{
_stdout.WriteLine($" {code.Code} {code.Name} (continuous)");
}
else
{
var values = code.DiscreteValues is null
? Resources.Text_NoValuesReported
: string.Join(", ", code.DiscreteValues);
_stdout.WriteLine($" {code.Code} {code.Name}: {values}");
}
}
}
if (!string.IsNullOrEmpty(result.RawCapabilities))
{
_stdout.WriteLine($" Raw: {result.RawCapabilities}");
}
}
public void WriteProfileListResult(CliProfileListResult result)
{
if (result.Profiles.Count == 0)
{
_stdout.WriteLine(Resources.Text_NoProfilesSaved);
return;
}
_stdout.WriteLine($"{"Name",-24} {"Monitors",-9} {"Last modified"}");
foreach (var p in result.Profiles)
{
var name = Truncate(p.Name, 24);
_stdout.WriteLine($"{name,-24} {p.MonitorCount,-9} {p.LastModified}");
}
}
public void WriteApplyProfileResult(CliApplyProfileResult result)
{
_stdout.WriteLine(Resources.Text_AppliedProfile(result.Profile));
foreach (var m in result.Monitors)
{
if (!m.Connected)
{
_stdout.WriteLine($" Monitor {m.Monitor.Id}: {Resources.Text_NotConnectedSkipped}");
continue;
}
var label = MonitorLabel(m.Monitor);
if (m.Changes.Count == 0)
{
_stdout.WriteLine($" {label}: {Resources.Text_NoSettingsInProfile}");
continue;
}
foreach (var c in m.Changes)
{
var detail = c.Status switch
{
CliProfileChange.StatusApplied => $"{c.Setting} → {c.Display}",
CliProfileChange.StatusUnsupported => $"{c.Setting} {Resources.Text_NotSupported}",
CliProfileChange.StatusOutOfRange => $"{c.Setting} {c.Value} {Resources.Text_OutOfRangeSkipped}",
CliProfileChange.StatusInvalidDiscreteValue => $"{c.Setting} {c.Value} {Resources.Text_InvalidValueSkipped}",
CliProfileChange.StatusHardwareFailure => $"{c.Setting} → {c.Value} {Resources.Text_Failed} ({c.Error})",
_ => $"{c.Setting}: {c.Status}",
};
_stdout.WriteLine($" {label}: {detail}");
}
}
}
public void WriteError(CliErrorResult result)
{
var err = result.Error;
var (message, hint) = CliErrorLocalizer.Localize(err);
_stderr.WriteLine($"{Resources.Label_Error}: {message}");
if (result.Monitor is { Number: > 0 })
{
_stderr.WriteLine($" {Resources.Label_Monitor}: {MonitorLabel(result.Monitor)}");
}
if (!string.IsNullOrEmpty(err.ExpectedRange))
{
_stderr.WriteLine($" {Resources.Label_Expected}: {Resources.Text_ExpectedInteger(err.ExpectedRange)}");
}
if (err.Supported is { Count: > 0 })
{
_stderr.WriteLine($" {Resources.Label_Supported}: " + string.Join(", ", err.Supported.Select(v => $"{v.Name} ({v.Vcp})")));
}
if (!string.IsNullOrEmpty(err.Detail))
{
_stderr.WriteLine($" {Resources.Label_Diagnostic}: {err.Detail}");
}
if (!string.IsNullOrEmpty(hint))
{
_stderr.WriteLine($" {Resources.Label_Hint}: {hint}");
}
}
public void WriteWarning(string message)
{
if (!_quiet)
{
_stderr.WriteLine(message);
}
}
private static string MonitorLabel(CliMonitorRef m) => $"Monitor {m.Number} ({m.Name})";
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max)
{
return s ?? string.Empty;
}
return s[..(max - 1)] + "…";
}
}

View File

@@ -1,56 +0,0 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>PowerDisplay.Cli</RootNamespace>
<ApplicationIcon>..\PowerDisplay\Assets\PowerDisplay\PowerDisplay.ico</ApplicationIcon>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<AssemblyName>PowerToys.PowerDisplay.Cli</AssemblyName>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<!-- Globalization is enabled (not invariant) so the human-readable text output can be localized
via satellite resources. The machine contract (JSON keys, error codes, status strings, exit
codes, VCP names) stays culture-independent because every parse/format site passes
CultureInfo.InvariantCulture explicitly. -->
</PropertyGroup>
<!-- Native AOT Configuration -->
<PropertyGroup>
<PublishSingleFile>false</PublishSingleFile>
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
<PublishAot>true</PublishAot>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
<ItemGroup>
<!-- Hide build log files from Solution Explorer -->
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="PowerDisplay.Cli.UnitTests" />
</ItemGroup>
<ItemGroup>
<!-- Add WindowsDesktop.App framework reference to align System.CodeDom.dll version
(pulled in transitively via System.Management) with the other apps, which get it from
the WindowsDesktop runtime pack instead of the NuGet package. Without this, the deps.json
audit fails because this app ships the older package version of System.CodeDom.dll.
This does NOT enable WPF/WinForms, it only ensures consistent runtime DLL versions. -->
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,445 +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.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Parsing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using PowerDisplay.Cli.Commands;
using PowerDisplay.Cli.Ipc;
using PowerDisplay.Cli.Options;
using PowerDisplay.Cli.Output;
using PowerDisplay.Cli.Properties;
using PowerDisplay.Contracts;
namespace PowerDisplay.Cli;
public static class Program
{
// Overall wall-clock deadline for one CLI invocation (pipe connect + request/response + any
// hardware write). There is deliberately no --timeout option: the CLI is a thin client that
// blocks waiting on the app, and the app's DDC/CI writes are synchronous and cannot be cancelled
// mid-call, so the client must bound its own wait or a slow/stuck monitor (or a hung app) would
// hang it indefinitely. 5s covers a normal connect plus one VCP exchange with margin. When it
// elapses the invocation is reported as TIMEOUT (exit 8).
internal static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5);
// Bound on just the pipe-connect phase. MUST stay strictly less than OperationTimeout:
// NamedPipeClientStream.ConnectAsync polls until either its own timeout (-> TimeoutException,
// which CliPipeClient maps to a null response -> PROVIDER_UNAVAILABLE, exit 10) or ct
// cancellation. If this equalled OperationTimeout, the deadline timer would cancel ct at the same
// instant and win the race, so a not-running app would be misreported as TIMEOUT (exit 8) after a
// full 5s wait instead of a fast, correct PROVIDER_UNAVAILABLE ("PowerDisplay is not running").
// A running app connects near-instantly, so the shorter bound never affects the normal path.
internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(2);
// Canonical args for routing any version request through the default invocation pipeline's
// version renderer (static readonly to satisfy CA1861 — the array is passed, never mutated).
private static readonly string[] VersionArgs = { "--version" };
// Stable program identifier stamped into the `command` field of root-level error envelopes.
// For an error that resolves to the RootCommand (e.g. an unrecognized top-level option),
// CommandResult.Command.Name is the auto-derived executable name ("PowerToys.PowerDisplay.Cli");
// mapping it to this constant keeps the machine-readable field a documented command identifier.
private const string ProgramCommandLabel = "powerdisplay";
// The command name for the error envelope: a real subcommand keeps its name; a root-level error
// is reported as the program label instead of leaking the binary name.
private static string CommandLabelFor(ParseResult parseResult)
=> parseResult.CommandResult.Command is RootCommand
? ProgramCommandLabel
: parseResult.CommandResult.Command.Name;
public static async Task<int> Main(string[] args)
{
// Emit UTF-8 so non-ASCII glyphs in human-readable output (the → arrow, ° degree sign,
// … ellipsis) and any UTF-8 JSON render correctly instead of as '?' on legacy code pages.
TrySetUtf8Output();
var root = new PowerDisplayRootCommand();
var parser = new Parser(root);
var parseResult = parser.Parse(args);
// Help / version short-circuit through the default invocation pipeline (which owns
// the version + help renderers). Done BEFORE the logger is created so a pure
// --help/--version invocation has no file-system side effects.
if (parseResult.Tokens.Count == 0 || HasHelpToken(parseResult))
{
return await root.InvokeAsync(args);
}
if (IsVersionRequest(parseResult))
{
// Route through the canonical root `--version` invocation rather than re-invoking the
// original args. This also covers `apply-profile --version`, where the version token was
// greedily bound to the profile-name argument (see IsVersionRequest) and replaying args
// would instead dispatch "apply a profile literally named --version".
return await root.InvokeAsync(VersionArgs);
}
var quiet = parseResult.GetValueForOption(CliOptions.Quiet);
ICliOutput output = new TextCliOutput(quiet);
if (parseResult.Errors.Count > 0)
{
// System.CommandLine can report several parse errors for one bad invocation; collapse
// them into a single envelope so consumers always receive exactly one parseable
// object (text output) instead of N concatenated ones.
output.WriteError(BuildParseErrorResult(
CommandLabelFor(parseResult),
parseResult.Errors.Select(e => e.Message)));
return CliExitCodes.ArgumentError;
}
// Logs go to %LOCALAPPDATA%\Microsoft\PowerToys\PowerDisplay\Logs\<version>.
// Guard initialization: an unwritable log path (locked profile, full disk, policy
// redirection) creates the directory / trace listener eagerly and would otherwise throw
// here — OUTSIDE the try below — crashing with a raw stack trace and bypassing the
// single-envelope error contract. The requested operation does not need the log file,
// so degrade to no file listener and continue.
try
{
Logger.InitializeLogger("\\PowerDisplay\\Logs");
}
catch (Exception)
{
}
var timedOut = false;
Timer? timeoutTimer = null;
ConsoleCancelEventHandler? cancelHandler = null;
using var cts = new CancellationTokenSource();
try
{
// Captured in a local so the finally can unsubscribe it. Console.CancelKeyPress is a
// process-global static event; leaving the handler attached would leak a closure over a
// disposed cts across repeated DispatchAsync/Main invocations (e.g. in tests).
cancelHandler = (_, e) =>
{
e.Cancel = true;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
};
Console.CancelKeyPress += cancelHandler;
// Fire the fixed deadline. `timedOut` is set on the timer thread before cts.Cancel(); the
// cancel→token propagation establishes happens-before, so the catch below reads it
// reliably. The flag lets the error envelope distinguish a timeout from a Ctrl+C
// cancellation (both map to exit 8).
timeoutTimer = new Timer(
_ =>
{
timedOut = true;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
},
null,
OperationTimeout,
Timeout.InfiniteTimeSpan);
// The dispatcher's own timeout bounds only the pipe-connect phase (ConnectTimeout, shorter
// than OperationTimeout) so a not-running app surfaces as PROVIDER_UNAVAILABLE quickly
// rather than racing the overall deadline into a misleading TIMEOUT.
var dispatcher = new IpcDispatcher(output, ConnectTimeout);
return await DispatchAsync(root, args, parseResult, dispatcher, output, cts.Token);
}
catch (OperationCanceledException)
{
output.WriteError(BuildTimeoutErrorResult(CommandLabelFor(parseResult), timedOut));
return CliExitCodes.Timeout;
}
catch (Exception ex)
{
Logger.LogError($"PowerDisplay CLI failed: {ex}");
output.WriteError(new CliErrorResult
{
Command = CommandLabelFor(parseResult),
Error = new CliError
{
Code = CliErrorCodes.InternalError,
Message = Resources.Error_UnexpectedError(ex.Message),
},
});
return CliExitCodes.InternalError;
}
finally
{
if (cancelHandler is not null)
{
Console.CancelKeyPress -= cancelHandler;
}
timeoutTimer?.Dispose();
}
}
/// <summary>
/// Routes the parsed command to the appropriate IPC send-and-render helper.
/// Pure-syntactic validation (setting count, setting name) is checked here before
/// any IPC round-trip. Extracted as a static method so tests can drive it directly.
/// </summary>
internal static async Task<int> DispatchAsync(
PowerDisplayRootCommand root,
string[] args,
ParseResult parseResult,
IpcDispatcher dispatcher,
ICliOutput output,
CancellationToken cancellationToken)
{
// Dispatch on the parsed command's name against the shared CliCommandNames constants,
// so no shared reference-equality singletons are required.
switch (parseResult.CommandResult.Command.Name)
{
// ── list ──────────────────────────────────────────────────────────
case CliCommandNames.List:
return await dispatcher.SendListAsync(CliRequestBuilder.BuildList(), cancellationToken);
// ── get ───────────────────────────────────────────────────────────
case CliCommandNames.Get:
{
var monitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber);
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
// CLI-side syntactic validation: reject unknown --setting names here so the error
// is surfaced without a round-trip and matches the existing ARGUMENT_ERROR (7) shape.
if (settingFilter is not null
&& System.Array.IndexOf(CliSettingNames.All, settingFilter.ToLowerInvariant()) < 0)
{
output.WriteError(ArgumentError(
CliCommandNames.Get,
Resources.Error_UnknownSetting(settingFilter),
Resources.Hint_ValidSettings(string.Join(", ", CliSettingNames.All))));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, monitorNumber, monitorId);
return await dispatcher.SendGetAsync(
CliRequestBuilder.BuildGet(monitorNumber, monitorId, settingFilter),
cancellationToken);
}
// ── set ───────────────────────────────────────────────────────────
case CliCommandNames.Set:
{
var inputs = new SetCommandInputs
{
MonitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber),
MonitorId = parseResult.GetValueForOption(CliOptions.MonitorId),
Brightness = parseResult.GetValueForOption(CliOptions.Brightness),
Contrast = parseResult.GetValueForOption(CliOptions.Contrast),
Volume = parseResult.GetValueForOption(CliOptions.Volume),
ColorTemperature = parseResult.GetValueForOption(CliOptions.ColorTemperature),
InputSource = parseResult.GetValueForOption(CliOptions.InputSource),
PowerState = parseResult.GetValueForOption(CliOptions.PowerState),
Orientation = parseResult.GetValueForOption(CliOptions.Orientation),
ConfirmPowerOff = parseResult.GetValueForOption(CliOptions.ConfirmPowerOff),
};
// CLI-side syntactic validation: exactly one setting must be specified.
var selected = SetCommand.CountSelectedSettings(inputs);
if (selected == 0)
{
output.WriteError(ArgumentError(CliCommandNames.Set, Resources.Error_NoSettingSpecified));
return CliExitCodes.ArgumentError;
}
if (selected > 1)
{
output.WriteError(ArgumentError(CliCommandNames.Set, Resources.Error_OnlyOneSetting, Resources.Hint_OnlyOneSetting));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, inputs.MonitorNumber, inputs.MonitorId);
return await dispatcher.SendSetAsync(CliRequestBuilder.BuildSet(inputs), cancellationToken);
}
// ── up / down ─────────────────────────────────────────────────────
case CliCommandNames.Up:
case CliCommandNames.Down:
{
var inputs = new AdjustCommandInputs
{
MonitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber),
MonitorId = parseResult.GetValueForOption(CliOptions.MonitorId),
Brightness = parseResult.GetValueForOption(CliOptions.BrightnessFlag),
Contrast = parseResult.GetValueForOption(CliOptions.ContrastFlag),
Volume = parseResult.GetValueForOption(CliOptions.VolumeFlag),
Step = parseResult.GetValueForOption(CliOptions.Step),
};
var commandName = parseResult.CommandResult.Command.Name;
// CLI-side syntactic validation: exactly one continuous setting must be specified.
var selected = AdjustCommand.CountSelectedSettings(inputs);
if (selected == 0)
{
output.WriteError(ArgumentError(commandName, Resources.Error_NoAdjustSettingSpecified));
return CliExitCodes.ArgumentError;
}
if (selected > 1)
{
output.WriteError(ArgumentError(commandName, Resources.Error_OnlyOneSetting, Resources.Hint_OnlyOneSetting));
return CliExitCodes.ArgumentError;
}
WarnIfMonitorNumberIgnored(output, inputs.MonitorNumber, inputs.MonitorId);
return await dispatcher.SendAdjustAsync(CliRequestBuilder.BuildAdjust(commandName, inputs), cancellationToken);
}
// ── capabilities ──────────────────────────────────────────────────
case CliCommandNames.Capabilities:
{
var monitorNumber = parseResult.GetValueForOption(CliOptions.MonitorNumber);
var monitorId = parseResult.GetValueForOption(CliOptions.MonitorId);
var settingFilter = parseResult.GetValueForOption(CliOptions.SettingFilter);
WarnIfMonitorNumberIgnored(output, monitorNumber, monitorId);
// An out-of-range --setting (not one of the 3 discrete settings) is validated app-side
// and comes back as a single ARGUMENT_ERROR envelope.
return await dispatcher.SendCapabilitiesAsync(
CliRequestBuilder.BuildCapabilities(monitorNumber, monitorId, settingFilter),
cancellationToken);
}
// ── profiles ──────────────────────────────────────────────────────
case CliCommandNames.Profiles:
return await dispatcher.SendProfilesAsync(CliRequestBuilder.BuildProfiles(), cancellationToken);
// ── apply-profile ─────────────────────────────────────────────────
case CliCommandNames.ApplyProfile:
{
var profileName = parseResult.GetValueForArgument(CliOptions.ProfileName);
return await dispatcher.SendApplyProfileAsync(
CliRequestBuilder.BuildApplyProfile(profileName),
cancellationToken);
}
default:
return await root.InvokeAsync(args);
}
}
// Carry-forward: the app discards -n when -i is also supplied; surface that warning
// CLI-side without a round-trip. Shared by the get/set/capabilities branches.
private static void WarnIfMonitorNumberIgnored(ICliOutput output, int? monitorNumber, string? monitorId)
{
if (monitorNumber.HasValue && !string.IsNullOrEmpty(monitorId))
{
output.WriteWarning(Resources.Warn_MonitorNumberIgnored(monitorNumber.GetValueOrDefault()));
}
}
public static bool HasHelpToken(ParseResult parseResult)
=> parseResult.UnmatchedTokens.Any(IsHelpToken)
|| HelpBoundToProfileNameArgument(parseResult);
private static bool IsHelpToken(string token)
=> token is "--help" or "-h" or "-?" or "/?";
// The `apply-profile <name>` positional argument greedily captures a "--help" token (it binds to
// the argument, so it never reaches UnmatchedTokens). Without this, `apply-profile --help` would
// be dispatched as "apply a profile literally named --help" instead of printing help like every
// other command. Option *values* that look like help (e.g. `set -i -h`) are unaffected: they are
// matched to an option, not to this argument.
private static bool HelpBoundToProfileNameArgument(ParseResult parseResult)
=> parseResult.CommandResult.Command.Name == CliCommandNames.ApplyProfile
&& IsHelpToken(parseResult.GetValueForArgument(CliOptions.ProfileName) ?? string.Empty);
public static bool HasVersionToken(ParseResult parseResult)
=> parseResult.UnmatchedTokens.Any(t => t == "--version");
public static bool IsVersionRequest(ParseResult parseResult)
=> (HasVersionToken(parseResult) && parseResult.CommandResult.Command is RootCommand)
|| VersionBoundToProfileNameArgument(parseResult);
// Mirror of HelpBoundToProfileNameArgument for "--version": the `apply-profile <name>` positional
// argument greedily captures a "--version" token (it binds to the argument, so it never reaches
// UnmatchedTokens and IsVersionRequest's RootCommand gate cannot see it). Without this,
// `apply-profile --version` would be dispatched as "apply a profile literally named --version".
private static bool VersionBoundToProfileNameArgument(ParseResult parseResult)
=> parseResult.CommandResult.Command.Name == CliCommandNames.ApplyProfile
&& parseResult.GetValueForArgument(CliOptions.ProfileName) == "--version";
/// <summary>
/// Collapses one or more System.CommandLine parse-error messages into a single
/// <see cref="CliErrorResult"/> so the error stream stays a single parseable envelope.
/// </summary>
public static CliErrorResult BuildParseErrorResult(string command, IEnumerable<string> messages)
{
var combined = string.Join("; ", messages.Where(m => !string.IsNullOrWhiteSpace(m)));
return ArgumentError(command, combined.Length == 0 ? Resources.Error_InvalidArguments : combined);
}
// Single ARGUMENT_ERROR envelope shape, shared by the syntactic-validation sites in
// DispatchAsync and by BuildParseErrorResult. Setting/Hint default to null (omitted from JSON).
private static CliErrorResult ArgumentError(string command, string message, string? hint = null)
=> new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.ArgumentError,
Message = message,
Hint = hint,
},
};
// Shared TIMEOUT envelope for the OperationCanceledException catch path. Distinguishes the fixed
// deadline elapsing (timedOut) from a Ctrl+C cancellation; both map to exit 8.
private static CliErrorResult BuildTimeoutErrorResult(string command, bool timedOut)
=> new()
{
Command = command,
Error = new CliError
{
Code = CliErrorCodes.Timeout,
Message = timedOut
? Resources.Error_TimedOut((int)OperationTimeout.TotalSeconds)
: Resources.Error_Cancelled,
},
};
private static void TrySetUtf8Output()
{
try
{
// UTF-8 without a BOM: a leading BOM in redirected/piped output can confuse some
// consumers that don't strip it (e.g. some parsers and shells).
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
catch (IOException)
{
// No real console attached (handles redirected/closed); leave the default encoding.
}
catch (System.Security.SecurityException)
{
// Host policy forbids changing console encoding; not fatal for the operation.
}
}
}

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.Globalization;
using System.Resources;
namespace PowerDisplay.Cli.Properties;
/// <summary>
/// Strongly-typed accessor for the CLI's localizable human-readable strings (Resources.resx,
/// localized into satellite assemblies by the build pipeline).
/// Only prose lives here — error messages/hints and text-mode labels. The machine contract (JSON
/// keys, error <c>code</c> strings, <c>status</c> strings, exit codes, VCP names) stays as invariant
/// literals elsewhere and is never routed through this class.
/// </summary>
internal static class Resources
{
private static readonly ResourceManager Manager =
new("PowerDisplay.Cli.Properties.Resources", typeof(Resources).Assembly);
// ---- plain (no-argument) labels ----
internal static string Text_NoMonitorsDiscovered => Get(nameof(Text_NoMonitorsDiscovered));
internal static string Text_NotSupported => Get(nameof(Text_NotSupported));
internal static string Text_Unknown => Get(nameof(Text_Unknown));
internal static string Text_Failed => Get(nameof(Text_Failed));
internal static string Text_NotConnectedSkipped => Get(nameof(Text_NotConnectedSkipped));
internal static string Text_NoSettingsInProfile => Get(nameof(Text_NoSettingsInProfile));
internal static string Text_OutOfRangeSkipped => Get(nameof(Text_OutOfRangeSkipped));
internal static string Text_InvalidValueSkipped => Get(nameof(Text_InvalidValueSkipped));
internal static string Text_NoProfilesSaved => Get(nameof(Text_NoProfilesSaved));
internal static string Text_NoVcpCapabilities => Get(nameof(Text_NoVcpCapabilities));
internal static string Text_NoValuesReported => Get(nameof(Text_NoValuesReported));
// ---- error messages / hints (with arguments) ----
internal static string Text_AppliedProfile(string profile) => Format(nameof(Text_AppliedProfile), profile);
internal static string Warn_MonitorNumberIgnored(int number) => Format(nameof(Warn_MonitorNumberIgnored), number);
internal static string Error_NoSettingSpecified => Get(nameof(Error_NoSettingSpecified));
internal static string Error_OnlyOneSetting => Get(nameof(Error_OnlyOneSetting));
internal static string Hint_OnlyOneSetting => Get(nameof(Hint_OnlyOneSetting));
internal static string Error_UnknownSetting(string setting) => Format(nameof(Error_UnknownSetting), setting);
internal static string Hint_ValidSettings(string settings) => Format(nameof(Hint_ValidSettings), settings);
internal static string Error_TimedOut(int seconds) => Format(nameof(Error_TimedOut), seconds);
internal static string Error_Cancelled => Get(nameof(Error_Cancelled));
internal static string Error_InvalidArguments => Get(nameof(Error_InvalidArguments));
internal static string Error_UnexpectedError(string message) => Format(nameof(Error_UnexpectedError), message);
internal static string Error_ProviderUnavailable => Get(nameof(Error_ProviderUnavailable));
internal static string Error_DeserializeMismatch => Get(nameof(Error_DeserializeMismatch));
internal static string Error_NegativeStep => Get(nameof(Error_NegativeStep));
internal static string Error_NoAdjustSettingSpecified => Get(nameof(Error_NoAdjustSettingSpecified));
// ---- error-line labels (no arguments) ----
internal static string Label_Error => Get(nameof(Label_Error));
internal static string Label_Monitor => Get(nameof(Label_Monitor));
internal static string Label_Expected => Get(nameof(Label_Expected));
internal static string Label_Supported => Get(nameof(Label_Supported));
internal static string Label_Diagnostic => Get(nameof(Label_Diagnostic));
internal static string Label_Hint => Get(nameof(Label_Hint));
internal static string Text_ExpectedInteger(string range) => Format(nameof(Text_ExpectedInteger), range);
// ---- app-side error message templates (keyed by CliMessageIds) ----
internal static string ErrMsg_OutOfRange(string value, string setting) => Format(nameof(ErrMsg_OutOfRange), value, setting);
internal static string ErrMsg_InvalidInteger(string value, string setting) => Format(nameof(ErrMsg_InvalidInteger), value, setting);
internal static string ErrMsg_InvalidDiscrete(string value, string setting) => Format(nameof(ErrMsg_InvalidDiscrete), value, setting);
internal static string ErrMsg_DiscreteNotInSet(string value, string setting) => Format(nameof(ErrMsg_DiscreteNotInSet), value, setting);
internal static string ErrMsg_InvalidOrientation(string value) => Format(nameof(ErrMsg_InvalidOrientation), value);
internal static string ErrMsg_Unsupported(string setting) => Format(nameof(ErrMsg_Unsupported), setting);
internal static string ErrMsg_PowerBlankingConfirm => Get(nameof(ErrMsg_PowerBlankingConfirm));
internal static string ErrMsg_HardwareFailure => Get(nameof(ErrMsg_HardwareFailure));
internal static string ErrMsg_UnknownSetting(string value) => Format(nameof(ErrMsg_UnknownSetting), value);
internal static string ErrMsg_NotDiscreteSetting(string value) => Format(nameof(ErrMsg_NotDiscreteSetting), value);
internal static string ErrMsg_SelectorMissing => Get(nameof(ErrMsg_SelectorMissing));
internal static string ErrMsg_MonitorNotFoundNumber(string value) => Format(nameof(ErrMsg_MonitorNotFoundNumber), value);
internal static string ErrMsg_MonitorNotFoundId(string value) => Format(nameof(ErrMsg_MonitorNotFoundId), value);
internal static string ErrMsg_NotAdjustable(string setting) => Format(nameof(ErrMsg_NotAdjustable), setting);
internal static string ErrMsg_AdjustValueUnknown(string setting) => Format(nameof(ErrMsg_AdjustValueUnknown), setting);
internal static string ErrMsg_ProfileNotFound(string value) => Format(nameof(ErrMsg_ProfileNotFound), value);
internal static string ErrMsg_UnknownCommand(string value) => Format(nameof(ErrMsg_UnknownCommand), value);
internal static string ErrMsg_InternalError => Get(nameof(ErrMsg_InternalError));
// ---- hints (CLI-generated; some carry a CLI-known list) ----
internal static string Hint_ValidDiscreteSettings(string settings) => Format(nameof(Hint_ValidDiscreteSettings), settings);
internal static string Hint_AdjustSettings(string settings) => Format(nameof(Hint_AdjustSettings), settings);
internal static string Hint_UseSetForAbsolute => Get(nameof(Hint_UseSetForAbsolute));
internal static string Hint_UseHexVcp => Get(nameof(Hint_UseHexVcp));
internal static string Hint_RunList => Get(nameof(Hint_RunList));
internal static string Hint_SelectorMissing => Get(nameof(Hint_SelectorMissing));
internal static string Hint_Orientation => Get(nameof(Hint_Orientation));
internal static string Hint_ConfirmPowerOff => Get(nameof(Hint_ConfirmPowerOff));
internal static string Hint_RunProfiles => Get(nameof(Hint_RunProfiles));
private static string Get(string name) => Manager.GetString(name, CultureInfo.CurrentUICulture) ?? name;
// Defensive formatting: a translator can break a placeholder ({0} -> {1}, an unescaped brace,
// an extra index). That must never crash the CLI or mask the real result. Try the localized
// template; on FormatException fall back to the neutral (English) template we ship and control;
// if even that is malformed, return it unformatted. So a broken translation degrades to English.
private static string Format(string name, params object[] args)
{
var localized = Manager.GetString(name, CultureInfo.CurrentUICulture);
if (localized is not null)
{
try
{
return string.Format(CultureInfo.CurrentCulture, localized, args);
}
catch (FormatException)
{
}
}
var neutral = Manager.GetString(name, CultureInfo.InvariantCulture) ?? name;
return SafeFormat(neutral, args);
}
// Formats with the invariant English template, swallowing a malformed-template FormatException
// by returning the template unformatted. Internal so the no-crash guarantee can be unit-tested.
internal static string SafeFormat(string template, params object[] args)
{
try
{
return string.Format(CultureInfo.InvariantCulture, template, args);
}
catch (FormatException)
{
return template;
}
}
}

View File

@@ -1,293 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Text_NoMonitorsDiscovered" xml:space="preserve">
<value>No monitors discovered.</value>
<comment>Text-mode output when list/get finds no monitors.</comment>
</data>
<data name="Text_NotSupported" xml:space="preserve">
<value>(not supported)</value>
<comment>Text-mode marker for an unsupported setting.</comment>
</data>
<data name="Text_Unknown" xml:space="preserve">
<value>(unknown)</value>
<comment>Text-mode marker for a supported setting whose value was not read.</comment>
</data>
<data name="Text_Failed" xml:space="preserve">
<value>FAILED</value>
<comment>Text-mode marker that an apply-profile setting failed at the hardware.</comment>
</data>
<data name="Text_NotConnectedSkipped" xml:space="preserve">
<value>not connected, skipped</value>
<comment>apply-profile: a monitor named by the profile is not currently connected.</comment>
</data>
<data name="Text_NoSettingsInProfile" xml:space="preserve">
<value>no settings in profile</value>
<comment>apply-profile: the profile entry for a connected monitor had no values.</comment>
</data>
<data name="Text_OutOfRangeSkipped" xml:space="preserve">
<value>(out of range, skipped)</value>
<comment>apply-profile: a profile value was outside the valid range and was not written.</comment>
</data>
<data name="Text_InvalidValueSkipped" xml:space="preserve">
<value>(not a supported value, skipped)</value>
<comment>apply-profile: a discrete profile value was not in the monitor's advertised set and was not written.</comment>
</data>
<data name="Text_NoProfilesSaved" xml:space="preserve">
<value>No profiles saved.</value>
<comment>profiles command: no saved profiles exist.</comment>
</data>
<data name="Text_NoVcpCapabilities" xml:space="preserve">
<value>No VCP capabilities reported.</value>
</data>
<data name="Text_NoValuesReported" xml:space="preserve">
<value>(no values reported)</value>
<comment>capabilities: a discrete VCP code advertised no enumerated values.</comment>
</data>
<data name="Text_AppliedProfile" xml:space="preserve">
<value>Applied profile '{0}':</value>
<comment>{0} = profile name.</comment>
</data>
<data name="Warn_MonitorNumberIgnored" xml:space="preserve">
<value>warning: --monitor-number {0} ignored because --monitor-id was also provided</value>
<comment>{0} = monitor number. Flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_NoSettingSpecified" xml:space="preserve">
<value>no setting specified; pass one of --brightness/--contrast/--volume/--color-temperature/--input-source/--power-state/--orientation</value>
<comment>The flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Error_OnlyOneSetting" xml:space="preserve">
<value>only one setting may be applied per 'set' call</value>
<comment>'set' is a literal command name and must not be translated.</comment>
</data>
<data name="Hint_OnlyOneSetting" xml:space="preserve">
<value>split into multiple invocations: one --&lt;setting&gt; per call</value>
</data>
<data name="Error_UnknownSetting" xml:space="preserve">
<value>unknown setting '{0}'</value>
<comment>{0} = the setting name the user passed to --setting.</comment>
</data>
<data name="Hint_ValidSettings" xml:space="preserve">
<value>valid settings: {0}</value>
<comment>{0} = comma-separated canonical setting names (CLI syntax, not translated).</comment>
</data>
<data name="Error_TimedOut" xml:space="preserve">
<value>operation timed out after {0}s</value>
<comment>{0} = number of seconds.</comment>
</data>
<data name="Error_Cancelled" xml:space="preserve">
<value>operation was cancelled</value>
</data>
<data name="Error_InvalidArguments" xml:space="preserve">
<value>invalid arguments</value>
</data>
<data name="Error_UnexpectedError" xml:space="preserve">
<value>unexpected error: {0}</value>
<comment>{0} = exception message.</comment>
</data>
<data name="Error_ProviderUnavailable" xml:space="preserve">
<value>PowerDisplay is not running. Enable it in PowerToys settings.</value>
<comment>Shown when the CLI cannot reach the PowerDisplay app over the IPC pipe.</comment>
</data>
<data name="Error_DeserializeMismatch" xml:space="preserve">
<value>Response could not be deserialized as expected type.</value>
<comment>Shown when the app's IPC response does not match the CLI's expected schema (version skew).</comment>
</data>
<data name="Error_NegativeStep" xml:space="preserve">
<value>--step must be &gt;= 0.</value>
<comment>--step is CLI syntax and must not be translated.</comment>
</data>
<data name="Error_NoAdjustSettingSpecified" xml:space="preserve">
<value>no setting specified; pass one of --brightness/--contrast/--volume</value>
<comment>The flag names are CLI syntax and must not be translated.</comment>
</data>
<data name="Label_Error" xml:space="preserve">
<value>Error</value>
<comment>Prefix label for an error line, e.g. "Error: unknown setting foo".</comment>
</data>
<data name="Label_Monitor" xml:space="preserve">
<value>monitor</value>
<comment>Label for the monitor line under an error, e.g. "monitor: 1 (Dell U2720Q)".</comment>
</data>
<data name="Label_Expected" xml:space="preserve">
<value>expected</value>
<comment>Label for the expected-value line under an error.</comment>
</data>
<data name="Label_Supported" xml:space="preserve">
<value>supported</value>
<comment>Label for the supported-values line under an error.</comment>
</data>
<data name="Label_Diagnostic" xml:space="preserve">
<value>diagnostic</value>
<comment>Label for a low-level technical diagnostic line under an error (e.g. a VCP capability reason or a driver error string, shown verbatim in English).</comment>
</data>
<data name="Label_Hint" xml:space="preserve">
<value>hint</value>
<comment>Label for the hint line under an error.</comment>
</data>
<data name="Text_ExpectedInteger" xml:space="preserve">
<value>integer in {0}</value>
<comment>{0} = an inclusive numeric range like "[0, 100]" (not translated). Shown on the "expected" line for a numeric out-of-range error.</comment>
</data>
<data name="ErrMsg_OutOfRange" xml:space="preserve">
<value>{0} is out of range for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name (e.g. brightness). Neither is translated.</comment>
</data>
<data name="ErrMsg_InvalidInteger" xml:space="preserve">
<value>{0} is not a valid integer for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. Neither is translated.</comment>
</data>
<data name="ErrMsg_InvalidDiscrete" xml:space="preserve">
<value>{0} is not a valid value for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. Neither is translated.</comment>
</data>
<data name="ErrMsg_DiscreteNotInSet" xml:space="preserve">
<value>{0} is not in the supported set for {1}</value>
<comment>{0} = the value the user passed; {1} = the setting name. The supported values are listed on a separate line.</comment>
</data>
<data name="ErrMsg_InvalidOrientation" xml:space="preserve">
<value>{0} is not a valid orientation</value>
<comment>{0} = the value the user passed (not translated).</comment>
</data>
<data name="ErrMsg_Unsupported" xml:space="preserve">
<value>{0} is not supported</value>
<comment>{0} = the setting name (e.g. volume), not translated.</comment>
</data>
<data name="ErrMsg_PowerBlankingConfirm" xml:space="preserve">
<value>this power state blanks the display</value>
</data>
<data name="ErrMsg_HardwareFailure" xml:space="preserve">
<value>hardware write failed</value>
</data>
<data name="ErrMsg_UnknownSetting" xml:space="preserve">
<value>unknown setting {0}</value>
<comment>{0} = the setting name the user passed (not translated).</comment>
</data>
<data name="ErrMsg_NotDiscreteSetting" xml:space="preserve">
<value>{0} is not a discrete setting</value>
<comment>{0} = the setting name the user passed to --setting (not translated).</comment>
</data>
<data name="ErrMsg_SelectorMissing" xml:space="preserve">
<value>a monitor must be specified</value>
</data>
<data name="ErrMsg_MonitorNotFoundNumber" xml:space="preserve">
<value>no monitor found with number {0}</value>
<comment>{0} = the 1-based monitor number the user passed (not translated).</comment>
</data>
<data name="ErrMsg_MonitorNotFoundId" xml:space="preserve">
<value>no monitor found with id {0}</value>
<comment>{0} = the monitor id the user passed (not translated).</comment>
</data>
<data name="ErrMsg_NotAdjustable" xml:space="preserve">
<value>{0} cannot be adjusted relatively</value>
<comment>{0} = the setting name (not translated). Shown for up/down on a non-continuous setting.</comment>
</data>
<data name="ErrMsg_AdjustValueUnknown" xml:space="preserve">
<value>the current {0} value could not be read</value>
<comment>{0} = the setting name (not translated). Shown when up/down cannot read the starting value.</comment>
</data>
<data name="ErrMsg_ProfileNotFound" xml:space="preserve">
<value>profile {0} not found</value>
<comment>{0} = the profile name the user passed (not translated).</comment>
</data>
<data name="ErrMsg_UnknownCommand" xml:space="preserve">
<value>unknown command {0}</value>
<comment>{0} = the command name (not translated).</comment>
</data>
<data name="ErrMsg_InternalError" xml:space="preserve">
<value>internal error</value>
</data>
<data name="Hint_ValidDiscreteSettings" xml:space="preserve">
<value>valid discrete settings: {0}</value>
<comment>{0} = comma-separated discrete setting names (CLI syntax, not translated).</comment>
</data>
<data name="Hint_AdjustSettings" xml:space="preserve">
<value>relative up/down supports only: {0}</value>
<comment>{0} = comma-separated continuous setting names (CLI syntax, not translated).</comment>
</data>
<data name="Hint_UseSetForAbsolute" xml:space="preserve">
<value>use 'powerdisplay set' to assign an absolute value</value>
<comment>'powerdisplay set' is a literal command and must not be translated.</comment>
</data>
<data name="Hint_UseHexVcp" xml:space="preserve">
<value>use a hex VCP value (0x??); run 'powerdisplay capabilities' to list supported values</value>
<comment>The command and 0x?? are CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_RunList" xml:space="preserve">
<value>run 'powerdisplay list' to see available monitors</value>
<comment>'powerdisplay list' is a literal command and must not be translated.</comment>
</data>
<data name="Hint_SelectorMissing" xml:space="preserve">
<value>specify --monitor-number/-n or --monitor-id/-i; run 'powerdisplay list' to see available monitors</value>
<comment>The option and command names are CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_Orientation" xml:space="preserve">
<value>specify orientation in degrees: 0, 90, 180, or 270</value>
<comment>The degree values must not be translated.</comment>
</data>
<data name="Hint_ConfirmPowerOff" xml:space="preserve">
<value>use --confirm-power-off to allow power states that blank the display</value>
<comment>--confirm-power-off is CLI syntax and must not be translated.</comment>
</data>
<data name="Hint_RunProfiles" xml:space="preserve">
<value>run 'powerdisplay profiles' to see available profiles</value>
<comment>'powerdisplay profiles' is a literal command and must not be translated.</comment>
</data>
</root>

View File

@@ -1,36 +0,0 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>PowerDisplay.Contracts.UnitTests</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Contracts.UnitTests\</OutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="*.log" />
<None Remove="*.binlog" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.CodeDom">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerDisplay.Contracts\PowerDisplay.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,407 +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.Collections.Generic;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Contracts;
namespace PowerDisplay.Contracts.UnitTests;
[TestClass]
public class RoundTripTests
{
[TestMethod]
public void SetRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Set,
Set = new SetRequest { MonitorNumber = 1, Setting = "brightness", RawValue = "50", ConfirmPowerOff = false },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Set, back!.Command);
Assert.AreEqual(1, back.Set!.MonitorNumber);
Assert.AreEqual("brightness", back.Set.Setting);
Assert.AreEqual("50", back.Set.RawValue);
}
[TestMethod]
public void GetRequest_envelope_round_trips_inherited_selector_fields()
{
// GetRequest/CapabilitiesRequest derive their selector fields from MonitorSelectorRequest;
// verify source-gen serializes the inherited properties on both payload slots.
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Get,
Get = new GetRequest { MonitorNumber = 2, MonitorId = "MON2", SettingFilter = "brightness" },
Capabilities = new CapabilitiesRequest { MonitorNumber = 3, SettingFilter = "input-source" },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(2, back!.Get!.MonitorNumber);
Assert.AreEqual("MON2", back.Get.MonitorId);
Assert.AreEqual("brightness", back.Get.SettingFilter);
Assert.AreEqual(3, back.Capabilities!.MonitorNumber);
Assert.AreEqual("input-source", back.Capabilities.SettingFilter);
}
[TestMethod]
public void ErrorResult_round_trips_and_preserves_exit_code()
{
var error = new CliErrorResult
{
Command = "set",
Error = new CliError
{
Code = CliErrorCodes.ProviderUnavailable,
Message = "PowerDisplay is not running.",
Supported = new List<CliSupportedValue>
{
new CliSupportedValue { Name = "DVI", Vcp = "60" },
new CliSupportedValue { Name = "HDMI-1", Vcp = "61" },
},
},
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
};
var json = JsonSerializer.Serialize(error, ContractsJsonContext.Default.CliErrorResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliErrorResult);
Assert.IsNotNull(back);
Assert.AreEqual(CliExitCodes.ProviderUnavailable, back!.Error!.ExitCode);
Assert.AreEqual("PROVIDER_UNAVAILABLE", back.Error.Code);
Assert.IsNotNull(back.Error.Supported);
Assert.AreEqual(2, back.Error.Supported!.Count);
Assert.AreEqual("DVI", back.Error.Supported[0].Name);
Assert.AreEqual("60", back.Error.Supported[0].Vcp);
Assert.AreEqual("HDMI-1", back.Error.Supported[1].Name);
// Discriminator, schema version, and the optional monitor ref must survive the round trip.
Assert.IsTrue(back.IsError);
Assert.AreEqual(CliSchema.Version, back.Version);
Assert.IsNotNull(back.Monitor);
Assert.AreEqual("MON1", back.Monitor!.Id);
Assert.AreEqual("Monitor A", back.Monitor.Name);
// Wire-format compatibility: ExitCode is now a derived (computed) property, but it MUST
// still be serialized for external JSON consumers that read error.exitCode.
StringAssert.Contains(json, "\"exitCode\":10");
}
[TestMethod]
public void ForErrorCode_maps_each_error_code_to_its_matching_exit_code()
{
Assert.AreEqual(CliExitCodes.MonitorNotFound, CliExitCodes.ForErrorCode(CliErrorCodes.MonitorNotFound));
Assert.AreEqual(CliExitCodes.OutOfRange, CliExitCodes.ForErrorCode(CliErrorCodes.OutOfRange));
Assert.AreEqual(CliExitCodes.InvalidDiscreteValue, CliExitCodes.ForErrorCode(CliErrorCodes.InvalidDiscreteValue));
Assert.AreEqual(CliExitCodes.UnsupportedFeature, CliExitCodes.ForErrorCode(CliErrorCodes.UnsupportedFeature));
Assert.AreEqual(CliExitCodes.HardwareFailure, CliExitCodes.ForErrorCode(CliErrorCodes.HardwareFailure));
Assert.AreEqual(CliExitCodes.SelectorMissing, CliExitCodes.ForErrorCode(CliErrorCodes.SelectorMissing));
Assert.AreEqual(CliExitCodes.ArgumentError, CliExitCodes.ForErrorCode(CliErrorCodes.ArgumentError));
Assert.AreEqual(CliExitCodes.Timeout, CliExitCodes.ForErrorCode(CliErrorCodes.Timeout));
Assert.AreEqual(CliExitCodes.InternalError, CliExitCodes.ForErrorCode(CliErrorCodes.InternalError));
Assert.AreEqual(CliExitCodes.ProviderUnavailable, CliExitCodes.ForErrorCode(CliErrorCodes.ProviderUnavailable));
// Unknown code degrades to InternalError; and a CliError's ExitCode tracks its Code.
Assert.AreEqual(CliExitCodes.InternalError, CliExitCodes.ForErrorCode("NOT_A_REAL_CODE"));
Assert.AreEqual(CliExitCodes.OutOfRange, new CliError { Code = CliErrorCodes.OutOfRange }.ExitCode);
}
[TestMethod]
public void CliListResult_round_trips_with_nested_monitors()
{
var result = new CliListResult
{
Monitors = new List<CliMonitorRef>
{
new CliMonitorRef
{
Number = 1,
Id = "DISPLAY\\DEL0A8C\\4&1a2b3c4d&0&UID12345",
Name = "Dell U2722D",
Method = "DDC/CI",
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliListResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliListResult);
Assert.IsNotNull(back);
Assert.AreEqual("list", back!.Command);
Assert.AreEqual(1, back.Monitors.Count);
Assert.AreEqual("Dell U2722D", back.Monitors[0].Name);
Assert.AreEqual("DDC/CI", back.Monitors[0].Method);
Assert.IsFalse(back.IsError, "success DTOs carry isError=false");
Assert.AreEqual(CliSchema.Version, back.Version);
}
[TestMethod]
public void CliGetResult_round_trips_with_nested_settings()
{
var result = new CliGetResult
{
Monitors = new List<CliGetMonitorEntry>
{
new CliGetMonitorEntry
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
Settings = new List<CliSettingValue>
{
new CliSettingValue { Setting = "brightness", Display = "75%", Supported = true },
new CliSettingValue { Setting = "contrast", Display = "50%", Supported = true },
new CliSettingValue { Setting = "volume", Supported = false },
},
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliGetResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliGetResult);
Assert.IsNotNull(back);
Assert.AreEqual("get", back!.Command);
Assert.AreEqual(1, back.Monitors.Count);
Assert.AreEqual("MON1", back.Monitors[0].Monitor.Id);
Assert.AreEqual(3, back.Monitors[0].Settings.Count);
Assert.AreEqual("75%", back.Monitors[0].Settings[0].Display);
Assert.IsFalse(back.Monitors[0].Settings[2].Supported);
}
[TestMethod]
public void CliSetResult_round_trips_with_before_after_values()
{
var result = new CliSetResult
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A", Method = "DDC/CI" },
Setting = "brightness",
BeforeDisplay = "50%",
AfterDisplay = "75%",
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliSetResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliSetResult);
Assert.IsNotNull(back);
Assert.AreEqual("set", back!.Command);
Assert.AreEqual("brightness", back.Setting);
Assert.AreEqual("50%", back.BeforeDisplay);
Assert.AreEqual("75%", back.AfterDisplay);
Assert.AreEqual("MON1", back.Monitor.Id);
}
[TestMethod]
public void CliCapabilitiesResult_round_trips_with_vcp_codes()
{
var result = new CliCapabilitiesResult
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
CommunicationMethod = "DDC/CI",
RawCapabilities = "(prot(monitor)type(LCD)model(U2722D)cmds(01 02 03 07 0C E3 F3)vcp(02 04 05 08 10 12 14(05 08 0B 0C) 16 18 1A 52 60(01 03 04 0F 11 12) AC AE B6 C0 C6 C8 C9 D6 DF E1 E2 F1 F2 FD)mswhql(1)mccs_ver(2.1))",
Model = "U2722D",
MccsVersion = "2.1",
VcpCodes = new List<CliVcpCodeInfo>
{
new CliVcpCodeInfo { Code = "10", Name = "Luminance", Continuous = true },
new CliVcpCodeInfo { Code = "60", Name = "Input Source", Continuous = false, DiscreteValues = new List<string> { "DP1", "HDMI1" } },
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliCapabilitiesResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliCapabilitiesResult);
Assert.IsNotNull(back);
Assert.AreEqual("capabilities", back!.Command);
Assert.AreEqual("DDC/CI", back.CommunicationMethod);
Assert.AreEqual(result.RawCapabilities, back.RawCapabilities);
Assert.AreEqual("U2722D", back.Model);
Assert.AreEqual("2.1", back.MccsVersion);
Assert.AreEqual(2, back.VcpCodes.Count);
Assert.IsTrue(back.VcpCodes[0].Continuous);
Assert.IsFalse(back.VcpCodes[1].Continuous);
Assert.IsNotNull(back.VcpCodes[1].DiscreteValues);
Assert.AreEqual(2, back.VcpCodes[1].DiscreteValues!.Count);
Assert.AreEqual("DP1", back.VcpCodes[1].DiscreteValues![0]);
}
[TestMethod]
public void CliProfileListResult_round_trips_with_profiles()
{
var result = new CliProfileListResult
{
Profiles = new List<CliProfileInfo>
{
new CliProfileInfo { Name = "Gaming", MonitorCount = 2, LastModified = "2024-01-15T10:30:00Z" },
new CliProfileInfo { Name = "Work", MonitorCount = 1, LastModified = null },
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliProfileListResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliProfileListResult);
Assert.IsNotNull(back);
Assert.AreEqual("profiles", back!.Command);
Assert.AreEqual(2, back.Profiles.Count);
Assert.AreEqual("Gaming", back.Profiles[0].Name);
Assert.AreEqual(2, back.Profiles[0].MonitorCount);
Assert.AreEqual("2024-01-15T10:30:00Z", back.Profiles[0].LastModified);
Assert.AreEqual("Work", back.Profiles[1].Name);
Assert.IsNull(back.Profiles[1].LastModified);
}
[TestMethod]
public void CliApplyProfileResult_round_trips_with_outcomes()
{
var result = new CliApplyProfileResult
{
Profile = "Gaming",
Monitors = new List<CliProfileMonitorOutcome>
{
new CliProfileMonitorOutcome
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
Connected = true,
Changes = new List<CliProfileChange>
{
new CliProfileChange { Setting = "brightness", Value = 80, Display = "80%", Status = CliProfileChange.StatusApplied },
new CliProfileChange { Setting = "volume", Value = 0, Status = CliProfileChange.StatusUnsupported },
},
},
new CliProfileMonitorOutcome
{
Monitor = new CliMonitorRef { Number = 2, Id = "MON2", Name = "Monitor B" },
Connected = false,
Changes = new List<CliProfileChange>(),
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliApplyProfileResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(back);
Assert.AreEqual(CliExitCodes.Ok, back!.ExitCode);
Assert.AreEqual("apply-profile", back.Command);
Assert.AreEqual("Gaming", back.Profile);
Assert.AreEqual(2, back.Monitors.Count);
Assert.IsTrue(back.Monitors[0].Connected);
Assert.AreEqual(2, back.Monitors[0].Changes.Count);
Assert.AreEqual(CliProfileChange.StatusApplied, back.Monitors[0].Changes[0].Status);
Assert.AreEqual("80%", back.Monitors[0].Changes[0].Display);
Assert.AreEqual(CliProfileChange.StatusUnsupported, back.Monitors[0].Changes[1].Status);
Assert.IsFalse(back.Monitors[1].Connected);
Assert.AreEqual(0, back.Monitors[1].Changes.Count);
}
[TestMethod]
public void CliApplyProfileResult_ExitCode_survives_round_trip()
{
// Verify that a non-default ExitCode (OutOfRange=2) survives JSON serialization/
// deserialization. This is the Contracts-layer gate for the apply-profile exit-code bug fix.
var result = new CliApplyProfileResult
{
ExitCode = CliExitCodes.OutOfRange,
Profile = "Night",
Monitors = new List<CliProfileMonitorOutcome>
{
new CliProfileMonitorOutcome
{
Monitor = new CliMonitorRef { Number = 1, Id = "MON1", Name = "Monitor A" },
Connected = true,
Changes = new List<CliProfileChange>
{
new CliProfileChange { Setting = "brightness", Value = 110, Status = CliProfileChange.StatusOutOfRange },
},
},
},
};
var json = JsonSerializer.Serialize(result, ContractsJsonContext.Default.CliApplyProfileResult);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliApplyProfileResult);
Assert.IsNotNull(back);
Assert.IsFalse(back!.IsError, "an apply-profile partial failure is still a success envelope (isError=false)");
Assert.AreEqual(CliExitCodes.OutOfRange, back.ExitCode, "ExitCode=2 (OutOfRange) must survive the JSON round-trip");
Assert.AreEqual("Night", back.Profile);
}
[TestMethod]
public void CapabilitiesRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Capabilities,
Capabilities = new CapabilitiesRequest { MonitorNumber = 1, MonitorId = "MON1" },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Capabilities, back!.Command);
Assert.AreEqual(1, back.Capabilities!.MonitorNumber);
Assert.AreEqual("MON1", back.Capabilities.MonitorId);
}
[TestMethod]
public void ApplyProfileRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.ApplyProfile,
ApplyProfile = new ApplyProfileRequest { ProfileName = "Gaming" },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.ApplyProfile, back!.Command);
Assert.AreEqual("Gaming", back.ApplyProfile!.ProfileName);
}
[TestMethod]
public void AdjustRequest_envelope_round_trips_through_source_gen()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Up,
Adjust = new AdjustRequest { MonitorNumber = 2, MonitorId = "MON2", Setting = "brightness", Step = 10 },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.IsNotNull(back);
Assert.AreEqual(CliCommandNames.Up, back!.Command);
Assert.AreEqual(2, back.Adjust!.MonitorNumber);
Assert.AreEqual("MON2", back.Adjust.MonitorId);
Assert.AreEqual("brightness", back.Adjust.Setting);
Assert.AreEqual(10, back.Adjust.Step);
}
[TestMethod]
public void AdjustRequest_omitted_step_round_trips_as_null()
{
var envelope = new CliRequestEnvelope
{
Command = CliCommandNames.Down,
Adjust = new AdjustRequest { MonitorNumber = 1, Setting = "contrast", Step = null },
};
var json = JsonSerializer.Serialize(envelope, ContractsJsonContext.Default.CliRequestEnvelope);
var back = JsonSerializer.Deserialize(json, ContractsJsonContext.Default.CliRequestEnvelope);
Assert.AreEqual(CliCommandNames.Down, back!.Command);
Assert.IsNull(back.Adjust!.Step, "omitted --step must serialize/deserialize as null so the app applies the settings default");
}
}

View File

@@ -1,71 +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.Collections.Generic;
namespace PowerDisplay.Contracts;
/// <summary>
/// Structured CLI error returned by validators and commands. Mapped 1:1 to the JSON
/// <c>error</c> envelope. <see cref="ExitCode"/> is derived from <see cref="Code"/> via
/// <see cref="CliExitCodes.ForErrorCode"/>, so the two can never disagree; callers set only
/// <see cref="Code"/>.
/// </summary>
public sealed class CliError
{
public string Code { get; init; } = string.Empty;
/// <summary>
/// Stable, fine-grained identifier for the localized message + hint template (e.g.
/// <c>out-of-range</c>, <c>unknown-setting</c>, <c>invalid-integer</c>). Decoupled from
/// <see cref="Code"/>: <see cref="Code"/> is coarse and drives the exit code, while several
/// distinct messages can share one <see cref="Code"/> (e.g. many argument errors are all
/// <c>ARGUMENT_ERROR</c>). The CLI maps this id to a localized template and fills it from the
/// structured fields below. Never localized. Empty falls back to <see cref="Message"/>.
/// </summary>
public string MessageId { get; init; } = string.Empty;
/// <summary>
/// Optional English fallback message. The app leaves this empty and sends only <see cref="Code"/>
/// plus the structured fields below; the CLI composes the localized, human-readable message from
/// <see cref="Code"/> (see <c>Resources</c>). This is populated only as a last-resort fallback for
/// a <see cref="Code"/> the CLI does not recognize.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>Process exit code for this error, derived from <see cref="Code"/>. Serialized for
/// JSON consumers; recomputed from <see cref="Code"/> on deserialization.</summary>
public int ExitCode => CliExitCodes.ForErrorCode(Code);
/// <summary>
/// Canonical setting name involved in the error (e.g. <c>brightness</c>, <c>color-temperature</c>).
/// An identifier, never localized; the CLI substitutes it into the localized template for this
/// <see cref="Code"/>. Null when the error is not setting-specific.
/// </summary>
public string? Setting { get; init; }
/// <summary>
/// The offending or selector value as the user supplied it (e.g. <c>150</c>, <c>0x99</c>, a monitor
/// number/id). Data, never localized; the CLI substitutes it into the localized template. Null when
/// the error carries no such value.
/// </summary>
public string? Value { get; init; }
public string? ExpectedRange { get; init; }
public IReadOnlyList<CliSupportedValue>? Supported { get; init; }
/// <summary>
/// Optional technical diagnostic kept verbatim (e.g. a VESA/VCP capability reason or a driver error
/// string). Rendered as-is, not localized: it is low-level hardware jargon aimed at technical users.
/// </summary>
public string? Detail { get; init; }
/// <summary>
/// Optional English fallback hint. Like <see cref="Message"/>, the app normally leaves this empty
/// and the CLI derives the localized hint from <see cref="Code"/>; used only as a fallback for an
/// unrecognized <see cref="Code"/>.
/// </summary>
public string? Hint { get; init; }
}

View File

@@ -1,22 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable error codes emitted as <c>error.code</c> in JSON output.
/// </summary>
public static class CliErrorCodes
{
public const string MonitorNotFound = "MONITOR_NOT_FOUND";
public const string OutOfRange = "OUT_OF_RANGE";
public const string InvalidDiscreteValue = "INVALID_DISCRETE_VALUE";
public const string UnsupportedFeature = "UNSUPPORTED_FEATURE";
public const string HardwareFailure = "HARDWARE_FAILURE";
public const string SelectorMissing = "SELECTOR_MISSING";
public const string ArgumentError = "ARGUMENT_ERROR";
public const string Timeout = "TIMEOUT";
public const string InternalError = "INTERNAL_ERROR";
public const string ProviderUnavailable = "PROVIDER_UNAVAILABLE";
}

View File

@@ -1,42 +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.
namespace PowerDisplay.Contracts;
public static class CliExitCodes
{
public const int Ok = 0;
public const int MonitorNotFound = 1;
public const int OutOfRange = 2;
public const int InvalidDiscreteValue = 3;
public const int UnsupportedFeature = 4;
public const int HardwareFailure = 5;
public const int SelectorMissing = 6;
public const int ArgumentError = 7;
public const int Timeout = 8;
public const int InternalError = 9;
/// <summary>The PowerDisplay app/provider is not running or could not be reached.</summary>
public const int ProviderUnavailable = 10;
/// <summary>
/// Maps a <see cref="CliErrorCodes"/> value to its corresponding process exit code. The two
/// sets are a 1:1 name mirror; this is the single source of that pairing so an error's code and
/// its exit code can never disagree. An unrecognized code maps to <see cref="InternalError"/>.
/// </summary>
public static int ForErrorCode(string errorCode) => errorCode switch
{
CliErrorCodes.MonitorNotFound => MonitorNotFound,
CliErrorCodes.OutOfRange => OutOfRange,
CliErrorCodes.InvalidDiscreteValue => InvalidDiscreteValue,
CliErrorCodes.UnsupportedFeature => UnsupportedFeature,
CliErrorCodes.HardwareFailure => HardwareFailure,
CliErrorCodes.SelectorMissing => SelectorMissing,
CliErrorCodes.ArgumentError => ArgumentError,
CliErrorCodes.Timeout => Timeout,
CliErrorCodes.InternalError => InternalError,
CliErrorCodes.ProviderUnavailable => ProviderUnavailable,
_ => InternalError,
};
}

View File

@@ -1,43 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable, fine-grained identifiers for CLI error messages, shared by the app (which stamps one on
/// <see cref="CliError.MessageId"/>) and the CLI (which maps it to a localized template). Decoupled
/// from <see cref="CliErrorCodes"/>: several messages can share one coarse error code / exit code
/// (e.g. many are <see cref="CliErrorCodes.ArgumentError"/>). Never localized; never surfaced to users.
/// </summary>
public static class CliMessageIds
{
// set / common
public const string OutOfRange = "out-of-range";
public const string InvalidInteger = "invalid-integer";
public const string InvalidDiscrete = "invalid-discrete";
public const string DiscreteNotInSet = "discrete-not-in-set";
public const string InvalidOrientation = "invalid-orientation";
public const string Unsupported = "unsupported";
public const string PowerBlankingConfirm = "power-blanking-confirm";
public const string HardwareFailure = "hardware-failure";
// get / capabilities
public const string UnknownSetting = "unknown-setting";
public const string NotDiscreteSetting = "not-discrete-setting";
// monitor resolution
public const string SelectorMissing = "selector-missing";
public const string MonitorNotFoundNumber = "monitor-not-found-number";
public const string MonitorNotFoundId = "monitor-not-found-id";
// up / down
public const string UnknownSettingAdjust = "unknown-setting-adjust";
public const string NotAdjustable = "not-adjustable";
public const string AdjustValueUnknown = "adjust-value-unknown";
// profiles / internal
public const string ProfileNotFound = "profile-not-found";
public const string UnknownCommand = "unknown-command";
public const string InternalError = "internal-error";
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text;
namespace PowerDisplay.Contracts;
/// <summary>
/// Wire-framing constants for the CLI&lt;-&gt;app named pipe, shared by the client and server so the
/// two ends cannot drift. The exchange is one '\n'-delimited request line and one '\n'-delimited
/// response line.
/// </summary>
public static class CliPipeProtocol
{
/// <summary>
/// BOM-less UTF-16 LE. <see cref="Encoding.Unicode"/> emits a BOM on the first write which
/// corrupts line framing on a named pipe; this encoding is identical in every other respect
/// (UTF-16 LE, 2 bytes per ASCII char). Both pipe ends MUST use this exact encoding.
/// </summary>
public static readonly Encoding PipeEncoding = new UnicodeEncoding(bigEndian: false, byteOrderMark: false);
/// <summary>Stream reader/writer buffer size used by both pipe ends.</summary>
public const int BufferSize = 1024;
/// <summary>
/// Maximum length (in characters) the server will accept for a single request line. The
/// protocol carries one short JSON object, so this is a generous sanity bound that prevents an
/// unbounded read from buffering arbitrary amounts of memory in the app process.
/// </summary>
public const int MaxRequestChars = 64 * 1024;
/// <summary>
/// How long the server waits for a connected client to send its request line before abandoning
/// the connection. Without this a client that connects but never sends a line would stall the
/// single-threaded accept loop for every other CLI invocation.
/// </summary>
public const int ReadTimeoutMilliseconds = 10_000;
/// <summary>
/// How long the server waits for the response write and drain (<c>WaitForPipeDrain</c>) to
/// complete before abandoning the connection. Bounds the write phase the same way
/// <see cref="ReadTimeoutMilliseconds"/> bounds the read phase: the pipe uses a 0-byte output
/// buffer, so both the write and the drain block until the client reads, and a connected client
/// that never reads the response would otherwise wedge the single-threaded accept loop
/// indefinitely (<c>WaitForPipeDrain</c> has no timeout/<c>CancellationToken</c> overload).
/// </summary>
public const int WriteTimeoutMilliseconds = 10_000;
}

View File

@@ -1,18 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Stable schema version stamped onto every IPC request and response envelope as informational
/// metadata. NOTE: neither side validates this today — a mismatched CLI/app currently surfaces as
/// a deserialization failure (INTERNAL_ERROR, exit 9), not a dedicated version error, and because
/// the source-gen serializer ignores unknown members, additive ("minor") drift is accepted
/// silently. Version negotiation (rejecting an incompatible major) is intentionally out of scope
/// for v1; wire it up here and in the dispatcher if forward-compat becomes a requirement.
/// </summary>
public static class CliSchema
{
public const string Version = "1.0";
}

View File

@@ -1,41 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Canonical setting names accepted by the CLI (the value of <c>--setting</c> and the
/// per-setting <c>--&lt;name&gt;</c> flags). Shared by the CLI argument layer and the app-side
/// executor/projector so the single list cannot drift between the two sides. The same
/// identifiers appear in <see cref="CliSettingValue.Setting"/> so JSON consumers can
/// switch on them.
/// </summary>
public static class CliSettingNames
{
public const string Brightness = "brightness";
public const string Contrast = "contrast";
public const string Volume = "volume";
public const string ColorTemperature = "color-temperature";
public const string InputSource = "input-source";
public const string PowerState = "power-state";
public const string Orientation = "orientation";
/// <summary>All canonical setting names, in canonical (display) order.</summary>
public static readonly string[] All =
[
Brightness,
Contrast,
Volume,
ColorTemperature,
InputSource,
PowerState,
Orientation,
];
}

View File

@@ -1,15 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// A discrete-value choice carried in error details so users can self-correct.
/// </summary>
public sealed class CliSupportedValue
{
public string Name { get; init; } = string.Empty;
public string Vcp { get; init; } = string.Empty;
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace PowerDisplay.Contracts;
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(CliRequestEnvelope))]
[JsonSerializable(typeof(CliListResult))]
[JsonSerializable(typeof(CliGetResult))]
[JsonSerializable(typeof(CliSetResult))]
[JsonSerializable(typeof(CliCapabilitiesResult))]
[JsonSerializable(typeof(CliProfileListResult))]
[JsonSerializable(typeof(CliApplyProfileResult))]
[JsonSerializable(typeof(CliErrorResult))]
[JsonSerializable(typeof(CliResponseHeader))]
public sealed partial class ContractsJsonContext : System.Text.Json.Serialization.JsonSerializerContext
{
}

View File

@@ -1,26 +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.Diagnostics;
namespace PowerDisplay.Contracts;
/// <summary>Single source of truth for the CLI&lt;-&gt;app named-pipe name.
/// Session-scoped so concurrent user sessions never collide; the app is single-instance
/// per session (AppInstance), so the session id alone uniquely identifies the server.</summary>
public static class PipeNames
{
// The current process's session id is fixed for the process lifetime, so resolve it once.
// Process.GetCurrentProcess() returns an IDisposable wrapping a native handle; dispose it
// immediately rather than leaking the handle until finalization (CA2000).
private static readonly int SessionId = GetCurrentSessionId();
public static string CliServer()
=> $"PowerDisplay_Cli_Session_{SessionId}";
private static int GetCurrentSessionId()
{
using var process = Process.GetCurrentProcess();
return process.SessionId;
}
}

View File

@@ -1,21 +0,0 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<RootNamespace>PowerDisplay.Contracts</RootNamespace>
<AssemblyName>PowerToys.PowerDisplay.Contracts</AssemblyName>
<Platforms>x64;ARM64</Platforms>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsAotCompatible>true</IsAotCompatible>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="PowerDisplay.Contracts.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -1,22 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Request for the relative <c>up</c>/<c>down</c> commands. The direction is carried by
/// <see cref="CliRequestEnvelope.Command"/> ("up" or "down"); this payload names the target
/// continuous setting and an optional step.
/// </summary>
public sealed class AdjustRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>One of the continuous setting names: brightness, contrast, volume.</summary>
public string Setting { get; set; } = string.Empty;
/// <summary>Step amount; <see langword="null"/> means "use the mouse_wheel_increment setting".</summary>
public int? Step { get; set; }
}

View File

@@ -1,9 +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.
namespace PowerDisplay.Contracts;
public sealed class ApplyProfileRequest
{
public string ProfileName { get; set; } = string.Empty;
}

View File

@@ -1,13 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Payload for <c>powerdisplay capabilities</c>. See <see cref="MonitorSelectorRequest"/>; the
/// <see cref="MonitorSelectorRequest.SettingFilter"/> restricts the result to a single discrete
/// setting's VCP code (<c>color-temperature</c>, <c>input-source</c>, or <c>power-state</c>).
/// </summary>
public sealed class CapabilitiesRequest : MonitorSelectorRequest
{
}

View File

@@ -1,17 +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.
namespace PowerDisplay.Contracts;
/// <summary>Canonical command discriminators shared by CLI and app.</summary>
public static class CliCommandNames
{
public const string List = "list";
public const string Get = "get";
public const string Set = "set";
public const string Capabilities = "capabilities";
public const string Profiles = "profiles";
public const string ApplyProfile = "apply-profile";
public const string Up = "up";
public const string Down = "down";
}

View File

@@ -1,23 +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.
namespace PowerDisplay.Contracts;
/// <summary>Top-level request envelope. Exactly one payload property is non-null,
/// selected by <see cref="Command"/>. Concrete payloads (not polymorphic object) keep AOT happy.</summary>
public sealed class CliRequestEnvelope
{
public string Version { get; set; } = CliSchema.Version;
public string Command { get; set; } = string.Empty;
public GetRequest? Get { get; set; }
public SetRequest? Set { get; set; }
public CapabilitiesRequest? Capabilities { get; set; }
public ApplyProfileRequest? ApplyProfile { get; set; }
public AdjustRequest? Adjust { get; set; }
}

View File

@@ -1,9 +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.
namespace PowerDisplay.Contracts;
/// <summary>Payload for <c>powerdisplay get</c>. See <see cref="MonitorSelectorRequest"/>.</summary>
public sealed class GetRequest : MonitorSelectorRequest
{
}

View File

@@ -1,23 +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.
namespace PowerDisplay.Contracts;
/// <summary>
/// Shared selector shape for the read commands that target a single monitor and optionally a single
/// setting (<c>get</c>, <c>capabilities</c>). Exactly one of <see cref="MonitorNumber"/> /
/// <see cref="MonitorId"/> identifies the monitor; <see cref="SettingFilter"/> optionally narrows
/// the result to one setting. Concrete subclasses keep the envelope's payload slots distinct types.
/// </summary>
public abstract class MonitorSelectorRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>
/// Optional filter restricting the result to a single setting (e.g. a discrete setting's VCP
/// code for <c>capabilities</c>). Null = no filter.
/// </summary>
public string? SettingFilter { get; set; }
}

View File

@@ -1,20 +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.
namespace PowerDisplay.Contracts;
public sealed class SetRequest
{
public int? MonitorNumber { get; set; }
public string? MonitorId { get; set; }
/// <summary>One of the canonical setting names: brightness, contrast, volume,
/// color-temperature, input-source, power-state, orientation.</summary>
public string Setting { get; set; } = string.Empty;
/// <summary>Raw user-supplied value; the app parses/validates against capabilities.</summary>
public string RawValue { get; set; } = string.Empty;
public bool ConfirmPowerOff { get; set; }
}

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