Compare commits

...

22 Commits

Author SHA1 Message Date
Clint Rutkas
ab08e318d8 Fix MathHelper.Modulo for non-positive divisors; improve test coverage
- Add ArgumentOutOfRangeException guard for b<=0 in MathHelper.Modulo
  (previously threw raw DivideByZeroException for b=0, returned wrong
  results for negative divisors)
- Add test for invalid Position enum (only untested code branch)
- Add test for negative Modulo divisor
- Update zero-divisor test to expect ArgumentOutOfRangeException
- Remove redundant AllPositions smoke test (covered by 9 exact-value tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 17:20:31 -07:00
Clint Rutkas
ce0439f580 Enhance PowerAccent.Core and Peek.Common tests: fix bugs, strengthen assertions
- Fixed Rect.cs: Added Width/Height zero-division guards (was producing Infinity)
- Fixed Size.cs: Removed duplicate condition in division operator
- Strengthened Currency test with specific symbol assertions (euro, dollar, pound)
- Added cache identity check with Assert.AreSame
- Renamed misleading UNC test method (UncFileUri -> StandardUncWithFile)
- Added 10 new tests: Rect zero-Width/Height/AllZero, int.MinValue overflow,
  Modulo zero divisor, TopRight/BottomLeft exact values, null UNC path,
  Point both-zero, Size both-zero
- Added XML doc comments to all 123 test methods

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-12 19:09:21 -07:00
Clint Rutkas
5b7d3c0a82 Fix deps.json version conflicts in Peek.Common.UnitTests
Remove Common.SelfContained.props (only 3 of 72 test projects use it) and add
UseWindowsForms to bring in the WindowsDesktop shared framework. This ensures
runtime DLLs (System.CodeDom, Microsoft.VisualBasic, WindowsBase, System.Drawing)
resolve to the same versions as all other projects in the solution.

Without UseWindowsForms, Peek.Common (which uses WinUI, not WPF/WinForms) doesn't
transitively provide the WindowsDesktop framework reference, causing NuGet package
versions to appear in deps.json instead of the shared runtime versions.

Verified: All 487 libraries pass verifyDepsJsonLibraryVersions.ps1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-12 14:04:38 -07:00
Clint Rutkas
5e12d2476d Merge remote-tracking branch 'origin/copilot/upgrade-microsoft-semantic-kernel-core' into copilot/improve-test-coverage 2026-04-11 15:23:42 -07:00
Clint Rutkas
3b6bf9c0ce Fix deps.json version conflicts for System.Diagnostics.EventLog and System.Threading.Channels
Add PackageReference for System.Diagnostics.EventLog and System.Threading.Channels
to all C# projects via Directory.Build.props, ensuring the NuGet 10.x versions
take precedence over the 9.x versions bundled in the .NET 9 runtime. This resolves
the verifyDepsJsonLibraryVersions CI check failure where different projects would
reference different DLL versions of these libraries.

- Add System.Threading.Channels 10.0.4 to Directory.Packages.props
- Add PackageReference for both packages in Directory.Build.props (C# only)
- Remove ExcludeAssets=runtime from System.Diagnostics.EventLog in 6 projects
  (no longer needed since we want the 10.x NuGet version to override the runtime)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 14:56:16 -07:00
Clint Rutkas
9020b18a9f Upgrade transitive dependencies for SemanticKernel 1.74.0
Upgrade Microsoft.Extensions.*, OpenAI, System.Text.Json, and other
transitive dependencies to versions compatible with SemanticKernel 1.74.0:

- Microsoft.Extensions.AI: 9.9.1 -> 10.4.1
- Microsoft.Extensions.AI.OpenAI: 9.9.1-preview -> 10.4.1
- Microsoft.Extensions.Caching.Abstractions: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Caching.Memory: 9.0.10 -> 10.0.5
- Microsoft.Extensions.DependencyInjection: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Logging: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Logging.Abstractions: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Hosting: 9.0.10 -> 10.0.5
- Microsoft.Extensions.Hosting.WindowsServices: 9.0.10 -> 10.0.5
- Microsoft.Bcl.AsyncInterfaces: 9.0.10 -> 10.0.5
- Newtonsoft.Json: 13.0.3 -> 13.0.4
- OpenAI: 2.5.0 -> 2.9.1
- System.ClientModel: 1.7.0 -> 1.9.0
- System.Diagnostics.EventLog: 9.0.10 -> 10.0.5
- System.Numerics.Tensors: 9.0.11 -> 10.0.4
- System.ServiceProcess.ServiceController: 9.0.10 -> 10.0.5
- System.Text.Json: 9.0.10 -> 10.0.5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 13:04:10 -07:00
Clint Rutkas
f59f67cb08 Merge remote-tracking branch 'origin/main' into copilot/improve-test-coverage 2026-04-11 12:24:44 -07:00
copilot-swe-agent[bot]
890ea40f8a Upgrade Microsoft.SemanticKernel packages from 1.66.0 to 1.74.0
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/39021150-d623-45a0-98e1-6eb8a65d44c0

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-11 04:10:23 +00:00
moooyo
0089de33bd [PD] Re-enable PowerDisplay (#46489)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
1. Re-enable PowerDisplay for PowerToys.
2. Add PowerDisplay back into installer.
3. Use new PowerDisplay icon and logo.
4. Fix some DPI related issue.
5. UI/UX improvement.


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Pull new code from this branch. Set up PowerDisplay.UI as startup
project. Click run in VS.

Or, build whole solution, set up runner as startup project. Click run to
test full experience.

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-04-10 15:14:41 +08:00
Copilot
3e2914a0b2 Add unit tests for Hosts ValidationHelper and ColorPicker format conversions (#46679)
## Summary of the Pull Request

Adds comprehensive unit tests for two previously untested areas to
improve test coverage and prevent regressions:

1. **Hosts ValidationHelper** (`ValidationHelperTest.cs`) — 25+ test
cases covering `ValidIPv4`, `ValidIPv6`, and `ValidHosts` methods
2. **ColorPicker ColorFormatHelper conversions**
(`ColorFormatConversionTest.cs`) — 50+ test cases covering CMYK, HSB,
HSI, HWB, CIE XYZ, CIE LAB, Oklab, Oklch, sRGB-to-linear, NCol
conversions, plus expanded `GetStringRepresentation` tests for Red,
White, Green, and Blue colors across all supported formats

## PR Checklist

- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized —
N/A (test-only changes)
- [ ] **Dev docs:** Added/updated — N/A (test-only changes)
- [ ] **New binaries:** Added on the required places — N/A (no new
binaries)

## Detailed Description of the Pull Request / Additional comments

### Hosts ValidationHelper
(`src/modules/Hosts/Hosts.Tests/ValidationHelperTest.cs`)
- Tests `ValidIPv4` with valid addresses (loopback, private ranges,
broadcast), invalid addresses (out of range octets, wrong format, CIDR
notation), and null/whitespace inputs
- Tests `ValidIPv6` with valid addresses (loopback, full/compressed
notation, link-local, IPv4-mapped), invalid addresses (extra groups,
invalid hex digits), and null/whitespace inputs
- Tests `ValidHosts` with valid hostnames, FQDNs, max host count
boundary (using `Consts.MaxHostsCount` dynamically for both
exact-boundary and exceeds-boundary tests), and invalid hostnames

### ColorPicker Format Conversions
(`src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorFormatConversionTest.cs`)
- Tests `ConvertToCMYKColor` for Black, White, Red, Green, Blue, and Mid
Gray
- Tests `ConvertToHSBColor`, `ConvertToHSIColor`, `ConvertToHWBColor`
for primary colors
- Tests `ConvertToCIEXYZColor` and `ConvertToCIELABColor` including D65
illuminant verification and negative b* assertion for Blue
- Tests `ConvertToOklabColor` and `ConvertToOklchColor` including chroma
non-negativity
- Tests `ConvertSRGBToLinearRGB` for linear and gamma paths
- Tests `ConvertToNaturalColor` for hue letter mapping (R exact match, G
and B prefix assertions)
- Tests `GetStringRepresentation` with Red, White, Green, Blue across
all 11+ format types (Decimal values use BGR byte order via `%Dv`
format: Red → "255", Blue → "16711680")
- Tests `GetDefaultFormat` returns non-empty strings for all known
format names
- Tests edge cases: empty/null format strings defaulting to hex output

### Spell-check allow list
- Added `SRGBTo` to `.github/actions/spell-check/allow/code.txt` to
resolve unrecognized-spelling alerts

### StyleCop / code analysis fixes
- Resolved SA1512, SA1515, CA1866, and CA1310 analyzer warnings to
comply with repo coding standards

### Code review fixes
- Renamed `ConvertToCIELAB_Blue_HasNegativeA` →
`ConvertToCIELAB_Blue_HasNegativeB` with corrected comment to match the
actual b* axis assertion
- Replaced hardcoded 12-host string with dynamic `Consts.MaxHostsCount +
1` in the exceeds-max-count boundary test
- Renamed `ConvertToNaturalColor_Green_ReturnsG0` →
`ConvertToNaturalColor_Green_HueStartsWithG` and
`ConvertToNaturalColor_Blue_ReturnsB0` →
`ConvertToNaturalColor_Blue_HueStartsWithB` to accurately reflect
prefix-only assertions

## Validation Steps Performed

- Verified test files follow existing MSTest patterns (`[TestClass]`,
`[TestMethod]`, `[DataTestMethod]`, `[DataRow]`)
- Verified all referenced classes and methods exist and are accessible
(correct namespaces and visibility)
- Verified namespace consistency with existing test files in each
project
- Used dynamic `Consts.MaxHostsCount` rather than hardcoded values for
all boundary tests
- Verified Decimal format expected values match the `%Dv` (BGR order)
implementation: `R + G*256 + B*65536`
- Verified test method names accurately describe their assertions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-10 09:07:48 +02:00
Dustin L. Howett
3554f0884b spelling: move to v0.0.26 (#46851)
This fixes, among other things, the issue with fork PRs.

---------

Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2026-04-10 06:43:27 +02:00
Jaylyn Barbee
ad60090096 [KBM] Fixes to text replacement issues (#46794)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This PR attempts to fix some of the issues that were introduced in
0.98.0 with text replacement

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

- [x] Closes: #46498
- [x] Closes: #46440
- [x] Closes: #46366

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
In 0.98.0 I made a change to support multiline text replacement using
Ctrl + V. This was very inconsistent so I have reverted back to
_basically_ the same approach we were using before except now when we
encounter a newline indicator we send "Shift + Enter" so that this works
in chat boxes and plan editors.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Manual testing with single line and multiline replacements in Teams,
Terminal, Git bash, Edge, etc
2026-04-09 09:58:10 -05:00
Niels Laute
de6a609d16 [KBM] Manual key selection — code review fixes (#46377)
Addresses code review feedback on the KBM manual key selection feature.
No new user-facing behavior; all changes are correctness, robustness,
and maintainability fixes.

## Summary of the Pull Request

- **Localization:** All hard-coded `RemappingDialog.Title` assignments
replaced with `ResourceHelper.GetString()`; added
`RemappingDialog_TitleEdit` resource key
- **VK_DISABLED centralization:** Replaced scattered `0x100`/`"256"`
literals and local `const string vkDisabledCode` with `private const int
VkDisabled = 0x100` / `private const string VkDisabledString = "256"` on
`MainPage`
- **Disable action validation:** Added
`ValidationHelper.ValidateDisableMapping()` — same trigger-key rules as
other action types (empty keys, modifier-only, illegal shortcuts,
duplicates, conflicting modifier variants); wired into
`ValidateMapping()` switch
- **Binding-safe dropdown revert:** `TriggerKeyDropDown_KeyChanged` /
`ActionKeyDropDown_KeyChanged` no longer set `dropDown.KeyName =
e.OldKeyName` on failure (breaks `{Binding}` expression); now use
`RevertKeySelection(keys, index)` which does
`ObservableCollection.RemoveAt` + `Insert` to force a binding-tracked
refresh without touching the DP directly. `NewKeyCode == 0` ("None") is
rejected via the same path
- **Dropdown validation:** `ValidateDropDownSelection` skips
`string.IsNullOrEmpty` placeholder slots (added by
`HandleAutoGrowShrink`) when checking repeated-modifier and max-size
rules
- **`SetActionType`:** Replaced hard-coded `SelectedIndex` with
tag-matching iteration over `ActionTypeComboBox.Items`; immune to XAML
item reorder
- **`ServiceStatusHelper`:** Dispose all `Process` objects returned by
`GetProcessesByName` before returning; prevents handle accumulation on
the 3-second polling timer
- **`KeyDropDownButton.GetKeyList()`:** Filter out `KeyCode == 0`
entries (native "None" sentinel for shortcut lists) before caching
- **`SettingsManager`:** `_mappingService!` used consistently in
`CreateSettingsFromKeyboardManagerService`
- **`KeyboardHookHelper`:** Constructor catch broadened from
`DllNotFoundException or InvalidOperationException` to `Exception`

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments

The `RevertKeySelection` pattern: in WinUI, assigning directly to a
bound `DependencyProperty` overwrites the binding expression. Using
`ObservableCollection.RemoveAt` + `Insert` instead raises
`CollectionChanged(Replace)`, causing the binding to re-read from the
source without clearing the expression.

```csharp
private static void RevertKeySelection(ObservableCollection<string> keys, int index)
{
    string current = keys[index];
    keys.RemoveAt(index);
    keys.Insert(index, current);
}
```

## Validation Steps Performed

Manually verified: dropdown key selection and revert on invalid
selection, Disable mapping save/load with the new validation, "None"
absent from key picker flyout, `SetActionType` correctly selects items
with preserved XAML order.

---------

Co-authored-by: Zach Teutsch <88554871+zateutsch@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-09 10:45:25 -04:00
Rin
243255ecea Fix quote breakout in launcher shell plugin commands (#45554)
## Summary of the Pull Request
Fixes a command breakout vulnerability in the Shell plugin where user
input containing double quotes could be manipulated to execute arbitrary
sub-processes. By escaping double quotes, inputs like `test" & calc.exe`
are treated as literal strings rather than shell command separators.

## PR Checklist
- [ ] Closes: #xxx
- [x] **Communication:** Proactive fix for command execution logic.
- [x] **Tests:** Verified locally.
- [x] **Localization:** N/A
- [x] **Dev docs:** N/A
- [x] **New binaries:** N/A
- [x] **Documentation updated:** N/A

## Detailed Description of the Pull Request / Additional comments
Escaping double quotes in the command string before it is passed to the
shell prevents the breakout vector I identified while still allowing
environment variables to expand as expected. I also removed some
redundant property assignments in the shell helpers to keep the logic
focused on the core fix.

## Validation Steps Performed
Standard diagnostic commands like `ping` and `ipconfig` continue to
function as expected via PowerToys Run. Malicious strings designed to
break out of quotes now fail to execute sub-commands, as confirmed
during local verification.

---------

Co-authored-by: LegendaryBlair <legendaryblair@icloud.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 16:31:24 +08:00
copilot-swe-agent[bot]
70dd9db67a Fix deps.json version mismatch: add SelfContained props to test projects
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/a7787ea5-42eb-4e09-96c8-c295385faa9c

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-05 23:14:55 +00:00
copilot-swe-agent[bot]
fb7c945a2c Fix SA1508 and CS0246: remove blank lines before closing braces, add missing using System
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/a6ae0e3d-68ed-4a4f-8b91-e039e2f05689

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-05 16:37:15 +00:00
copilot-swe-agent[bot]
c72580c8f2 Fix build error: add missing using System.Linq to LanguagesTests.cs
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/0b83d8fc-328d-47ec-a746-685956604afe

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-04 05:08:11 +00:00
copilot-swe-agent[bot]
ce6debf68b Add traies and udit to spell-check allow list
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/1daa557f-51f2-454e-a7f9-aacc3912d36f

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-03 01:41:54 +00:00
copilot-swe-agent[bot]
1eca1713e1 Remove all #region/#endregion directives to fix SA1124 errors
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/00075017-9397-4cb6-ad53-1afda756d401

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-01 20:20:30 +00:00
copilot-swe-agent[bot]
1254cba088 Add test projects to PowerToys.slnx so CI discovers and runs them
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/9ffea101-795b-4b8c-817a-ab5af7004f72

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-04-01 04:40:47 +00:00
copilot-swe-agent[bot]
b5373cbb2b Update spell-check: add Ene to allow list, remove unused entries from expect list
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/717795b3-9c5f-484e-9b9c-63de37852f81

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-03-31 20:39:18 +00:00
copilot-swe-agent[bot]
b82e6c508d Add unit tests for PowerAccent.Core and Peek.Common helpers
Add PowerAccent.Core.UnitTests project with tests for:
- Point, Size, Rect struct constructors and division operators
- Calculation position logic with boundary conditions and DPI
- Languages character mapping for multiple languages

Add Peek.Common.UnitTests project with tests for:
- MathHelper.Modulo with positive/negative values
- MathHelper.NumberOfDigits with edge cases
- PathHelper.IsUncPath with various path formats

Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/643cb57b-c352-45a6-83d0-d6b09011290b

Co-authored-by: crutkas <1462282+crutkas@users.noreply.github.com>
2026-03-31 19:10:40 +00:00
121 changed files with 6261 additions and 2863 deletions

View File

@@ -19,6 +19,7 @@ OLIVEGREEN
PALEBLUE
PArgb
Pbgra
SRGBTo
WHITEONBLACK
@@ -327,10 +328,14 @@ MRUCMPPROC
MRUINFO
REGSTR
# Quick Accent
Ene
# Misc Win32 APIs and PInvokes
INVOKEIDLIST
MEMORYSTATUSEX
ABE
Mdt
HTCAPTION
POSCHANGED
QUERYPOS
@@ -387,5 +392,9 @@ nostdin
engtype
Nonpaged
# Spell-check fragments
traies
udit
# XAML
Untargeted

View File

@@ -140,8 +140,6 @@
^tools/project_template/ModuleTemplate/resource\.h$
^tools/Verification scripts/Check preview handler registration\.ps1$
ignore$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/common/CalculatorEngineCommon/exprtk\.hpp$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
^src/modules/powerrename/unittests/testdata/heif_test\.heic$

View File

@@ -791,7 +791,6 @@ lowlevel
LOWORD
lparam
LPBITMAPINFOHEADER
LPCFHOOKPROC
lpch
LPCITEMIDLIST
LPCLSID
@@ -833,7 +832,6 @@ lstrlen
LTEXT
LTRREADING
luid
LUMA
lusrmgr
LVal
LWA
@@ -852,7 +850,6 @@ MAPPEDTOSAMEKEY
MAPTOSAMESHORTCUT
MAPVK
MARKDOWNPREVIEWHANDLERCPP
MAXDWORD
MAXSHORTCUTSIZE
maxversiontested
MBM
@@ -917,7 +914,6 @@ MOUSEINPUT
MOVESIZEEND
MOVESIZESTART
MRM
MRT
mru
msc
mscorlib
@@ -1037,9 +1033,7 @@ NORMALDISPLAY
NORMALUSER
NOSEARCH
NOSENDCHANGING
NOSIZE
notdefault
Nosize
NOTHOUSANDS
NOTICKS
NOTIFICATIONSDLL
@@ -1694,6 +1688,7 @@ UOI
UPDATENOW
updown
UPGRADINGPRODUCTCODE
upserts
Uptool
urld
Usb
@@ -1778,7 +1773,6 @@ webpage
websites
wekyb
wgpocpl
WIC
wic
wifi
winapi
@@ -2176,7 +2170,7 @@ nodiscard
nologo
nomove
nosize
notopmost
NOTOPMOST
Notupdated
notwindows
nowarn
@@ -2305,7 +2299,6 @@ virama
vnd
vredraw
VSpeed
VSync
WASDK
WCRAPI
wft

View File

@@ -191,15 +191,6 @@ aka\.ms/[a-zA-Z0-9]+
# #pragma lib
^\s*#pragma comment\(lib, ".*?"\)
# UnitTests
\[DataRow\(.*\)\]
# AdditionalDependencies
<AdditionalDependencies>.*<
# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files
^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$
RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|return/.*?/
# Questionably acceptable forms of `in to`

View File

@@ -93,7 +93,7 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
with:
config: .github/actions/spell-check
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
@@ -135,6 +135,7 @@ jobs:
cspell:cpp/compiler-msvc.txt
cspell:python/common/extra.txt
cspell:scala/scala.txt
ignored: ignored-expect-variant
comment-push:
name: Report (Push)
@@ -147,10 +148,8 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
with:
config: .github/actions/spell-check
checkout: true
spell_check_this: microsoft/PowerToys@main
task: ${{ needs.spelling.outputs.followup }}
@@ -166,10 +165,8 @@ jobs:
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
with:
config: .github/actions/spell-check
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
@@ -193,7 +190,7 @@ jobs:
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
with:
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
checkout: true

View File

@@ -217,7 +217,11 @@
"PowerToys.PowerAccentModuleInterface.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"PowerDisplay.Lib.dll",
"PowerDisplay.Models.dll",
"WinUI3Apps\\PowerToys.PowerRenameExt.dll",
"WinUI3Apps\\PowerToys.PowerRename.exe",

View File

@@ -85,6 +85,14 @@
<ForceImportBeforeCppProps>$(RepoRoot)Cpp.Build.props</ForceImportBeforeCppProps>
</PropertyGroup>
<!-- Force all C# projects to reference these packages from NuGet so their 10.x versions
take precedence over the 9.x versions bundled in the .NET 9 runtime. Without this,
projects that don't transitively depend on these packages would get the runtime 9.x
version, causing deps.json version conflicts with projects that do. -->
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'">
<PackageReference Include="System.Diagnostics.EventLog" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj' and '$(_IsSkippedTestProject)' != 'true'">
<PackageReference Include="StyleCop.Analyzers">

View File

@@ -42,26 +42,26 @@
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
<!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
<PackageVersion Include="Microsoft.Windows.CppWinRT" Version="2.0.250303.1" />
<PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.16" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.1-preview.1.25474.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
<PackageVersion Include="Microsoft.AI.Foundry.Local" Version="0.3.0" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.66.0-beta" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.74.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.74.0-beta" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.74.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
@@ -89,11 +89,11 @@
<PackageVersion Include="MSTest" Version="$(MSTestVersion)" />
<PackageVersion Include="MSTest.TestFramework" Version="$(MSTestVersion)" />
<PackageVersion Include="NJsonSchema" Version="11.4.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="NLog" Version="5.2.8" />
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
<PackageVersion Include="OpenAI" Version="2.5.0" />
<PackageVersion Include="OpenAI" Version="2.9.1" />
<PackageVersion Include="Polly.Core" Version="8.6.5" />
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
@@ -113,23 +113,27 @@
<PackageVersion Include="System.Data.OleDb" Version="9.0.10" />
<!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. -->
<PackageVersion Include="System.Data.SqlClient" Version="4.9.0" />
<!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="9.0.10" />
<!-- Package System.Diagnostics.EventLog force-pinned to 10.x to match other Microsoft.Extensions packages;
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.5" />
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->
<PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="9.0.10" />
<PackageVersion Include="System.ClientModel" Version="1.7.0" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.Drawing.Common" Version="9.0.10" />
<PackageVersion Include="System.IO.Abstractions" Version="22.0.13" />
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
<PackageVersion Include="System.Management" Version="9.0.10" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Numerics.Tensors" Version="9.0.11" />
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.4" />
<!-- Including System.Threading.Channels to force version across all projects;
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
<PackageVersion Include="System.Threading.Channels" Version="10.0.4" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />
<PackageVersion Include="System.Runtime.Caching" Version="9.0.10" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.10" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.5" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageVersion Include="ToolGood.Words.Pinyin" Version="3.1.0.3" />
<PackageVersion Include="UnicodeInformation" Version="2.6.0" />

View File

@@ -709,17 +709,19 @@
</Project>
</Folder>
<Folder Name="/modules/PowerDisplay/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.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" />
</Project>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<Project Path="src/modules/powerdisplay/PowerDisplay/PowerDisplay.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/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj">
@@ -789,6 +791,10 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/peek/peek/peek.vcxproj" Id="a1425b53-3d61-4679-8623-e64a0d3d0a48" />
<Project Path="src/modules/peek/Peek.Common.UnitTests/Peek.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/PowerAccent/">
<Project Path="src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj">
@@ -801,6 +807,10 @@
</Project>
<Project Path="src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj" Id="c97d9a5d-206c-454e-997e-009e227d7f02" />
<Project Path="src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj" Id="34a354c5-23c7-4343-916c-c52daf4fc39d" />
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/PowerOCR/">
<Project Path="src/modules/PowerOCR/PowerOCR/PowerOCR.csproj">

View File

@@ -1594,6 +1594,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.PowerRename.exe",
L"PowerToys.ImageResizer.exe",
L"PowerToys.LightSwitchService.exe",
L"PowerToys.PowerDisplay.exe",
L"PowerToys.GcodeThumbnailProvider.exe",
L"PowerToys.BgcodeThumbnailProvider.exe",
L"PowerToys.PdfThumbnailProvider.exe",

View File

@@ -47,6 +47,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs
call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs
call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs
call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs
call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs
call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs
call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs
@@ -123,6 +124,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="KeyboardManager.wxs" />
<Compile Include="Peek.wxs" />
<Compile Include="PowerRename.wxs" />
<Compile Include="PowerDisplay.wxs" />
<Compile Include="DscResources.wxs" />
<Compile Include="RegistryPreview.wxs" />
<Compile Include="Run.wxs" />

View File

@@ -53,6 +53,7 @@
<ComponentGroupRef Id="LightSwitchComponentGroup" />
<ComponentGroupRef Id="PeekComponentGroup" />
<ComponentGroupRef Id="PowerRenameComponentGroup" />
<ComponentGroupRef Id="PowerDisplayComponentGroup" />
<ComponentGroupRef Id="RegistryPreviewComponentGroup" />
<ComponentGroupRef Id="RunComponentGroup" />
<ComponentGroupRef Id="SettingsComponentGroup" />

View File

@@ -212,6 +212,10 @@ Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRo
Generate-FileList -fileDepsJson "" -fileListName PowerRenameAssetsFiles -wxsFilePath $PSScriptRoot\PowerRename.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerRename\"
Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs
#PowerDisplay
Generate-FileList -fileDepsJson "" -fileListName PowerDisplayAssetsFiles -wxsFilePath $PSScriptRoot\PowerDisplay.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerDisplay\"
Generate-FileComponents -fileListName "PowerDisplayAssetsFiles" -wxsFilePath $PSScriptRoot\PowerDisplay.wxs
#RegistryPreview
Generate-FileList -fileDepsJson "" -fileListName RegistryPreviewAssetsFiles -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\RegistryPreview\"
Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs

View File

@@ -150,7 +150,6 @@
<decimal value="0" />
</disabledValue>
</policy>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" />
@@ -161,7 +160,6 @@
<decimal value="0" />
</disabledValue>
</policy>
-->
<policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" />

View File

@@ -249,7 +249,7 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
<!-- <string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string> --><!-- TEMPORARILY_DISABLED: PowerDisplay -->
<string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string>
<string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>

View File

@@ -17,10 +17,7 @@
<PackageReference Include="MSTest" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
<PackageReference Include="System.Diagnostics.EventLog">
<!-- This package is a dependency of Microsoft.Extensions.Logging.EventLog, but we need to set it here so we can exclude the assets, so it doesn't conflict with the 8.0.1 dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<!-- HACK: CmdPal uses CommunityToolkit.Common directly. Align the version. -->
<PackageReference Include="CommunityToolkit.Common" />
</ItemGroup>

View File

@@ -0,0 +1,180 @@
// 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;
using HostsUILib;
using HostsUILib.Helpers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Hosts.Tests
{
[TestClass]
public class ValidationHelperTest
{
[DataTestMethod]
[DataRow("0.0.0.0")]
[DataRow("127.0.0.1")]
[DataRow("192.168.1.1")]
[DataRow("255.255.255.255")]
[DataRow("10.0.0.1")]
[DataRow("172.16.0.1")]
[DataRow("1.2.3.4")]
[DataRow("01.01.01.01")]
[DataRow("0.0.0.1")]
public void ValidIPv4_ValidAddresses_ReturnsTrue(string address)
{
Assert.IsTrue(ValidationHelper.ValidIPv4(address));
}
[DataTestMethod]
[DataRow("256.0.0.0")]
[DataRow("0.256.0.0")]
[DataRow("0.0.256.0")]
[DataRow("0.0.0.256")]
[DataRow("999.999.999.999")]
[DataRow("1.2.3")]
[DataRow("1.2.3.4.5")]
[DataRow("1.2.3.")]
[DataRow(".1.2.3")]
[DataRow("1..2.3")]
[DataRow("abc.def.ghi.jkl")]
[DataRow("192.168.1.1/24")]
[DataRow("192.168.1.1:80")]
[DataRow("192.168.1")]
[DataRow("-1.0.0.0")]
public void ValidIPv4_InvalidAddresses_ReturnsFalse(string address)
{
Assert.IsFalse(ValidationHelper.ValidIPv4(address));
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
[DataRow("\n")]
public void ValidIPv4_NullOrWhitespace_ReturnsFalse(string address)
{
Assert.IsFalse(ValidationHelper.ValidIPv4(address));
}
[DataTestMethod]
[DataRow("::1")]
[DataRow("::")]
[DataRow("2001:0db8:85a3:0000:0000:8a2e:0370:7334")]
[DataRow("2001:db8:85a3:0:0:8a2e:370:7334")]
[DataRow("2001:db8:85a3::8a2e:370:7334")]
[DataRow("fe80::1")]
[DataRow("ff02::1")]
[DataRow("2001:db8::1")]
[DataRow("::ffff:192.168.1.1")]
[DataRow("fe80::1%eth0")]
public void ValidIPv6_ValidAddresses_ReturnsTrue(string address)
{
Assert.IsTrue(ValidationHelper.ValidIPv6(address));
}
[DataTestMethod]
[DataRow("2001:db8:85a3:0:0:8a2e:370:7334:extra")]
[DataRow("gggg::1")]
[DataRow("12345::1")]
[DataRow("192.168.1.1")]
[DataRow("::ffff:999.999.999.999")]
[DataRow("hello")]
[DataRow("2001:db8:85a3::8a2e:370:7334:1234:5678")]
public void ValidIPv6_InvalidAddresses_ReturnsFalse(string address)
{
Assert.IsFalse(ValidationHelper.ValidIPv6(address));
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
public void ValidIPv6_NullOrWhitespace_ReturnsFalse(string address)
{
Assert.IsFalse(ValidationHelper.ValidIPv6(address));
}
[DataTestMethod]
[DataRow("localhost")]
[DataRow("example.com")]
[DataRow("sub.domain.example.com")]
[DataRow("my-host")]
[DataRow("host1 host2")]
[DataRow("host1 host2 host3")]
[DataRow("example.com www.example.com")]
public void ValidHosts_ValidHostnames_ReturnsTrue(string hosts)
{
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
public void ValidHosts_NullOrWhitespace_ReturnsFalse(string hosts)
{
Assert.IsFalse(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
}
[TestMethod]
public void ValidHosts_WithLengthValidation_ExceedsMaxCount_ReturnsFalse()
{
// Create a host string with one more than MaxHostsCount hosts
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount + 1).Select(i => $"h{i}"));
Assert.IsFalse(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
}
[TestMethod]
public void ValidHosts_WithLengthValidation_AtMaxCount_ReturnsTrue()
{
// Create a host string with exactly MaxHostsCount hosts
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount).Select(i => $"h{i}"));
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
}
[TestMethod]
public void ValidHosts_WithLengthValidation_BelowMaxCount_ReturnsTrue()
{
string hosts = "host1 host2 host3";
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
}
[TestMethod]
public void ValidHosts_WithoutLengthValidation_ExceedsMaxCount_ReturnsTrue()
{
// When validateHostsLength is false, exceeding max count should still return true
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount + 1).Select(i => $"h{i}"));
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
}
[TestMethod]
public void ValidHosts_SingleHost_ReturnsTrue()
{
Assert.IsTrue(ValidationHelper.ValidHosts("localhost", validateHostsLength: true));
}
[TestMethod]
public void ValidHosts_InvalidHostname_ReturnsFalse()
{
Assert.IsFalse(ValidationHelper.ValidHosts("host_with!invalid@chars", validateHostsLength: false));
}
[TestMethod]
public void ValidHosts_HostWithSubdomains_ReturnsTrue()
{
Assert.IsTrue(ValidationHelper.ValidHosts("sub.domain.example.com", validateHostsLength: true));
}
[TestMethod]
public void ValidHosts_MultipleValidHosts_WithLengthValidation_ReturnsTrue()
{
Assert.IsTrue(ValidationHelper.ValidHosts("example.com www.example.com api.example.com", validateHostsLength: true));
}
}
}

View File

@@ -0,0 +1,459 @@
// 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.Drawing;
using System.Globalization;
using ColorPicker.Helpers;
using ManagedCommon;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ColorPicker.UnitTests.Helpers
{
/// <summary>
/// Test class to test <see cref="ColorFormatHelper"/> conversion methods not covered
/// by the existing <see cref="ColorConverterTest"/> (which tests HSL and HSV).
/// Covers: CMYK, HSB, HSI, HWB, CIE LAB, CIE XYZ, Oklab, Oklch, sRGB-to-linear, NCol.
/// </summary>
[TestClass]
public class ColorFormatConversionTest
{
[TestMethod]
public void ConvertToCMYK_Black_Returns0_0_0_1()
{
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Cyan);
Assert.AreEqual(0d, result.Magenta);
Assert.AreEqual(0d, result.Yellow);
Assert.AreEqual(1d, result.BlackKey);
}
[TestMethod]
public void ConvertToCMYK_White_Returns0_0_0_0()
{
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(0d, result.Cyan, 0.01);
Assert.AreEqual(0d, result.Magenta, 0.01);
Assert.AreEqual(0d, result.Yellow, 0.01);
Assert.AreEqual(0d, result.BlackKey, 0.01);
}
[TestMethod]
public void ConvertToCMYK_Red_Returns0_1_1_0()
{
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual(0d, result.Cyan, 0.01);
Assert.AreEqual(1d, result.Magenta, 0.01);
Assert.AreEqual(1d, result.Yellow, 0.01);
Assert.AreEqual(0d, result.BlackKey, 0.01);
}
[TestMethod]
public void ConvertToCMYK_Green_Returns1_0_1_0()
{
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 255, 0));
Assert.AreEqual(1d, result.Cyan, 0.01);
Assert.AreEqual(0d, result.Magenta, 0.01);
Assert.AreEqual(1d, result.Yellow, 0.01);
Assert.AreEqual(0d, result.BlackKey, 0.01);
}
[TestMethod]
public void ConvertToCMYK_Blue_Returns1_1_0_0()
{
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 0, 255));
Assert.AreEqual(1d, result.Cyan, 0.01);
Assert.AreEqual(1d, result.Magenta, 0.01);
Assert.AreEqual(0d, result.Yellow, 0.01);
Assert.AreEqual(0d, result.BlackKey, 0.01);
}
[TestMethod]
public void ConvertToCMYK_MidGray_Returns0_0_0_Half()
{
// RGB(128, 128, 128) should give roughly K ≈ 0.498
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 128, 128, 128));
Assert.AreEqual(0d, result.Cyan, 0.01);
Assert.AreEqual(0d, result.Magenta, 0.01);
Assert.AreEqual(0d, result.Yellow, 0.01);
Assert.AreEqual(128d / 255d, result.BlackKey, 0.01);
}
[TestMethod]
public void ConvertToHSB_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Saturation, 0.01);
Assert.AreEqual(0d, result.Brightness, 0.01);
}
[TestMethod]
public void ConvertToHSB_White_Returns0_0_1()
{
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Saturation, 0.01);
Assert.AreEqual(1d, result.Brightness, 0.01);
}
[TestMethod]
public void ConvertToHSB_Red_Returns0_1_1()
{
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(1d, result.Saturation, 0.01);
Assert.AreEqual(1d, result.Brightness, 0.01);
}
[TestMethod]
public void ConvertToHSI_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Saturation, 0.01);
Assert.AreEqual(0d, result.Intensity, 0.01);
}
[TestMethod]
public void ConvertToHSI_White_Returns0_0_1()
{
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Saturation, 0.01);
Assert.AreEqual(1d, result.Intensity, 0.01);
}
[TestMethod]
public void ConvertToHSI_Red_Returns0_1_Third()
{
// Pure red: intensity = (255+0+0)/(3*255) = 1/3
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(1d, result.Saturation, 0.01);
Assert.AreEqual(1d / 3d, result.Intensity, 0.01);
}
[TestMethod]
public void ConvertToHWB_Black_Returns0_0_1()
{
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Whiteness, 0.01);
Assert.AreEqual(1d, result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToHWB_White_Returns0_1_0()
{
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(1d, result.Whiteness, 0.01);
Assert.AreEqual(0d, result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToHWB_Red_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(0d, result.Whiteness, 0.01);
Assert.AreEqual(0d, result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToHWB_MidGray_Returns0_Half_Half()
{
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 128, 128, 128));
Assert.AreEqual(0d, result.Hue, 0.5);
Assert.AreEqual(128d / 255d, result.Whiteness, 0.01);
Assert.AreEqual(1d - (128d / 255d), result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToCIEXYZ_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.X, 0.001);
Assert.AreEqual(0d, result.Y, 0.001);
Assert.AreEqual(0d, result.Z, 0.001);
}
[TestMethod]
public void ConvertToCIEXYZ_White_ReturnsD65Illuminant()
{
// White should be close to D65 illuminant: X≈0.9505, Y≈1.0, Z≈1.089
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(0.9505d, result.X, 0.02);
Assert.AreEqual(1.0d, result.Y, 0.02);
Assert.AreEqual(1.089d, result.Z, 0.02);
}
[TestMethod]
public void ConvertToCIEXYZ_Red_HasExpectedValues()
{
// Pure red: X≈0.4124, Y≈0.2126, Z≈0.0193
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual(0.4124d, result.X, 0.02);
Assert.AreEqual(0.2126d, result.Y, 0.02);
Assert.AreEqual(0.0193d, result.Z, 0.02);
}
[TestMethod]
public void ConvertToCIELAB_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Lightness, 0.5);
Assert.AreEqual(0d, result.ChromaticityA, 0.5);
Assert.AreEqual(0d, result.ChromaticityB, 0.5);
}
[TestMethod]
public void ConvertToCIELAB_White_Returns100_0_0()
{
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(100d, result.Lightness, 1.0);
Assert.AreEqual(0d, result.ChromaticityA, 1.0);
Assert.AreEqual(0d, result.ChromaticityB, 1.0);
}
[TestMethod]
public void ConvertToCIELAB_Red_HasPositiveA()
{
// Red is in the +a* direction
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 255, 0, 0));
Assert.IsTrue(result.ChromaticityA > 0, "Red should have positive a* in CIE LAB");
}
[TestMethod]
public void ConvertToCIELAB_Blue_HasNegativeB()
{
// Blue is in the -b* direction
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 0, 0, 255));
Assert.IsTrue(result.ChromaticityB < 0, "Blue should have negative b* in CIE LAB");
}
[TestMethod]
public void ConvertToOklab_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToOklabColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Lightness, 0.01);
Assert.AreEqual(0d, result.ChromaticityA, 0.01);
Assert.AreEqual(0d, result.ChromaticityB, 0.01);
}
[TestMethod]
public void ConvertToOklab_White_Returns1_0_0()
{
var result = ColorFormatHelper.ConvertToOklabColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(1d, result.Lightness, 0.02);
Assert.AreEqual(0d, result.ChromaticityA, 0.02);
Assert.AreEqual(0d, result.ChromaticityB, 0.02);
}
[TestMethod]
public void ConvertToOklch_Black_Returns0_0_0()
{
var result = ColorFormatHelper.ConvertToOklchColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Lightness, 0.01);
Assert.AreEqual(0d, result.Chroma, 0.01);
}
[TestMethod]
public void ConvertToOklch_White_Returns1_0_Any()
{
var result = ColorFormatHelper.ConvertToOklchColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(1d, result.Lightness, 0.02);
Assert.AreEqual(0d, result.Chroma, 0.02);
// Hue is undefined for achromatic colors, so we don't assert it
}
[TestMethod]
public void ConvertToOklch_Chroma_IsNonNegative()
{
// Chroma should always be non-negative
var colors = new[] { Color.Red, Color.Green, Color.Blue, Color.Yellow, Color.Cyan, Color.Magenta };
foreach (var color in colors)
{
var result = ColorFormatHelper.ConvertToOklchColor(color);
Assert.IsTrue(result.Chroma >= 0, $"Chroma should be non-negative for {color.Name}");
}
}
[TestMethod]
public void ConvertSRGBToLinear_Zero_ReturnsZero()
{
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0, 0, 0);
Assert.AreEqual(0d, result.R, 0.001);
Assert.AreEqual(0d, result.G, 0.001);
Assert.AreEqual(0d, result.B, 0.001);
}
[TestMethod]
public void ConvertSRGBToLinear_One_ReturnsOne()
{
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(1, 1, 1);
Assert.AreEqual(1d, result.R, 0.001);
Assert.AreEqual(1d, result.G, 0.001);
Assert.AreEqual(1d, result.B, 0.001);
}
[TestMethod]
public void ConvertSRGBToLinear_SmallValues_UsesLinearPath()
{
// For small values (≤ 0.04045), the formula is linear: value / 12.92
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0.04, 0.04, 0.04);
Assert.AreEqual(0.04 / 12.92, result.R, 0.001);
}
[TestMethod]
public void ConvertSRGBToLinear_LargeValues_UsesGammaPath()
{
// For larger values, the gamma function is applied
// sRGB 0.5 should map to ~0.214
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0.5, 0.5, 0.5);
Assert.AreEqual(0.214, result.R, 0.01);
}
[TestMethod]
public void ConvertToNaturalColor_Red_ReturnsR0()
{
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 255, 0, 0));
Assert.AreEqual("R0", result.Hue);
Assert.AreEqual(0d, result.Whiteness, 0.01);
Assert.AreEqual(0d, result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToNaturalColor_Green_HueStartsWithG()
{
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 128, 0));
Assert.IsTrue(result.Hue.StartsWith('G'), $"Green should start with G, got {result.Hue}");
}
[TestMethod]
public void ConvertToNaturalColor_Blue_HueStartsWithB()
{
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 0, 255));
Assert.IsTrue(result.Hue.StartsWith('B'), $"Blue should start with B, got {result.Hue}");
}
[TestMethod]
public void ConvertToNaturalColor_Black_Returns0_0_100()
{
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 0, 0));
Assert.AreEqual(0d, result.Whiteness, 0.01);
Assert.AreEqual(1d, result.Blackness, 0.01);
}
[TestMethod]
public void ConvertToNaturalColor_White_Returns0_100_0()
{
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 255, 255, 255));
Assert.AreEqual(1d, result.Whiteness, 0.01);
Assert.AreEqual(0d, result.Blackness, 0.01);
}
[TestMethod]
[DataRow("CMYK", "cmyk(0%, 100%, 100%, 0%)")]
[DataRow("HEX", "ff0000")]
[DataRow("RGB", "rgb(255, 0, 0)")]
[DataRow("HSL", "hsl(0, 100%, 50%)")]
[DataRow("HSV", "hsv(0, 100%, 100%)")]
[DataRow("HSB", "hsb(0, 100%, 100%)")]
[DataRow("HSI", "hsi(0, 100%, 33%)")]
[DataRow("HWB", "hwb(0, 0%, 0%)")]
[DataRow("Decimal", "255")]
[DataRow("HEX Int", "0xFFFF0000")]
[DataRow("VEC4", "(1f, 0f, 0f, 1f)")]
public void GetStringRepresentation_Red(string type, string expected)
{
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), type, ColorFormatHelper.GetDefaultFormat(type));
Assert.AreEqual(expected, result);
}
[TestMethod]
[DataRow("CMYK", "cmyk(0%, 0%, 0%, 0%)")]
[DataRow("HEX", "ffffff")]
[DataRow("RGB", "rgb(255, 255, 255)")]
[DataRow("HSL", "hsl(0, 0%, 100%)")]
[DataRow("HSV", "hsv(0, 0%, 100%)")]
[DataRow("Decimal", "16777215")]
[DataRow("HEX Int", "0xFFFFFFFF")]
[DataRow("VEC4", "(1f, 1f, 1f, 1f)")]
public void GetStringRepresentation_White(string type, string expected)
{
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 255, 255), type, ColorFormatHelper.GetDefaultFormat(type));
Assert.AreEqual(expected, result);
}
[TestMethod]
[DataRow("CMYK", "cmyk(100%, 0%, 100%, 0%)")]
[DataRow("HEX", "00ff00")]
[DataRow("RGB", "rgb(0, 255, 0)")]
[DataRow("HSL", "hsl(120, 100%, 50%)")]
[DataRow("HSV", "hsv(120, 100%, 100%)")]
[DataRow("Decimal", "65280")]
[DataRow("HEX Int", "0xFF00FF00")]
public void GetStringRepresentation_Green(string type, string expected)
{
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 0, 255, 0), type, ColorFormatHelper.GetDefaultFormat(type));
Assert.AreEqual(expected, result);
}
[TestMethod]
[DataRow("CMYK", "cmyk(100%, 100%, 0%, 0%)")]
[DataRow("HEX", "0000ff")]
[DataRow("RGB", "rgb(0, 0, 255)")]
[DataRow("HSL", "hsl(240, 100%, 50%)")]
[DataRow("HSV", "hsv(240, 100%, 100%)")]
[DataRow("Decimal", "16711680")]
[DataRow("HEX Int", "0xFF0000FF")]
public void GetStringRepresentation_Blue(string type, string expected)
{
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 0, 0, 255), type, ColorFormatHelper.GetDefaultFormat(type));
Assert.AreEqual(expected, result);
}
[TestMethod]
public void GetStringRepresentation_EmptyFormat_ReturnsHex()
{
// When colorFormat is null or empty, should return hex
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), "RGB", string.Empty);
Assert.AreEqual("ff0000", result);
}
[TestMethod]
public void GetStringRepresentation_NullFormat_ReturnsHex()
{
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), "RGB", null);
Assert.AreEqual("ff0000", result);
}
[TestMethod]
[DataRow("RGB")]
[DataRow("HEX")]
[DataRow("CMYK")]
[DataRow("HSL")]
[DataRow("HSV")]
[DataRow("HSB")]
[DataRow("HSI")]
[DataRow("HWB")]
[DataRow("NCol")]
[DataRow("CIEXYZ")]
[DataRow("CIELAB")]
[DataRow("Oklab")]
[DataRow("Oklch")]
[DataRow("VEC4")]
[DataRow("Decimal")]
[DataRow("HEX Int")]
public void GetDefaultFormat_KnownTypes_ReturnsNonEmptyString(string formatName)
{
var result = ColorFormatHelper.GetDefaultFormat(formatName);
Assert.IsFalse(string.IsNullOrEmpty(result), $"Default format for {formatName} should not be empty");
}
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace KeyboardManagerEditorUI.Controls
{
public class KeyChangedEventArgs : EventArgs
{
public string OldKeyName { get; }
public string NewKeyName { get; }
public int NewKeyCode { get; }
public KeyChangedEventArgs(string oldKeyName, string newKeyName, int newKeyCode)
{
OldKeyName = oldKeyName;
NewKeyName = newKeyName;
NewKeyCode = newKeyCode;
}
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="KeyboardManagerEditorUI.Controls.KeyDropDownButton"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
xmlns:commoncontrols="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">
<DropDownButton
x:Name="KeyButton"
MinWidth="48"
MinHeight="36"
Padding="8"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
CornerRadius="{StaticResource OverlayCornerRadius}"
FontSize="16"
Style="{StaticResource DefaultKeyVisualDropDownButtonStyle}">
<DropDownButton.Flyout>
<Flyout
x:Name="KeyListFlyout"
Closed="KeyListFlyout_Closed"
Opening="KeyListFlyout_Opening"
Placement="Bottom">
<ListView
x:Name="KeyListView"
MinWidth="200"
MaxHeight="320"
IsItemClickEnabled="True"
ItemClick="KeyListView_ItemClick"
SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</DropDownButton.Flyout>
<commoncontrols:KeyCharPresenter x:Name="KeyNamePresenter" Content="{x:Bind KeyName, Mode=OneWay}" />
</DropDownButton>
</UserControl>

View File

@@ -0,0 +1,137 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using KeyboardManagerEditorUI.Interop;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace KeyboardManagerEditorUI.Controls
{
public sealed partial class KeyDropDownButton : UserControl
{
private static List<KeyNameEntry>? _cachedKeyList;
private static List<KeyNameEntry>? _cachedShortcutKeyList;
public static readonly DependencyProperty KeyNameProperty =
DependencyProperty.Register(
nameof(KeyName),
typeof(string),
typeof(KeyDropDownButton),
new PropertyMetadata(string.Empty));
public static readonly DependencyProperty IsShortcutProperty =
DependencyProperty.Register(
nameof(IsShortcut),
typeof(bool),
typeof(KeyDropDownButton),
new PropertyMetadata(true));
public static readonly DependencyProperty UseAccentStyleProperty =
DependencyProperty.Register(
nameof(UseAccentStyle),
typeof(bool),
typeof(KeyDropDownButton),
new PropertyMetadata(false));
public string KeyName
{
get => (string)GetValue(KeyNameProperty);
set => SetValue(KeyNameProperty, value);
}
public bool IsShortcut
{
get => (bool)GetValue(IsShortcutProperty);
set => SetValue(IsShortcutProperty, value);
}
public bool UseAccentStyle
{
get => (bool)GetValue(UseAccentStyleProperty);
set => SetValue(UseAccentStyleProperty, value);
}
public event EventHandler<KeyChangedEventArgs>? KeyChanged;
public KeyDropDownButton()
{
this.InitializeComponent();
this.Loaded += (_, _) =>
{
if (UseAccentStyle)
{
KeyButton.Style = (Style)Application.Current.Resources["AccentKeyVisualDropDownButtonStyle"];
}
};
}
private void KeyListView_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is KeyNameEntry entry)
{
string oldKeyName = KeyName;
KeyListFlyout.Hide();
KeyChanged?.Invoke(this, new KeyChangedEventArgs(oldKeyName, entry.DisplayName, entry.KeyCode));
}
}
private void KeyListFlyout_Closed(object sender, object e)
{
// Clear selection when flyout closes
KeyListView.SelectedItem = null;
}
private void KeyListFlyout_Opening(object sender, object e)
{
RefreshKeyList();
}
private List<KeyNameEntry> GetKeyList()
{
bool isShortcut = IsShortcut;
ref var cached = ref (isShortcut ? ref _cachedShortcutKeyList : ref _cachedKeyList);
if (cached == null)
{
try
{
using (var service = new KeyboardMappingService())
{
var list = service.GetKeyboardKeysList(isShortcut);
// Filter out the synthetic "None" entry (keycode 0) that the native layer
// injects for shortcut lists; selecting it would store an invalid key code.
cached = list.Where(e => e.KeyCode != 0).ToList();
}
}
catch
{
cached = new List<KeyNameEntry>();
}
}
return cached;
}
internal void RefreshKeyList()
{
KeyListView.ItemsSource = GetKeyList();
// Scroll to current key if possible
var list = GetKeyList();
for (int i = 0; i < list.Count; i++)
{
if (string.Equals(list[i].DisplayName, KeyName, StringComparison.Ordinal))
{
KeyListView.SelectedIndex = i;
KeyListView.ScrollIntoView(list[i]);
break;
}
}
}
}
}

