mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-04 09:30:04 +02:00
Compare commits
11 Commits
yuleng/pd/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ef90d168 | ||
|
|
e0fe3c48cf | ||
|
|
029dd04ce4 | ||
|
|
3d3cef73da | ||
|
|
03e5f3e837 | ||
|
|
9039451e2f | ||
|
|
af45c3ec7c | ||
|
|
de4859454c | ||
|
|
93669df118 | ||
|
|
56fabda79c | ||
|
|
70555459ab |
11
.github/actions/spell-check/expect.txt
vendored
11
.github/actions/spell-check/expect.txt
vendored
@@ -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
1
.gitignore
vendored
@@ -381,4 +381,3 @@ deps/vcpkg/
|
||||
|
||||
# Superpowers-generated docs (specs, design, plans) — local-only, not committed
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -160,7 +160,7 @@ bool WindowBorder::Init(HINSTANCE hinstance)
|
||||
|
||||
void WindowBorder::UpdateBorderPosition() const
|
||||
{
|
||||
if (!m_trackingWindow)
|
||||
if (!m_trackingWindow || !m_frameDrawer || !m_window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" />
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
SetWindowPos
|
||||
GetSystemMetrics
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
36
src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs
Normal file
36
src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)] + "…";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 --<setting> 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 >= 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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<->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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>--<name></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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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<->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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user