Files
PowerToys/src/settings-ui/Settings.UI.Library/KeysDataModel.cs
moooyo 9039451e2f [Quick Accent] Migrate UI to WinUI 3 (#48891)
## Summary of the Pull Request

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


demo:

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


## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments

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

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

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

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

## Validation Steps Performed

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

---------

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

319 lines
10 KiB
C#

// 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.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Windows.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library.Utilities;
using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class KeysDataModel : INotifyPropertyChanged
{
[JsonPropertyName("originalKeys")]
public string OriginalKeys { get; set; }
[JsonPropertyName("secondKeyOfChord")]
public uint SecondKeyOfChord { get; set; }
[JsonPropertyName("newRemapKeys")]
public string NewRemapKeys { get; set; }
[JsonPropertyName("unicodeText")]
public string NewRemapString { get; set; }
[JsonPropertyName("runProgramFilePath")]
public string RunProgramFilePath { get; set; }
[JsonPropertyName("runProgramArgs")]
public string RunProgramArgs { get; set; }
[JsonPropertyName("openUri")]
public string OpenUri { get; set; }
[JsonPropertyName("operationType")]
public int OperationType { get; set; }
private enum KeyboardManagerEditorType
{
KeyEditor = 0,
ShortcutEditor,
}
public const string CommaSeparator = "<comma>";
private static Process editor;
private ICommand _editShortcutItemCommand;
public ICommand EditShortcutItem => _editShortcutItemCommand ?? (_editShortcutItemCommand = new RelayCommand<object>(OnEditShortcutItem));
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public void OnEditShortcutItem(object parameter)
{
OpenEditor((int)KeyboardManagerEditorType.ShortcutEditor);
}
private async void OpenEditor(int type)
{
if (editor != null)
{
BringProcessToFront(editor);
return;
}
const string PowerToyName = KeyboardManagerSettings.ModuleName;
const string KeyboardManagerEditorPath = "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe";
try
{
if (editor != null && editor.HasExited)
{
Logger.LogInfo($"Previous instance of {PowerToyName} editor exited");
editor = null;
}
if (editor != null)
{
Logger.LogInfo($"The {PowerToyName} editor instance {editor.Id} exists. Bringing the process to the front");
BringProcessToFront(editor);
return;
}
string path = Path.Combine(Environment.CurrentDirectory, KeyboardManagerEditorPath);
Logger.LogInfo($"Starting {PowerToyName} editor from {path}");
// InvariantCulture: type represents the KeyboardManagerEditorType enum value
editor = Process.Start(path, $"{type.ToString(CultureInfo.InvariantCulture)} {Environment.ProcessId}");
await editor.WaitForExitAsync();
editor = null;
}
catch (Exception e)
{
editor = null;
Logger.LogError($"Exception encountered when opening an {PowerToyName} editor", e);
}
}
private static void BringProcessToFront(Process process)
{
if (process == null)
{
return;
}
IntPtr handle = process.MainWindowHandle;
if (NativeMethods.IsIconic(handle))
{
NativeMethods.ShowWindow(handle, NativeMethods.SWRESTORE);
}
NativeMethods.SetForegroundWindow(handle);
}
private static List<string> MapKeysOnlyChord(uint secondKeyOfChord)
{
var result = new List<string>();
if (secondKeyOfChord <= 0)
{
return result;
}
result.Add(Helper.GetKeyName(secondKeyOfChord));
return result;
}
private static List<string> MapKeys(string stringOfKeys, uint secondKeyOfChord, bool splitChordsWithComma = false)
{
if (stringOfKeys == null)
{
return new List<string>();
}
if (secondKeyOfChord > 0)
{
var keys = stringOfKeys.Split(';');
return keys.Take(keys.Length - 1)
.Select(uint.Parse)
.Select(Helper.GetKeyName)
.ToList();
}
else
{
if (splitChordsWithComma)
{
var keys = stringOfKeys.Split(';')
.Select(uint.Parse)
.Select(Helper.GetKeyName)
.ToList();
keys.Insert(keys.Count - 1, CommaSeparator);
return keys;
}
else
{
return stringOfKeys
.Split(';')
.Select(uint.Parse)
.Select(Helper.GetKeyName)
.ToList();
}
}
}
private static List<string> MapKeys(string stringOfKeys)
{
return MapKeys(stringOfKeys, 0);
}
public List<string> GetMappedOriginalKeys(bool ignoreSecondKeyInChord, bool splitChordsWithComma = false)
{
if (ignoreSecondKeyInChord && SecondKeyOfChord > 0)
{
return MapKeys(OriginalKeys, SecondKeyOfChord);
}
else
{
return MapKeys(OriginalKeys, 0, splitChordsWithComma);
}
}
public List<string> GetMappedOriginalKeysOnlyChord()
{
return MapKeysOnlyChord(SecondKeyOfChord);
}
public List<string> GetMappedOriginalKeys()
{
return GetMappedOriginalKeys(false);
}
public List<string> GetMappedOriginalKeysWithSplitChord()
{
return GetMappedOriginalKeys(false, true);
}
public bool IsRunProgram
{
get
{
return OperationType == 1;
}
}
public bool IsOpenURI
{
get
{
return OperationType == 2;
}
}
public bool IsOpenUriOrIsRunProgram
{
get
{
return IsOpenURI || IsRunProgram;
}
}
public bool HasChord
{
get
{
return SecondKeyOfChord > 0;
}
}
public List<string> GetMappedNewRemapKeys(int runProgramMaxLength)
{
if (IsRunProgram)
{
// we're going to just pretend this is a "key" if we have a RunProgramFilePath
if (string.IsNullOrEmpty(RunProgramFilePath))
{
return new List<string>();
}
else
{
return new List<string> { FormatFakeKeyForDisplay(runProgramMaxLength) };
}
}
else if (IsOpenURI)
{
// we're going to just pretend this is a "key" if we have a RunProgramFilePath
if (string.IsNullOrEmpty(OpenUri))
{
return new List<string>();
}
else
{
if (OpenUri.Length > runProgramMaxLength)
{
return new List<string> { $"{OpenUri.Substring(0, runProgramMaxLength - 3)}..." };
}
else
{
return new List<string> { OpenUri };
}
}
}
return (string.IsNullOrEmpty(NewRemapString) || NewRemapString == "*Unsupported*") ? MapKeys(NewRemapKeys) : new List<string> { NewRemapString };
}
// Instead of doing something fancy pants, we'll just display the RunProgramFilePath data when it's IsRunProgram
// It truncates the start of the program to run, if it's long and truncates the end of the args if it's long
// e.g.: c:\MyCool\PathIs\Long\software.exe myArg1 myArg2 myArg3 -> (something like) "...ng\software.exe myArg1..."
// the idea is you get the most important part of the program to run and some of the args in case that the only thing thats different,
// e.g: "...path\software.exe cool1.txt" and "...path\software.exe cool3.txt"
private string FormatFakeKeyForDisplay(int runProgramMaxLength)
{
// was going to use this:
var fakeKey = Environment.ExpandEnvironmentVariables(RunProgramFilePath);
try
{
if (File.Exists(fakeKey))
{
fakeKey = Path.GetFileName(Environment.ExpandEnvironmentVariables(RunProgramFilePath));
}
}
catch
{
}
fakeKey = $"{fakeKey} {RunProgramArgs}".Trim();
if (fakeKey.Length > runProgramMaxLength)
{
fakeKey = $"{fakeKey.Substring(0, runProgramMaxLength - 3)}...";
}
return fakeKey;
}
public string ToJsonString()
{
return JsonSerializer.Serialize(this);
}
}
}