View File

@@ -6,6 +6,7 @@
xmlns:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:KeyboardManagerEditorUI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
@@ -80,28 +81,29 @@
Style="{StaticResource CustomShortcutToggleButtonStyle}"
Unchecked="TriggerKeyToggleBtn_Unchecked">
<ToggleButton.Content>
<ItemsControl x:Name="TriggerKeys">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WrapPanel
HorizontalSpacing="4"
Orientation="Horizontal"
VerticalSpacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<commoncontrols:KeyVisual
Padding="8"
Background="{ThemeResource ControlFillColorDefaultBrush}"
BorderThickness="1"
Content="{Binding}"
CornerRadius="{StaticResource OverlayCornerRadius}"
FontSize="16"
Style="{StaticResource DefaultKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid>
<TextBlock
x:Name="TriggerKeyPlaceholder"
x:Uid="TriggerKeyPlaceholder"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorDisabledBrush}" />
<ItemsControl x:Name="TriggerKeys" IsTabStop="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WrapPanel
HorizontalSpacing="4"
Orientation="Horizontal"
VerticalSpacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<local:KeyDropDownButton KeyName="{Binding}" Loaded="TriggerKeyDropDown_Loaded" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ToggleButton.Content>
</ToggleButton>
<CheckBox
@@ -204,6 +206,12 @@
<TextBlock x:Uid="ActionType_OpenApp_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem x:Uid="ActionType_Disable" Tag="Disable">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE711;" />
<TextBlock x:Uid="ActionType_Disable_Text" />
</StackPanel>
</ComboBoxItem>
<!--
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -235,29 +243,32 @@
Style="{StaticResource CustomShortcutToggleButtonStyle}"
Unchecked="ActionKeyToggleBtn_Unchecked">
<ToggleButton.Content>
<ItemsControl x:Name="ActionKeys">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WrapPanel
HorizontalSpacing="4"
Orientation="Horizontal"
VerticalSpacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<commoncontrols:KeyVisual
Padding="8"
Background="{ThemeResource CustomAccentBackgroundBrush}"
BorderThickness="0"
Content="{Binding}"
CornerRadius="{StaticResource OverlayCornerRadius}"
FontSize="16"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Style="{StaticResource DefaultKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid>
<TextBlock
x:Name="ActionKeyPlaceholder"
x:Uid="ActionKeyPlaceholder"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorDisabledBrush}" />
<ItemsControl x:Name="ActionKeys" IsTabStop="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<controls:WrapPanel
HorizontalSpacing="4"
Orientation="Horizontal"
VerticalSpacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<local:KeyDropDownButton
KeyName="{Binding}"
Loaded="ActionKeyDropDown_Loaded"
UseAccentStyle="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ToggleButton.Content>
</ToggleButton>
</tkcontrols:Case>
@@ -288,7 +299,7 @@
<!-- Open App Action -->
<tkcontrols:Case Value="OpenApp">
<StackPanel Orientation="Vertical" Spacing="16">
<Grid ColumnSpacing="8">
<Grid ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -307,13 +318,17 @@
Click="ProgramPathSelectButton_Click"
Content="{ui:FontIcon Glyph=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="ProgramPathSelectButtonTooltip" />
</ToolTipService.ToolTip>
</Button>
</Grid>
<TextBox
x:Name="ProgramArgsInput"
x:Uid="ProgramArgsInput"
GotFocus="ProgramArgsInput_GotFocus" />
<Grid ColumnSpacing="8">
<Grid ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -331,7 +346,11 @@
Click="StartInSelectButton_Click"
Content="{ui:FontIcon Glyph=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="StartInSelectButtonTooltip" />
</ToolTipService.ToolTip>
</Button>
</Grid>
<ComboBox
x:Name="ElevationComboBox"
@@ -372,6 +391,13 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</tkcontrols:Case>
<!-- Disable Action -->
<tkcontrols:Case Value="Disable">
<TextBlock
x:Uid="DisableDescription"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</StackPanel>
<!-- Validation InfoBar spanning all columns -->

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Interop;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage;
@@ -77,6 +78,7 @@ namespace KeyboardManagerEditorUI.Controls
OpenUrl,
OpenApp,
MouseClick,
Disable,
}
/// <summary>
@@ -129,6 +131,7 @@ namespace KeyboardManagerEditorUI.Controls
"OpenUrl" => ActionType.OpenUrl,
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
_ => ActionType.KeyOrShortcut,
};
}
@@ -148,8 +151,17 @@ namespace KeyboardManagerEditorUI.Controls
TriggerKeys.ItemsSource = _triggerKeys;
ActionKeys.ItemsSource = _actionKeys;
_triggerKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged();
_actionKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged();
_triggerKeys.CollectionChanged += (_, _) =>
{
UpdatePlaceholderVisibility();
RaiseValidationStateChanged();
};
_actionKeys.CollectionChanged += (_, _) =>
{
UpdatePlaceholderVisibility();
RaiseValidationStateChanged();
};
this.Unloaded += UnifiedMappingControl_Unloaded;
}
@@ -209,6 +221,9 @@ namespace KeyboardManagerEditorUI.Controls
ActionKeyToggleBtn.IsChecked = false;
}
// Disable dropdowns during recording
SetDropDownsEnabled(TriggerKeys, false);
KeyboardHookHelper.Instance.ActivateHook(this);
}
}
@@ -219,6 +234,8 @@ namespace KeyboardManagerEditorUI.Controls
{
CleanupKeyboardHook();
}
SetDropDownsEnabled(TriggerKeys, true);
}
#endregion
@@ -262,6 +279,9 @@ namespace KeyboardManagerEditorUI.Controls
TriggerKeyToggleBtn.IsChecked = false;
}
// Disable dropdowns during recording
SetDropDownsEnabled(ActionKeys, false);
KeyboardHookHelper.Instance.ActivateHook(this);
}
}
@@ -272,6 +292,238 @@ namespace KeyboardManagerEditorUI.Controls
{
CleanupKeyboardHook();
}
SetDropDownsEnabled(ActionKeys, true);
}
#endregion
#region Key Dropdown Handling
private void TriggerKeyDropDown_Loaded(object sender, RoutedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
// Ensure we do not accumulate multiple subscriptions when Loaded fires repeatedly.
dropDown.KeyChanged -= TriggerKeyDropDown_KeyChanged;
dropDown.KeyChanged += TriggerKeyDropDown_KeyChanged;
// Use a named Unloaded handler so we can detach it and avoid accumulating handlers.
dropDown.Unloaded -= TriggerKeyDropDown_Unloaded;
dropDown.Unloaded += TriggerKeyDropDown_Unloaded;
}
}
private void TriggerKeyDropDown_Unloaded(object sender, RoutedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
dropDown.KeyChanged -= TriggerKeyDropDown_KeyChanged;
dropDown.Unloaded -= TriggerKeyDropDown_Unloaded;
}
}
private void ActionKeyDropDown_Loaded(object sender, RoutedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
// Ensure we do not accumulate multiple subscriptions when Loaded fires repeatedly.
dropDown.KeyChanged -= ActionKeyDropDown_KeyChanged;
dropDown.KeyChanged += ActionKeyDropDown_KeyChanged;
// Use a named Unloaded handler so we can detach it and avoid accumulating handlers.
dropDown.Unloaded -= ActionKeyDropDown_Unloaded;
dropDown.Unloaded += ActionKeyDropDown_Unloaded;
}
}
private void ActionKeyDropDown_Unloaded(object sender, RoutedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
dropDown.KeyChanged -= ActionKeyDropDown_KeyChanged;
dropDown.Unloaded -= ActionKeyDropDown_Unloaded;
}
}
private void TriggerKeyDropDown_KeyChanged(object? sender, KeyChangedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
int index = GetDropDownIndex(TriggerKeys, dropDown);
if (index >= 0 && index < _triggerKeys.Count)
{
// KeyCode 0 means "None" — treat as invalid selection and do not update.
if (e.NewKeyCode == 0)
{
RevertKeySelection(_triggerKeys, index);
return;
}
string? validationError = ValidateDropDownSelection(_triggerKeys, index, e.NewKeyCode, e.NewKeyName);
if (validationError != null)
{
RevertKeySelection(_triggerKeys, index);
ShowNotificationTip(validationError);
return;
}
_triggerKeys[index] = e.NewKeyName;
HandleAutoGrowShrink(_triggerKeys, index, e.NewKeyCode);
}
}
}
private void ActionKeyDropDown_KeyChanged(object? sender, KeyChangedEventArgs e)
{
if (sender is KeyDropDownButton dropDown)
{
int index = GetDropDownIndex(ActionKeys, dropDown);
if (index >= 0 && index < _actionKeys.Count)
{
// KeyCode 0 means "None" — treat as invalid selection and do not update.
if (e.NewKeyCode == 0)
{
RevertKeySelection(_actionKeys, index);
return;
}
string? validationError = ValidateDropDownSelection(_actionKeys, index, e.NewKeyCode, e.NewKeyName);
if (validationError != null)
{
RevertKeySelection(_actionKeys, index);
ShowNotificationTip(validationError);
return;
}
_actionKeys[index] = e.NewKeyName;
HandleAutoGrowShrink(_actionKeys, index, e.NewKeyCode);
}
}
}
/// <summary>
/// Reverts a key selection by re-inserting the current value via the bound ObservableCollection,
/// which forces the binding to refresh without breaking the binding expression.
/// </summary>
private static void RevertKeySelection(ObservableCollection<string> keys, int index)
{
string current = keys[index];
keys.RemoveAt(index);
keys.Insert(index, current);
}
private static int GetDropDownIndex(ItemsControl itemsControl, KeyDropDownButton dropDown)
{
for (int i = 0; i < itemsControl.Items.Count; i++)
{
var container = itemsControl.ContainerFromIndex(i) as ContentPresenter;
if (container != null)
{
// Walk the visual tree to find the KeyDropDownButton
var child = FindChild<KeyDropDownButton>(container);
if (child == dropDown)
{
return i;
}
}
}
return -1;
}
private static T? FindChild<T>(Microsoft.UI.Xaml.DependencyObject parent)
where T : Microsoft.UI.Xaml.DependencyObject
{
int childCount = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < childCount; i++)
{
var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, i);
if (child is T result)
{
return result;
}
var descendant = FindChild<T>(child);
if (descendant != null)
{
return descendant;
}
}
return null;
}
/// <summary>
/// Validates a key selection from a dropdown before it is applied.
/// Returns null if valid, or an error message string if invalid.
/// </summary>
private static string? ValidateDropDownSelection(ObservableCollection<string> keys, int changedIndex, int newKeyCode, string newKeyName)
{
const int maxShortcutSize = 5;
// KeyType: 0=Win, 1=Ctrl, 2=Alt, 3=Shift, 4=Action
int newKeyType = KeyboardManagerInterop.GetKeyType(newKeyCode);
bool isModifier = newKeyType < 4;
// Count only non-empty (real) entries to determine effective shortcut size.
int nonEmptyCount = keys.Count(k => !string.IsNullOrEmpty(k));
// Rule: action key at position 0 in multi-key shortcut (shortcut must start with modifier)
if (!isModifier && changedIndex == 0 && nonEmptyCount > 1)
{
return ResourceHelper.GetString("Warning_ShortcutStartWithModifier");
}
// Rule: no repeated modifier types (skip empty placeholder slots)
if (isModifier)
{
for (int i = 0; i < keys.Count; i++)
{
if (i == changedIndex || string.IsNullOrEmpty(keys[i]))
{
continue;
}
int existingKeyCode = KeyboardManagerInterop.GetKeyCodeFromName(keys[i]);
int existingKeyType = KeyboardManagerInterop.GetKeyType(existingKeyCode);
if (existingKeyType == newKeyType)
{
return ResourceHelper.GetString("Warning_RepeatedModifier");
}
}
}
// Rule: modifier at last position when already at max size
if (isModifier && changedIndex == keys.Count - 1 && nonEmptyCount >= maxShortcutSize)
{
return ResourceHelper.GetString("Warning_MaxShortcutSize");
}
return null;
}
private void HandleAutoGrowShrink(ObservableCollection<string> keys, int changedIndex, int newKeyCode)
{
const int maxShortcutSize = 5;
int keyType = KeyboardManagerInterop.GetKeyType(newKeyCode);
bool isModifier = keyType < 4;
if (isModifier && changedIndex == keys.Count - 1 && keys.Count < maxShortcutSize)
{
// Modifier at last position — auto-grow: add placeholder for next key
keys.Add(string.Empty);
}
else if (!isModifier)
{
// Action key — trim any trailing entries after this one
while (keys.Count > changedIndex + 1)
{
keys.RemoveAt(keys.Count - 1);
}
}
}
#endregion
@@ -453,7 +705,7 @@ namespace KeyboardManagerEditorUI.Controls
public void OnInputLimitReached()
{
ShowNotificationTip("Shortcuts can only have up to 4 modifier keys");
ShowNotificationTip(ResourceHelper.GetString("Warning_InputLimitReached"));
}
#endregion
@@ -463,12 +715,12 @@ namespace KeyboardManagerEditorUI.Controls
/// <summary>
/// Gets the trigger keys.
/// </summary>
public List<string> GetTriggerKeys() => _triggerKeys.ToList();
public List<string> GetTriggerKeys() => _triggerKeys.Where(k => !string.IsNullOrEmpty(k)).ToList();
/// <summary>
/// Gets the action keys (for Key/Shortcut action type).
/// </summary>
public List<string> GetActionKeys() => _actionKeys.ToList();
public List<string> GetActionKeys() => _actionKeys.Where(k => !string.IsNullOrEmpty(k)).ToList();
/// <summary>
/// Gets the selected mouse trigger.
@@ -567,6 +819,7 @@ namespace KeyboardManagerEditorUI.Controls
ActionType.Text => !string.IsNullOrEmpty(TextContentBox?.Text),
ActionType.OpenUrl => !string.IsNullOrWhiteSpace(UrlPathInput?.Text),
ActionType.OpenApp => !string.IsNullOrWhiteSpace(ProgramPathInput?.Text),
ActionType.Disable => true,
_ => false,
};
}
@@ -612,18 +865,28 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public void SetActionType(ActionType actionType)
{
int index = actionType switch
if (ActionTypeComboBox == null)
{
ActionType.Text => 1,
ActionType.OpenUrl => 2,
ActionType.OpenApp => 3,
ActionType.MouseClick => 4,
_ => 0,
return;
}
string tag = actionType switch
{
ActionType.Text => "Text",
ActionType.OpenUrl => "OpenUrl",
ActionType.OpenApp => "OpenApp",
ActionType.Disable => "Disable",
ActionType.MouseClick => "MouseClick",
_ => "KeyOrShortcut",
};
if (ActionTypeComboBox != null)
foreach (var item in ActionTypeComboBox.Items)
{
ActionTypeComboBox.SelectedIndex = index;
if (item is ComboBoxItem comboBoxItem && comboBoxItem.Tag is string itemTag && itemTag == tag)
{
ActionTypeComboBox.SelectedItem = comboBoxItem;
return;
}
}
}
@@ -748,11 +1011,44 @@ namespace KeyboardManagerEditorUI.Controls
}
}
private static void SetDropDownsEnabled(ItemsControl itemsControl, bool enabled)
{
for (int i = 0; i < itemsControl.Items.Count; i++)
{
var container = itemsControl.ContainerFromIndex(i) as ContentPresenter;
if (container != null)
{
var dropDown = FindChild<KeyDropDownButton>(container);
if (dropDown != null)
{
dropDown.IsEnabled = enabled;
}
}
}
}
private void CleanupKeyboardHook()
{
KeyboardHookHelper.Instance.CleanupHook();
}
private void UpdatePlaceholderVisibility()
{
if (TriggerKeyPlaceholder != null)
{
TriggerKeyPlaceholder.Visibility = _triggerKeys.Count == 0
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
}
if (ActionKeyPlaceholder != null)
{
ActionKeyPlaceholder.Visibility = _actionKeys.Count == 0
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
}
}
private void RaiseValidationStateChanged()
{
UpdateInlineValidation();
@@ -910,7 +1206,7 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public void ShowNotificationTip(string message)
{
ShowValidationMessage("Warning", message, InfoBarSeverity.Warning);
ShowValidationMessage(ResourceHelper.GetString("Warning_Title"), message, InfoBarSeverity.Warning);
}
/// <summary>
@@ -932,7 +1228,7 @@ namespace KeyboardManagerEditorUI.Controls
}
else
{
ShowValidationError("Validation Error", "An unknown validation error occurred.");
ShowValidationError(ResourceHelper.GetString("Error_UnknownValidation_Title"), ResourceHelper.GetString("Error_UnknownValidation_Message"));
}
}

View File

@@ -8,6 +8,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using KeyboardManagerEditorUI.Interop;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Windows.System;
@@ -19,7 +20,7 @@ namespace KeyboardManagerEditorUI.Helpers
public static KeyboardHookHelper Instance => _instance ??= new KeyboardHookHelper();
private KeyboardMappingService _mappingService;
private KeyboardMappingService? _mappingService;
private HotkeySettingsControlHook? _keyboardHook;
@@ -34,7 +35,14 @@ namespace KeyboardManagerEditorUI.Helpers
// Singleton to make sure only one instance of the hook is active
private KeyboardHookHelper()
{
_mappingService = new KeyboardMappingService();
try
{
_mappingService = new KeyboardMappingService();
}
catch (Exception ex)
{
Logger.LogWarning($"Native KBM library unavailable for keyboard hook: {ex.Message}");
}
}
public void ActivateHook(IKeyboardHookTarget target)
@@ -46,11 +54,18 @@ namespace KeyboardManagerEditorUI.Helpers
_currentlyPressedKeys.Clear();
_keyPressOrder.Clear();
_keyboardHook = new HotkeySettingsControlHook(
KeyDown,
KeyUp,
() => true,
(key, extraInfo) => true);
try
{
_keyboardHook = new HotkeySettingsControlHook(
KeyDown,
KeyUp,
() => true,
(key, extraInfo) => true);
}
catch (Exception ex)
{
Logger.LogWarning($"Keyboard hook unavailable: {ex.Message}");
}
}
public void CleanupHook()
@@ -110,6 +125,17 @@ namespace KeyboardManagerEditorUI.Helpers
{
_keyPressOrder.Add(virtualKey);
// When building chords, cap at 2 action keys: if a third action key arrives,
// remove the oldest (shift behavior matching old editor).
if (_activeTarget.AllowChords && !RemappingHelper.IsModifierKey(virtualKey))
{
var actionKeysInOrder = _keyPressOrder.Where(k => !RemappingHelper.IsModifierKey(k)).ToList();
if (actionKeysInOrder.Count > 2)
{
_keyPressOrder.Remove(actionKeysInOrder[0]);
}
}
// Notify the target page
_activeTarget.OnKeyDown(virtualKey, GetFormattedKeyList());
}
@@ -126,7 +152,13 @@ namespace KeyboardManagerEditorUI.Helpers
if (_currentlyPressedKeys.Remove(virtualKey))
{
_keyPressOrder.Remove(virtualKey);
// When building chords, preserve released action keys in _keyPressOrder
// so the next action key press is recognized as the chord's second key.
// Only modifier releases clear from _keyPressOrder (matching old editor behavior).
if (!_activeTarget.AllowChords || RemappingHelper.IsModifierKey(virtualKey))
{
_keyPressOrder.Remove(virtualKey);
}
_activeTarget.OnKeyUp(virtualKey, GetFormattedKeyList());
}
@@ -140,6 +172,11 @@ namespace KeyboardManagerEditorUI.Helpers
return new List<string>();
}
if (_mappingService is null)
{
return new List<string>();
}
List<string> keyList = new List<string>();
List<VirtualKey> modifierKeys = new List<VirtualKey>();
VirtualKey? actionKey = null;
@@ -147,9 +184,15 @@ namespace KeyboardManagerEditorUI.Helpers
foreach (var key in _keyPressOrder)
{
// For modifiers, only include if currently pressed.
// For action keys when building chords, also include released keys
// so the chord's first key stays visible while waiting for the second.
if (!_currentlyPressedKeys.Contains(key))
{
continue;
if (RemappingHelper.IsModifierKey(key) || !_activeTarget.AllowChords)
{
continue;
}
}
if (RemappingHelper.IsModifierKey(key))
@@ -189,6 +232,11 @@ namespace KeyboardManagerEditorUI.Helpers
private void RemoveExistingModifierVariant(VirtualKey key)
{
if (_mappingService is null)
{
return;
}
KeyType keyType = (KeyType)KeyboardManagerInterop.GetKeyType((int)key);
// No need to remove if the key is an action key

View File

@@ -0,0 +1,19 @@
// 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.Windows.ApplicationModel.Resources;
namespace KeyboardManagerEditorUI.Helpers
{
public static class ResourceHelper
{
private static ResourceLoader? _resourceLoader;
public static string GetString(string resourceKey)
{
_resourceLoader ??= new ResourceLoader();
return _resourceLoader.GetString(resourceKey);
}
}
}

View File

@@ -0,0 +1,52 @@
// 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;
namespace KeyboardManagerEditorUI.Helpers
{
public static class ServiceStatusHelper
{
private const string KeyboardManagerEngineProcessName = "PowerToys.KeyboardManagerEngine";
public static bool IsKeyboardManagerServiceRunning()
{
try
{
var processes = Process.GetProcessesByName(KeyboardManagerEngineProcessName);
bool running = processes.Length > 0;
foreach (var process in processes)
{
process.Dispose();
}
return running;
}
catch (Exception)
{
return false;
}
}
public static bool IsPowerToysRunning()
{
try
{
var processes = Process.GetProcessesByName("PowerToys");
bool running = processes.Length > 0;
foreach (var process in processes)
{
process.Dispose();
}
return running;
}
catch (Exception)
{
return false;
}
}
}
}

View File

@@ -19,6 +19,7 @@ namespace KeyboardManagerEditorUI.Helpers
EmptyAppName,
IllegalShortcut,
DuplicateMapping,
ConflictingModifier,
SelfMapping,
EmptyTargetText,
EmptyUrl,

View File

@@ -16,17 +16,18 @@ namespace KeyboardManagerEditorUI.Helpers
{
public static readonly Dictionary<ValidationErrorType, (string Title, string Message)> ValidationMessages = new()
{
{ ValidationErrorType.EmptyOriginalKeys, ("Missing Original Keys", "Please enter at least one original key to create a remapping.") },
{ ValidationErrorType.EmptyRemappedKeys, ("Missing Target Keys", "Please enter at least one target key to create a remapping.") },
{ ValidationErrorType.ModifierOnly, ("Invalid Shortcut", "Shortcuts must contain at least one action key in addition to modifier keys (Ctrl, Alt, Shift, Win).") },
{ ValidationErrorType.EmptyAppName, ("Missing Application Name", "You've selected app-specific remapping but haven't specified an application name. Please enter the application name.") },
{ ValidationErrorType.IllegalShortcut, ("Reserved System Shortcut", "Win+L and Ctrl+Alt+Delete are reserved system shortcuts and cannot be remapped.") },
{ ValidationErrorType.DuplicateMapping, ("Duplicate Remapping", "This key or shortcut is already remapped.") },
{ ValidationErrorType.SelfMapping, ("Invalid Remapping", "A key or shortcut cannot be remapped to itself. Please choose a different target.") },
{ ValidationErrorType.EmptyTargetText, ("Missing Target Text", "Please enter the text to be inserted when the shortcut is pressed.") },
{ ValidationErrorType.EmptyUrl, ("Missing URL", "Please enter the URL to open when the shortcut is pressed.") },
{ ValidationErrorType.EmptyProgramPath, ("Missing Program Path", "Please enter the program path to launch when the shortcut is pressed.") },
{ ValidationErrorType.OneKeyMapping, ("Invalid Remapping", "A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.") },
{ ValidationErrorType.EmptyOriginalKeys, (ResourceHelper.GetString("Validation_EmptyOriginalKeys_Title"), ResourceHelper.GetString("Validation_EmptyOriginalKeys_Message")) },
{ ValidationErrorType.EmptyRemappedKeys, (ResourceHelper.GetString("Validation_EmptyRemappedKeys_Title"), ResourceHelper.GetString("Validation_EmptyRemappedKeys_Message")) },
{ ValidationErrorType.ModifierOnly, (ResourceHelper.GetString("Validation_ModifierOnly_Title"), ResourceHelper.GetString("Validation_ModifierOnly_Message")) },
{ ValidationErrorType.EmptyAppName, (ResourceHelper.GetString("Validation_EmptyAppName_Title"), ResourceHelper.GetString("Validation_EmptyAppName_Message")) },
{ ValidationErrorType.IllegalShortcut, (ResourceHelper.GetString("Validation_IllegalShortcut_Title"), ResourceHelper.GetString("Validation_IllegalShortcut_Message")) },
{ ValidationErrorType.DuplicateMapping, (ResourceHelper.GetString("Validation_DuplicateMapping_Title"), ResourceHelper.GetString("Validation_DuplicateMapping_Message")) },
{ ValidationErrorType.ConflictingModifier, (ResourceHelper.GetString("Validation_ConflictingModifier_Title"), ResourceHelper.GetString("Validation_ConflictingModifier_Message")) },
{ ValidationErrorType.SelfMapping, (ResourceHelper.GetString("Validation_SelfMapping_Title"), ResourceHelper.GetString("Validation_SelfMapping_Message")) },
{ ValidationErrorType.EmptyTargetText, (ResourceHelper.GetString("Validation_EmptyTargetText_Title"), ResourceHelper.GetString("Validation_EmptyTargetText_Message")) },
{ ValidationErrorType.EmptyUrl, (ResourceHelper.GetString("Validation_EmptyUrl_Title"), ResourceHelper.GetString("Validation_EmptyUrl_Message")) },
{ ValidationErrorType.EmptyProgramPath, (ResourceHelper.GetString("Validation_EmptyProgramPath_Title"), ResourceHelper.GetString("Validation_EmptyProgramPath_Message")) },
{ ValidationErrorType.OneKeyMapping, (ResourceHelper.GetString("Validation_OneKeyMapping_Title"), ResourceHelper.GetString("Validation_OneKeyMapping_Message")) },
};
public static ValidationErrorType ValidateKeyMapping(
@@ -69,6 +70,11 @@ namespace KeyboardManagerEditorUI.Helpers
return ValidationErrorType.DuplicateMapping;
}
if (originalKeys.Count == 1 && HasConflictingModifierMapping(originalKeys[0], isEditMode, mappingService))
{
return ValidationErrorType.ConflictingModifier;
}
if (IsSelfMapping(originalKeys, remappedKeys, mappingService))
{
return ValidationErrorType.SelfMapping;
@@ -77,6 +83,47 @@ namespace KeyboardManagerEditorUI.Helpers
return ValidationErrorType.NoError;
}
public static ValidationErrorType ValidateDisableMapping(
List<string> originalKeys,
bool isAppSpecific,
string appName,
KeyboardMappingService mappingService,
bool isEditMode = false,
Remapping? editingRemapping = null)
{
if (originalKeys == null || originalKeys.Count == 0)
{
return ValidationErrorType.EmptyOriginalKeys;
}
if (originalKeys.Count > 1 && ContainsOnlyModifierKeys(originalKeys))
{
return ValidationErrorType.ModifierOnly;
}
if (isAppSpecific && string.IsNullOrWhiteSpace(appName))
{
return ValidationErrorType.EmptyAppName;
}
if (originalKeys.Count > 1 && IsIllegalShortcut(originalKeys, mappingService))
{
return ValidationErrorType.IllegalShortcut;
}
if (IsDuplicateMapping(originalKeys, isEditMode, mappingService, appName))
{
return ValidationErrorType.DuplicateMapping;
}
if (originalKeys.Count == 1 && HasConflictingModifierMapping(originalKeys[0], isEditMode, mappingService))
{
return ValidationErrorType.ConflictingModifier;
}
return ValidationErrorType.NoError;
}
public static ValidationErrorType ValidateTextMapping(
List<string> keys,
string textContent,
@@ -239,6 +286,58 @@ namespace KeyboardManagerEditorUI.Helpers
return KeyboardManagerInterop.IsShortcutIllegal(shortcutKeysString);
}
/// <summary>
/// Checks if a single key conflicts with existing single-key mappings via modifier variants.
/// E.g., remapping LCtrl when Ctrl is already mapped, or vice versa.
/// </summary>
private static bool HasConflictingModifierMapping(string keyName, bool isEditMode, KeyboardMappingService mappingService)
{
int keyCode = KeyboardManagerInterop.GetKeyCodeFromName(keyName);
int keyType = KeyboardManagerInterop.GetKeyType(keyCode);
// Only modifier keys can conflict with their variants
if (keyType >= 4)
{
return false;
}
int upperLimit = isEditMode ? 1 : 0;
int conflictCount = 0;
foreach (var settings in SettingsManager.EditorSettings.ShortcutSettingsDictionary.Values)
{
string existingOriginal = settings.Shortcut.OriginalKeys;
// Only check single-key mappings (no semicolons)
if (string.IsNullOrEmpty(existingOriginal) || existingOriginal.Contains(';'))
{
continue;
}
if (int.TryParse(existingOriginal, out int existingKeyCode))
{
if (existingKeyCode == keyCode)
{
continue; // Exact match handled by DuplicateMapping
}
int existingKeyType = KeyboardManagerInterop.GetKeyType(existingKeyCode);
// Same modifier type (e.g., Ctrl and LCtrl) = conflict
if (existingKeyType == keyType)
{
conflictCount++;
if (conflictCount > upperLimit)
{
return true;
}
}
}
}
return false;
}
private static string BuildKeyCodeString(List<string> keys, KeyboardMappingService mappingService)
{
return string.Join(";", keys.Select(k => mappingService.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));

View File

@@ -0,0 +1,8 @@
// 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 KeyboardManagerEditorUI.Interop
{
public record KeyNameEntry(int KeyCode, string DisplayName);
}

View File

@@ -98,6 +98,10 @@ namespace KeyboardManagerEditorUI.Interop
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool DeleteShortcutRemap(IntPtr mappingConfiguration, [MarshalAs(UnmanagedType.LPWStr)] string originalKeys, [MarshalAs(UnmanagedType.LPWStr)] string targetApp);
// Key List Functions
[DllImport(DllName, CallingConvention = Convention)]
internal static extern int GetKeyboardKeysList([MarshalAs(UnmanagedType.Bool)] bool isShortcut, [Out] KeyNamePair[] keyList, int maxCount);
// Key Utility Functions
[DllImport(DllName, CallingConvention = Convention, CharSet = CharSet.Unicode)]
internal static extern int GetKeyCodeFromName([MarshalAs(UnmanagedType.LPWStr)] string keyName);
@@ -162,4 +166,13 @@ namespace KeyboardManagerEditorUI.Interop
public IntPtr ProgramArgs;
public IntPtr UriToOpen;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct KeyNamePair
{
public int KeyCode;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string KeyName;
}
}

View File

@@ -145,6 +145,21 @@ namespace KeyboardManagerEditorUI.Interop
return keyCode;
}
public List<KeyNameEntry> GetKeyboardKeysList(bool isShortcut)
{
const int maxKeys = 512;
var buffer = new KeyNamePair[maxKeys];
int count = KeyboardManagerInterop.GetKeyboardKeysList(isShortcut, buffer, maxKeys);
var result = new List<KeyNameEntry>(count);
for (int i = 0; i < count; i++)
{
result.Add(new KeyNameEntry(buffer[i].KeyCode, buffer[i].KeyName));
}
return result;
}
public bool AddSingleKeyMapping(int originalKey, int targetKey)
{
return KeyboardManagerInterop.AddSingleKeyRemap(_configHandle, originalKey, targetKey);

View File

@@ -15,6 +15,7 @@ using KeyboardManagerEditorUI.Helpers;
using KeyboardManagerEditorUI.Interop;
using KeyboardManagerEditorUI.Settings;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using static KeyboardManagerEditorUI.Interop.ShortcutKeyMapping;
@@ -27,11 +28,19 @@ namespace KeyboardManagerEditorUI.Pages
#pragma warning disable SA1124 // Do not use regions
public sealed partial class MainPage : Page, IDisposable, INotifyPropertyChanged
{
/// <summary>VK_DISABLED sentinel: target key code that tells the engine to suppress the key.</summary>
private const int VkDisabled = 0x100;
/// <summary>String form of <see cref="VkDisabled"/> used in shortcut key mapping serialization.</summary>
private const string VkDisabledString = "256";
private DispatcherTimer? _serviceCheckTimer;
private KeyboardMappingService? _mappingService;
private bool _disposed;
private bool _isEditMode;
private EditingItem? _editingItem;
private string _mappingState = "Empty";
private bool _isServiceRunning = true;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -48,8 +57,24 @@ namespace KeyboardManagerEditorUI.Pages
}
}
public bool IsServiceRunning
{
get => _isServiceRunning;
private set
{
if (_isServiceRunning != value)
{
_isServiceRunning = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsServiceRunning)));
UpdateServiceBannerVisibility();
}
}
}
public ObservableCollection<Remapping> RemappingList { get; } = new();
public ObservableCollection<Remapping> DisabledList { get; } = new();
public ObservableCollection<TextMapping> TextMappings { get; } = new();
public ObservableCollection<ProgramShortcut> ProgramShortcuts { get; } = new();
@@ -83,22 +108,51 @@ namespace KeyboardManagerEditorUI.Pages
public MainPage()
{
this.InitializeComponent();
try
{
_mappingService = new KeyboardMappingService();
LoadAllMappings();
}
catch (Exception ex)
{
Logger.LogError("Failed to initialize KeyboardMappingService in MainPage page: " + ex.Message);
Logger.LogError("Failed to initialize mapping service: " + ex.Message);
IsServiceRunning = false;
return;
}
if (_mappingService != null)
{
LoadAllMappings();
}
else
{
MappingState = "Error";
}
Unloaded += All_Unloaded;
CheckServiceStatus();
// Set up periodic checks every 3 seconds
_serviceCheckTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(3),
};
_serviceCheckTimer.Tick += (s, e) => CheckServiceStatus();
_serviceCheckTimer.Start();
}
private void All_Unloaded(object sender, RoutedEventArgs e) => Dispose();
private void CheckServiceStatus()
{
IsServiceRunning = ServiceStatusHelper.IsKeyboardManagerServiceRunning();
}
private void UpdateServiceBannerVisibility()
{
ServiceDownBanner.Visibility = IsServiceRunning ? Visibility.Collapsed : Visibility.Visible;
}
#region Dialog Show Methods
private async void NewRemappingBtn_Click(object sender, RoutedEventArgs e)
@@ -106,7 +160,7 @@ namespace KeyboardManagerEditorUI.Pages
_isEditMode = false;
_editingItem = null;
UnifiedMappingControl.Reset();
RemappingDialog.Title = "New remapping";
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog/Title");
await ShowRemappingDialog();
}
@@ -132,7 +186,32 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.KeyOrShortcut);
UnifiedMappingControl.SetActionKeys(remapping.RemappedKeys.ToList());
UnifiedMappingControl.SetAppSpecific(!remapping.IsAllApps, remapping.AppName);
RemappingDialog.Title = "Edit remapping";
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
private async void DisabledList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not Remapping disabledMapping)
{
return;
}
_isEditMode = true;
_editingItem = new EditingItem
{
Type = EditingItem.ItemType.Remapping,
Item = disabledMapping,
OriginalTriggerKeys = disabledMapping.Shortcut.ToList(),
AppName = disabledMapping.AppName,
IsAllApps = disabledMapping.IsAllApps,
};
UnifiedMappingControl.Reset();
UnifiedMappingControl.SetTriggerKeys(disabledMapping.Shortcut.ToList());
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.Disable);
UnifiedMappingControl.SetAppSpecific(!disabledMapping.IsAllApps, disabledMapping.AppName);
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
@@ -158,7 +237,7 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.Text);
UnifiedMappingControl.SetTextContent(textMapping.Text);
UnifiedMappingControl.SetAppSpecific(!textMapping.IsAllApps, textMapping.AppName);
RemappingDialog.Title = "Edit remapping";
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
@@ -196,7 +275,7 @@ namespace KeyboardManagerEditorUI.Pages
}
UnifiedMappingControl.SetAppSpecific(!programShortcut.IsAllApps, programShortcut.AppName);
RemappingDialog.Title = "Edit remapping";
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
@@ -222,7 +301,7 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenUrl);
UnifiedMappingControl.SetUrl(urlShortcut.URL);
UnifiedMappingControl.SetAppSpecific(!urlShortcut.IsAllApps, urlShortcut.AppName);
RemappingDialog.Title = "Edit remapping";
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
@@ -279,7 +358,7 @@ namespace KeyboardManagerEditorUI.Pages
if (_mappingService == null)
{
Logger.LogError("Mapping service is null, cannot save mapping");
UnifiedMappingControl.ShowValidationError("Error", "Mapping service is not available.");
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_MappingServiceUnavailable_Title"), ResourceHelper.GetString("Error_MappingServiceUnavailable_Message"));
args.Cancel = true;
return;
}
@@ -290,7 +369,7 @@ namespace KeyboardManagerEditorUI.Pages
if (triggerKeys == null || triggerKeys.Count == 0)
{
UnifiedMappingControl.ShowValidationError("Missing Original Keys", "Please enter at least one original key to create a remapping.");
UnifiedMappingControl.ShowValidationErrorFromType(ValidationErrorType.EmptyOriginalKeys);
args.Cancel = true;
return;
}
@@ -314,6 +393,7 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.ActionType.Text => SaveTextMapping(triggerKeys),
UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys),
UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys),
UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys),
UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."),
_ => false,
};
@@ -324,19 +404,19 @@ namespace KeyboardManagerEditorUI.Pages
}
else
{
UnifiedMappingControl.ShowValidationError("Save Failed", "Failed to save the remapping. Please try again.");
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_SaveFailed_Title"), ResourceHelper.GetString("Error_SaveFailed_Message"));
args.Cancel = true;
}
}
catch (NotImplementedException ex)
{
UnifiedMappingControl.ShowValidationError("Not Implemented", ex.Message);
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_NotImplemented_Title"), ex.Message);
args.Cancel = true;
}
catch (Exception ex)
{
Logger.LogError("Error saving mapping: " + ex.Message);
UnifiedMappingControl.ShowValidationError("Error", "An error occurred while saving: " + ex.Message);
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_Generic_Title"), ResourceHelper.GetString("Error_Generic_Message") + ex.Message);
args.Cancel = true;
}
}
@@ -357,6 +437,8 @@ namespace KeyboardManagerEditorUI.Pages
triggerKeys, UnifiedMappingControl.GetUrl(), isAppSpecific, appName, _mappingService!, _isEditMode),
UnifiedMappingControl.ActionType.OpenApp => ValidationHelper.ValidateAppMapping(
triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode),
UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping(
triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping),
_ => ValidationErrorType.NoError,
};
}
@@ -429,6 +511,46 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.GetAppName());
}
private bool SaveDisableMapping(List<string> triggerKeys)
{
bool isAppSpecific = UnifiedMappingControl.GetIsAppSpecific();
string appName = UnifiedMappingControl.GetAppName();
string originalKeysString = string.Join(
";",
triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(System.Globalization.CultureInfo.InvariantCulture)));
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RemapShortcut,
OriginalKeys = originalKeysString,
TargetKeys = VkDisabledString,
TargetApp = isAppSpecific ? appName : string.Empty,
};
if (triggerKeys.Count == 1)
{
int originalKey = _mappingService!.GetKeyCodeFromName(triggerKeys[0]);
if (originalKey == 0)
{
return false;
}
shortcutKeyMapping.OriginalKeys = originalKey.ToString(System.Globalization.CultureInfo.InvariantCulture);
_mappingService.AddSingleKeyMapping(originalKey, VkDisabled);
}
else
{
_mappingService!.AddShortcutMapping(
originalKeysString,
VkDisabledString,
isAppSpecific ? appName : string.Empty);
}
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
return _mappingService.SaveSettings();
}
private bool SaveTextMapping(List<string> triggerKeys)
{
string textContent = UnifiedMappingControl.GetTextContent();
@@ -667,7 +789,16 @@ namespace KeyboardManagerEditorUI.Pages
{
if (shortcut is Remapping remapping)
{
RemappingHelper.SaveMapping(_mappingService!, remapping.Shortcut, remapping.RemappedKeys, !remapping.IsAllApps, remapping.AppName, false);
if (remapping.RemappedKeys == null || remapping.RemappedKeys.Count == 0)
{
// Disabled mapping — re-enable by adding back the VK_DISABLED target
EnableDisabledMapping(remapping);
}
else
{
RemappingHelper.SaveMapping(_mappingService!, remapping.Shortcut, remapping.RemappedKeys, !remapping.IsAllApps, remapping.AppName, false);
}
shortcut.IsActive = true;
SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id);
return;
@@ -710,6 +841,31 @@ namespace KeyboardManagerEditorUI.Pages
}
}
private void EnableDisabledMapping(Remapping remapping)
{
string originalKeysString = string.Join(
";",
remapping.Shortcut.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(System.Globalization.CultureInfo.InvariantCulture)));
if (remapping.Shortcut.Count == 1)
{
int originalKey = _mappingService!.GetKeyCodeFromName(remapping.Shortcut[0]);
if (originalKey != 0)
{
_mappingService.AddSingleKeyMapping(originalKey, VkDisabled);
}
}
else
{
_mappingService!.AddShortcutMapping(
originalKeysString,
VkDisabledString,
!remapping.IsAllApps ? remapping.AppName : string.Empty);
}
_mappingService!.SaveSettings();
}
private bool DeleteSingleKeyToTextMapping(string keyName)
{
int originalKey = _mappingService!.GetKeyCodeFromName(keyName);
@@ -731,7 +887,7 @@ namespace KeyboardManagerEditorUI.Pages
private void UpdateHasAnyMappings()
{
bool hasAny = RemappingList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
MappingState = hasAny ? "HasMappings" : "Empty";
}
@@ -739,29 +895,40 @@ namespace KeyboardManagerEditorUI.Pages
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapShortcut, out var remapShortcutIds);
if (_mappingService == null || remapShortcutIds == null)
if (remapShortcutIds == null)
{
return;
}
RemappingList.Clear();
DisabledList.Clear();
foreach (var id in remapShortcutIds)
{
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
var remappedKeyNames = ParseKeyCodes(mapping.TargetKeys);
RemappingList.Add(new Remapping
bool isDisabled = mapping.TargetKeys == VkDisabledString;
var remapping = new Remapping
{
Shortcut = originalKeyNames,
RemappedKeys = remappedKeyNames,
RemappedKeys = isDisabled ? new List<string>() : ParseKeyCodes(mapping.TargetKeys),
IsAllApps = string.IsNullOrEmpty(mapping.TargetApp),
AppName = mapping.TargetApp ?? string.Empty,
Id = shortcutSettings.Id,
IsActive = shortcutSettings.IsActive,
});
};
if (isDisabled)
{
DisabledList.Add(remapping);
}
else
{
RemappingList.Add(remapping);
}
}
}
@@ -769,7 +936,7 @@ namespace KeyboardManagerEditorUI.Pages
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapText, out var remapShortcutIds);
if (_mappingService == null || remapShortcutIds == null)
if (remapShortcutIds == null)
{
return;
}
@@ -798,7 +965,7 @@ namespace KeyboardManagerEditorUI.Pages
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RunProgram, out var remapShortcutIds);
if (_mappingService == null || remapShortcutIds == null)
if (remapShortcutIds == null)
{
return;
}
@@ -832,7 +999,7 @@ namespace KeyboardManagerEditorUI.Pages
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.OpenUri, out var remapShortcutIds);
if (_mappingService == null || remapShortcutIds == null)
if (remapShortcutIds == null)
{
return;
}
@@ -860,8 +1027,12 @@ namespace KeyboardManagerEditorUI.Pages
private List<string> ParseKeyCodes(string keyCodesString)
{
return keyCodesString.Split(';')
.Where(keyCode => int.TryParse(keyCode, out int code))
.Select(keyCode => _mappingService!.GetKeyDisplayName(int.Parse(keyCode, CultureInfo.InvariantCulture)))
.Where(keyCode => int.TryParse(keyCode, out _))
.Select(keyCode =>
{
int code = int.Parse(keyCode, CultureInfo.InvariantCulture);
return _mappingService?.GetKeyDisplayName(code) ?? $"VK {code}";
})
.ToList();
}
@@ -884,6 +1055,8 @@ namespace KeyboardManagerEditorUI.Pages
if (disposing)
{
_serviceCheckTimer?.Stop();
_serviceCheckTimer = null;
_mappingService?.Dispose();
_mappingService = null;
}

View File

@@ -24,12 +24,28 @@ namespace KeyboardManagerEditorUI.Settings
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { WriteIndented = true };
private static readonly KeyboardMappingService _mappingService = new KeyboardMappingService();
private static readonly KeyboardMappingService? _mappingService;
/// <summary>
/// Gets a value indicating whether the native C++ wrapper DLL is available.
/// When false the editor runs in read-only / XAML-preview mode using JSON settings only.
/// </summary>
internal static bool IsNativeServiceAvailable => _mappingService is not null;
public static EditorSettings EditorSettings { get; set; }
static SettingsManager()
{
try
{
_mappingService = new KeyboardMappingService();
}
catch (Exception ex) when (ex is DllNotFoundException or InvalidOperationException)
{
ManagedCommon.Logger.LogWarning($"Native KBM library unavailable, running in standalone mode: {ex.Message}");
_mappingService = null;
}
EditorSettings = LoadSettings();
}
@@ -39,9 +55,14 @@ namespace KeyboardManagerEditorUI.Settings
{
if (!File.Exists(_settingsFilePath))
{
EditorSettings createdSettings = CreateSettingsFromKeyboardManagerService();
WriteSettings(createdSettings);
return createdSettings;
if (_mappingService is not null)
{
EditorSettings createdSettings = CreateSettingsFromKeyboardManagerService();
WriteSettings(createdSettings);
return createdSettings;
}
return new EditorSettings();
}
string json = File.ReadAllText(_settingsFilePath);
@@ -75,13 +96,13 @@ namespace KeyboardManagerEditorUI.Settings
EditorSettings settings = new EditorSettings();
// Process all shortcut mappings (RunProgram, OpenUri, RemapShortcut, RemapText)
foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings())
foreach (ShortcutKeyMapping mapping in _mappingService!.GetShortcutMappings())
{
AddShortcutMapping(settings, mapping);
}
// Process single key to key mappings
foreach (var mapping in _mappingService.GetSingleKeyMappings())
foreach (var mapping in _mappingService!.GetSingleKeyMappings())
{
var shortcutMapping = new ShortcutKeyMapping
{
@@ -93,7 +114,7 @@ namespace KeyboardManagerEditorUI.Settings
}
// Process single key to text mappings
foreach (var mapping in _mappingService.GetKeyToTextMappings())
foreach (var mapping in _mappingService!.GetKeyToTextMappings())
{
var shortcutMapping = new ShortcutKeyMapping
{
@@ -110,10 +131,15 @@ namespace KeyboardManagerEditorUI.Settings
public static void CorrelateServiceAndEditorMappings()
{
if (_mappingService is not { } service)
{
return;
}
bool shortcutSettingsChanged = false;
// Process all shortcut mappings
foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings())
foreach (ShortcutKeyMapping mapping in service.GetShortcutMappings())
{
if (!EditorSettings.ShortcutSettingsDictionary.Values.Any(s => s.Shortcut.OriginalKeys == mapping.OriginalKeys))
{
@@ -123,7 +149,7 @@ namespace KeyboardManagerEditorUI.Settings
}
// Process single key to key mappings
foreach (var mapping in _mappingService.GetSingleKeyMappings())
foreach (var mapping in service.GetSingleKeyMappings())
{
var shortcutMapping = new ShortcutKeyMapping
{
@@ -140,7 +166,7 @@ namespace KeyboardManagerEditorUI.Settings
}
// Process single key to text mappings
foreach (var mapping in _mappingService.GetKeyToTextMappings())
foreach (var mapping in service.GetKeyToTextMappings())
{
var shortcutMapping = new ShortcutKeyMapping
{
@@ -158,9 +184,9 @@ namespace KeyboardManagerEditorUI.Settings
}
// Mark inactive mappings
var singleKeyMappings = _mappingService.GetSingleKeyMappings();
var keyToTextMappings = _mappingService.GetKeyToTextMappings();
var shortcutKeyMappings = _mappingService.GetShortcutMappings();
var singleKeyMappings = service.GetSingleKeyMappings();
var keyToTextMappings = service.GetKeyToTextMappings();
var shortcutKeyMappings = service.GetShortcutMappings();
foreach (ShortcutSettings shortcutSettings in EditorSettings.ShortcutSettingsDictionary.Values.ToList())
{

View File

@@ -1,5 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<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">
@@ -112,6 +171,9 @@
<data name="RemappingDialog.Title" xml:space="preserve">
<value>Add new remapping</value>
</data>
<data name="RemappingDialog_TitleEdit" xml:space="preserve">
<value>Edit remapping</value>
</data>
<data name="RemappingDialog.PrimaryButtonText" xml:space="preserve">
<value>Save</value>
</data>
@@ -151,6 +213,12 @@
<data name="AllowChordsCheckBox.Content" xml:space="preserve">
<value>Allow chords</value>
</data>
<data name="TriggerKeyPlaceholder.Text" xml:space="preserve">
<value>Press to start recording keys</value>
</data>
<data name="ActionKeyPlaceholder.Text" xml:space="preserve">
<value>Press to start recording keys</value>
</data>
<data name="ExactMatchCheckBox.Content" xml:space="preserve">
<value>Exact match</value>
</data>
@@ -208,6 +276,18 @@
<data name="ActionType_MouseClick_Text.Text" xml:space="preserve">
<value>Remap to mouse click</value>
</data>
<data name="ActionType_Disable.AutomationProperties.Name" xml:space="preserve">
<value>Disable key or shortcut</value>
</data>
<data name="ActionType_Disable_Text.Text" xml:space="preserve">
<value>Disable key or shortcut</value>
</data>
<data name="DisableDescription.Text" xml:space="preserve">
<value>This key or shortcut will be disabled.</value>
</data>
<data name="DisabledHeader.Text" xml:space="preserve">
<value>Disabled</value>
</data>
<data name="TextContentBox.Header" xml:space="preserve">
<value>Text to type</value>
</data>
@@ -229,6 +309,9 @@
<data name="ProgramPathSelectButton.AutomationProperties.Name" xml:space="preserve">
<value>Select program path</value>
</data>
<data name="ProgramPathSelectButtonTooltip.Text" xml:space="preserve">
<value>Select program path</value>
</data>
<data name="ProgramArgsInput.Header" xml:space="preserve">
<value>Arguments (optional)</value>
</data>
@@ -243,6 +326,9 @@
</data>
<data name="StartInSelectButton.AutomationProperties.Name" xml:space="preserve">
<value>Select start directory</value>
</data>
<data name="StartInSelectButtonTooltip.Text" xml:space="preserve">
<value>Select start directory</value>
</data>
<data name="ElevationComboBox.Header" xml:space="preserve">
<value>Run as</value>
@@ -292,4 +378,127 @@
<data name="MouseClickPlaceholder.Text" xml:space="preserve">
<value>Mouse click action - coming soon</value>
</data>
<data name="Validation_EmptyOriginalKeys_Title" xml:space="preserve">
<value>Missing original keys</value>
</data>
<data name="Validation_EmptyOriginalKeys_Message" xml:space="preserve">
<value>Please enter at least one original key to create a remapping.</value>
</data>
<data name="Validation_EmptyRemappedKeys_Title" xml:space="preserve">
<value>Missing target keys</value>
</data>
<data name="Validation_EmptyRemappedKeys_Message" xml:space="preserve">
<value>Please enter at least one target key to create a remapping.</value>
</data>
<data name="Validation_ModifierOnly_Title" xml:space="preserve">
<value>Invalid shortcut</value>
</data>
<data name="Validation_ModifierOnly_Message" xml:space="preserve">
<value>Shortcuts must contain at least one action key in addition to modifier keys (Ctrl, Alt, Shift, Win).</value>
</data>
<data name="Validation_EmptyAppName_Title" xml:space="preserve">
<value>Missing application name</value>
</data>
<data name="Validation_EmptyAppName_Message" xml:space="preserve">
<value>You've selected app-specific remapping but haven't specified an application name. Please enter the application name.</value>
</data>
<data name="Validation_IllegalShortcut_Title" xml:space="preserve">
<value>Reserved system shortcut</value>
</data>
<data name="Validation_IllegalShortcut_Message" xml:space="preserve">
<value>Win+L and Ctrl+Alt+Delete are reserved system shortcuts and cannot be remapped.</value>
</data>
<data name="Validation_DuplicateMapping_Title" xml:space="preserve">
<value>Duplicate remapping</value>
</data>
<data name="Validation_DuplicateMapping_Message" xml:space="preserve">
<value>This key or shortcut is already remapped.</value>
</data>
<data name="Validation_ConflictingModifier_Title" xml:space="preserve">
<value>Conflicting modifier</value>
</data>
<data name="Validation_ConflictingModifier_Message" xml:space="preserve">
<value>This key conflicts with an existing remapping that uses a related modifier variant (e.g., Ctrl vs Left Ctrl).</value>
</data>
<data name="Validation_SelfMapping_Title" xml:space="preserve">
<value>Invalid remapping</value>
</data>
<data name="Validation_SelfMapping_Message" xml:space="preserve">
<value>A key or shortcut cannot be remapped to itself. Please choose a different target.</value>
</data>
<data name="Validation_EmptyTargetText_Title" xml:space="preserve">
<value>Missing target text</value>
</data>
<data name="Validation_EmptyTargetText_Message" xml:space="preserve">
<value>Please enter the text to be inserted when the shortcut is pressed.</value>
</data>
<data name="Validation_EmptyUrl_Title" xml:space="preserve">
<value>Missing URL</value>
</data>
<data name="Validation_EmptyUrl_Message" xml:space="preserve">
<value>Please enter the URL to open when the shortcut is pressed.</value>
</data>
<data name="Validation_EmptyProgramPath_Title" xml:space="preserve">
<value>Missing program path</value>
</data>
<data name="Validation_EmptyProgramPath_Message" xml:space="preserve">
<value>Please enter the program path to launch when the shortcut is pressed.</value>
</data>
<data name="Validation_OneKeyMapping_Title" xml:space="preserve">
<value>Invalid remapping</value>
</data>
<data name="Validation_OneKeyMapping_Message" xml:space="preserve">
<value>A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.</value>
</data>
<data name="Warning_ShortcutStartWithModifier" xml:space="preserve">
<value>Shortcuts must start with a modifier key (Ctrl, Alt, Shift, or Win).</value>
</data>
<data name="Warning_RepeatedModifier" xml:space="preserve">
<value>This shortcut already contains a modifier of the same type.</value>
</data>
<data name="Warning_MaxShortcutSize" xml:space="preserve">
<value>Maximum shortcut size reached (4 modifiers + 1 action key).</value>
</data>
<data name="Warning_InputLimitReached" xml:space="preserve">
<value>Shortcuts can only have up to 4 modifier keys.</value>
</data>
<data name="Error_MappingServiceUnavailable_Title" xml:space="preserve">
<value>Error</value>
</data>
<data name="Error_MappingServiceUnavailable_Message" xml:space="preserve">
<value>Mapping service is not available.</value>
</data>
<data name="Error_SaveFailed_Title" xml:space="preserve">
<value>Save failed</value>
</data>
<data name="Error_SaveFailed_Message" xml:space="preserve">
<value>Failed to save the remapping. Please try again.</value>
</data>
<data name="Error_NotImplemented_Title" xml:space="preserve">
<value>Not implemented</value>
</data>
<data name="Error_Generic_Title" xml:space="preserve">
<value>Error</value>
</data>
<data name="Error_Generic_Message" xml:space="preserve">
<value>An error occurred while saving: </value>
</data>
<data name="Warning_Title" xml:space="preserve">
<value>Warning</value>
</data>
<data name="Error_UnknownValidation_Title" xml:space="preserve">
<value>Validation error</value>
</data>
<data name="Error_UnknownValidation_Message" xml:space="preserve">
<value>An unknown validation error occurred.</value>
</data>
<data name="ServiceDownBanner.Title" xml:space="preserve">
<value>Keyboard Manager service is down</value>
</data>
<data name="ServiceDownBanner.Message" xml:space="preserve">
<value>Remappings will not function and you will be unable to edit any entries until the service is restored.</value>
</data>
<data name="CheckServiceBtn.Content" xml:space="preserve">
<value>Check service status</value>
</data>
</root>

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals">
<Style x:Key="CustomShortcutToggleButtonStyle" TargetType="ToggleButton">
<Setter Property="Background" Value="{ThemeResource ToggleButtonBackground}" />
@@ -135,58 +138,7 @@
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="Indeterminate">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminate}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminate}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminate}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="IndeterminatePointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminatePointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminatePointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminatePointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="IndeterminatePressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminatePressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminatePressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminatePressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="IndeterminateDisabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminateDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminateDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminateDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</ContentPresenter>
@@ -756,4 +708,232 @@
</Setter>
</Style.Setters>
</Style>
<Style x:Key="DefaultKeyVisualDropDownButtonStyle" TargetType="DropDownButton">
<Setter Property="Background" Value="{ThemeResource ControlFillColorDefaultBrush}" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="16" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource OverlayCornerRadius}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid
x:Name="RootGrid"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="ContentPresenter"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AutomationProperties.AccessibilityView="Raw"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}" />
<AnimatedIcon
xmlns:local="using:Microsoft.UI.Xaml.Controls"
x:Name="ChevronIcon"
Grid.Column="1"
Width="12"
Height="12"
Margin="8,0,0,0"
local:AnimatedIcon.State="Normal"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource DropDownButtonForegroundSecondary}">
<animatedvisuals:AnimatedChevronDownSmallVisualSource />
<AnimatedIcon.FallbackIconSource>
<FontIconSource
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="8"
Glyph="&#xE96E;"
IsTextScaleFactorEnabled="False" />
</AnimatedIcon.FallbackIconSource>
</AnimatedIcon>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource DropDownButtonForegroundSecondaryPointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="PointerOver" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource DropDownButtonForegroundSecondaryPressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="Pressed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<!-- DisabledVisual Should be handled by the control, not the animated icon. -->
<Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="Normal" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="AccentKeyVisualDropDownButtonStyle" TargetType="DropDownButton">
<Setter Property="Background" Value="{ThemeResource CustomAccentBackgroundBrush}" />
<Setter Property="Foreground" Value="{ThemeResource AccentTextFillColorPrimaryBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="8" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="16" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid
x:Name="RootGrid"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="ContentPresenter"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AutomationProperties.AccessibilityView="Raw"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Foreground="{TemplateBinding Foreground}" />
<FontIcon
x:Name="ChevronIcon"
Grid.Column="1"
Margin="8,0,0,0"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="8"
Foreground="{TemplateBinding Foreground}"
Glyph="&#xE96E;" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorSecondaryBrush}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorSecondaryBrush}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters />
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorTertiaryBrush}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorTertiaryBrush}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters />
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters />
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8" ?>
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush

View File

@@ -538,10 +538,11 @@ namespace KeyboardEventHandlers
// Release original shortcut state (release in reverse order of shortcut to be accurate)
Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
// Send modifier release events first, then paste text via clipboard
// Send modifier release events first, then send text directly
// (SendTextInput handles multiline by flushing between chunks)
ii.SendVirtualInput(keyEventList);
keyEventList.clear();
Helpers::SendTextViaClipboard(remapping);
Helpers::SendTextInput(remapping);
}
it->second.isShortcutInvoked = true;
@@ -722,7 +723,8 @@ namespace KeyboardEventHandlers
else if (remapToText)
{
auto& remapping = std::get<std::wstring>(it->second.targetShortcut);
Helpers::SendTextViaClipboard(remapping);
ii.SendVirtualInput(keyEventList);
Helpers::SendTextInput(remapping);
return 1;
}
@@ -1797,7 +1799,7 @@ namespace KeyboardEventHandlers
return 0;
}
Helpers::SendTextViaClipboard(*remapping);
Helpers::SendTextInput(*remapping);
return 1;
}

View File

@@ -60,85 +60,4 @@ namespace RemappingLogicTests
Assert::AreEqual<unsigned int>(0, inputs[1].ki.wScan);
}
};
// Tests for the SetTextKeyEvents method
TEST_CLASS (SetTextKeyEventsTests)
{
public:
// Test that plain ASCII text produces KEYEVENTF_UNICODE events with correct scan codes
TEST_METHOD (SetTextKeyEvents_ShouldUseUnicodeFlag_WhenTextIsPlainAscii)
{
std::vector<INPUT> inputs;
std::wstring text = L"abc";
Helpers::SetTextKeyEvents(inputs, text);
// 3 characters × 2 events (down+up) = 6 events
Assert::AreEqual<size_t>(6, inputs.size());
for (size_t i = 0; i < inputs.size(); i++)
{
Assert::AreEqual(true, bool(inputs[i].ki.dwFlags & KEYEVENTF_UNICODE));
}
Assert::AreEqual<unsigned short>(L'a', inputs[0].ki.wScan);
Assert::AreEqual<unsigned short>(L'b', inputs[2].ki.wScan);
Assert::AreEqual<unsigned short>(L'c', inputs[4].ki.wScan);
}
// Test that each character generates a keydown and keyup event pair
TEST_METHOD (SetTextKeyEvents_ShouldGenerateDownUpPairs_WhenTextHasMultipleChars)
{
std::vector<INPUT> inputs;
std::wstring text = L"xy";
Helpers::SetTextKeyEvents(inputs, text);
Assert::AreEqual<size_t>(4, inputs.size());
// First event: 'x' keydown (no KEYEVENTF_KEYUP flag)
Assert::AreEqual<unsigned short>(L'x', inputs[0].ki.wScan);
Assert::IsFalse(bool(inputs[0].ki.dwFlags & KEYEVENTF_KEYUP));
// Second event: 'x' keyup
Assert::AreEqual<unsigned short>(L'x', inputs[1].ki.wScan);
Assert::AreEqual(true, bool(inputs[1].ki.dwFlags & KEYEVENTF_KEYUP));
}
// Test that newline characters are passed through as Unicode events (actual newline handling is done via clipboard)
TEST_METHOD (SetTextKeyEvents_ShouldPassNewlinesAsUnicode_WhenTextContainsNewlines)
{
std::vector<INPUT> inputs;
std::wstring text = L"a\r\nb";
Helpers::SetTextKeyEvents(inputs, text);
// All 4 characters (a, \r, \n, b) × 2 events = 8 events
Assert::AreEqual<size_t>(8, inputs.size());
Assert::AreEqual<unsigned short>(L'a', inputs[0].ki.wScan);
Assert::AreEqual<unsigned short>(L'\r', inputs[2].ki.wScan);
Assert::AreEqual<unsigned short>(L'\n', inputs[4].ki.wScan);
Assert::AreEqual<unsigned short>(L'b', inputs[6].ki.wScan);
}
// Test empty string produces no events
TEST_METHOD (SetTextKeyEvents_ShouldProduceNoEvents_WhenTextIsEmpty)
{
std::vector<INPUT> inputs;
std::wstring text = L"";
Helpers::SetTextKeyEvents(inputs, text);
Assert::AreEqual<size_t>(0, inputs.size());
}
// Test that extraInfo flag is set correctly for KBM identification
TEST_METHOD (SetTextKeyEvents_ShouldSetExtraInfoFlag_WhenTextIsProvided)
{
std::vector<INPUT> inputs;
std::wstring text = L"a";
Helpers::SetTextKeyEvents(inputs, text);
Assert::AreEqual<size_t>(2, inputs.size());
Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[0].ki.dwExtraInfo);
Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[1].ki.dwExtraInfo);
}
};
}

View File

@@ -1,11 +1,6 @@
#include "pch.h"
#include "Helpers.h"
#include <atomic>
#include <condition_variable>
#include <queue>
#include <sstream>
#include <mutex>
#include <thread>
#include <common/interop/shared_constants.h>
#include <common/utils/process_path.h>
@@ -314,238 +309,72 @@ namespace Helpers
}
}
void SetTextKeyEvents(std::vector<INPUT>& keyEventArray, const std::wstring& remapping)
// Sends text input directly via SendInput, handling newlines by sending
// Shift+Enter. Each character is sent individually to avoid a synchronization
// error across key-down and key-up events that causes repeated or dropped characters
// when large batches of KEYEVENTF_UNICODE events are sent at once.
void SendTextInput(const std::wstring& text)
{
for (wchar_t c : remapping)
for (size_t i = 0; i < text.size(); ++i)
{
for (DWORD flag : { 0, KEYEVENTF_KEYUP })
wchar_t c = text[i];
// Handle \r\n as a single newline
if (c == L'\r' && i + 1 < text.size() && text[i + 1] == L'\n')
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwFlags = KEYEVENTF_UNICODE | flag;
input.ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
input.ki.wScan = c;
keyEventArray.push_back(input);
++i;
}
}
}
// Helper to set clipboard text. Returns true on success.
static bool SetClipboardText(const std::wstring& text)
{
if (!OpenClipboard(nullptr))
{
return false;
}
EmptyClipboard();
size_t byteSize = (text.size() + 1) * sizeof(wchar_t);
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, byteSize);
if (!hMem)
{
CloseClipboard();
return false;
}
wchar_t* pMem = static_cast<wchar_t*>(GlobalLock(hMem));
if (!pMem)
{
GlobalFree(hMem);
CloseClipboard();
return false;
}
wcscpy_s(pMem, text.size() + 1, text.c_str());
GlobalUnlock(hMem);
if (!SetClipboardData(CF_UNICODETEXT, hMem))
{
GlobalFree(hMem);
CloseClipboard();
return false;
}
// Exclude this entry from clipboard history and cloud clipboard so the
// temporary paste text does not pollute the user's clipboard history.
static const UINT excludeFromHistory = RegisterClipboardFormat(L"ExcludeClipboardContentFromMonitorProcessing");
if (excludeFromHistory != 0)
{
HGLOBAL hExclude = GlobalAlloc(GMEM_MOVEABLE, sizeof(DWORD));
if (hExclude)
if (c == L'\r' || c == L'\n')
{
SetClipboardData(excludeFromHistory, hExclude);
// Send Shift+Enter instead of bare Enter so that chat apps
// (Teams, Slack, Discord, etc.) insert a new line rather than
// submitting the message. In plain text editors both behave
// the same.
INPUT returnInputs[4]{};
// Shift down
returnInputs[0].type = INPUT_KEYBOARD;
returnInputs[0].ki.wVk = VK_SHIFT;
returnInputs[0].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Return down
returnInputs[1].type = INPUT_KEYBOARD;
returnInputs[1].ki.wVk = VK_RETURN;
returnInputs[1].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
returnInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Return up
returnInputs[2].type = INPUT_KEYBOARD;
returnInputs[2].ki.wVk = VK_RETURN;
returnInputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
returnInputs[2].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
returnInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
// Shift up
returnInputs[3].type = INPUT_KEYBOARD;
returnInputs[3].ki.wVk = VK_SHIFT;
returnInputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
SendInput(ARRAYSIZE(returnInputs), returnInputs, sizeof(INPUT));
continue;
}
}
CloseClipboard();
return true;
}
INPUT charInputs[2]{};
charInputs[0].type = INPUT_KEYBOARD;
charInputs[0].ki.dwFlags = KEYEVENTF_UNICODE;
charInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[0].ki.wScan = c;
// Simulate Ctrl+V paste keystroke, tagged with KBM flag so our own hook
// passes it through without re-intercepting.
static void SendPasteKeystroke()
{
INPUT pasteInputs[4]{};
charInputs[1].type = INPUT_KEYBOARD;
charInputs[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[1].ki.wScan = c;
pasteInputs[0].type = INPUT_KEYBOARD;
pasteInputs[0].ki.wVk = VK_CONTROL;
pasteInputs[0].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC));
pasteInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
pasteInputs[1].type = INPUT_KEYBOARD;
pasteInputs[1].ki.wVk = 'V';
pasteInputs[1].ki.wScan = static_cast<WORD>(MapVirtualKey('V', MAPVK_VK_TO_VSC));
pasteInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
pasteInputs[2].type = INPUT_KEYBOARD;
pasteInputs[2].ki.wVk = 'V';
pasteInputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
pasteInputs[2].ki.wScan = static_cast<WORD>(MapVirtualKey('V', MAPVK_VK_TO_VSC));
pasteInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
pasteInputs[3].type = INPUT_KEYBOARD;
pasteInputs[3].ki.wVk = VK_CONTROL;
pasteInputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
pasteInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC));
pasteInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
SendInput(ARRAYSIZE(pasteInputs), pasteInputs, sizeof(INPUT));
}
// Single background worker that processes clipboard-paste requests
// sequentially. Keeping one thread avoids creating/tearing-down a
// thread on every keypress and naturally serializes clipboard access
// without a separate mutex.
static std::queue<std::wstring> s_clipboardQueue;
static std::mutex s_queueMutex;
static std::condition_variable s_queueCV;
static std::once_flag s_workerInitFlag;
static std::atomic<bool> s_shutdown{ false };
static std::thread s_workerThread;
static void ClipboardWorkerLoop()
{
while (true)
{
try
{
std::wstring text;
{
std::unique_lock<std::mutex> lock(s_queueMutex);
s_queueCV.wait(lock, [] { return !s_clipboardQueue.empty() || s_shutdown.load(); });
if (s_shutdown.load())
{
break;
}
text = std::move(s_clipboardQueue.front());
s_clipboardQueue.pop();
}
// Snapshot current clipboard state
bool hadOriginalText = false;
std::wstring originalClipboardText;
if (OpenClipboard(nullptr))
{
if (IsClipboardFormatAvailable(CF_UNICODETEXT))
{
HANDLE hData = GetClipboardData(CF_UNICODETEXT);
if (hData)
{
wchar_t* pText = static_cast<wchar_t*>(GlobalLock(hData));
if (pText)
{
originalClipboardText = pText;
hadOriginalText = true;
GlobalUnlock(hData);
}
}
}
CloseClipboard();
}
// Place our text on the clipboard (with history exclusion)
if (!SetClipboardText(text))
{
continue;
}
SendPasteKeystroke();
// Ctrl+V is asynchronous (SendInput queues the input), so the
// target app needs time to process the keystroke and read the
// clipboard before we restore the original content.
Sleep(100);
if (hadOriginalText)
{
SetClipboardText(originalClipboardText);
}
else
{
if (OpenClipboard(nullptr))
{
EmptyClipboard();
CloseClipboard();
}
}
}
catch (...)
{
OutputDebugStringA("KBM ClipboardWorker exception caught, continuing\n");
}
}
}
// Inner implementation that may throw C++ exceptions.
static bool SendTextViaClipboardImpl(const std::wstring& text)
{
// Lazily start the worker on first use.
std::call_once(s_workerInitFlag, [] {
s_workerThread = std::thread(ClipboardWorkerLoop);
std::atexit([] {
{
std::lock_guard<std::mutex> lock(s_queueMutex);
s_shutdown.store(true);
}
s_queueCV.notify_one();
if (s_workerThread.joinable())
{
s_workerThread.join();
}
});
});
// Enqueue the text and return immediately so we never block the
// low-level keyboard hook (WH_KEYBOARD_LL).
{
std::lock_guard<std::mutex> lock(s_queueMutex);
if (!s_clipboardQueue.empty())
{
return true;
}
s_clipboardQueue.push(text);
}
s_queueCV.notify_one();
return true;
}
// Function to send text via clipboard paste (Ctrl+V).
// Saves the previous clipboard content and restores it asynchronously.
// This function MUST NOT throw — it's called from noexcept hook handlers.
// We use __try/__except (SEH) because C++ try/catch may be optimized away
// in Release builds when the caller is noexcept.
bool SendTextViaClipboard(const std::wstring& text) noexcept
{
__try
{
return SendTextViaClipboardImpl(text);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
OutputDebugStringA("KBM SendTextViaClipboard SEH exception caught\n");
return false;
SendInput(ARRAYSIZE(charInputs), charInputs, sizeof(INPUT));
}
}

View File

@@ -38,11 +38,10 @@ namespace Helpers
// Function to set the dummy key events used for remapping shortcuts, required to ensure releasing a modifier doesn't trigger another action (For example, Win->Start Menu or Alt->Menu bar)
void SetDummyKeyEvent(std::vector<INPUT>& keyEventArray, ULONG_PTR extraInfo);
// Function to set key events for remapping text.
void SetTextKeyEvents(std::vector<INPUT>& keyEventArray, const std::wstring& remapping);
// Function to send text via clipboard paste (Ctrl+V). Saves and restores previous clipboard content.
bool SendTextViaClipboard(const std::wstring& text) noexcept;
// Function to send text input directly, with multiline support.
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
// as separate SendInput calls to avoid mixing event types.
void SendTextInput(const std::wstring& text);
// Function to return window handle for a full screen UWP app
HWND GetFullscreenUWPWindowHandle();

View File

@@ -20,9 +20,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

@@ -188,10 +188,29 @@ namespace Microsoft.Plugin.Shell
return history.ToList();
}
/// <summary>
/// Escapes embedded double-quotes in a string for cmd.exe.
/// cmd.exe uses doubled double-quotes ("") for escaping inside a double-quoted string.
/// </summary>
private static string EscapeCmdArgument(string arg)
{
return string.IsNullOrEmpty(arg) ? string.Empty : arg.Replace("\"", "\"\"");
}
/// <summary>
/// Escapes embedded double-quotes in a string for PowerShell.
/// PowerShell honors backslash-escaping (\") when receiving a command string via the -Command/-C parameter.
/// </summary>
private static string EscapePowerShellArgument(string arg)
{
return string.IsNullOrEmpty(arg) ? string.Empty : arg.Replace("\"", "\\\"");
}
private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None)
{
string trimmedCommand = command.Trim();
command = Environment.ExpandEnvironmentVariables(trimmedCommand);
var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Set runAsArg
@@ -208,34 +227,36 @@ namespace Microsoft.Plugin.Shell
ProcessStartInfo info;
if (_settings.Shell == ExecutionShell.Cmd)
{
var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause";
var arguments = _settings.LeaveShellOpen ? $"/k {command}" : $"/c {command} & pause";
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (_settings.Shell == ExecutionShell.Powershell)
{
string escapedPS = EscapePowerShellArgument(command);
string arguments;
if (_settings.LeaveShellOpen)
{
arguments = $"-NoExit \"{command}\"";
arguments = $"-NoExit -C \"{escapedPS}\"";
}
else
{
arguments = $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
arguments = $"-C \"{escapedPS} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
}
info = ShellCommand.SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (_settings.Shell == ExecutionShell.PowerShellSeven)
{
string escapedPS = EscapePowerShellArgument(command);
string arguments;
if (_settings.LeaveShellOpen)
{
arguments = $"-NoExit -C \"{command}\"";
arguments = $"-NoExit -C \"{escapedPS}\"";
}
else
{
arguments = $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
arguments = $"-C \"{escapedPS} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
}
info = ShellCommand.SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg);
@@ -245,39 +266,41 @@ namespace Microsoft.Plugin.Shell
string arguments;
if (_settings.LeaveShellOpen)
{
arguments = $"cmd.exe /k \"{command}\"";
arguments = $"cmd.exe /k {command}";
}
else
{
arguments = $"cmd.exe /c \"{command}\" & pause";
arguments = $"cmd.exe /c {command} & pause";
}
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (_settings.Shell == ExecutionShell.WindowsTerminalPowerShell)
{
string escapedPS = EscapePowerShellArgument(command);
string arguments;
if (_settings.LeaveShellOpen)
{
arguments = $"powershell -NoExit -C \"{command}\"";
arguments = $"powershell -NoExit -C \"{escapedPS}\"";
}
else
{
arguments = $"powershell -C \"{command}\"";
arguments = $"powershell -C \"{escapedPS}\"";
}
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (_settings.Shell == ExecutionShell.WindowsTerminalPowerShellSeven)
{
string escapedPS = EscapePowerShellArgument(command);
string arguments;
if (_settings.LeaveShellOpen)
{
arguments = $"pwsh.exe -NoExit -C \"{command}\"";
arguments = $"pwsh.exe -NoExit -C \"{escapedPS}\"";
}
else
{
arguments = $"pwsh.exe -C \"{command}\"";
arguments = $"pwsh.exe -C \"{escapedPS}\"";
}
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
@@ -301,7 +324,7 @@ namespace Microsoft.Plugin.Shell
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg);
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {filename} {arguments}", runAsVerbArg);
}
else
{
@@ -310,10 +333,11 @@ namespace Microsoft.Plugin.Shell
}
else
{
string escapedCmd = EscapeCmdArgument(command);
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {escapedCmd}", runAsVerbArg);
}
else
{
@@ -323,10 +347,11 @@ namespace Microsoft.Plugin.Shell
}
else
{
string escapedCmd = EscapeCmdArgument(command);
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {escapedCmd}", runAsVerbArg);
}
else
{
@@ -452,7 +477,7 @@ namespace Microsoft.Plugin.Shell
{
PluginName = Assembly.GetExecutingAssembly().GetName().Name,
Title = Properties.Resources.wox_plugin_cmd_run_as_administrator,
Glyph = "\xE7EF",
Glyph = "\uE7EF",
FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets",
AcceleratorKey = Key.Enter,
AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift,
@@ -466,7 +491,7 @@ namespace Microsoft.Plugin.Shell
{
PluginName = Assembly.GetExecutingAssembly().GetName().Name,
Title = Properties.Resources.wox_plugin_cmd_run_as_user,
Glyph = "\xE7EE",
Glyph = "\uE7EE",
FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets",
AcceleratorKey = Key.U,
AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift,

View File

@@ -42,9 +42,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

@@ -20,9 +20,7 @@
<ItemGroup>
<PackageReference Include="System.ServiceProcess.ServiceController" />
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
</ItemGroup>
<ItemGroup>

View File

@@ -35,9 +35,7 @@
<PackageReference Include="System.Configuration.ConfigurationManager">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
<PackageReference Include="System.Diagnostics.PerformanceCounter">
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
</PackageReference>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,369 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class CalculationTests
{
// Screen representing a standard 1920x1080 monitor at position (0,0)
private static readonly Rect StandardScreen = new Rect(0, 0, 1920, 1080);
// A typical toolbar window size (300x50 in WPF DIPs)
private static readonly Size ToolbarWindow = new Size(300, 50);
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret
/// What: Verifies toolbar is centered horizontally above the caret at screen center
/// Why: The most common placement scenario — regression here affects every user
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_CenterOfScreen_ShouldCenterToolbar()
{
var caret = new Point(960, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// X should be caret.X - window.Width/2 = 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y should be caret.Y - window.Height - 20 = 540 - 50 - 20 = 470
Assert.AreEqual(470.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — left edge clamping
/// What: Verifies toolbar is clamped to screen left when caret is near left edge
/// Why: Without clamping, toolbar would extend off-screen and be unusable
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearLeftEdge_ShouldClampToScreenLeft()
{
var caret = new Point(50, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 50 - 150 = -100, which is < screen.X (0), so X should be clamped to 0
Assert.AreEqual(0.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — right edge clamping
/// What: Verifies toolbar is clamped to screen right when caret is near right edge
/// Why: Toolbar extending past screen right would be clipped by the display
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearRightEdge_ShouldClampToScreenRight()
{
var caret = new Point(1900, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 1900 - 150 = 1750, left + window.Width = 1750 + 300 = 2050 > 1920
// So X should be clamped to screen.X + screen.Width - window.Width = 1920 - 300 = 1620
Assert.AreEqual(1620.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — top edge flip
/// What: Verifies toolbar is placed below the caret when there is no room above
/// Why: When caret is near top of screen, toolbar must flip below to remain visible
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_NearTopEdge_ShouldPlaceBelow()
{
var caret = new Point(960, 30);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// top = 30 - 50 - 20 = -40, which is < screen.Y (0)
// So Y should be caret.Y + 20 = 50 (placed below caret)
Assert.AreEqual(50.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — normal above placement
/// What: Verifies toolbar is placed above the caret when there is sufficient space
/// Why: Default behavior — toolbar should appear above to avoid occluding text below
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_SufficientSpaceAbove_ShouldPlaceAbove()
{
var caret = new Point(960, 200);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// top = 200 - 50 - 20 = 130, which is >= screen.Y (0)
Assert.AreEqual(130.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — multi-monitor offset
/// What: Verifies toolbar respects non-zero screen origin (second monitor at X=1920)
/// Why: Multi-monitor setups have offset screen coordinates — clamping must use screen.X
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_OffsetScreen_ShouldRespectScreenOrigin()
{
var screen = new Rect(1920, 0, 1920, 1080);
var caret = new Point(1950, 540);
var result = Calculation.GetRawCoordinatesFromCaret(caret, screen, ToolbarWindow);
// left = 1950 - 150 = 1800, which is < screen.X (1920)
// So X should be clamped to 1920
Assert.AreEqual(1920.0, result.X);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromCaret — exact boundary case
/// What: Verifies no clamping occurs when toolbar fits exactly at the boundary
/// Why: Off-by-one errors in boundary checks are common — this catches ≤ vs &lt; bugs
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromCaret_ExactBoundary_ShouldNotClamp()
{
var caret = new Point(150, 100);
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
// left = 150 - 150 = 0, which == screen.X, so no left clamp needed
Assert.AreEqual(0.0, result.X);
// top = 100 - 50 - 20 = 30, which >= 0
Assert.AreEqual(30.0, result.Y);
}
[TestMethod]
[ExpectedException(typeof(NotImplementedException))]
public void GetRawCoordinatesFromPosition_InvalidPosition_ShouldThrow()
{
Calculation.GetRawCoordinatesFromPosition((Position)999, StandardScreen, ToolbarWindow, 1.0);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Top
/// What: Verifies toolbar is horizontally centered at the top of the screen with offset
/// Why: Exact value test — catches formula regressions in the Top positioning branch
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopCenter_ShouldBeCenteredAtTop()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Top, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Bottom
/// What: Verifies toolbar is horizontally centered at the bottom of the screen
/// Why: Bottom placement subtracts window height + offset — easy to get wrong
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomCenter_ShouldBeCenteredAtBottom()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Bottom, StandardScreen, ToolbarWindow, 1.0);
// X: centered = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Center
/// What: Verifies toolbar is centered both horizontally and vertically
/// Why: Center is the simplest case — validates the baseline formula for both axes
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Center_ShouldBeTrulyCentered()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
Assert.AreEqual(810.0, result.X);
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - 25 = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopLeft
/// What: Verifies toolbar is placed at top-left corner with offset margin
/// Why: Corner placements use raw offset — validates the simplest X/Y path
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopLeft_ShouldBeAtTopLeftCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopRight
/// What: Verifies exact coordinates for top-right corner placement
/// Why: TopRight combines right-aligned X with top Y — validates both formula branches
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_TopRight_ShouldBeAtTopRightCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopRight, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
Assert.AreEqual(1596.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomLeft
/// What: Verifies exact coordinates for bottom-left corner placement
/// Why: BottomLeft combines left-aligned X with bottom Y — validates both formula branches
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomLeft_ShouldBeAtBottomLeftCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomLeft, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomRight
/// What: Verifies toolbar is placed at bottom-right corner with offset margin
/// Why: BottomRight uses the most complex formula (subtracts from both edges)
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_BottomRight_ShouldBeAtBottomRightCorner()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
Assert.AreEqual(1596.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
Assert.AreEqual(1006.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Left
/// What: Verifies toolbar is placed at left edge, vertically centered
/// Why: Left position uses offset for X and centering for Y — tests the mix
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Left_ShouldBeAtLeftMiddle()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Left, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.X);
// Y: centered vertically = 0 + 540 - 25 = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Right
/// What: Verifies toolbar is placed at right edge, vertically centered
/// Why: Right position subtracts window width from screen edge — tests the right-align formula
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_Right_ShouldBeAtRightMiddle()
{
var result = Calculation.GetRawCoordinatesFromPosition(Position.Right, StandardScreen, ToolbarWindow, 1.0);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 1920 - 324 = 1596
Assert.AreEqual(1596.0, result.X);
// Y: centered vertically = 515
Assert.AreEqual(515.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=1.5
/// What: Verifies that DPI scaling is applied to window size in centering formula
/// Why: High-DPI monitors are common — incorrect scaling makes toolbar appear off-center
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithHighDpi_ShouldScaleWindow()
{
double dpi = 1.5;
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - (300*1.5)/2 = 960 - 225 = 735
Assert.AreEqual(735.0, result.X);
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - (50*1.5)/2 = 540 - 37.5 = 502.5
Assert.AreEqual(502.5, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, TopLeft
/// What: Verifies that offset is NOT DPI-scaled for corner positions
/// Why: The 24px offset is a fixed margin — DPI scaling only affects window size
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithDpi2_TopLeft_ShouldUseOffset()
{
double dpi = 2.0;
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + offset = 0 + 24 = 24 (offset is not DPI-scaled)
Assert.AreEqual(24.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, BottomRight
/// What: Verifies DPI scaling of window size in bottom-right corner formula
/// Why: At 2x DPI, the window occupies 600×100 pixels — offset from edge must account for this
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_WithDpi2_BottomRight_ShouldScaleWindowSize()
{
double dpi = 2.0;
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, dpi);
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300*2 + 24) = 1920 - 624 = 1296
Assert.AreEqual(1296.0, result.X);
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50*2 + 24) = 1080 - 124 = 956
Assert.AreEqual(956.0, result.Y);
}
/// <summary>
/// Product code: Calculation.GetRawCoordinatesFromPosition with offset screen origin
/// What: Verifies screen.X is added as the base for TopLeft on a secondary monitor
/// Why: Without adding screen origin, toolbar appears on the wrong monitor
/// </summary>
[TestMethod]
public void GetRawCoordinatesFromPosition_OffsetScreen_ShouldRespectScreenOrigin()
{
var screen = new Rect(1920, 0, 1920, 1080);
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, screen, ToolbarWindow, 1.0);
// X: screen.X + offset = 1920 + 24 = 1944
Assert.AreEqual(1944.0, result.X);
// Y: screen.Y + offset = 0 + 24 = 24
Assert.AreEqual(24.0, result.Y);
}
}
}

View File

@@ -0,0 +1,265 @@
// 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.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerToys.PowerAccentKeyboardService;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class LanguagesTests
{
/// <summary>
/// Product code: Languages.GetDefaultLetterKey
/// What: Verifies that passing an empty language array returns an empty result
/// Why: Guards against NullReferenceException or incorrect fallback when no languages are configured
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_EmptyLanguageArray_ShouldReturnEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, Array.Empty<Language>());
Assert.AreEqual(0, result.Length);
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.FR
/// What: Verifies that French returns at least one accented character for the letter A
/// Why: Regression guard — ensures the French language mapping is wired up and non-empty
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_SingleLanguage_ShouldReturnNonEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
Assert.IsTrue(result.Length > 0, "French should have accented characters for A");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with multiple languages
/// What: Verifies that combining French and Spanish merges characters and removes duplicates
/// Why: Guards against duplicate entries when languages share accent characters (e.g., à)
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_MultipleLanguages_ShouldMergeAndDeduplicate()
{
var frResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
var spResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.SP });
var combinedResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR, Language.SP });
Assert.AreEqual(combinedResult.Length, combinedResult.Distinct().Count(), "Combined result should contain no duplicates");
foreach (var ch in frResult)
{
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain French character '{ch}'");
}
foreach (var ch in spResult)
{
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain Spanish character '{ch}'");
}
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.FR for VK_A
/// What: Verifies that French returns specific expected accent characters (à, â, æ)
/// Why: Pinpoints exact character expectations — catches silent data regressions in language tables
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_French_A_ShouldContainExpectedAccents()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
CollectionAssert.Contains(result, "à", "French should contain à");
CollectionAssert.Contains(result, "â", "French should contain â");
CollectionAssert.Contains(result, "æ", "French should contain æ");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_O
/// What: Verifies German returns the ö umlaut for the O key
/// Why: Core German character — regression would break German users' workflow
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_O_ShouldContainUmlaut()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_O, new[] { Language.DE });
CollectionAssert.Contains(result, "ö", "German should contain ö for O");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_U
/// What: Verifies German returns the ü umlaut for the U key
/// Why: Core German character — regression would break German users' workflow
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_U_ShouldContainUmlaut()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_U, new[] { Language.DE });
CollectionAssert.Contains(result, "ü", "German should contain ü for U");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_S
/// What: Verifies German returns the ß (Eszett) for the S key
/// Why: ß is unique to German — its absence would be a critical language regression
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_German_S_ShouldContainEszett()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.DE });
CollectionAssert.Contains(result, "ß", "German should contain ß for S");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SP for VK_N
/// What: Verifies Spanish returns the ñ character for the N key
/// Why: ñ is the most recognizable Spanish-specific character
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Spanish_N_ShouldContainEne()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_N, new[] { Language.SP });
CollectionAssert.Contains(result, "ñ", "Spanish should contain ñ for N");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.CZ for VK_C
/// What: Verifies Czech returns the č (c-caron) for the C key
/// Why: č is essential for Czech — its absence would break Czech language support
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Czech_C_ShouldContainCaron()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_C, new[] { Language.CZ });
CollectionAssert.Contains(result, "č", "Czech should contain č for C");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.PL for VK_L
/// What: Verifies Polish returns the ł (L with stroke) for the L key
/// Why: ł is fundamental to Polish — its absence would break Polish language support
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Polish_L_ShouldContainStroke()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_L, new[] { Language.PL });
CollectionAssert.Contains(result, "ł", "Polish should contain ł for L");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.VI for VK_A
/// What: Verifies Vietnamese returns a rich set of A variants (≥10 accented forms)
/// Why: Vietnamese has extensive diacritics — guards against truncation of the character table
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Vietnamese_A_ShouldContainMultipleAccents()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.VI });
Assert.IsTrue(result.Length >= 10, "Vietnamese should have many accented A characters");
CollectionAssert.Contains(result, "à", "Vietnamese should contain à");
CollectionAssert.Contains(result, "ă", "Vietnamese should contain ă");
CollectionAssert.Contains(result, "â", "Vietnamese should contain â");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_0
/// What: Verifies that digit 0 returns superscript (⁰) and subscript (₀) variants
/// Why: Special characters for digits are used in mathematical/scientific input
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Special_Digits_ShouldReturnSubscriptsSuperscripts()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_0, new[] { Language.SPECIAL });
Assert.IsTrue(result.Length > 0, "Special characters for 0 should exist");
CollectionAssert.Contains(result, "⁰", "Special 0 should contain superscript 0");
CollectionAssert.Contains(result, "₀", "Special 0 should contain subscript 0");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_1
/// What: Verifies that digit 1 returns fraction (½) and superscript (¹) variants
/// Why: Fraction characters are commonly needed for recipes, measurements, etc.
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Special_1_ShouldContainFractions()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_1, new[] { Language.SPECIAL });
CollectionAssert.Contains(result, "½", "Special 1 should contain ½");
CollectionAssert.Contains(result, "¹", "Special 1 should contain ¹");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.CUR
/// What: Verifies that Currency language returns well-known currency symbols (€, $, £)
/// Why: Currency symbols are the primary use case — tests exact symbol presence, not just non-null
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_Currency_ShouldReturnCurrencySymbols()
{
var euroResult = Languages.GetDefaultLetterKey(LetterKey.VK_E, new[] { Language.CUR });
CollectionAssert.Contains(euroResult, "€", "Currency VK_E should contain Euro symbol");
var dollarResult = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.CUR });
CollectionAssert.Contains(dollarResult, "$", "Currency VK_S should contain Dollar symbol");
var poundResult = Languages.GetDefaultLetterKey(LetterKey.VK_P, new[] { Language.CUR });
CollectionAssert.Contains(poundResult, "£", "Currency VK_P should contain Pound symbol");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_Q
/// What: Verifies that a key with no accented characters for a language returns empty
/// Why: Guards against spurious characters appearing for keys that should have no accents
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_UnusedKeyForLanguage_ShouldReturnEmpty()
{
var result = Languages.GetDefaultLetterKey(LetterKey.VK_Q, new[] { Language.DE });
Assert.AreEqual(0, result.Length, "German should have no accented characters for Q");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKeyALL (cache path via ConcurrentDictionary)
/// What: Verifies that calling with all languages twice returns the same cached array instance
/// Why: Cache hit should return the identical object reference, not a recomputed copy
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_AllLanguages_ShouldReturnCachedResults()
{
var allLanguages = Enum.GetValues<Language>();
var result1 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
var result2 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
Assert.IsTrue(result1.Length > 0, "All languages combined should have accented A characters");
CollectionAssert.AreEqual(result1, result2, "Cached results should be identical");
// Verify cache hit returns same object instance, not just equal contents
Assert.AreSame(result1, result2, "Second call should return cached reference");
}
/// <summary>
/// Product code: Languages.GetDefaultLetterKey across all LetterKey × Language combinations
/// What: Smoke-tests every letter key with all languages to ensure no exceptions are thrown
/// Why: Catches missing switch arms or null returns that would crash the toolbar at runtime
/// </summary>
[TestMethod]
public void GetDefaultLetterKey_AllLanguages_EachLetterKey_ShouldNotThrow()
{
var allLanguages = Enum.GetValues<Language>();
var allLetterKeys = Enum.GetValues<LetterKey>();
foreach (var letterKey in allLetterKeys)
{
var result = Languages.GetDefaultLetterKey(letterKey, allLanguages);
Assert.IsNotNull(result, $"Result for {letterKey} should not be null");
Assert.AreEqual(result.Length, result.Distinct().Count(), $"Result for {letterKey} should have no duplicates");
}
}
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class PointTests
{
/// <summary>
/// Product code: Point() default constructor
/// What: Verifies X and Y initialize to zero
/// Why: Default state must be deterministic — uninitialized coordinates cause misplacement
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var point = new Point();
Assert.AreEqual(0, point.X);
Assert.AreEqual(0, point.Y);
}
/// <summary>
/// Product code: Point(double, double) constructor
/// What: Verifies that fractional coordinates are preserved exactly
/// Why: Sub-pixel precision is needed for DPI-aware positioning
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetCoordinates()
{
var point = new Point(3.5, 7.2);
Assert.AreEqual(3.5, point.X);
Assert.AreEqual(7.2, point.Y);
}
/// <summary>
/// Product code: Point(int, int) constructor
/// What: Verifies integer coordinates are stored as doubles without loss
/// Why: Implicit int→double conversion must be exact for pixel-aligned positioning
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetCoordinates()
{
var point = new Point(10, 20);
Assert.AreEqual(10.0, point.X);
Assert.AreEqual(20.0, point.Y);
}
/// <summary>
/// Product code: Point(System.Drawing.Point) constructor
/// What: Verifies conversion from System.Drawing.Point preserves coordinates
/// Why: Win32 API interop uses System.Drawing.Point — conversion must be lossless
/// </summary>
[TestMethod]
public void DrawingPointConstructor_ShouldConvertCoordinates()
{
var drawingPoint = new System.Drawing.Point(15, 25);
var point = new Point(drawingPoint);
Assert.AreEqual(15.0, point.X);
Assert.AreEqual(25.0, point.Y);
}
/// <summary>
/// Product code: Point implicit operator from System.Drawing.Point
/// What: Verifies implicit conversion works at assignment sites
/// Why: Enables seamless interop without explicit casts in calling code
/// </summary>
[TestMethod]
public void ImplicitConversion_FromDrawingPoint_ShouldWork()
{
Point point = new System.Drawing.Point(100, 200);
Assert.AreEqual(100.0, point.X);
Assert.AreEqual(200.0, point.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double)
/// What: Verifies scalar division halves both coordinates
/// Why: Core arithmetic for DPI normalization
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideCoordinates()
{
var point = new Point(10.0, 20.0);
var result = point / 2.0;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(10.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) with negative divisor
/// What: Verifies negative divisor negates both coordinates
/// Why: Ensures correct sign propagation in coordinate math
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldNegateCoordinates()
{
var point = new Point(10.0, 20.0);
var result = point / -2.0;
Assert.AreEqual(-5.0, result.X);
Assert.AreEqual(-10.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity coordinates from corrupting toolbar placement
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var point = new Point(10.0, 20.0);
_ = point / 0.0;
}
/// <summary>
/// Product code: Point operator /(Point, Point)
/// What: Verifies component-wise division (X/X, Y/Y)
/// Why: Used for coordinate space transformations
/// </summary>
[TestMethod]
public void DivisionByPoint_ShouldDivideComponentwise()
{
var point = new Point(10.0, 20.0);
var divider = new Point(2.0, 5.0);
var result = point / divider;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(4.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for X
/// What: Verifies that a divider with X=0 throws DivideByZeroException
/// Why: X=0 in a divider would produce Infinity for the X coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithZeroX_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(0.0, 5.0);
_ = point / divider;
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for Y
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
/// Why: Y=0 in a divider would produce Infinity for the Y coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithZeroY_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(5.0, 0.0);
_ = point / divider;
}
/// <summary>
/// Product code: Point(double, double) constructor with negatives
/// What: Verifies that negative coordinates are stored correctly
/// Why: Negative coordinates are valid in multi-monitor setups (left/above primary)
/// </summary>
[TestMethod]
public void NegativeCoordinates_ShouldBeAllowed()
{
var point = new Point(-5.5, -10.3);
Assert.AreEqual(-5.5, point.X);
Assert.AreEqual(-10.3, point.Y);
}
/// <summary>
/// Product code: Point operator /(Point, double) with fractional divisor
/// What: Verifies dividing by 0.5 effectively doubles coordinates
/// Why: Fractional DPI values (1.25x, 1.5x) are common in production
/// </summary>
[TestMethod]
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
{
var point = new Point(10.0, 20.0);
var result = point / 0.5;
Assert.AreEqual(20.0, result.X);
Assert.AreEqual(40.0, result.Y);
}
/// <summary>
/// Product code: Point operator /(Point, Point) zero guard for both X and Y
/// What: Verifies that a divider with both X=0 and Y=0 throws
/// Why: Degenerate origin-point divider must be rejected — tests the first guard hit
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByPoint_WithBothZero_ShouldThrow()
{
var point = new Point(10.0, 20.0);
var divider = new Point(0.0, 0.0);
_ = point / divider;
}
}
}

View File

@@ -0,0 +1,25 @@
<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" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerAccent.Core.Tests\</OutputPath>
<RootNamespace>PowerAccent.Core.UnitTests</RootNamespace>
<AssemblyName>PowerToys.PowerAccent.Core.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,223 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class RectTests
{
/// <summary>
/// Product code: Rect() default constructor
/// What: Verifies all properties initialize to zero
/// Why: Default state must be well-defined to avoid uninitialized geometry bugs
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var rect = new Rect();
Assert.AreEqual(0, rect.X);
Assert.AreEqual(0, rect.Y);
Assert.AreEqual(0, rect.Width);
Assert.AreEqual(0, rect.Height);
}
/// <summary>
/// Product code: Rect(int, int, int, int) constructor
/// What: Verifies that integer arguments are correctly stored as doubles
/// Why: Implicit int→double conversion must preserve exact values
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetAllProperties()
{
var rect = new Rect(10, 20, 800, 600);
Assert.AreEqual(10.0, rect.X);
Assert.AreEqual(20.0, rect.Y);
Assert.AreEqual(800.0, rect.Width);
Assert.AreEqual(600.0, rect.Height);
}
/// <summary>
/// Product code: Rect(double, double, double, double) constructor
/// What: Verifies that fractional double values are stored exactly
/// Why: Ensures no rounding or truncation occurs during construction
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetAllProperties()
{
var rect = new Rect(1.5, 2.5, 100.3, 200.7);
Assert.AreEqual(1.5, rect.X);
Assert.AreEqual(2.5, rect.Y);
Assert.AreEqual(100.3, rect.Width);
Assert.AreEqual(200.7, rect.Height);
}
/// <summary>
/// Product code: Rect(Point, Size) constructor
/// What: Verifies that Point and Size components map to correct Rect properties
/// Why: X/Y come from Point, Width/Height from Size — a swap would silently corrupt layout
/// </summary>
[TestMethod]
public void PointSizeConstructor_ShouldSetFromComponents()
{
var point = new Point(50.0, 100.0);
var size = new Size(400.0, 300.0);
var rect = new Rect(point, size);
Assert.AreEqual(50.0, rect.X);
Assert.AreEqual(100.0, rect.Y);
Assert.AreEqual(400.0, rect.Width);
Assert.AreEqual(300.0, rect.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double)
/// What: Verifies component-wise division by a scalar
/// Why: Used for DPI scaling — incorrect division would misposition the toolbar
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideAllComponents()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var result = rect / 2.0;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(10.0, result.Y);
Assert.AreEqual(50.0, result.Width);
Assert.AreEqual(100.0, result.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity values from propagating through layout calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
_ = rect / 0.0;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect)
/// What: Verifies component-wise division (X/X, Y/Y, Width/Width, Height/Height)
/// Why: Used for coordinate system transformations between screen and window space
/// </summary>
[TestMethod]
public void DivisionByRect_ShouldDivideComponentwise()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(2.0, 5.0, 10.0, 20.0);
var result = rect / divider;
Assert.AreEqual(5.0, result.X);
Assert.AreEqual(4.0, result.Y);
Assert.AreEqual(10.0, result.Width);
Assert.AreEqual(10.0, result.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for X component
/// What: Verifies that a divider with X=0 throws DivideByZeroException
/// Why: X=0 in a divider Rect would produce Infinity for the X coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroX_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(0.0, 5.0, 10.0, 20.0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Y component
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
/// Why: Y=0 in a divider Rect would produce Infinity for the Y coordinate
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroY_ShouldThrow()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var divider = new Rect(5.0, 0.0, 10.0, 20.0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Width component
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
/// Why: Guards against producing Infinity values that propagate through calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroWidth_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(1, 1, 0, 1);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for Height component
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
/// Why: Guards against producing Infinity values that propagate through calculations
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithZeroHeight_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(1, 1, 1, 0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect operator /(Rect, Rect) zero guard for all components
/// What: Verifies that a divider with all zero components throws DivideByZeroException
/// Why: Degenerate all-zero divider must be caught — tests the first guard hit (X=0)
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByRect_WithAllZeroComponents_ShouldThrow()
{
var rect = new Rect(10, 20, 100, 200);
var divider = new Rect(0, 0, 0, 0);
_ = rect / divider;
}
/// <summary>
/// Product code: Rect(double, double, double, double) constructor
/// What: Verifies that negative X/Y coordinates are allowed
/// Why: Multi-monitor setups can have negative screen coordinates (left/above primary)
/// </summary>
[TestMethod]
public void NegativeCoordinates_ShouldBeAllowed()
{
var rect = new Rect(-100.0, -200.0, 400.0, 300.0);
Assert.AreEqual(-100.0, rect.X);
Assert.AreEqual(-200.0, rect.Y);
Assert.AreEqual(400.0, rect.Width);
Assert.AreEqual(300.0, rect.Height);
}
/// <summary>
/// Product code: Rect operator /(Rect, double) with negative divisor
/// What: Verifies that negative divisor negates all components
/// Why: Ensures sign propagation is correct — used in coordinate flipping scenarios
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldNegateComponents()
{
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
var result = rect / -1.0;
Assert.AreEqual(-10.0, result.X);
Assert.AreEqual(-20.0, result.Y);
Assert.AreEqual(-100.0, result.Width);
Assert.AreEqual(-200.0, result.Height);
}
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerAccent.Core.UnitTests
{
[TestClass]
public class SizeTests
{
/// <summary>
/// Product code: Size() default constructor
/// What: Verifies Width and Height initialize to zero
/// Why: Default state must be deterministic to avoid layout corruption
/// </summary>
[TestMethod]
public void DefaultConstructor_ShouldInitializeToZero()
{
var size = new Size();
Assert.AreEqual(0, size.Width);
Assert.AreEqual(0, size.Height);
}
/// <summary>
/// Product code: Size(double, double) constructor
/// What: Verifies fractional values are preserved exactly
/// Why: Ensures no rounding during construction for sub-pixel precision
/// </summary>
[TestMethod]
public void DoubleConstructor_ShouldSetDimensions()
{
var size = new Size(100.5, 200.3);
Assert.AreEqual(100.5, size.Width);
Assert.AreEqual(200.3, size.Height);
}
/// <summary>
/// Product code: Size(int, int) constructor
/// What: Verifies integer dimensions are stored as doubles without loss
/// Why: Implicit int→double conversion must be exact
/// </summary>
[TestMethod]
public void IntConstructor_ShouldSetDimensions()
{
var size = new Size(800, 600);
Assert.AreEqual(800.0, size.Width);
Assert.AreEqual(600.0, size.Height);
}
/// <summary>
/// Product code: Size implicit operator from System.Drawing.Size
/// What: Verifies implicit conversion from System.Drawing.Size works
/// Why: Interop boundary — System.Drawing types are used by Win32 APIs
/// </summary>
[TestMethod]
public void ImplicitConversion_FromDrawingSize_ShouldWork()
{
Size size = new System.Drawing.Size(1920, 1080);
Assert.AreEqual(1920.0, size.Width);
Assert.AreEqual(1080.0, size.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double)
/// What: Verifies scalar division halves both dimensions
/// Why: Core arithmetic used for DPI scaling — must produce exact results
/// </summary>
[TestMethod]
public void DivisionByScalar_ShouldDivideDimensions()
{
var size = new Size(100.0, 200.0);
var result = size / 2.0;
Assert.AreEqual(50.0, result.Width);
Assert.AreEqual(100.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) with fractional divisor
/// What: Verifies that dividing by 0.5 effectively doubles dimensions
/// Why: Fractional DPI values are real (e.g., 1.25x, 1.5x scaling)
/// </summary>
[TestMethod]
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
{
var size = new Size(100.0, 200.0);
var result = size / 0.5;
Assert.AreEqual(200.0, result.Width);
Assert.AreEqual(400.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) zero guard
/// What: Verifies that dividing by zero throws DivideByZeroException
/// Why: Prevents Infinity dimensions that would corrupt window layout
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByScalar_WithZero_ShouldThrow()
{
var size = new Size(100.0, 200.0);
_ = size / 0.0;
}
/// <summary>
/// Product code: Size operator /(Size, Size)
/// What: Verifies component-wise division (Width/Width, Height/Height)
/// Why: Used for computing scaling ratios between two rectangles
/// </summary>
[TestMethod]
public void DivisionBySize_ShouldDivideComponentwise()
{
var size = new Size(100.0, 200.0);
var divider = new Size(10.0, 20.0);
var result = size / divider;
Assert.AreEqual(10.0, result.Width);
Assert.AreEqual(10.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero Width guard
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
/// Why: Width=0 divider would produce Infinity — must be caught early
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithZeroWidth_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(0.0, 20.0);
_ = size / divider;
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero Height guard
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
/// Why: Height=0 divider would produce Infinity — must be caught early
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithZeroHeight_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(10.0, 0.0);
_ = size / divider;
}
/// <summary>
/// Product code: Size operator /(Size, double) with negative divisor
/// What: Verifies that negative divisor correctly negates both dimensions
/// Why: Ensures sign propagation is correct in arithmetic
/// </summary>
[TestMethod]
public void DivisionByScalar_WithNegativeDivider_ShouldWork()
{
var size = new Size(100.0, 200.0);
var result = size / -2.0;
Assert.AreEqual(-50.0, result.Width);
Assert.AreEqual(-100.0, result.Height);
}
/// <summary>
/// Product code: Size operator /(Size, double) with very small divisor
/// What: Verifies division by a small number produces expected large result
/// Why: Near-zero divisors (e.g., very low DPI) must not crash or produce NaN
/// </summary>
[TestMethod]
public void DivisionByScalar_WithVerySmallDivider_ShouldYieldLargeResult()
{
var size = new Size(1.0, 1.0);
var result = size / 0.001;
Assert.AreEqual(1000.0, result.Width, 0.01);
Assert.AreEqual(1000.0, result.Height, 0.01);
}
/// <summary>
/// Product code: Size operator /(Size, Size) zero guard for both dimensions
/// What: Verifies that a divider with both Width=0 and Height=0 throws
/// Why: Degenerate zero-size divider must be rejected — tests the first guard hit
/// </summary>
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionBySize_WithBothZeroDimensions_ShouldThrow()
{
var size = new Size(100.0, 200.0);
var divider = new Size(0.0, 0.0);
_ = size / divider;
}
}
}

View File

@@ -58,7 +58,7 @@ public struct Rect
public static Rect operator /(Rect rect, Rect divider)
{
if (divider.X == 0 || divider.Y == 0)
if (divider.X == 0 || divider.Y == 0 || divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}

View File

@@ -42,7 +42,7 @@ public struct Size
public static Size operator /(Size size, Size divider)
{
if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0)
if (divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}

View File

@@ -33,4 +33,10 @@
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PowerToys.PowerAccent.Core.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -28,11 +28,7 @@
so it doesn't conflict with the dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="System.Diagnostics.EventLog">
<!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets,
so it doesn't conflict with the dll coming from .NET SDK. -->
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,48 @@
// 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.Common.Utils;
using PowerDisplay.Models;
namespace PowerDisplay.Common.Extensions
{
/// <summary>
/// Extension methods for <see cref="CustomVcpValueMapping"/> that provide display-related properties.
/// These depend on <see cref="VcpNames"/> and are therefore kept in PowerDisplay.Lib
/// rather than in the shared PowerDisplay.Models project.
/// </summary>
public static class CustomVcpValueMappingExtensions
{
/// <summary>
/// Gets the display name for the VCP code (e.g., "Select Color Preset").
/// </summary>
public static string GetVcpCodeDisplayName(this CustomVcpValueMapping mapping)
{
return VcpNames.GetCodeName(mapping.VcpCode);
}
/// <summary>
/// Gets the formatted display name for the VCP value (e.g., "6500K (0x05)").
/// </summary>
public static string GetValueDisplayName(this CustomVcpValueMapping mapping)
{
return VcpNames.GetFormattedValueName(mapping.VcpCode, mapping.Value);
}
/// <summary>
/// Gets a summary string for display in the UI list.
/// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)"
/// </summary>
public static string GetDisplaySummary(this CustomVcpValueMapping mapping)
{
var baseSummary = $"{VcpNames.GetValueName(mapping.VcpCode, mapping.Value) ?? $"0x{mapping.Value:X2}"} \u2192 {mapping.CustomName}";
if (!mapping.ApplyToAll && !string.IsNullOrEmpty(mapping.TargetMonitorName))
{
return $"{baseSummary} ({mapping.TargetMonitorName})";
}
return baseSummary;
}
}
}

View File

@@ -2,13 +2,13 @@
// 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.Common.Models;
using PowerDisplay.Models;
namespace PowerDisplay.Common.Interfaces
{
/// <summary>
/// Interface for profile management service.
/// Provides abstraction for loading, saving, and managing PowerDisplay profiles.
/// Provides abstraction for loading and saving PowerDisplay profiles.
/// Enables dependency injection and unit testing.
/// </summary>
public interface IProfileService
@@ -25,38 +25,5 @@ namespace PowerDisplay.Common.Interfaces
/// <param name="profiles">The profiles collection to save.</param>
/// <returns>True if save was successful, false otherwise.</returns>
bool SaveProfiles(PowerDisplayProfiles profiles);
/// <summary>
/// Adds or updates a profile in the collection and persists to disk.
/// </summary>
/// <param name="profile">The profile to add or update.</param>
/// <returns>True if operation was successful, false otherwise.</returns>
bool AddOrUpdateProfile(PowerDisplayProfile profile);
/// <summary>
/// Removes a profile by name and persists to disk.
/// </summary>
/// <param name="profileName">The name of the profile to remove.</param>
/// <returns>True if profile was found and removed, false otherwise.</returns>
bool RemoveProfile(string profileName);
/// <summary>
/// Gets a profile by name.
/// </summary>
/// <param name="profileName">The name of the profile to retrieve.</param>
/// <returns>The profile if found, null otherwise.</returns>
PowerDisplayProfile? GetProfile(string profileName);
/// <summary>
/// Checks if the profiles file exists.
/// </summary>
/// <returns>True if profiles file exists, false otherwise.</returns>
bool ProfilesFileExists();
/// <summary>
/// Gets the path to the profiles file.
/// </summary>
/// <returns>The full path to the profiles file.</returns>
string GetProfilesFilePath();
}
}

View File

@@ -1,88 +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;
using PowerDisplay.Common.Utils;
namespace PowerDisplay.Common.Models
{
/// <summary>
/// Represents a custom name mapping for a VCP code value.
/// Used to override the default VCP value names with user-defined names.
/// This class is shared between PowerDisplay app and Settings UI.
/// </summary>
public class CustomVcpValueMapping
{
/// <summary>
/// Gets or sets the VCP code (e.g., 0x14 for color temperature, 0x60 for input source).
/// </summary>
[JsonPropertyName("vcpCode")]
public byte VcpCode { get; set; }
/// <summary>
/// Gets or sets the VCP value to map (e.g., 0x11 for HDMI-1).
/// </summary>
[JsonPropertyName("value")]
public int Value { get; set; }
/// <summary>
/// Gets or sets the custom name to display instead of the default name.
/// </summary>
[JsonPropertyName("customName")]
public string CustomName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this mapping applies to all monitors.
/// When true, the mapping is applied globally. When false, only applies to TargetMonitorId.
/// </summary>
[JsonPropertyName("applyToAll")]
public bool ApplyToAll { get; set; } = true;
/// <summary>
/// Gets or sets the target monitor ID when ApplyToAll is false.
/// This is the monitor's unique identifier.
/// </summary>
[JsonPropertyName("targetMonitorId")]
public string TargetMonitorId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the target monitor display name (for UI display only, not serialized).
/// </summary>
[JsonIgnore]
public string TargetMonitorName { get; set; } = string.Empty;
/// <summary>
/// Gets the display name for the VCP code (for UI display).
/// Uses VcpNames.GetCodeName() to get the standard MCCS VCP code name.
/// Note: For localized display in Settings UI, use VcpCodeToDisplayNameConverter instead.
/// </summary>
[JsonIgnore]
public string VcpCodeDisplayName => VcpNames.GetCodeName(VcpCode);
/// <summary>
/// Gets the display name for the VCP value (using built-in mapping).
/// </summary>
[JsonIgnore]
public string ValueDisplayName => VcpNames.GetFormattedValueName(VcpCode, Value);
/// <summary>
/// Gets a summary string for display in the UI list.
/// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)"
/// </summary>
[JsonIgnore]
public string DisplaySummary
{
get
{
var baseSummary = $"{VcpNames.GetValueName(VcpCode, Value) ?? $"0x{Value:X2}"} → {CustomName}";
if (!ApplyToAll && !string.IsNullOrEmpty(TargetMonitorName))
{
return $"{baseSummary} ({TargetMonitorName})";
}
return baseSummary;
}
}
}
}

View File

@@ -39,5 +39,6 @@
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\PowerDisplay.Models\PowerDisplay.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using PowerDisplay.Common.Models;
namespace PowerDisplay.Common.Serialization
{
/// <summary>
/// JSON serialization context for MonitorState types.
/// Provides source-generated serialization for Native AOT compatibility.
/// Separated from ProfileSerializationContext which moved to PowerDisplay.Models.
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
IncludeFields = true)]
[JsonSerializable(typeof(MonitorStateFile))]
[JsonSerializable(typeof(MonitorStateEntry))]
[JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))]
public partial class MonitorStateSerializationContext : JsonSerializerContext
{
}
}

View File

@@ -156,7 +156,7 @@ namespace PowerDisplay.Common.Services
}
var json = File.ReadAllText(_stateFilePath);
var stateFile = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.MonitorStateFile);
var stateFile = JsonSerializer.Deserialize(json, MonitorStateSerializationContext.Default.MonitorStateFile);
if (stateFile?.Monitors != null)
{
@@ -257,7 +257,7 @@ namespace PowerDisplay.Common.Services
};
}
return JsonSerializer.Serialize(stateFile, ProfileSerializationContext.Default.MonitorStateFile);
return JsonSerializer.Serialize(stateFile, MonitorStateSerializationContext.Default.MonitorStateFile);
}
/// <summary>

View File

@@ -2,249 +2,52 @@
// 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.Text.Json;
using ManagedCommon;
using PowerDisplay.Common.Interfaces;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Serialization;
using PowerDisplay.Models;
using ModelsProfileHelper = PowerDisplay.Models.ProfileHelper;
namespace PowerDisplay.Common.Services
{
/// <summary>
/// Centralized service for managing PowerDisplay profiles storage and retrieval.
/// Provides unified access to profile data for PowerDisplay, Settings UI, and LightSwitch modules.
/// Thread-safe and AOT-compatible.
/// Thin facade over <see cref="ProfileHelper"/> that satisfies <see cref="IProfileService"/>
/// and provides named static entry points for PowerDisplay.exe callers.
/// All locking and compound-operation atomicity is handled by <see cref="PowerDisplay.Models.ProfileHelper"/>.
/// </summary>
public class ProfileService : IProfileService
{
private const string LogPrefix = "[ProfileService]";
private static readonly object _lock = new object();
/// <summary>
/// Gets the singleton instance of the ProfileService.
/// Use this for dependency injection or when interface-based access is needed.
/// </summary>
public static IProfileService Instance { get; } = new ProfileService();
/// <summary>
/// Initializes a new instance of the <see cref="ProfileService"/> class.
/// Private constructor to enforce singleton pattern for instance-based access.
/// Static methods remain available for backward compatibility.
/// </summary>
private ProfileService()
{
}
/// <summary>
/// Loads PowerDisplay profiles from disk.
/// Thread-safe operation with automatic legacy profile cleanup.
/// </summary>
/// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails</returns>
public static PowerDisplayProfiles LoadProfiles()
{
lock (_lock)
{
var (profiles, _) = LoadProfilesInternal();
return profiles;
}
}
/// <inheritdoc cref="ModelsProfileHelper.LoadProfiles"/>
public static PowerDisplayProfiles LoadProfiles() => ModelsProfileHelper.LoadProfiles();
/// <summary>
/// Saves PowerDisplay profiles to disk.
/// Thread-safe operation with automatic timestamp update and legacy profile cleanup.
/// </summary>
/// <param name="profiles">The profiles collection to save</param>
/// <returns>True if save was successful, false otherwise</returns>
public static bool SaveProfiles(PowerDisplayProfiles profiles)
{
lock (_lock)
{
if (profiles == null)
{
Logger.LogWarning($"{LogPrefix} Cannot save null profiles");
return false;
}
/// <inheritdoc cref="ModelsProfileHelper.SaveProfiles"/>
public static bool SaveProfiles(PowerDisplayProfiles profiles) => ModelsProfileHelper.SaveProfiles(profiles);
var (success, _) = SaveProfilesInternal(profiles);
return success;
}
}
/// <inheritdoc cref="ModelsProfileHelper.AddOrUpdateProfile"/>
public static bool AddOrUpdateProfile(PowerDisplayProfile profile) => ModelsProfileHelper.AddOrUpdateProfile(profile);
/// <summary>
/// Adds or updates a profile in the collection and persists to disk.
/// Thread-safe operation.
/// </summary>
/// <param name="profile">The profile to add or update</param>
/// <returns>True if operation was successful, false otherwise</returns>
public static bool AddOrUpdateProfile(PowerDisplayProfile profile)
{
lock (_lock)
{
if (profile == null || !profile.IsValid())
{
Logger.LogWarning($"{LogPrefix} Cannot add invalid profile");
return false;
}
/// <inheritdoc cref="ModelsProfileHelper.RenameAndUpdateProfile"/>
public static bool RenameAndUpdateProfile(string oldName, PowerDisplayProfile newProfile) => ModelsProfileHelper.RenameAndUpdateProfile(oldName, newProfile);
var (profiles, _) = LoadProfilesInternal();
profiles.SetProfile(profile);
/// <inheritdoc cref="ModelsProfileHelper.RemoveProfile"/>
public static bool RemoveProfile(string profileName) => ModelsProfileHelper.RemoveProfile(profileName);
var (success, _) = SaveProfilesInternal(profiles);
return success;
}
}
/// <inheritdoc cref="ModelsProfileHelper.GetProfile"/>
public static PowerDisplayProfile? GetProfile(string profileName) => ModelsProfileHelper.GetProfile(profileName);
/// <summary>
/// Removes a profile by name and persists to disk.
/// Thread-safe operation.
/// </summary>
/// <param name="profileName">The name of the profile to remove</param>
/// <returns>True if profile was found and removed, false otherwise</returns>
public static bool RemoveProfile(string profileName)
{
lock (_lock)
{
var (profiles, _) = LoadProfilesInternal();
bool removed = profiles.RemoveProfile(profileName);
if (removed)
{
SaveProfilesInternal(profiles);
}
return removed;
}
}
/// <summary>
/// Gets a profile by name.
/// Thread-safe operation.
/// </summary>
/// <param name="profileName">The name of the profile to retrieve</param>
/// <returns>The profile if found, null otherwise</returns>
public static PowerDisplayProfile? GetProfile(string profileName)
{
lock (_lock)
{
var (profiles, _) = LoadProfilesInternal();
return profiles.GetProfile(profileName);
}
}
/// <summary>
/// Checks if the profiles file exists.
/// </summary>
/// <returns>True if profiles file exists, false otherwise</returns>
public static bool ProfilesFileExists()
{
try
{
return File.Exists(PathConstants.ProfilesFilePath);
}
catch (Exception ex)
{
Logger.LogError($"{LogPrefix} Error checking if profiles file exists: {ex.Message}");
return false;
}
}
/// <summary>
/// Gets the path to the profiles file.
/// </summary>
/// <returns>The full path to the profiles file</returns>
public static string GetProfilesFilePath()
{
return PathConstants.ProfilesFilePath;
}
// Internal methods without lock for use within already-locked contexts
// Returns tuple with result and optional log message
private static (PowerDisplayProfiles Profiles, string? Message) LoadProfilesInternal()
{
try
{
var filePath = PathConstants.ProfilesFilePath;
PathConstants.EnsurePowerDisplayFolderExists();
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath);
var profiles = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles);
if (profiles != null)
{
profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase));
return (profiles, $"Loaded {profiles.Profiles.Count} profiles from {filePath}");
}
}
else
{
return (new PowerDisplayProfiles(), $"No profiles file found at {filePath}, returning empty collection");
}
return (new PowerDisplayProfiles(), null);
}
catch (Exception ex)
{
Logger.LogError($"{LogPrefix} Failed to load profiles: {ex.Message}");
return (new PowerDisplayProfiles(), null);
}
}
// Returns tuple with success status and optional log message
private static (bool Success, string? Message) SaveProfilesInternal(PowerDisplayProfiles profiles)
{
try
{
if (profiles == null)
{
return (false, null);
}
PathConstants.EnsurePowerDisplayFolderExists();
profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase));
profiles.LastUpdated = DateTime.UtcNow;
var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles);
var filePath = PathConstants.ProfilesFilePath;
File.WriteAllText(filePath, json);
return (true, $"Saved {profiles.Profiles.Count} profiles to {filePath}");
}
catch (Exception ex)
{
Logger.LogError($"{LogPrefix} Failed to save profiles: {ex.Message}");
return (false, null);
}
}
// IProfileService Implementation
// Explicit interface implementation to satisfy IProfileService
// These methods delegate to the static methods for backward compatibility
// IProfileService explicit implementation
/// <inheritdoc/>
PowerDisplayProfiles IProfileService.LoadProfiles() => LoadProfiles();
PowerDisplayProfiles IProfileService.LoadProfiles() => ModelsProfileHelper.LoadProfiles();
/// <inheritdoc/>
bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => SaveProfiles(profiles);
/// <inheritdoc/>
bool IProfileService.AddOrUpdateProfile(PowerDisplayProfile profile) => AddOrUpdateProfile(profile);
/// <inheritdoc/>
bool IProfileService.RemoveProfile(string profileName) => RemoveProfile(profileName);
/// <inheritdoc/>
PowerDisplayProfile? IProfileService.GetProfile(string profileName) => GetProfile(profileName);
/// <inheritdoc/>
bool IProfileService.ProfilesFileExists() => ProfilesFileExists();
/// <inheritdoc/>
string IProfileService.GetProfilesFilePath() => GetProfilesFilePath();
bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => ModelsProfileHelper.SaveProfiles(profiles);
}
}

View File

@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using PowerDisplay.Common.Drivers;
using PowerDisplay.Common.Models;
using PowerDisplay.Models;
namespace PowerDisplay.Common.Utils
{

View File

@@ -5,14 +5,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PowerDisplay.Common.Models;
using PowerDisplay.Models;
namespace PowerDisplay.Common.Utils
{
/// <summary>
/// Provides human-readable names for VCP codes and their values based on MCCS v2.2a specification.
/// Combines VCP code names (e.g., 0x10 = "Brightness") and VCP value names (e.g., 0x14:0x05 = "6500K").
/// Supports localization through the LocalizedCodeNameProvider delegate.
/// </summary>
public static class VcpNames
{
@@ -249,20 +248,11 @@ namespace PowerDisplay.Common.Utils
/// <summary>
/// Get the friendly name for a VCP code.
/// Uses LocalizedCodeNameProvider if set; falls back to built-in MCCS names if not.
/// </summary>
/// <param name="code">VCP code (e.g., 0x10)</param>
/// <returns>Friendly name, or hex representation if unknown</returns>
public static string GetCodeName(byte code)
{
// Try localized name first
var localizedName = LocalizedCodeNameProvider?.Invoke(code);
if (!string.IsNullOrEmpty(localizedName))
{
return localizedName;
}
// Fallback to built-in MCCS names
return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})";
}
@@ -410,17 +400,7 @@ namespace PowerDisplay.Common.Utils
};
/// <summary>
/// Get all known values for a VCP code
/// </summary>
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
/// <returns>Dictionary of value to name mappings, or null if no mappings exist</returns>
public static IReadOnlyDictionary<int, string>? GetValueMappings(byte vcpCode)
{
return ValueNames.TryGetValue(vcpCode, out var values) ? values : null;
}
/// <summary>
/// Get human-readable name for a VCP value
/// Get human-readable name for a VCP value.
/// </summary>
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
/// <param name="value">Value to translate</param>
@@ -439,7 +419,7 @@ namespace PowerDisplay.Common.Utils
}
/// <summary>
/// Get formatted display name for a VCP value (with hex value in parentheses)
/// Get formatted display name for a VCP value (with hex value in parentheses).
/// </summary>
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
/// <param name="value">Value to translate</param>
@@ -458,21 +438,17 @@ namespace PowerDisplay.Common.Utils
/// <summary>
/// Get human-readable name for a VCP value with custom mapping support.
/// Custom mappings take priority over built-in mappings.
/// Monitor ID is required to properly filter monitor-specific mappings.
/// </summary>
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
/// <param name="value">Value to translate</param>
/// <param name="customMappings">Optional custom mappings that take priority</param>
/// <param name="monitorId">Monitor ID to filter mappings</param>
/// <param name="customMappings">Custom mappings that take priority over built-in names</param>
/// <param name="monitorId">Monitor ID to filter monitor-specific mappings</param>
/// <returns>Name string like "sRGB" or null if unknown</returns>
public static string? GetValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId)
{
// 1. Priority: Check custom mappings first
if (customMappings != null)
{
// Find a matching custom mapping:
// - ApplyToAll = true (global), OR
// - ApplyToAll = false AND TargetMonitorId matches the given monitorId
var custom = customMappings.FirstOrDefault(m =>
m.VcpCode == vcpCode &&
m.Value == value &&
@@ -487,26 +463,5 @@ namespace PowerDisplay.Common.Utils
// 2. Fallback to built-in mappings
return GetValueName(vcpCode, value);
}
/// <summary>
/// Get formatted display name for a VCP value with custom mapping support.
/// Custom mappings take priority over built-in mappings.
/// Monitor ID is required to properly filter monitor-specific mappings.
/// </summary>
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
/// <param name="value">Value to translate</param>
/// <param name="customMappings">Optional custom mappings that take priority</param>
/// <param name="monitorId">Monitor ID to filter mappings</param>
/// <returns>Formatted string like "sRGB (0x01)" or "0x01" if unknown</returns>
public static string GetFormattedValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId)
{
var name = GetValueName(vcpCode, value, customMappings, monitorId);
if (name != null)
{
return $"{name} (0x{value:X2})";
}
return $"0x{value:X2}";
}
}
}

View File

@@ -6,7 +6,7 @@ using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace PowerDisplay.Common.Models
namespace PowerDisplay.Models
{
/// <summary>
/// Represents a color temperature preset item for VCP code 0x14.

View File

@@ -4,12 +4,12 @@
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library
namespace PowerDisplay.Models
{
/// <summary>
/// Represents a custom name mapping for a VCP code value.
/// Used to override the default VCP value names with user-defined names.
/// This is a local copy maintained in Settings.UI.Library to avoid a dependency on PowerDisplay.Lib.
/// This class is shared between PowerDisplay app and Settings UI.
/// </summary>
public class CustomVcpValueMapping
{
@@ -53,16 +53,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
/// <summary>
/// Gets the display name for the VCP code (for UI display).
/// Uses simple hex formatting. For richer names, use extension methods in PowerDisplay.Lib.
/// </summary>
[JsonIgnore]
public string VcpCodeDisplayName => $"VCP 0x{VcpCode:X2}";
/// <summary>
/// Gets the display name for the VCP value.
/// </summary>
[JsonIgnore]
public string ValueDisplayName => $"0x{Value:X2}";
/// <summary>
/// Gets a summary string for display in the UI list.
/// Format: "0xVV → CustomName" or "0xVV → CustomName (MonitorName)"

View File

@@ -0,0 +1,23 @@
<!-- 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.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>PowerDisplay.Models</RootNamespace>
<Platforms>x64;ARM64</Platforms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<AssemblyName>PowerDisplay.Models</AssemblyName>
</PropertyGroup>
<!-- Native AOT Configuration -->
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>

View File

@@ -6,7 +6,7 @@ using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace PowerDisplay.Common.Models
namespace PowerDisplay.Models
{
/// <summary>
/// Represents a PowerDisplay profile containing monitor settings

View File

@@ -9,17 +9,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace PowerDisplay.Common.Models
namespace PowerDisplay.Models
{
/// <summary>
/// Container for all PowerDisplay profiles
/// </summary>
public class PowerDisplayProfiles
{
// NOTE: Custom profile concept has been removed. Profiles are now templates, not states.
// This constant is kept for backward compatibility (cleaning up legacy Custom profiles).
public const string CustomProfileName = "Custom";
[JsonPropertyName("profiles")]
public List<PowerDisplayProfile> Profiles { get; set; }

View File

@@ -0,0 +1,196 @@
// 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.Text.Json;
namespace PowerDisplay.Models
{
/// <summary>
/// Helper for loading and saving PowerDisplay profiles from/to disk.
/// Provides shared file I/O logic used by both Settings UI and PowerDisplay module.
/// Thread-safe and AOT-compatible.
/// All compound operations (load → modify → save) are atomic within a single process.
/// </summary>
public static class ProfileHelper
{
private static readonly object _lock = new object();
private static readonly Lazy<string> _profilesFilePath = new Lazy<string>(() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"PowerDisplay",
"profiles.json"));
/// <summary>
/// Gets the full path to the profiles JSON file.
/// </summary>
public static string ProfilesFilePath => _profilesFilePath.Value;
/// <summary>
/// Loads PowerDisplay profiles from disk.
/// Thread-safe operation.
/// </summary>
/// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails.</returns>
public static PowerDisplayProfiles LoadProfiles()
{
lock (_lock)
{
return LoadProfilesCore();
}
}
/// <summary>
/// Saves PowerDisplay profiles to disk.
/// Thread-safe operation with automatic timestamp update.
/// </summary>
/// <param name="profiles">The profiles collection to save.</param>
/// <returns>True if save was successful, false otherwise.</returns>
public static bool SaveProfiles(PowerDisplayProfiles profiles)
{
lock (_lock)
{
return SaveProfilesCore(profiles);
}
}
/// <summary>
/// Adds or updates a profile and persists to disk atomically.
/// </summary>
/// <param name="profile">The profile to add or update.</param>
/// <returns>True if the operation was successful, false otherwise.</returns>
public static bool AddOrUpdateProfile(PowerDisplayProfile profile)
{
if (profile == null || !profile.IsValid())
{
return false;
}
lock (_lock)
{
var profiles = LoadProfilesCore();
profiles.SetProfile(profile);
return SaveProfilesCore(profiles);
}
}
/// <summary>
/// Renames and updates a profile atomically (for rename or edit operations).
/// Removes the old entry by <paramref name="oldName"/> and upserts the updated profile.
/// </summary>
/// <param name="oldName">The current name of the profile to replace.</param>
/// <param name="newProfile">The updated profile.</param>
/// <returns>True if the operation was successful, false otherwise.</returns>
public static bool RenameAndUpdateProfile(string oldName, PowerDisplayProfile newProfile)
{
if (newProfile == null || !newProfile.IsValid())
{
return false;
}
lock (_lock)
{
var profiles = LoadProfilesCore();
profiles.RemoveProfile(oldName);
profiles.SetProfile(newProfile);
return SaveProfilesCore(profiles);
}
}
/// <summary>
/// Removes a profile by name and persists to disk atomically.
/// </summary>
/// <param name="profileName">The name of the profile to remove.</param>
/// <returns>True if the profile was found and removed, false otherwise.</returns>
public static bool RemoveProfile(string profileName)
{
lock (_lock)
{
var profiles = LoadProfilesCore();
bool removed = profiles.RemoveProfile(profileName);
if (removed)
{
SaveProfilesCore(profiles);
}
return removed;
}
}
/// <summary>
/// Gets a profile by name.
/// </summary>
/// <param name="profileName">The name of the profile to retrieve.</param>
/// <returns>The profile if found, null otherwise.</returns>
public static PowerDisplayProfile? GetProfile(string profileName)
{
lock (_lock)
{
return LoadProfilesCore().GetProfile(profileName);
}
}
// Lock-free core methods — only call from within a lock (_lock) block.
private static PowerDisplayProfiles LoadProfilesCore()
{
try
{
EnsureFolderExists();
if (File.Exists(ProfilesFilePath))
{
var json = File.ReadAllText(ProfilesFilePath);
var profiles = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles);
if (profiles != null)
{
return profiles;
}
}
return new PowerDisplayProfiles();
}
catch (Exception)
{
return new PowerDisplayProfiles();
}
}
private static bool SaveProfilesCore(PowerDisplayProfiles profiles)
{
try
{
if (profiles == null)
{
return false;
}
EnsureFolderExists();
profiles.LastUpdated = DateTime.UtcNow;
var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles);
File.WriteAllText(ProfilesFilePath, json);
return true;
}
catch (Exception)
{
return false;
}
}
private static void EnsureFolderExists()
{
var folder = Path.GetDirectoryName(ProfilesFilePath);
if (folder != null && !Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
}
}
}

View File

@@ -4,7 +4,7 @@
using System.Text.Json.Serialization;
namespace PowerDisplay.Common.Models
namespace PowerDisplay.Models
{
/// <summary>
/// Monitor settings for a specific profile

View File

@@ -4,9 +4,8 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using PowerDisplay.Common.Models;
namespace PowerDisplay.Common.Serialization
namespace PowerDisplay.Models
{
/// <summary>
/// JSON serialization context for PowerDisplay Profile types.
@@ -23,11 +22,6 @@ namespace PowerDisplay.Common.Serialization
[JsonSerializable(typeof(PowerDisplayProfile))]
[JsonSerializable(typeof(List<PowerDisplayProfile>))]
[JsonSerializable(typeof(PowerDisplayProfiles))]
// Monitor State Types
[JsonSerializable(typeof(MonitorStateEntry))]
[JsonSerializable(typeof(MonitorStateFile))]
[JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))]
public partial class ProfileSerializationContext : JsonSerializerContext
{
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View File

@@ -14,11 +14,25 @@ namespace PowerDisplay.Configuration
/// </summary>
public static class UI
{
// Window dimensions
public const int WindowWidth = 362;
public const int MinWindowHeight = 100;
public const int MaxWindowHeight = 650;
public const int WindowRightMargin = 12;
// Main flyout dimensions in device-independent pixels (DIP)
public const int WindowWidthDip = 362;
public const int WindowMinHeightDip = 100;
public const int WindowMaxHeightDip = 650;
public const int WindowRightMarginDip = 12;
public const int WindowBottomMarginDip = WindowRightMarginDip;
public const double WindowMaxWorkAreaHeightRatio = 0.75;
// Adaptive flyout bounds in device-independent pixels (DIP)
public const int FlyoutContextMenuMaxWidthDip = 320;
public const int FlyoutContextMenuAdaptiveMaxWidthDip = 420;
public const double FlyoutContextMenuMaxWorkAreaWidthRatio = 0.35;
// Identify overlay bounds in device-independent pixels (DIP)
public const int IdentifyWindowPreferredWidthDip = 300;
public const int IdentifyWindowPreferredHeightDip = 280;
public const int IdentifyWindowMinWidthDip = 160;
public const int IdentifyWindowMinHeightDip = 160;
public const double IdentifyWindowMaxWorkAreaRatio = 0.28;
/// <summary>
/// Icon glyph for internal/laptop displays (WMI)

View File

@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace PowerDisplay.Helpers
{
/// <summary>
/// Subclasses a window's WndProc to suppress WM_DPICHANGED messages during
/// cross-monitor MoveAndResize calls. Without suppression, the framework
/// auto-scales it a second time, causing double-scaling artifacts.
///
/// Usage:
/// var suppressor = new DpiSuppressor(window);
/// using (suppressor.Suppress())
/// {
/// window.AppWindow.MoveAndResize(rect, displayArea);
/// }
/// </summary>
internal sealed partial class DpiSuppressor : IDisposable
{
// Optional external WndProc handler (e.g., HotkeyService) called before default processing.
// Return true to indicate the message was handled.
private readonly Func<uint, nuint, nint, bool>? _preProcessor;
private const int GwlWndProc = -4;
private const uint WmDpiChanged = 0x02E0;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _suppressing;
private bool _disposed;
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="DpiSuppressor"/> class.
/// Subclass the window's WndProc to enable DPI suppression.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Optional callback invoked for every message before default processing.
/// Receives (uMsg, wParam, lParam). Return true to swallow the message.</param>
public DpiSuppressor(WindowEx window, Func<uint, nuint, nint, bool>? preProcessor = null)
{
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
/// <summary>
/// Returns a disposable scope during which WM_DPICHANGED is suppressed.
/// </summary>
public SuppressScope Suppress() => new(this);
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
// Let external handler process first (e.g., hotkey messages)
if (_preProcessor?.Invoke(uMsg, wParam, lParam) == true)
{
return 0;
}
if (uMsg == WmDpiChanged && _suppressing)
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Restore original WndProc
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
internal readonly struct SuppressScope : IDisposable
{
private readonly DpiSuppressor _owner;
internal SuppressScope(DpiSuppressor owner)
{
_owner = owner;
_owner._suppressing = true;
}
public void Dispose()
{
_owner._suppressing = false;
}
}
}
}

View File

@@ -6,7 +6,6 @@ using System;
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using WinRT.Interop;
namespace PowerDisplay.Helpers
{
@@ -23,10 +22,6 @@ namespace PowerDisplay.Helpers
private readonly Action _hotkeyAction;
private nint _hwnd;
private nint _originalWndProc;
// Must keep delegate reference to prevent GC collection
private WndProcDelegate? _hotkeyWndProc;
private bool _isRegistered;
private bool _disposed;
@@ -38,25 +33,37 @@ namespace PowerDisplay.Helpers
/// <summary>
/// Initialize the hotkey service with a window handle.
/// Must be called after window is created.
/// Must be called after window is created and WndProcService is attached.
/// </summary>
/// <param name="window">The WinUI window to attach to.</param>
public void Initialize(Microsoft.UI.Xaml.Window window)
public void Initialize(nint hwnd)
{
_hwnd = WindowNative.GetWindowHandle(window);
// LOAD BEARING: If you don't stick the pointer to the WndProc into a
// member (and instead use a local), then the pointer we marshal
// into the WindowLongPtr will be useless after we leave this function,
// and our WndProc will explode.
_hotkeyWndProc = HotkeyWndProc;
var wndProcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc);
_originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndProc, wndProcPointer);
// Register hotkey based on current settings
_hwnd = hwnd;
ReloadSettings();
}
/// <summary>
/// Handle WM_HOTKEY messages. Called by WndProcService.
/// </summary>
/// <returns>True if the message was handled.</returns>
public bool HandleMessage(uint uMsg, nuint wParam)
{
if (uMsg == WmHotkey && (int)wParam == HotkeyId)
{
try
{
_hotkeyAction?.Invoke();
}
catch (Exception ex)
{
Logger.LogError($"[HotkeyService] Hotkey action failed: {ex.Message}");
}
return true;
}
return false;
}
/// <summary>
/// Reload settings and re-register hotkey.
/// Call this when settings change.
@@ -119,25 +126,6 @@ namespace PowerDisplay.Helpers
_isRegistered = false;
}
private nint HotkeyWndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
if (uMsg == WmHotkey && (int)wParam == HotkeyId)
{
try
{
_hotkeyAction?.Invoke();
}
catch (Exception ex)
{
Logger.LogError($"[HotkeyService] Hotkey action failed: {ex.Message}");
}
return 0;
}
return CallWindowProcNative(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
@@ -150,7 +138,6 @@ namespace PowerDisplay.Helpers
}
// P/Invoke constants
private const int GwlWndProc = -4;
private const uint WmHotkey = 0x0312;
// HOT_KEY_MODIFIERS flags
@@ -160,12 +147,6 @@ namespace PowerDisplay.Helpers
private const uint ModWin = 0x0008;
private const uint ModNoRepeat = 0x4000;
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProcNative(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
[LibraryImport("user32.dll", EntryPoint = "RegisterHotKey", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool RegisterHotKeyNative(nint hWnd, int id, uint fsModifiers, uint vk);

View File

@@ -144,44 +144,6 @@ namespace PowerDisplay.Helpers
}
}
/// <summary>
/// Get brightness of the specified monitor
/// </summary>
public async Task<VcpFeatureValue> GetBrightnessAsync(string monitorId, CancellationToken cancellationToken = default)
{
var monitor = GetMonitor(monitorId);
if (monitor == null)
{
return VcpFeatureValue.Invalid;
}
var controller = GetControllerForMonitor(monitor);
if (controller == null)
{
return VcpFeatureValue.Invalid;
}
try
{
var brightnessInfo = await controller.GetBrightnessAsync(monitor, cancellationToken);
// Update cached brightness value
if (brightnessInfo.IsValid)
{
monitor.UpdateStatus(brightnessInfo.ToPercentage(), true);
}
return brightnessInfo;
}
catch (Exception ex)
{
// Mark monitor as unavailable
Logger.LogError($"Failed to get brightness for monitor {monitorId}: {ex.Message}");
monitor.IsAvailable = false;
return VcpFeatureValue.Invalid;
}
}
/// <summary>
/// Set brightness of the specified monitor
/// </summary>
@@ -215,33 +177,6 @@ namespace PowerDisplay.Helpers
(mon, val) => mon.CurrentVolume = val,
cancellationToken);
/// <summary>
/// Get monitor color temperature
/// </summary>
public async Task<VcpFeatureValue> GetColorTemperatureAsync(string monitorId, CancellationToken cancellationToken = default)
{
var monitor = GetMonitor(monitorId);
if (monitor == null)
{
return VcpFeatureValue.Invalid;
}
var controller = GetControllerForMonitor(monitor);
if (controller == null)
{
return VcpFeatureValue.Invalid;
}
try
{
return await controller.GetColorTemperatureAsync(monitor, cancellationToken);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
return VcpFeatureValue.Invalid;
}
}
/// <summary>
/// Set monitor color temperature
/// </summary>
@@ -253,33 +188,6 @@ namespace PowerDisplay.Helpers
(mon, val) => mon.CurrentColorTemperature = val,
cancellationToken);
/// <summary>
/// Get current input source for a monitor
/// </summary>
public async Task<VcpFeatureValue> GetInputSourceAsync(string monitorId, CancellationToken cancellationToken = default)
{
var monitor = GetMonitor(monitorId);
if (monitor == null)
{
return VcpFeatureValue.Invalid;
}
var controller = GetControllerForMonitor(monitor);
if (controller == null)
{
return VcpFeatureValue.Invalid;
}
try
{
return await controller.GetInputSourceAsync(monitor, cancellationToken);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
return VcpFeatureValue.Invalid;
}
}
/// <summary>
/// Set input source for a monitor
/// </summary>

View File

@@ -250,7 +250,6 @@ namespace PowerDisplay.Helpers
break;
case PInvoke.WM_LBUTTONUP:
case PInvoke.WM_LBUTTONDBLCLK:
_toggleWindowAction?.Invoke();
break;
}

View File

@@ -3,9 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using WinUIEx;
@@ -26,6 +24,9 @@ namespace PowerDisplay.Helpers
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
// Window Styles
private const int GwlStyle = -16;
private const int WsCaption = 0x00C00000;
@@ -40,17 +41,11 @@ namespace PowerDisplay.Helpers
private const int WsExWindowedge = 0x00000100;
private const int WsExClientedge = 0x00000200;
private const int WsExStaticedge = 0x00020000;
private const int WsExToolwindow = 0x00000080;
private const uint SwpNosize = 0x0001;
private const uint SwpNomove = 0x0002;
private const uint SwpFramechanged = 0x0020;
private const nint HwndTopmost = -1;
private const nint HwndNotopmost = -2;
// ShowWindow commands
private const int SwHide = 0;
private const int SwShow = 5;
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
// P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64)
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
@@ -70,22 +65,6 @@ namespace PowerDisplay.Helpers
int cy,
uint uFlags);
[LibraryImport("user32.dll", EntryPoint = "ShowWindow")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindowNative(nint hWnd, int nCmdShow);
[LibraryImport("user32.dll", EntryPoint = "IsWindowVisible")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool IsWindowVisibleNative(nint hWnd);
/// <summary>
/// Check if window is visible
/// </summary>
public static bool IsWindowVisible(nint hWnd)
{
return IsWindowVisibleNative(hWnd);
}
/// <summary>
/// Disable window moving and resizing functionality
/// </summary>
@@ -123,54 +102,6 @@ namespace PowerDisplay.Helpers
SwpNomove | SwpNosize | SwpFramechanged);
}
/// <summary>
/// Set whether window is topmost
/// </summary>
public static void SetWindowTopmost(nint hWnd, bool topmost)
{
SetWindowPos(
hWnd,
topmost ? HwndTopmost : HwndNotopmost,
0,
0,
0,
0,
SwpNomove | SwpNosize);
}
/// <summary>
/// Show or hide window
/// </summary>
public static void ShowWindow(nint hWnd, bool show)
{
ShowWindowNative(hWnd, show ? SwShow : SwHide);
}
/// <summary>
/// Hide window from taskbar
/// </summary>
public static void HideFromTaskbar(nint hWnd)
{
// Get current extended style
nint exStyle = GetWindowLongPtr(hWnd, GwlExstyle);
// Add WS_EX_TOOLWINDOW style to hide window from taskbar
exStyle |= WsExToolwindow;
// Set new extended style
_ = SetWindowLong(hWnd, GwlExstyle, exStyle);
// Refresh window frame
SetWindowPos(
hWnd,
0,
0,
0,
0,
0,
SwpNomove | SwpNosize | SwpFramechanged);
}
/// <summary>
/// Get the DPI scale factor for a window (relative to standard 96 DPI)
/// </summary>
@@ -178,18 +109,39 @@ namespace PowerDisplay.Helpers
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(WindowEx window)
{
return (float)window.GetDpiForWindow() / 96.0;
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Convert device-independent units (DIU) to physical pixels
/// Get the DPI scale factor for a display area (relative to standard 96 DPI)
/// </summary>
/// <param name="diu">Device-independent unit value</param>
/// <param name="displayArea">Target display area</param>
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(DisplayArea displayArea)
{
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels.
/// </summary>
/// <param name="dip">Device-independent pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Physical pixel value</returns>
public static int ScaleToPhysicalPixels(int diu, double dpiScale)
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(diu * dpiScale);
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP).
/// </summary>
/// <param name="physicalPixels">Physical pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Device-independent pixel value</returns>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
@@ -200,62 +152,108 @@ namespace PowerDisplay.Helpers
/// - Different DPI settings
/// </summary>
/// <param name="window">WinUIEx window to position</param>
/// <param name="width">Window width in device-independent units (DIU)</param>
/// <param name="height">Window height in device-independent units (DIU)</param>
/// <param name="rightMargin">Right margin in device-independent units (DIU)</param>
/// <param name="widthDip">Window width in device-independent pixels (DIP)</param>
/// <param name="heightDip">Window height in device-independent pixels (DIP)</param>
/// <param name="rightMarginDip">Right margin in device-independent pixels (DIP)</param>
/// <param name="bottomMarginDip">Bottom margin in device-independent pixels (DIP)</param>
public static void PositionWindowBottomRight(
WindowEx window,
int width,
int height,
int rightMargin = 0)
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
// RectWork already includes correct offsets for taskbar position
var monitors = MonitorInfo.GetDisplayMonitors();
if (monitors == null || monitors.Count == 0)
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: No monitors found, skipping positioning");
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: Unable to determine target display from cursor, skipping positioning");
return;
}
// Find the monitor where the mouse cursor is located
var targetMonitor = GetMonitorAtCursor(monitors);
var workArea = targetMonitor.RectWork;
double dpiScale = GetDpiScale(window);
// Calculate bottom-right position
// RectWork.Right/Bottom already account for taskbar position
double x = workArea.Right - (dpiScale * (width + rightMargin));
double y = workArea.Bottom - (dpiScale * height);
window.MoveAndResize(x, y, width, height);
MoveWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Get the monitor where the mouse cursor is currently located.
/// Falls back to primary monitor if cursor position cannot be determined.
/// Center a window within the specified display area's work area.
/// </summary>
/// <param name="monitors">List of available monitors</param>
/// <returns>MonitorInfo of the monitor containing the cursor</returns>
private static MonitorInfo GetMonitorAtCursor(IList<MonitorInfo> monitors)
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
// Try to get cursor position using Win32 API
if (GetCursorPos(out var cursorPos))
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + ((rel.Width - w) / 2);
int y = rel.Y + ((rel.Height - h) / 2);
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
private static void MoveWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip,
int bottomMarginDip)
{
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + rel.Width - w - marginRight;
int y = rel.Y + rel.Height - h - marginBottom;
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
/// <summary>
/// Get the work area rectangle relative to the display area's origin.
/// MoveAndResize(rect, displayArea) expects coordinates relative to the DisplayArea,
/// but WorkArea.X/Y are in absolute screen coordinates, so we subtract the DisplayArea origin.
/// The resulting rect describes where the usable area is within the display (e.g., offset by taskbar).
/// </summary>
private static Windows.Graphics.RectInt32 GetWorkAreaRelativeToDisplay(DisplayArea displayArea)
{
var outer = displayArea.OuterBounds;
var work = displayArea.WorkArea;
return new Windows.Graphics.RectInt32(
work.X - outer.X,
work.Y - outer.Y,
work.Width,
work.Height);
}
internal static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
// Find the monitor that contains the cursor point
foreach (var monitor in monitors)
{
if (cursorPos.X >= monitor.RectMonitor.Left &&
cursorPos.X < monitor.RectMonitor.Right &&
cursorPos.Y >= monitor.RectMonitor.Top &&
cursorPos.Y < monitor.RectMonitor.Bottom)
{
return monitor;
}
}
return false;
}
// Fallback to first monitor (typically primary)
return monitors[0];
displayArea = DisplayArea.GetFromPoint(new Windows.Graphics.PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.None);
return displayArea is not null;
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}
}

View File

@@ -22,6 +22,7 @@
<ProjectPriFileName>PowerToys.PowerDisplay.pri</ProjectPriFileName>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<!-- Disable XAML-generated Main method, use custom Program.cs -->
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
@@ -33,6 +34,12 @@
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
<PropertyGroup>
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<!-- Hide build log files from Solution Explorer -->
<None Remove="*.log" />
@@ -81,11 +88,13 @@
</ItemGroup>
<ItemGroup>
<!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally -->
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<!-- Removed ManagedCsWin32 - using CsWin32 directly for TrayIcon APIs -->
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
<ProjectReference Include="..\PowerDisplay.Models\PowerDisplay.Models.csproj" />
</ItemGroup>
<!-- Copy Assets folder to output directory -->
<ItemGroup>

View File

@@ -10,15 +10,20 @@
IsResizable="False"
IsTitleBarVisible="False"
mc:Ignorable="d">
<Grid Background="#1A000000">
<TextBlock
x:Name="NumberText"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Segoe UI"
FontSize="200"
FontWeight="Bold"
Foreground="White"
Text="1" />
<Grid Background="{ThemeResource SolidBackgroundFillColorBaseBrush}">
<Grid.Resources>
<Thickness x:Key="IdentifyWindowContentMargin">24</Thickness>
</Grid.Resources>
<Viewbox Margin="{StaticResource IdentifyWindowContentMargin}" Stretch="Uniform">
<TextBlock
x:Name="NumberText"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="Bold"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Text="1"
TextAlignment="Center" />
</Viewbox>
</Grid>
</winuiex:WindowEx>

View File

@@ -5,7 +5,8 @@
using System;
using System.Threading.Tasks;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using PowerDisplay.Configuration;
using PowerDisplay.Helpers;
using WinUIEx;
namespace PowerDisplay.PowerDisplayXAML
@@ -13,13 +14,9 @@ namespace PowerDisplay.PowerDisplayXAML
/// <summary>
/// Interaction logic for IdentifyWindow.xaml
/// </summary>
public sealed partial class IdentifyWindow : WindowEx
public sealed partial class IdentifyWindow : WindowEx, IDisposable
{
// Window size in device-independent units (DIU)
private const int WindowWidthDiu = 300;
private const int WindowHeightDiu = 280;
private double _dpiScale = 1.0;
private DpiSuppressor? _dpiSuppressor;
public IdentifyWindow(string displayText)
{
@@ -33,13 +30,13 @@ namespace PowerDisplay.PowerDisplayXAML
{
// WinUI will throw if explorer is not running, safely ignore
}
catch (Exception)
{
}
// Configure window style
ConfigureWindow();
// Subclass WndProc to suppress WM_DPICHANGED during cross-DPI positioning
_dpiSuppressor = new DpiSuppressor(this);
// Auto close after 3 seconds
Task.Delay(3000).ContinueWith(_ =>
{
@@ -52,13 +49,8 @@ namespace PowerDisplay.PowerDisplayXAML
private void ConfigureWindow()
{
_dpiScale = this.GetDpiForWindow() / 96.0;
// Set window size scaled for DPI
// AppWindow.Resize expects physical pixels
int physicalWidth = (int)(WindowWidthDiu * _dpiScale);
int physicalHeight = (int)(WindowHeightDiu * _dpiScale);
this.AppWindow.Resize(new SizeInt32 { Width = physicalWidth, Height = physicalHeight });
// Set a preferred size in DIP. PositionOnDisplay will clamp it for the target monitor.
this.SetWindowSize(AppConstants.UI.IdentifyWindowPreferredWidthDip, AppConstants.UI.IdentifyWindowPreferredHeightDip);
this.IsAlwaysOnTop = true;
}
@@ -66,19 +58,42 @@ namespace PowerDisplay.PowerDisplayXAML
/// Position the window at the center of the specified display area
/// </summary>
public void PositionOnDisplay(DisplayArea displayArea)
{
var (windowWidthDip, windowHeightDip) = GetAdaptiveWindowSizeDip(displayArea);
// Suppress WM_DPICHANGED during MoveAndResize to prevent double-scaling
// when positioning on a monitor with different DPI than the primary.
using (_dpiSuppressor?.Suppress() ?? default)
{
WindowHelper.CenterWindowOnDisplay(this, displayArea, windowWidthDip, windowHeightDip);
}
}
private static (int WidthDip, int HeightDip) GetAdaptiveWindowSizeDip(DisplayArea displayArea)
{
var workArea = displayArea.WorkArea;
double dpiScale = WindowHelper.GetDpiScale(displayArea);
// Window size in physical pixels (already scaled for DPI)
int physicalWidth = (int)(WindowWidthDiu * _dpiScale);
int physicalHeight = (int)(WindowHeightDiu * _dpiScale);
int maxWidthDip = Math.Max(
AppConstants.UI.IdentifyWindowMinWidthDip,
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Width * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
int maxHeightDip = Math.Max(
AppConstants.UI.IdentifyWindowMinHeightDip,
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Height * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
// Calculate center position (WorkArea coordinates are in physical pixels)
int x = workArea.X + ((workArea.Width - physicalWidth) / 2);
int y = workArea.Y + ((workArea.Height - physicalHeight) / 2);
int widthDip = Math.Max(
AppConstants.UI.IdentifyWindowMinWidthDip,
Math.Min(AppConstants.UI.IdentifyWindowPreferredWidthDip, maxWidthDip));
int heightDip = Math.Max(
AppConstants.UI.IdentifyWindowMinHeightDip,
Math.Min(AppConstants.UI.IdentifyWindowPreferredHeightDip, maxHeightDip));
// Use WindowEx's AppWindow property
this.AppWindow.Move(new PointInt32(x, y));
return (widthDip, heightDip);
}
public void Dispose()
{
_dpiSuppressor?.Dispose();
}
}
}

View File

@@ -5,8 +5,7 @@
xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
xmlns:helpers="using:PowerDisplay.Helpers"
xmlns:local="using:PowerDisplay"
xmlns:models="using:PowerDisplay.Common.Models"
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:models="using:PowerDisplay.Models"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:vm="using:PowerDisplay.ViewModels"
xmlns:winuiex="using:WinUIEx"
@@ -25,13 +24,41 @@
IsTabStop="True"
TabFocusNavigation="Local">
<Grid.Resources>
<GridLength x:Key="FlyoutStatusBarHeight">48</GridLength>
<x:Double x:Key="FlyoutSeparatorHeight">1</x:Double>
<GridLength x:Key="FlyoutSliderIconColumnWidth">20</GridLength>
<x:Double x:Key="FlyoutActionGlyphFontSize">16</x:Double>
<x:Double x:Key="FlyoutActionGlyphSmallFontSize">14</x:Double>
<x:Double x:Key="FlyoutActionGlyphExtraSmallFontSize">12</x:Double>
<x:Double x:Key="FlyoutSecondaryTextFontSize">12</x:Double>
<x:Double x:Key="FlyoutRotationGlyphFontSize">14</x:Double>
<x:Double x:Key="FlyoutButtonSize">32</x:Double>
<x:Double x:Key="FlyoutCompactSpacing">4</x:Double>
<x:Double x:Key="FlyoutStandardSpacing">8</x:Double>
<x:Double x:Key="FlyoutMonitorSectionSpacing">32</x:Double>
<x:Double x:Key="FlyoutScanningIndicatorSize">24</x:Double>
<x:Double x:Key="FlyoutScanningSpacing">16</x:Double>
<Thickness x:Key="FlyoutButtonPadding">6</Thickness>
<Thickness x:Key="FlyoutScanningMargin">0,16,0,16</Thickness>
<Thickness x:Key="FlyoutContentPadding">16</Thickness>
<Thickness x:Key="FlyoutListHeaderMargin">16,0,8,0</Thickness>
<Thickness x:Key="FlyoutListItemPadding">0,4</Thickness>
<Thickness x:Key="FlyoutSelectionGlyphMargin">8,0,0,0</Thickness>
<Thickness x:Key="FlyoutSectionTopMargin">0,4,0,0</Thickness>
<Thickness x:Key="FlyoutSectionContainerPadding">8,0,8,8</Thickness>
<Thickness x:Key="FlyoutSeparatorMargin">8,8</Thickness>
<Thickness x:Key="FlyoutStatusBarMargin">4,0,4,0</Thickness>
<Thickness x:Key="MonitorHeaderOffsetMargin">10,0,0,0</Thickness>
<Thickness x:Key="MonitorNameBaselineMargin">2,0,0,2</Thickness>
<x:Double x:Key="FlyoutContextMenuMinWidth">240</x:Double>
<x:Double x:Key="FlyoutContextMenuMaxWidth">320</x:Double>
<Style
x:Key="FlyoutButtonStyle"
BasedOn="{StaticResource SubtleButtonStyle}"
TargetType="Button">
<Setter Property="Padding" Value="6" />
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="{StaticResource FlyoutButtonPadding}" />
<Setter Property="Width" Value="{StaticResource FlyoutButtonSize}" />
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
</Style>
</Grid.Resources>
@@ -39,7 +66,7 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="48" />
<RowDefinition Height="{StaticResource FlyoutStatusBarHeight}" />
</Grid.RowDefinitions>
<!-- Main Content Area with modern design -->
@@ -50,22 +77,21 @@
BorderThickness="0,0,0,1">
<Grid>
<StackPanel
Margin="0,16,0,16"
Margin="{StaticResource FlyoutScanningMargin}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="16"
Spacing="{StaticResource FlyoutScanningSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IsScanning), Mode=OneWay}">
<ProgressRing
Width="24"
Height="24"
Width="{StaticResource FlyoutScanningIndicatorSize}"
Height="{StaticResource FlyoutScanningIndicatorSize}"
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
IsActive="True" />
<TextBlock
x:Name="ScanningMonitorsTextBlock"
x:Uid="ScanningMonitorsText"
HorizontalAlignment="Center"
FontSize="12"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextAlignment="Center" />
</StackPanel>
@@ -77,94 +103,188 @@
IconSource="{ui:FontIconSource Glyph=&#xE7F4;}"
IsClosable="False"
IsOpen="{x:Bind ViewModel.ShowNoMonitorsMessage, Mode=OneWay}"
Severity="Informational"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowNoMonitorsMessage), Mode=OneWay}" />
Severity="Informational" />
<!-- Content Area -->
<ScrollViewer
x:Name="MainScrollViewer"
Padding="16,16,16,16"
VerticalAlignment="Stretch"
Padding="{StaticResource FlyoutContentPadding}"
HorizontalScrollBarVisibility="Disabled"
HorizontalScrollMode="Disabled"
IsTabStop="False"
VerticalScrollBarVisibility="Auto"
ZoomMode="Disabled">
VerticalScrollBarVisibility="Auto">
<!-- Monitors List with modern card design -->
<ItemsRepeater
x:Name="MonitorsRepeater"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
TabFocusNavigation="Local"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Vertical" Spacing="32" />
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:MonitorViewModel">
<StackPanel
HorizontalAlignment="Stretch"
Spacing="4"
TabFocusNavigation="Local">
<StackPanel Spacing="{StaticResource FlyoutCompactSpacing}" TabFocusNavigation="Local">
<!-- Monitor Name with Icon -->
<Grid Margin="2,0,0,0" HorizontalAlignment="Stretch">
<Grid
Margin="{StaticResource MonitorHeaderOffsetMargin}"
HorizontalAlignment="Stretch"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<local:MonitorIcon
VerticalAlignment="Center"
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Margin="0,0,0,2"
Grid.Column="1"
Margin="{StaticResource MonitorNameBaselineMargin}"
VerticalAlignment="Center"
Text="{x:Bind DisplayName, Mode=OneWay}" />
Text="{x:Bind DisplayName, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
<!-- Icon buttons for InputSource and ColorTemperature -->
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="4">
Spacing="{StaticResource FlyoutCompactSpacing}">
<!-- Color Temperature Button -->
<Button
x:Uid="ColorTemperatureTooltip"
Content="{ui:FontIcon Glyph=&#xE790;,
FontSize=16}"
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}">
<Button.Flyout>
<Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False">
<StackPanel Orientation="Vertical">
<ListView
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
SelectionChanged="{x:Bind HandleColorTemperatureSelectionChanged}"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ColorTemperatureHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:ColorTemperatureItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind DisplayName}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
<!-- More Button (Input Source + Power State) -->
<Button
x:Uid="MoreOptionsTooltip"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
Opened="Flyout_Opened"
Placement="BottomEdgeAlignedRight"
ShouldConstrainToRootBounds="False">
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
<!-- Input Source Section -->
<ListView
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
SelectionChanged="ColorTemperatureListView_SelectionChanged"
SelectionMode="Single">
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="ColorTemperatureHeader"
Margin="16,0,8,0"
FontSize="12"
x:Uid="InputSourceHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:ColorTemperatureItem">
<Grid Padding="0,4">
<DataTemplate x:DataType="vm:InputSourceItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" Text="{x:Bind DisplayName}" />
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Separator between Input Source and Power State -->
<Border
Height="{StaticResource FlyoutSeparatorHeight}"
Margin="{StaticResource FlyoutSeparatorMargin}"
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
<!-- Power State Section -->
<ListView
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
SelectionMode="Single"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
<ListView.Header>
<TextBlock
x:Uid="PowerStateHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:PowerStateItem">
<Grid Padding="{StaticResource FlyoutListItemPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<FontIcon
Grid.Column="1"
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
@@ -173,101 +293,11 @@
</Flyout>
</Button.Flyout>
</Button>
<!-- More Button (Input Source + Power State) -->
<Button
x:Uid="MoreOptionsTooltip"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
Opened="Flyout_Opened"
Placement="BottomEdgeAlignedRight"
ShouldConstrainToRootBounds="False">
<StackPanel
MinWidth="240"
MaxWidth="320"
Orientation="Vertical">
<!-- Input Source Section -->
<StackPanel Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
<ListView
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
SelectionChanged="InputSourceListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="InputSourceHeader"
Margin="16,0,8,0"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:InputSourceItem">
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" Text="{x:Bind Name}" />
<FontIcon
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
<!-- Separator between Input Source and Power State -->
<Border
Height="1"
Margin="8,8"
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
<!-- Power State Section -->
<StackPanel Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
<ListView
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
SelectionChanged="PowerStateListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="PowerStateHeader"
Margin="16,0,8,0"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:PowerStateItem">
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center" Text="{x:Bind Name}" />
<FontIcon
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Glyph="&#xE73E;"
Visibility="{x:Bind SelectionVisibility}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
<StackPanel
Margin="0,8,0,0"
Padding="8,0,16,8"
Margin="{StaticResource FlyoutSectionTopMargin}"
Padding="{StaticResource FlyoutSectionContainerPadding}"
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
@@ -275,190 +305,167 @@
TabFocusNavigation="Local"
XYFocusKeyboardNavigation="Enabled">
<!-- Brightness Control -->
<Grid Margin="0,8,0,0" HorizontalAlignment="Stretch">
<Grid Margin="{StaticResource FlyoutSectionTopMargin}" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Uid="BrightnessTooltip"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="18"
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
Glyph="&#xEC8A;" />
<Slider
x:Uid="BrightnessAutomation"
Grid.Column="2"
HorizontalAlignment="Stretch"
Grid.Column="1"
VerticalAlignment="Center"
DataContext="{x:Bind}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
KeyUp="Slider_KeyUp"
KeyUp="{x:Bind HandleBrightnessKeyUp}"
Maximum="{x:Bind MaxBrightness, Mode=OneWay}"
Minimum="{x:Bind MinBrightness, Mode=OneWay}"
PointerCaptureLost="Slider_PointerCaptureLost"
Tag="Brightness"
PointerCaptureLost="{x:Bind HandleBrightnessPointerCaptureLost}"
Value="{x:Bind Brightness, Mode=OneWay}" />
</Grid>
<!-- Contrast Control -->
<Grid
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Margin="{StaticResource FlyoutSectionTopMargin}"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowContrast), Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Uid="ContrastTooltip"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="12"
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
Glyph="&#xE7A1;" />
<Slider
x:Uid="ContrastAutomation"
Grid.Column="2"
Grid.Column="1"
VerticalAlignment="Center"
DataContext="{x:Bind}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
KeyUp="Slider_KeyUp"
KeyUp="{x:Bind HandleContrastKeyUp}"
Maximum="100"
Minimum="0"
PointerCaptureLost="Slider_PointerCaptureLost"
Tag="Contrast"
PointerCaptureLost="{x:Bind HandleContrastPointerCaptureLost}"
Value="{x:Bind ContrastPercent, Mode=OneWay}" />
</Grid>
<!-- Volume Control -->
<Grid
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Margin="{StaticResource FlyoutSectionTopMargin}"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowVolume), Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Uid="VolumeTooltip"
Margin="2,0,0,0"
Margin="4,0,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="&#xE993;" />
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
Glyph="&#xE994;" />
<Slider
x:Uid="VolumeAutomation"
Grid.Column="2"
HorizontalAlignment="Stretch"
Grid.Column="1"
VerticalAlignment="Center"
DataContext="{x:Bind}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
KeyUp="Slider_KeyUp"
KeyUp="{x:Bind HandleVolumeKeyUp}"
Maximum="{x:Bind MaxVolume, Mode=OneWay}"
Minimum="{x:Bind MinVolume, Mode=OneWay}"
PointerCaptureLost="Slider_PointerCaptureLost"
Tag="Volume"
PointerCaptureLost="{x:Bind HandleVolumePointerCaptureLost}"
Value="{x:Bind Volume, Mode=OneWay}" />
</Grid>
<!-- Rotation Controls -->
<Grid
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Margin="{StaticResource FlyoutSectionTopMargin}"
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowRotation), Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Uid="RotationTooltip"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
FontSize="{StaticResource FlyoutActionGlyphSmallFontSize}"
Glyph="&#xE7AD;" />
<Grid
Grid.Column="2"
<ComboBox
Grid.Column="1"
HorizontalAlignment="Stretch"
TabFocusNavigation="Local"
XYFocusKeyboardNavigation="Enabled">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="4" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="4" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="4" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Normal (0°) -->
<ToggleButton
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
SelectedIndex="{x:Bind SelectedRotationIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="RotationOptionLandscape" />
<ComboBoxItem x:Uid="RotationOptionPortrait" />
<ComboBoxItem x:Uid="RotationOptionLandscapeFlipped" />
<ComboBoxItem x:Uid="RotationOptionPortraitFlipped" />
</ComboBox>
<!-- Normal (0°) -->
<!--<ToggleButton
x:Uid="RotateNormalTooltip"
Grid.Column="0"
HorizontalAlignment="Stretch"
Click="RotationButton_Click"
DataContext="{x:Bind}"
Click="{x:Bind HandleRotation0Click}"
IsChecked="{x:Bind IsRotation0, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Tag="0">
<FontIcon FontSize="14" Glyph="&#xE74A;" />
IsTabStop="True">
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="&#xE74A;" />
</ToggleButton>
<!-- Left (270°) -->
-->
<!-- Left (270°) -->
<!--
<ToggleButton
x:Uid="RotateLeftTooltip"
Grid.Column="2"
Grid.Column="1"
HorizontalAlignment="Stretch"
Click="RotationButton_Click"
DataContext="{x:Bind}"
Click="{x:Bind HandleRotation3Click}"
IsChecked="{x:Bind IsRotation3, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Tag="3">
<FontIcon FontSize="14" Glyph="&#xE76B;" />
IsTabStop="True">
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="&#xE76B;" />
</ToggleButton>
<!-- Right (90°) -->
-->
<!-- Right (90°) -->
<!--
<ToggleButton
x:Uid="RotateRightTooltip"
Grid.Column="4"
Grid.Column="2"
HorizontalAlignment="Stretch"
Click="RotationButton_Click"
DataContext="{x:Bind}"
Click="{x:Bind HandleRotation1Click}"
IsChecked="{x:Bind IsRotation1, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Tag="1">
<FontIcon FontSize="14" Glyph="&#xE76C;" />
IsTabStop="True">
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="&#xE76C;" />
</ToggleButton>
<!-- Inverted (180°) -->
-->
<!-- Inverted (180°) -->
<!--
<ToggleButton
x:Uid="RotateInvertedTooltip"
Grid.Column="6"
Grid.Column="3"
HorizontalAlignment="Stretch"
Click="RotationButton_Click"
DataContext="{x:Bind}"
Click="{x:Bind HandleRotation2Click}"
IsChecked="{x:Bind IsRotation2, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
IsTabStop="True"
Tag="2">
<FontIcon FontSize="14" Glyph="&#xE74B;" />
</ToggleButton>
</Grid>
IsTabStop="True">
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="&#xE74B;" />
</ToggleButton>-->
</Grid>
</StackPanel>
</StackPanel>
@@ -469,72 +476,86 @@
</Grid>
</Border>
<Grid x:Name="StatusBar" Grid.Row="1">
<Grid
x:Name="StatusBar"
Grid.Row="1"
Padding="{StaticResource FlyoutStatusBarMargin}">
<!-- Action Buttons -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
x:Name="ProfilesButton"
x:Uid="ProfilesTooltip"
Content="{ui:FontIcon Glyph=&#xE748;,
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
<Button.Flyout>
<Flyout
x:Name="ProfilesFlyout"
Opened="Flyout_Opened"
ShouldConstrainToRootBounds="False">
<ListView
x:Name="ProfilesListView"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
SelectionChanged="ProfileListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ProfilesHeader"
Margin="{StaticResource FlyoutListHeaderMargin}"
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:PowerDisplayProfile">
<TextBlock
Padding="{StaticResource FlyoutListItemPadding}"
Text="{x:Bind Name}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
<StackPanel
Margin="0,0,8,8"
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
Spacing="{StaticResource FlyoutStandardSpacing}">
<Button
x:Name="ProfilesButton"
x:Uid="ProfilesTooltip"
Content="{ui:FontIcon Glyph=&#xE748;,
x:Uid="RefreshTooltip"
Content="{ui:FontIcon Glyph=&#xE777;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
Style="{StaticResource SubtleButtonStyle}">
<Button.Flyout>
<Flyout
x:Name="ProfilesFlyout"
Opened="Flyout_Opened"
ShouldConstrainToRootBounds="False">
<ListView
x:Name="ProfilesListView"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
SelectionChanged="ProfileListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
x:Uid="ProfilesHeader"
Margin="16,0,8,0"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</ListView.Header>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:PowerDisplayProfile">
<TextBlock Padding="0,4" Text="{x:Bind Name}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
<MenuFlyout>
<MenuFlyoutItem
x:Name="RefreshButton"
x:Uid="RefreshText"
Click="OnRefreshClick"
Icon="{ui:FontIcon Glyph=&#xE777;,
FontSize=16}" />
<MenuFlyoutItem
x:Name="IdentifyButton"
x:Uid="IdentifyText"
Command="{x:Bind ViewModel.IdentifyMonitorsCommand}"
Icon="{ui:FontIcon Glyph=&#xE71E;}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIdentifyMonitorsButton), Mode=OneWay}" />
</MenuFlyout>
</Button.Flyout>
</Button>
<Button
x:Name="RefreshButton"
x:Uid="RefreshTooltip"
Click="OnRefreshClick"
Content="{ui:FontIcon Glyph=&#xE72C;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Name="IdentifyButton"
x:Uid="IdentifyTooltip"
Command="{x:Bind ViewModel.IdentifyMonitorsCommand}"
Content="{ui:FontIcon Glyph=&#xE9D9;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIdentifyMonitorsButton), Mode=OneWay}" />
<Button
x:Name="SettingsBtn"
x:Uid="SettingsTooltip"
Padding="6"
Click="OnSettingsClick"
Style="{StaticResource FlyoutButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="SettingsTooltip" />
</ToolTipService.ToolTip>
<AnimatedIcon x:Name="SearchAnimatedIcon">
<AnimatedIcon x:Name="SettingsAnimatedIcon">
<AnimatedIcon.Source>
<animatedVisuals:AnimatedSettingsVisualSource />
</AnimatedIcon.Source>

View File

@@ -4,18 +4,16 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using PowerDisplay.Common.Models;
using PowerDisplay.Configuration;
using PowerDisplay.Helpers;
using PowerDisplay.Models;
using PowerDisplay.ViewModels;
using Windows.Graphics;
using WinUIEx;
@@ -32,6 +30,7 @@ namespace PowerDisplay
private readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private MainViewModel? _viewModel;
private HotkeyService? _hotkeyService;
private DpiSuppressor? _dpiSuppressor;
// Expose ViewModel as property for x:Bind
public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized");
@@ -65,10 +64,15 @@ namespace PowerDisplay
Logger.LogTrace("MainWindow constructor: Event handlers registered");
// 5. Initialize HotkeyService for in-process hotkey handling (CmdPal pattern)
// This avoids IPC timing issues with Runner's centralized hotkey mechanism
Logger.LogTrace("MainWindow constructor: Initializing HotkeyService");
var hwnd = this.GetWindowHandle();
_hotkeyService = new HotkeyService(_settingsUtils, ToggleWindow);
_hotkeyService.Initialize(this);
_hotkeyService.Initialize(hwnd);
// 6. Subclass WndProc for hotkey handling and DPI change suppression
Logger.LogTrace("MainWindow constructor: Setting up DpiSuppressor");
_dpiSuppressor = new DpiSuppressor(this, (uMsg, wParam, _) =>
_hotkeyService?.HandleMessage(uMsg, wParam) == true);
Logger.LogTrace("MainWindow constructor: HotkeyService initialized");
Logger.LogTrace("MainWindow constructor: Setting IsShownInSwitchers property");
@@ -118,6 +122,7 @@ namespace PowerDisplay
}
private bool _hasInitialized;
private bool _isShowingWindow;
private void ShowError(string message)
{
@@ -126,10 +131,12 @@ namespace PowerDisplay
private void OnWindowActivated(object sender, WindowActivatedEventArgs args)
{
Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}");
Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}, _isShowingWindow={_isShowingWindow}");
// Auto-hide window when it loses focus (deactivated)
if (args.WindowActivationState == WindowActivationState.Deactivated)
// But NOT during ShowWindow() sequence — the first Activate() for positioning
// may briefly deactivate before the final Activate()/Show() brings it back.
if (args.WindowActivationState == WindowActivationState.Deactivated && !_isShowingWindow)
{
Logger.LogInfo("OnWindowActivated: Window deactivated, hiding window");
HideWindow();
@@ -146,6 +153,7 @@ namespace PowerDisplay
public void ShowWindow()
{
Logger.LogInfo($"ShowWindow: Called, _hasInitialized={_hasInitialized}");
_isShowingWindow = true;
try
{
// If not initialized, log warning but continue showing
@@ -154,55 +162,36 @@ namespace PowerDisplay
Logger.LogWarning("ShowWindow: Window not fully initialized yet, showing anyway");
}
// Adjust size BEFORE showing to prevent flicker
// This measures content and positions window at correct size
Logger.LogTrace("ShowWindow: Adjusting window size to content");
// Adjust size and position on the correct monitor
// (DPI change suppression is handled inside AdjustWindowSizeToContent)
AdjustWindowSizeToContent();
// CRITICAL: WinUI3 windows must be Activated at least once to display properly.
// In PowerToys mode, window is created but never activated until first show.
// Without Activate(), Show() may not actually render the window on screen.
Logger.LogTrace("ShowWindow: Calling this.Activate()");
this.Activate();
// Now show the window - it should appear at the correct size
Logger.LogTrace("ShowWindow: Calling this.Show()");
this.Show();
// Ensure window stays on top of other windows
this.IsAlwaysOnTop = true;
Logger.LogTrace("ShowWindow: IsAlwaysOnTop set to true");
// Ensure window gets keyboard focus using WinUIEx's BringToFront
// This is necessary for Tab navigation to work without clicking first
// Ensure window gets keyboard focus
this.BringToFront();
Logger.LogTrace("ShowWindow: BringToFront called");
// Clear focus from any interactive element (e.g., Slider) to prevent
// showing the value tooltip when the window opens
RootGrid.Focus(FocusState.Programmatic);
// Verify window is visible
bool isVisible = IsWindowVisible();
Logger.LogInfo($"ShowWindow: Window visibility after show: {isVisible}");
if (!isVisible)
{
Logger.LogError("ShowWindow: Window not visible after show attempt, forcing visibility");
this.Activate();
this.Show();
this.BringToFront();
Logger.LogInfo($"ShowWindow: After forced show, visibility: {IsWindowVisible()}");
}
else
{
Logger.LogInfo("ShowWindow: Window shown successfully");
}
Logger.LogInfo($"ShowWindow: Window shown successfully, visibility={IsWindowVisible()}");
}
catch (Exception ex)
{
Logger.LogError($"ShowWindow: Failed to show window: {ex.Message}\n{ex.StackTrace}");
throw;
}
finally
{
_isShowingWindow = false;
}
}
public void HideWindow()
@@ -323,9 +312,9 @@ namespace PowerDisplay
// Window properties (IsResizable, IsMaximizable, IsMinimizable,
// IsTitleBarVisible, IsShownInSwitchers) are set in XAML
// Set minimal initial window size - will be adjusted before showing
// Using minimal height to prevent "large window shrinking" flicker
this.AppWindow.Resize(new SizeInt32 { Width = AppConstants.UI.WindowWidth, Height = 100 });
// Set a minimal initial window size in DIP - it will be adjusted before showing.
// Using minimal height prevents the "large window shrinking" flicker.
this.SetWindowSize(AppConstants.UI.WindowWidthDip, AppConstants.UI.WindowMinHeightDip);
// Position window at bottom right corner
PositionWindowAtBottomRight();
@@ -379,14 +368,26 @@ namespace PowerDisplay
// Force layout update and measure content height
RootGrid.UpdateLayout();
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidth, double.PositiveInfinity));
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidthDip, double.PositiveInfinity));
var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0);
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
// Apply min/max height limits and reposition (WindowEx handles DPI automatically)
// Apply min/max height limits and reposition using DIP values.
// Min height ensures window is visible even if content hasn't loaded yet
var finalHeight = Math.Max(AppConstants.UI.MinWindowHeight, Math.Min(contentHeight, AppConstants.UI.MaxWindowHeight));
Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, finalHeight={finalHeight}");
WindowHelper.PositionWindowBottomRight(this, AppConstants.UI.WindowWidth, finalHeight, AppConstants.UI.WindowRightMargin);
var finalHeightDip = Math.Min(contentHeight, maxHeightDip);
Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, maxHeightDip={maxHeightDip}, finalHeightDip={finalHeightDip}");
// Suppress WM_DPICHANGED during MoveAndResize to prevent double-scaling
// when moving across monitors with different DPI settings.
using (_dpiSuppressor?.Suppress() ?? default)
{
WindowHelper.PositionWindowBottomRight(
this,
AppConstants.UI.WindowWidthDip,
finalHeightDip,
AppConstants.UI.WindowRightMarginDip,
AppConstants.UI.WindowBottomMarginDip);
}
}
catch (Exception ex)
{
@@ -394,16 +395,48 @@ namespace PowerDisplay
}
}
private static int GetAdaptiveWindowMaxHeightDip()
{
if (!WindowHelper.TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
return AppConstants.UI.WindowMaxHeightDip;
}
double dpiScale = WindowHelper.GetDpiScale(displayArea);
int workAreaHeightDip = WindowHelper.ScaleToDip(displayArea.WorkArea.Height, dpiScale);
return (int)Math.Floor(workAreaHeightDip * AppConstants.UI.WindowMaxWorkAreaHeightRatio);
}
private static double GetAdaptiveFlyoutMaxWidthDip()
{
if (!WindowHelper.TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
return AppConstants.UI.FlyoutContextMenuMaxWidthDip;
}
double dpiScale = WindowHelper.GetDpiScale(displayArea);
int workAreaWidthDip = WindowHelper.ScaleToDip(displayArea.WorkArea.Width, dpiScale);
double adaptiveMaxWidthDip = Math.Floor(workAreaWidthDip * AppConstants.UI.FlyoutContextMenuMaxWorkAreaWidthRatio);
return Math.Max(
AppConstants.UI.FlyoutContextMenuMaxWidthDip,
Math.Min(AppConstants.UI.FlyoutContextMenuAdaptiveMaxWidthDip, adaptiveMaxWidthDip));
}
private void PositionWindowAtBottomRight()
{
try
{
var windowSize = this.AppWindow.Size;
var windowHeightDip = this.Height > 0
? (int)Math.Ceiling(this.Height)
: AppConstants.UI.WindowMinHeightDip;
WindowHelper.PositionWindowBottomRight(
this, // MainWindow inherits from WindowEx
AppConstants.UI.WindowWidth,
windowSize.Height,
AppConstants.UI.WindowRightMargin);
AppConstants.UI.WindowWidthDip,
windowHeightDip,
AppConstants.UI.WindowRightMarginDip,
AppConstants.UI.WindowBottomMarginDip);
}
catch (Exception)
{
@@ -411,207 +444,6 @@ namespace PowerDisplay
}
}
/// <summary>
/// Slider PointerCaptureLost event handler - updates ViewModel when drag completes
/// This is the WinUI3 recommended way to detect drag completion
/// </summary>
private void Slider_PointerCaptureLost(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
{
var slider = sender as Slider;
if (slider == null)
{
return;
}
var propertyName = slider.Tag as string;
var monitorVm = slider.DataContext as MonitorViewModel;
if (monitorVm == null || propertyName == null)
{
return;
}
// Get final value after drag completes
int finalValue = (int)slider.Value;
// Now update the ViewModel, which will trigger hardware operation
switch (propertyName)
{
case "Brightness":
monitorVm.Brightness = finalValue;
break;
case "Contrast":
monitorVm.ContrastPercent = finalValue;
break;
case "Volume":
monitorVm.Volume = finalValue;
break;
}
}
/// <summary>
/// Slider KeyUp event handler - updates ViewModel when arrow keys are released
/// This handles keyboard navigation for accessibility
/// </summary>
private void Slider_KeyUp(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
{
// Only handle arrow keys (Left, Right, Up, Down)
if (e.Key != Windows.System.VirtualKey.Left &&
e.Key != Windows.System.VirtualKey.Right &&
e.Key != Windows.System.VirtualKey.Up &&
e.Key != Windows.System.VirtualKey.Down)
{
return;
}
var slider = sender as Slider;
if (slider == null)
{
return;
}
var propertyName = slider.Tag as string;
var monitorVm = slider.DataContext as MonitorViewModel;
if (monitorVm == null || propertyName == null)
{
return;
}
// Get the current value after key press
int finalValue = (int)slider.Value;
// Update the ViewModel, which will trigger hardware operation
switch (propertyName)
{
case "Brightness":
monitorVm.Brightness = finalValue;
break;
case "Contrast":
monitorVm.ContrastPercent = finalValue;
break;
case "Volume":
monitorVm.Volume = finalValue;
break;
}
}
/// <summary>
/// Input source ListView selection changed handler - switches the monitor input source
/// </summary>
private async void InputSourceListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView)
{
return;
}
// Get the selected input source item
var selectedItem = listView.SelectedItem as InputSourceItem;
if (selectedItem == null)
{
return;
}
Logger.LogInfo($"[UI] InputSourceListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}");
// Find the monitor by ID
MonitorViewModel? monitorVm = null;
if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null)
{
monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId);
}
if (monitorVm == null)
{
Logger.LogWarning("[UI] InputSourceListView_SelectionChanged: Could not find MonitorViewModel");
return;
}
// Set the input source
await monitorVm.SetInputSourceAsync(selectedItem.Value);
}
/// <summary>
/// Power state ListView selection changed handler - switches the monitor power state.
/// Note: Selecting any state other than "On" will turn off the display.
/// </summary>
private async void PowerStateListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView)
{
return;
}
// Get the selected power state item
var selectedItem = listView.SelectedItem as PowerStateItem;
if (selectedItem == null)
{
return;
}
// Skip if "On" is selected - the monitor is already on
if (selectedItem.Value == PowerStateItem.PowerStateOn)
{
return;
}
Logger.LogInfo($"[UI] PowerStateListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}");
// Find the monitor by ID
MonitorViewModel? monitorVm = null;
if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null)
{
monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId);
}
if (monitorVm == null)
{
Logger.LogWarning("[UI] PowerStateListView_SelectionChanged: Could not find MonitorViewModel");
return;
}
// Set the power state - this will turn off the display
await monitorVm.SetPowerStateAsync(selectedItem.Value);
}
/// <summary>
/// Rotation button click handler - changes monitor orientation
/// </summary>
private async void RotationButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Microsoft.UI.Xaml.Controls.Primitives.ToggleButton toggleButton)
{
return;
}
// Get the orientation from the Tag
if (toggleButton.Tag is not string tagStr || !int.TryParse(tagStr, out int orientation))
{
Logger.LogWarning("[UI] RotationButton_Click: Invalid Tag");
return;
}
var monitorVm = toggleButton.DataContext as MonitorViewModel;
if (monitorVm == null)
{
Logger.LogWarning("[UI] RotationButton_Click: Could not find MonitorViewModel");
return;
}
// If clicking the current orientation, restore the checked state and do nothing
if (monitorVm.CurrentRotation == orientation)
{
toggleButton.IsChecked = true;
return;
}
Logger.LogInfo($"[UI] RotationButton_Click: Setting rotation for {monitorVm.Name} to {orientation}");
// Set the rotation
await monitorVm.SetRotationAsync(orientation);
}
/// <summary>
/// Profile selection changed handler - applies the selected profile
/// </summary>
@@ -643,44 +475,6 @@ namespace PowerDisplay
listView.SelectedItem = null;
}
/// <summary>
/// Color temperature selection changed handler - applies the selected color temperature preset
/// </summary>
private async void ColorTemperatureListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView)
{
return;
}
var selectedItem = listView.SelectedItem as ColorTemperatureItem;
if (selectedItem == null)
{
return;
}
Logger.LogInfo($"[UI] ColorTemperatureListView_SelectionChanged: Selected {selectedItem.DisplayName} (0x{selectedItem.VcpValue:X2}) for monitor {selectedItem.MonitorId}");
// Find the monitor by ID
MonitorViewModel? monitorVm = null;
if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null)
{
monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId);
}
if (monitorVm == null)
{
Logger.LogWarning("[UI] ColorTemperatureListView_SelectionChanged: Could not find MonitorViewModel");
return;
}
// Apply the color temperature
await monitorVm.SetColorTemperatureAsync(selectedItem.VcpValue);
// Clear selection to allow reselecting the same preset
listView.SelectedItem = null;
}
/// <summary>
/// Flyout opened event handler - sets focus to the first focusable element inside the flyout.
/// This enables keyboard navigation when the flyout opens.
@@ -689,6 +483,8 @@ namespace PowerDisplay
{
if (sender is Flyout flyout && flyout.Content is FrameworkElement content)
{
content.MaxWidth = GetAdaptiveFlyoutMaxWidthDip();
// Use DispatcherQueue to ensure the flyout content is fully rendered before setting focus
DispatcherQueue.TryEnqueue(() =>
{
@@ -704,6 +500,7 @@ namespace PowerDisplay
public void Dispose()
{
_hotkeyService?.Dispose();
_dpiSuppressor?.Dispose();
_viewModel?.Dispose();
GC.SuppressFinalize(this);
}

View File

@@ -1,49 +1,55 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="PowerDisplay.MonitorIcon"
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:local="using:PowerDisplay"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
AutomationProperties.AccessibilityView="Raw"
IsTabStop="False"
mc:Ignorable="d">
<UserControl.Resources>
<x:Double x:Key="MonitorGlyphFontSize">22</x:Double>
<x:Double x:Key="MonitorNumberFontSize">10</x:Double>
<Thickness x:Key="ExternalMonitorNumberMargin">0,0,0,4</Thickness>
<Thickness x:Key="BuiltInMonitorNumberMargin">0,0,0,6</Thickness>
</UserControl.Resources>
<Grid>
<Viewbox>
<!-- Decorative badge: keep fixed text scaling for the glyph overlay and rely on adjacent DisplayName text for accessible monitor numbering. -->
<Viewbox Width="16">
<Grid>
<Grid x:Name="MonitorGrid">
<Grid x:Name="MonitorGrid" x:Uid="ExternalMonitorTooltip">
<FontIcon
x:Uid="MonitorTooltip"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="22"
FontSize="{StaticResource MonitorGlyphFontSize}"
Glyph="&#xE7F4;" />
<TextBlock
Margin="0,0,0,4"
Margin="{StaticResource ExternalMonitorNumberMargin}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="10"
FontSize="{StaticResource MonitorNumberFontSize}"
FontWeight="SemiBold"
IsTextScaleFactorEnabled="False"
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
</Grid>
<Grid
x:Name="BuiltInDisplayGrid"
Padding="0,0,0,-4"
x:Uid="BuiltInMonitorTooltip"
Visibility="Collapsed">
<FontIcon
x:Uid="MonitorTooltip"
Margin="0,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="22"
FontSize="{StaticResource MonitorGlyphFontSize}"
Glyph="&#xE7FB;" />
<TextBlock
Margin="0,0,0,6"
Margin="{StaticResource BuiltInMonitorNumberMargin}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="10"
FontSize="{StaticResource MonitorNumberFontSize}"
FontWeight="SemiBold"
IsTextScaleFactorEnabled="False"
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
</Grid>
</Grid>

View File

@@ -34,6 +34,13 @@ namespace PowerDisplay
// Initialize COM wrappers first (needed for AppInstance)
WinRT.ComWrappersSupport.InitializeComWrappers();
// Exit before instance registration so a blocked launch cannot redirect
// activation to an already-running instance.
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredPowerDisplayEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
return 0;
}
// Single instance check BEFORE logger initialization to avoid creating extra log files
// Command Palette pattern: check for existing instance first
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();

View File

@@ -7,6 +7,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library;
using PowerDisplay.Common.Models;
using PowerDisplay.Models;
namespace PowerDisplay.Serialization
{

View File

@@ -24,8 +24,11 @@
<data name="SettingsTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Settings</value>
</data>
<data name="MonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
<data name="ExternalMonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Monitor</value>
</data>
<data name="BuiltInMonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Built-in display</value>
</data>
<data name="BrightnessTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Brightness</value>
@@ -50,6 +53,18 @@
</data>
<data name="RotateInvertedTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Inverted (180°)</value>
</data>
<data name="RotationOptionLandscape.Content" xml:space="preserve">
<value>Landscape</value>
</data>
<data name="RotationOptionPortrait.Content" xml:space="preserve">
<value>Portrait</value>
</data>
<data name="RotationOptionLandscapeFlipped.Content" xml:space="preserve">
<value>Landscape (flipped)</value>
</data>
<data name="RotationOptionPortraitFlipped.Content" xml:space="preserve">
<value>Portrait (flipped)</value>
</data>
<data name="VolumeAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Volume</value>
@@ -72,8 +87,11 @@
<data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Quick apply profiles</value>
</data>
<data name="IdentifyTooltip.ToolTipService.ToolTip" xml:space="preserve">
<data name="IdentifyText.Text" xml:space="preserve">
<value>Identify monitors</value>
</data>
<data name="RefreshText.Text" xml:space="preserve">
<value>Refresh</value>
</data>
<data name="InputSourceHeader.Text" xml:space="preserve">
<value>Input source</value>

View File

@@ -12,6 +12,7 @@ using Microsoft.PowerToys.Telemetry;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Common.Utils;
using PowerDisplay.Models;
using PowerDisplay.Serialization;
using PowerDisplay.Services;
using PowerDisplay.Telemetry.Events;

View File

@@ -5,13 +5,12 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
@@ -23,6 +22,7 @@ using PowerDisplay.Common.Drivers.DDC;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Helpers;
using PowerDisplay.Models;
using PowerDisplay.PowerDisplayXAML;
namespace PowerDisplay.ViewModels;
@@ -35,7 +35,7 @@ namespace PowerDisplay.ViewModels;
/// - MainViewModel.Settings.cs: Settings UI synchronization and profiles
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public partial class MainViewModel : INotifyPropertyChanged, IDisposable
public partial class MainViewModel : ObservableObject, IDisposable
{
[LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
@@ -48,12 +48,23 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
private readonly MonitorStateManager _stateManager;
private readonly DisplayChangeWatcher _displayChangeWatcher;
private ObservableCollection<MonitorViewModel> _monitors;
private ObservableCollection<PowerDisplayProfile> _profiles;
private bool _isScanning;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMonitors))]
[NotifyPropertyChangedFor(nameof(ShowNoMonitorsMessage))]
[NotifyPropertyChangedFor(nameof(IsInteractionEnabled))]
public partial bool IsScanning { get; set; }
private bool _isInitialized;
private bool _isLoading;
[ObservableProperty]
public partial ObservableCollection<MonitorViewModel> Monitors { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasProfiles))]
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
public partial ObservableCollection<PowerDisplayProfile> Profiles { get; set; }
/// <summary>
/// Event triggered when UI refresh is requested due to settings changes
/// </summary>
@@ -69,9 +80,11 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
{
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_cancellationTokenSource = new CancellationTokenSource();
_monitors = new ObservableCollection<MonitorViewModel>();
_profiles = new ObservableCollection<PowerDisplayProfile>();
_isScanning = true;
Monitors = new ObservableCollection<MonitorViewModel>();
Profiles = new ObservableCollection<PowerDisplayProfile>();
IsScanning = true;
ShowProfileSwitcher = true;
ShowIdentifyMonitorsButton = true;
// Initialize settings utils
_settingsUtils = SettingsUtils.Default;
@@ -97,71 +110,21 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
_ = InitializeAsync(_cancellationTokenSource.Token);
}
public ObservableCollection<MonitorViewModel> Monitors
{
get => _monitors;
set
{
_monitors = value;
OnPropertyChanged();
}
}
public ObservableCollection<PowerDisplayProfile> Profiles
{
get => _profiles;
set
{
_profiles = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasProfiles));
}
}
public bool HasProfiles => Profiles.Count > 0;
// UI display control properties - loaded from settings
private bool _showProfileSwitcher = true;
private bool _showIdentifyMonitorsButton = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
public partial bool ShowProfileSwitcher { get; set; }
[ObservableProperty]
public partial bool ShowIdentifyMonitorsButton { get; set; }
/// <summary>
/// Gets a value indicating whether to show the profile switcher button.
/// Combines settings value with HasProfiles check.
/// </summary>
public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles;
/// <summary>
/// Gets or sets a value indicating whether to show the profile switcher (from settings).
/// </summary>
public bool ShowProfileSwitcher
{
get => _showProfileSwitcher;
set
{
if (_showProfileSwitcher != value)
{
_showProfileSwitcher = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ShowProfileSwitcherButton));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether to show the identify monitors button.
/// </summary>
public bool ShowIdentifyMonitorsButton
{
get => _showIdentifyMonitorsButton;
set
{
if (_showIdentifyMonitorsButton != value)
{
_showIdentifyMonitorsButton = value;
OnPropertyChanged();
}
}
}
public bool ShowProfileSwitcherButton => ShowProfileSwitcher && HasProfiles;
// Custom VCP mappings - loaded from settings
private List<CustomVcpValueMapping> _customVcpMappings = new();
@@ -180,24 +143,6 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
}
}
public bool IsScanning
{
get => _isScanning;
set
{
if (_isScanning != value)
{
_isScanning = value;
OnPropertyChanged();
// Dependent properties that change with IsScanning
OnPropertyChanged(nameof(HasMonitors));
OnPropertyChanged(nameof(ShowNoMonitorsMessage));
OnPropertyChanged(nameof(IsInteractionEnabled));
}
}
}
public bool HasMonitors => !IsScanning && Monitors.Count > 0;
public bool ShowNoMonitorsMessage => !IsScanning && Monitors.Count == 0;
@@ -285,8 +230,8 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
// Create and position identify window
var identifyWindow = new IdentifyWindow(displayText);
identifyWindow.PositionOnDisplay(displayArea);
identifyWindow.Activate();
identifyWindow.PositionOnDisplay(displayArea);
windowsCreated++;
}
}
@@ -305,13 +250,6 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public void Dispose()
{
// Cancel all async operations first
@@ -381,10 +319,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
try
{
var profilesData = ProfileService.LoadProfiles();
_profiles.Clear();
Profiles.Clear();
foreach (var profile in profilesData.Profiles)
{
_profiles.Add(profile);
Profiles.Add(profile);
}
OnPropertyChanged(nameof(HasProfiles));

View File

@@ -6,16 +6,20 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using PowerDisplay.Common.Models;
using PowerDisplay.Configuration;
using PowerDisplay.Helpers;
using PowerDisplay.Models;
using Windows.System;
using Monitor = PowerDisplay.Common.Models.Monitor;
namespace PowerDisplay.ViewModels;
@@ -23,7 +27,7 @@ namespace PowerDisplay.ViewModels;
/// <summary>
/// ViewModel for individual monitor
/// </summary>
public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
public partial class MonitorViewModel : ObservableObject, IDisposable
{
private readonly Monitor _monitor;
private readonly MonitorManager _monitorManager;
@@ -32,13 +36,27 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
private int _brightness;
private int _contrast;
private int _volume;
private bool _isAvailable;
[ObservableProperty]
public partial bool IsAvailable { get; set; }
// Visibility settings (controlled by Settings UI)
private bool _showContrast;
private bool _showVolume;
private bool _showInputSource;
private bool _showRotation;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdvancedControls))]
public partial bool ShowContrast { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdvancedControls))]
public partial bool ShowVolume { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMoreButton))]
[NotifyPropertyChangedFor(nameof(ShowSeparatorAfterInputSource))]
public partial bool ShowInputSource { get; set; }
[ObservableProperty]
public partial bool ShowRotation { get; set; }
private bool _showPowerState;
/// <summary>
@@ -205,9 +223,9 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
_monitor.PropertyChanged += OnMonitorPropertyChanged;
// Initialize Show properties based on hardware capabilities
_showContrast = monitor.SupportsContrast;
_showVolume = monitor.SupportsVolume;
_showInputSource = monitor.SupportsInputSource;
ShowContrast = monitor.SupportsContrast;
ShowVolume = monitor.SupportsVolume;
ShowInputSource = monitor.SupportsInputSource;
_showPowerState = monitor.SupportsPowerState;
_showColorTemperature = monitor.SupportsColorTemperature;
@@ -215,7 +233,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
_brightness = monitor.CurrentBrightness;
_contrast = monitor.CurrentContrast;
_volume = monitor.CurrentVolume;
_isAvailable = monitor.IsAvailable;
IsAvailable = monitor.IsAvailable;
}
public string Id => _monitor.Id;
@@ -289,48 +307,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
/// </summary>
public bool SupportsVolume => _monitor.SupportsVolume;
public bool ShowContrast
{
get => _showContrast;
set
{
if (_showContrast != value)
{
_showContrast = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasAdvancedControls));
}
}
}
public bool ShowVolume
{
get => _showVolume;
set
{
if (_showVolume != value)
{
_showVolume = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasAdvancedControls));
}
}
}
public bool ShowInputSource
{
get => _showInputSource;
set
{
if (_showInputSource != value)
{
_showInputSource = value;
OnPropertyChanged();
OnMoreButtonPropertiesChanged();
}
}
}
/// <summary>
/// Gets or sets a value indicating whether to show power state control in the More Button flyout.
/// </summary>
@@ -369,22 +345,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
OnPropertyChanged(nameof(ShowSeparatorAfterInputSource));
}
/// <summary>
/// Gets or sets a value indicating whether to show rotation controls (controlled by Settings UI, default false).
/// </summary>
public bool ShowRotation
{
get => _showRotation;
set
{
if (_showRotation != value)
{
_showRotation = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°)
/// </summary>
@@ -410,6 +370,16 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
/// </summary>
public bool IsRotation3 => CurrentRotation == 3;
/// <summary>
/// Gets or sets the selected rotation index for binding to a ComboBox.
/// Maps directly to <see cref="CurrentRotation"/>: 0=Landscape, 1=Portrait, 2=Landscape (flipped), 3=Portrait (flipped).
/// </summary>
public int SelectedRotationIndex
{
get => CurrentRotation;
set => _ = SetRotationAsync(value);
}
/// <summary>
/// Set rotation/orientation for this monitor.
/// Note: MonitorManager.SetRotationAsync will refresh all monitors' orientations after success,
@@ -468,8 +438,14 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
/// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB")
/// Uses custom mappings if available; falls back to built-in names if not.
/// </summary>
public string ColorTemperaturePresetName =>
Common.Utils.VcpNames.GetFormattedValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id);
public string ColorTemperaturePresetName
{
get
{
var name = Common.Utils.VcpNames.GetValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id);
return name != null ? $"{name} (0x{_monitor.CurrentColorTemperature:X2})" : $"0x{_monitor.CurrentColorTemperature:X2}";
}
}
/// <summary>
/// Gets a value indicating whether this monitor supports color temperature via VCP 0x14
@@ -549,7 +525,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
_availableColorPresets = presetValues.Select(value => new ColorTemperatureItem
{
VcpValue = value,
DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id),
DisplayName = Common.Utils.VcpNames.GetValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) is string n ? $"{n} (0x{value:X2})" : $"0x{value:X2}",
IsSelected = value == _monitor.CurrentColorTemperature,
MonitorId = _monitor.Id,
}).ToList();
@@ -781,16 +757,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
}
}
public bool IsAvailable
{
get => _isAvailable;
set
{
_isAvailable = value;
OnPropertyChanged();
}
}
[RelayCommand]
private void SetBrightness(int? brightness)
{
@@ -850,13 +816,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
return min + (int)Math.Round(percent * (max - min) / 100.0);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnMainViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled))
@@ -882,9 +841,125 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
OnPropertyChanged(nameof(IsRotation1));
OnPropertyChanged(nameof(IsRotation2));
OnPropertyChanged(nameof(IsRotation3));
OnPropertyChanged(nameof(SelectedRotationIndex));
}
}
// Slider commit handlers — fire hardware update only on pointer release or arrow key up
public void HandleBrightnessPointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
if (sender is Slider slider)
{
Brightness = (int)slider.Value;
}
}
public void HandleBrightnessKeyUp(object sender, KeyRoutedEventArgs e)
{
if (IsArrowKey(e.Key) && sender is Slider slider)
{
Brightness = (int)slider.Value;
}
}
public void HandleContrastPointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
if (sender is Slider slider)
{
ContrastPercent = (int)slider.Value;
}
}
public void HandleContrastKeyUp(object sender, KeyRoutedEventArgs e)
{
if (IsArrowKey(e.Key) && sender is Slider slider)
{
ContrastPercent = (int)slider.Value;
}
}
public void HandleVolumePointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
if (sender is Slider slider)
{
Volume = (int)slider.Value;
}
}
public void HandleVolumeKeyUp(object sender, KeyRoutedEventArgs e)
{
if (IsArrowKey(e.Key) && sender is Slider slider)
{
Volume = (int)slider.Value;
}
}
private static bool IsArrowKey(VirtualKey key) =>
key == VirtualKey.Left || key == VirtualKey.Right ||
key == VirtualKey.Up || key == VirtualKey.Down;
// Rotation button handlers — one per orientation to avoid Tag string parsing
public async void HandleRotation0Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(0);
public async void HandleRotation1Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(1);
public async void HandleRotation2Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(2);
public async void HandleRotation3Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(3);
private async Task CommitRotationClickAsync(int orientation)
{
if (CurrentRotation == orientation)
{
// Force-notify to restore the ToggleButton checked state
// (ToggleButton auto-unchecks on click; OneWay binding only re-pushes on change)
OnPropertyChanged(nameof(IsRotation0));
OnPropertyChanged(nameof(IsRotation1));
OnPropertyChanged(nameof(IsRotation2));
OnPropertyChanged(nameof(IsRotation3));
return;
}
await SetRotationAsync(orientation);
}
// ListView selection handlers
public async void HandleColorTemperatureSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView || listView.SelectedItem is not ColorTemperatureItem item)
{
return;
}
await SetColorTemperatureAsync(item.VcpValue);
listView.SelectedItem = null;
}
public async void HandleInputSourceSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView || listView.SelectedItem is not InputSourceItem item)
{
return;
}
await SetInputSourceAsync(item.Value);
}
public async void HandlePowerStateSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView || listView.SelectedItem is not PowerStateItem item)
{
return;
}
if (item.Value == PowerStateItem.PowerStateOn)
{
return;
}
await SetPowerStateAsync(item.Value);
}
public void Dispose()
{
// Unsubscribe from MainViewModel events

View File

@@ -51,14 +51,25 @@ void PowerDisplayProcessManager::stop()
void PowerDisplayProcessManager::send_message(const std::wstring& message_type, const std::wstring& message_arg)
{
submit_task([this, message_type, message_arg] {
// Ensure process is running before sending message
// If process is not running, enable and start it - this allows Quick Access launch
// to work even when the module was not previously enabled
if (!m_enabled)
{
Logger::warn(L"Ignoring '{}' message because PowerDisplay is disabled", message_type);
return;
}
// If the process exited unexpectedly while the module is still enabled,
// bring it back before delivering the message.
if (!is_process_running())
{
m_enabled = true;
refresh();
if (!is_process_running())
{
Logger::warn(L"PowerDisplay process is not running; '{}' message was not delivered", message_type);
return;
}
}
send_named_pipe_message(message_type, message_arg);
});
}

View File

@@ -70,7 +70,8 @@ public:
Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast<void*>(m_hSendSettingsTelemetryEvent));
// Create Toggle event for Quick Access support
// This allows Quick Access to launch PowerDisplay even when module is not enabled
// Listener registration is tied to enable()/disable() so activation still
// flows through the module's runtime enable/GPO checks.
m_hToggleEvent = CreateDefaultEvent(CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT);
Logger::trace(L"Created TOGGLE_EVENT: handle={}", reinterpret_cast<void*>(m_hToggleEvent));
@@ -90,8 +91,6 @@ public:
Logger::info(L"All Windows Events created successfully");
}
// Start toggle event listener thread for Quick Access support
StartToggleEventListener();
}
~PowerDisplayModule()
@@ -129,7 +128,7 @@ public:
void StartToggleEventListener()
{
if (!m_hToggleEvent || !m_hStopEvent)
if (!m_hToggleEvent || !m_hStopEvent || m_toggleEventThread.joinable())
{
return;
}
@@ -152,7 +151,7 @@ public:
if (result == WAIT_OBJECT_0 + TOGGLE_EVENT_INDEX)
{
Logger::trace(L"Toggle event received");
TogglePowerDisplay();
TryTogglePowerDisplay(L"Toggle event");
}
else if (result == WAIT_OBJECT_0 + STOP_EVENT_INDEX)
{
@@ -172,6 +171,34 @@ public:
});
}
bool IsActivationAllowed(const wchar_t* source)
{
if (gpo_policy_enabled_configuration() == powertoys_gpo::gpo_rule_configured_t::gpo_rule_configured_disabled)
{
Logger::warn(L"{} ignored because PowerDisplay is disabled by GPO", source);
return false;
}
if (!m_enabled)
{
Logger::info(L"{} ignored because PowerDisplay module is disabled", source);
return false;
}
return true;
}
bool TrySendMessage(const std::wstring& message_type, const std::wstring& message_arg, const wchar_t* source)
{
if (!IsActivationAllowed(source))
{
return false;
}
m_processManager.send_message(message_type, message_arg);
return true;
}
void StopToggleEventListener()
{
if (m_hStopEvent)
@@ -191,8 +218,13 @@ public:
/// If process is running, launches again to trigger redirect activation (OnActivated handles toggle).
/// If process is not running, starts it via Named Pipe and sends toggle message.
/// </summary>
void TogglePowerDisplay()
bool TryTogglePowerDisplay(const wchar_t* source)
{
if (!IsActivationAllowed(source))
{
return false;
}
if (m_processManager.is_running())
{
// Process running - launch to trigger single instance redirect, OnActivated will toggle
@@ -208,6 +240,7 @@ public:
m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE);
}
Trace::ActivatePowerDisplay();
return true;
}
virtual void destroy() override
@@ -251,7 +284,7 @@ public:
if (action_object.get_name() == L"Launch")
{
Logger::trace(L"Launch action received");
TogglePowerDisplay();
TryTogglePowerDisplay(L"Launch action");
}
else if (action_object.get_name() == L"RefreshMonitors")
{
@@ -274,7 +307,7 @@ public:
Logger::trace(L"ApplyProfile: profile name = '{}'", profileName);
// Send ApplyProfile message with profile name via Named Pipe
m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE, profileName);
TrySendMessage(CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE, profileName, L"ApplyProfile action");
}
}
catch (std::exception&)
@@ -297,6 +330,8 @@ public:
m_enabled = true;
Trace::EnablePowerDisplay(true);
StartToggleEventListener();
// Start the process manager (launches PowerDisplay.exe with Named Pipe)
m_processManager.start();
@@ -307,13 +342,12 @@ public:
{
Logger::trace(L"PowerDisplay::disable()");
if (m_enabled)
{
// Stop the process manager (sends terminate message and waits for exit)
m_processManager.stop();
}
m_enabled = false;
StopToggleEventListener();
// Stop the process manager (sends terminate message and waits for exit)
m_processManager.stop();
Trace::EnablePowerDisplay(false);
}

View File

@@ -286,7 +286,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
L"PowerToys.CmdPalModuleInterface.dll",
L"PowerToys.ZoomItModuleInterface.dll",
L"PowerToys.LightSwitchModuleInterface.dll",
// L"PowerToys.PowerDisplayModuleInterface.dll", // TEMPORARILY_DISABLED: PowerDisplay
L"PowerToys.PowerDisplayModuleInterface.dll",
};
for (auto moduleSubdir : knownModules)

View File

@@ -77,12 +77,6 @@ public sealed class AllAppsViewModel : Observable
continue;
}
// TEMPORARILY_DISABLED: PowerDisplay
if (moduleType == ModuleType.PowerDisplay)
{
continue;
}
_allFlyoutMenuItems.Add(new FlyoutMenuItem
{
Tag = moduleType,

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