From 9039451e2ff59857feed6919d187bf38a844df24 Mon Sep 17 00:00:00 2001 From: moooyo <42196638+moooyo@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:58:04 +0800 Subject: [PATCH] [Quick Accent] Migrate UI to WinUI 3 (#48891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Niels Laute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/ESRPSigning_core.json | 10 +- PowerToys.slnx | 4 + doc/devdocs/modules/quickaccent.md | 44 +++-- .../PowerAccent.Common.csproj | 5 + .../CalculationTests.cs | 148 ++++++++++++++ .../PowerAccent.Core.UnitTests.csproj | 34 ++++ .../PowerAccent.Core/Models/Point.cs | 34 ---- .../PowerAccent.Core/Models/Rect.cs | 36 ---- .../PowerAccent.Core/Models/Size.cs | 28 --- .../PowerAccent.Core/PowerAccent.Core.csproj | 9 +- .../PowerAccent.Core/PowerAccent.cs | 27 +-- .../Tools/WindowsFunctions.cs | 37 +++- .../poweraccent/PowerAccent.UI/App.xaml | 6 - .../poweraccent/PowerAccent.UI/App.xaml.cs | 64 ------ .../PowerAccent.UI/AssemblyInfo.cs | 10 - .../PowerAccent.UI/NativeMethods.txt | 2 - .../PowerAccent.UI/PowerAccent.UI.csproj | 135 ++++++++++--- .../PowerAccent.UI/PowerAccentXAML/App.xaml | 13 ++ .../PowerAccentXAML/App.xaml.cs | 60 ++++++ .../PowerAccentXAML/MainWindow.xaml | 19 ++ .../PowerAccentXAML/MainWindow.xaml.cs | 177 +++++++++++++++++ .../PowerAccentXAML/SelectorControl.xaml | 141 +++++++++++++ .../PowerAccentXAML/SelectorControl.xaml.cs | 41 ++++ .../poweraccent/PowerAccent.UI/Program.cs | 75 ++++--- .../poweraccent/PowerAccent.UI/Selector.xaml | 129 ------------ .../PowerAccent.UI/Selector.xaml.cs | 185 ------------------ .../PowerAccent.UI/SelectorViewModel.cs | 36 ++++ .../PowerAccentKeyboardService.vcxproj | 2 +- .../PowerAccentModuleInterface/dllmain.cpp | 2 +- .../Settings.UI.Library/KeysDataModel.cs | 1 - .../verify-installation-script.ps1 | 16 +- 31 files changed, 928 insertions(+), 602 deletions(-) create mode 100644 src/modules/poweraccent/PowerAccent.Core.UnitTests/CalculationTests.cs create mode 100644 src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj delete mode 100644 src/modules/poweraccent/PowerAccent.UI/App.xaml delete mode 100644 src/modules/poweraccent/PowerAccent.UI/App.xaml.cs delete mode 100644 src/modules/poweraccent/PowerAccent.UI/AssemblyInfo.cs delete mode 100644 src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml create mode 100644 src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs delete mode 100644 src/modules/poweraccent/PowerAccent.UI/Selector.xaml delete mode 100644 src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs create mode 100644 src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index fbdff4712c..fab66d813d 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -211,12 +211,12 @@ "WinUI3Apps\\NewPlusPackage.msix", "WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll", - "PowerAccent.Core.dll", - "PowerAccent.Common.dll", - "PowerToys.PowerAccent.dll", - "PowerToys.PowerAccent.exe", + "WinUI3Apps\\PowerAccent.Core.dll", + "WinUI3Apps\\PowerAccent.Common.dll", + "WinUI3Apps\\PowerToys.PowerAccent.dll", + "WinUI3Apps\\PowerToys.PowerAccent.exe", "PowerToys.PowerAccentModuleInterface.dll", - "PowerToys.PowerAccentKeyboardService.dll", + "WinUI3Apps\\PowerToys.PowerAccentKeyboardService.dll", "PowerToys.PowerDisplayModuleInterface.dll", "WinUI3Apps\\PowerToys.PowerDisplay.dll", diff --git a/PowerToys.slnx b/PowerToys.slnx index 64d73c3ab5..3d9897d924 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -806,6 +806,10 @@ + + + + diff --git a/doc/devdocs/modules/quickaccent.md b/doc/devdocs/modules/quickaccent.md index 41b2c31dab..252df7fd8e 100644 --- a/doc/devdocs/modules/quickaccent.md +++ b/doc/devdocs/modules/quickaccent.md @@ -15,14 +15,15 @@ Quick Accent (formerly known as Power Accent) is a PowerToys module that allows ## Architecture -The Quick Accent module consists of four main components: +The Quick Accent module consists of five projects: ``` poweraccent/ -├── PowerAccent.Core/ # Core component containing Language Sets -├── PowerAccent.UI/ # The character selector UI -├── PowerAccentKeyboardService/ # Keyboard Hook -└── PowerAccentModuleInterface/ # DLL interface +├── PowerAccent.Common/ # Language data, character mappings, LetterKey enum +├── PowerAccent.Core/ # Accent logic, settings, positioning, usage statistics +├── PowerAccent.UI/ # WinUI 3 character selector app (PowerToys.PowerAccent.exe) +├── PowerAccentKeyboardService/ # WinRT keyboard-hook component +└── PowerAccentModuleInterface/ # Native runner module DLL ``` ### Module Interface (PowerAccentModuleInterface) @@ -32,21 +33,32 @@ The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, i - Managing module lifecycle (enable/disable/settings) - Launching and terminating the PowerToys.PowerAccent.exe process +### Shared Data (PowerAccent.Common) + +`PowerAccent.Common` holds the UI- and runtime-agnostic data the other projects share: +- The language / character-set definitions and per-letter accent mappings +- The managed `LetterKey` enum (kept in sync with the WinRT `LetterKey` in `PowerAccentKeyboardService/KeyboardListener.idl`) + +It has no UI or WinRT dependencies and is unit-tested in isolation (`PowerAccent.Common.UnitTests`). + ### Core Logic (PowerAccent.Core) The Core component contains: -- Main accent character logic -- Keyboard input detection -- Character mappings for different languages -- Management of language sets and special characters (currency, math symbols, etc.) -- Usage statistics for frequently used characters +- Main accent character logic, consuming the language data from `PowerAccent.Common` +- Toolbar positioning math (9 anchor points with per-monitor DPI) and settings handling +- Management of special characters (currency, math symbols, etc.) and usage statistics + +Core carries no UI-framework dependency: it raises events and accepts a UI-thread marshaller delegate instead of touching WPF/WinUI directly, and its positioning math is covered by `PowerAccent.Core.UnitTests`. ### UI Layer (PowerAccent.UI) -The UI component is responsible for: -- Displaying the toolbar with accent options -- Handling user selection of accented characters -- Managing the visual positioning of the toolbar +The UI component is a self-contained **WinUI 3 (Windows App SDK)** app, migrated from WPF. +It is responsible for: +- Displaying the accent toolbar — a non-activating, always-on-top `TransparentWindow` overlay shown with `SW_SHOWNA` so it never steals focus from the app being typed into +- Handling selection and the toolbar's sizing / positioning +- Following the system theme while the long-lived process runs + +It builds to `PowerToys.PowerAccent.exe` together with its `.pri` and the bundled Windows App SDK runtime, all under the `WinUI3Apps` output folder. ### Keyboard Service (PowerAccentKeyboardService) @@ -128,5 +140,5 @@ To directly debug the Quick Accent UI component: 5. Start debugging by pressing `F5` or clicking the "*Start*" button 6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code -**Known issue**: You may encounter approximately 78 errors during the start of debugging.
-**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again. +**Known issue**: A first incremental build can surface transient errors (for example from CsWinRT projection / WinUI XAML codegen ordering).
+**Solution**: Right-click the **PowerAccent** folder in Solution Explorer and select "*Rebuild*", then start debugging again. diff --git a/src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj b/src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj index 4ac857a002..e4f62989cc 100644 --- a/src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj +++ b/src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj @@ -1,5 +1,6 @@ + + true diff --git a/src/modules/poweraccent/PowerAccent.Core.UnitTests/CalculationTests.cs b/src/modules/poweraccent/PowerAccent.Core.UnitTests/CalculationTests.cs new file mode 100644 index 0000000000..7162ee8ab4 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.Core.UnitTests/CalculationTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerAccent.Core; +using PowerAccent.Core.Services; +using PowerAccent.Core.Tools; + +namespace PowerAccent.Core.UnitTests; + +/// +/// Exercises the pure anchor / DPI geometry in . These are the math that +/// the WinUI 3 Selector feeds into AppWindow.Move/Resize, so a regression here silently mis-places +/// the accent popup (the classic high-DPI / multi-monitor "double scaling" failure mode). +/// +[TestClass] +public sealed class CalculationTests +{ + // offset baked into Calculation: the gap from the screen edge for the edge anchors. + private const int Offset = 24; + + // A 1920x1080 primary monitor rooted at the virtual-desktop origin. + private static readonly Rect PrimaryScreen = new(0, 0, 1920, 1080); + + // A one-row accent bar, in DIP. + private static readonly Size Window = new(200, 52); + + // At 100% scaling (dpi = 1.0) the physical window size equals the DIP size, so each of the nine + // anchors lands at an easily hand-checkable coordinate. + [DataTestMethod] + [DataRow(Position.TopLeft, 24.0, 24.0)] + [DataRow(Position.Top, 860.0, 24.0)] + [DataRow(Position.TopRight, 1696.0, 24.0)] + [DataRow(Position.Left, 24.0, 514.0)] + [DataRow(Position.Center, 860.0, 514.0)] + [DataRow(Position.Right, 1696.0, 514.0)] + [DataRow(Position.BottomLeft, 24.0, 1004.0)] + [DataRow(Position.Bottom, 860.0, 1004.0)] + [DataRow(Position.BottomRight, 1696.0, 1004.0)] + public void GetRawCoordinatesFromPosition_AtDpi1_PlacesEachAnchor(Position position, double expectedX, double expectedY) + { + var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.0); + + Assert.AreEqual(expectedX, point.X, "X for " + position); + Assert.AreEqual(expectedY, point.Y, "Y for " + position); + } + + // At 150% scaling the physical window is 300x78. The centered anchors must subtract HALF of the + // scaled size (not the DIP size) and the right/bottom anchors must subtract the FULL scaled size + // plus the offset - this is exactly where a missing/extra dpi factor shows up. + [DataTestMethod] + [DataRow(Position.TopLeft, 24.0, 24.0)] + [DataRow(Position.Center, 810.0, 501.0)] + [DataRow(Position.BottomRight, 1596.0, 978.0)] + public void GetRawCoordinatesFromPosition_AtDpi150Percent_ScalesWindowFootprint(Position position, double expectedX, double expectedY) + { + var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.5); + + Assert.AreEqual(expectedX, point.X, "X for " + position); + Assert.AreEqual(expectedY, point.Y, "Y for " + position); + } + + // A secondary 2560x1440 monitor to the right of the primary at 200% scaling. Verifies the screen + // origin (screen.X / screen.Y) is honored for every anchor, not just the primary-at-origin case. + [DataTestMethod] + [DataRow(Position.TopLeft, 1944.0, 24.0)] + [DataRow(Position.Center, 3000.0, 668.0)] + [DataRow(Position.BottomRight, 4056.0, 1312.0)] + public void GetRawCoordinatesFromPosition_OnOffsetMonitor_HonorsScreenOrigin(Position position, double expectedX, double expectedY) + { + var secondaryScreen = new Rect(1920, 0, 2560, 1440); + + var point = Calculation.GetRawCoordinatesFromPosition(position, secondaryScreen, Window, dpi: 2.0); + + Assert.AreEqual(expectedX, point.X, "X for " + position); + Assert.AreEqual(expectedY, point.Y, "Y for " + position); + } + + // A monitor positioned to the LEFT of the primary has a negative virtual-desktop X origin. The + // edge anchors must still be offset relative to that negative origin. + [TestMethod] + public void GetRawCoordinatesFromPosition_OnNegativeOriginMonitor_OffsetsFromScreenEdge() + { + var leftScreen = new Rect(-1920, 0, 1920, 1080); + + var topLeft = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, leftScreen, Window, dpi: 1.0); + Assert.AreEqual(-1920 + Offset, topLeft.X); + Assert.AreEqual(Offset, topLeft.Y); + + var bottomRight = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, leftScreen, Window, dpi: 1.0); + Assert.AreEqual(-1920 + 1920 - (Window.Width + Offset), bottomRight.X); + Assert.AreEqual(1080 - (Window.Height + Offset), bottomRight.Y); + } + + [TestMethod] + public void GetRawCoordinatesFromPosition_UnknownPosition_Throws() + { + Assert.ThrowsException( + () => Calculation.GetRawCoordinatesFromPosition((Position)999, PrimaryScreen, Window, dpi: 1.0)); + } + + // Caret-relative placement centers the window horizontally on the caret and sits it 20px above. + [TestMethod] + public void GetRawCoordinatesFromCaret_WithRoom_CentersAboveCaret() + { + var caret = new Point(960, 540); + + var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window); + + Assert.AreEqual(960 - (Window.Width / 2), point.X); // 860 + Assert.AreEqual(540 - Window.Height - 20, point.Y); // 468 + } + + // Near the left edge the window would overflow off-screen, so X clamps to the screen's left edge. + [TestMethod] + public void GetRawCoordinatesFromCaret_NearLeftEdge_ClampsToScreenLeft() + { + var caret = new Point(50, 540); + + var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window); + + Assert.AreEqual(PrimaryScreen.X, point.X); + } + + // Near the right edge X clamps so the window's right side sits on the screen's right edge. + [TestMethod] + public void GetRawCoordinatesFromCaret_NearRightEdge_ClampsToScreenRight() + { + var caret = new Point(1900, 540); + + var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window); + + Assert.AreEqual(PrimaryScreen.X + PrimaryScreen.Width - Window.Width, point.X); // 1720 + } + + // When there is no room above the caret (top would land off-screen) the window flips to 20px + // BELOW the caret instead of being clipped at the top. + [TestMethod] + public void GetRawCoordinatesFromCaret_NoRoomAbove_FlipsBelowCaret() + { + var caret = new Point(960, 10); + + var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window); + + Assert.AreEqual(caret.Y + 20, point.Y); // 30 + } +} diff --git a/src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj b/src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj new file mode 100644 index 0000000000..2342d7f70e --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj @@ -0,0 +1,34 @@ + + + + + + PowerToys.PowerAccent.Core.UnitTests + Exe + $(RepoRoot)$(Platform)\$(Configuration)\tests\PowerAccent.Core.UnitTests\ + false + false + enable + enable + + + + + $(OutDir) + false + + + + + + + + + + + diff --git a/src/modules/poweraccent/PowerAccent.Core/Models/Point.cs b/src/modules/poweraccent/PowerAccent.Core/Models/Point.cs index d58d305a2f..b885276ebd 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Models/Point.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Models/Point.cs @@ -6,12 +6,6 @@ namespace PowerAccent.Core; public struct Point { - public Point() - { - X = 0; - Y = 0; - } - public Point(double x, double y) { X = x; @@ -24,35 +18,7 @@ public struct Point Y = y; } - public Point(System.Drawing.Point point) - { - X = point.X; - Y = point.Y; - } - public double X { get; init; } public double Y { get; init; } - - public static implicit operator Point(System.Drawing.Point point) => new Point(point.X, point.Y); - - public static Point operator /(Point point, double divider) - { - if (divider == 0) - { - throw new DivideByZeroException(); - } - - return new Point(point.X / divider, point.Y / divider); - } - - public static Point operator /(Point point, Point divider) - { - if (divider.X == 0 || divider.Y == 0) - { - throw new DivideByZeroException(); - } - - return new Point(point.X / divider.X, point.Y / divider.Y); - } } diff --git a/src/modules/poweraccent/PowerAccent.Core/Models/Rect.cs b/src/modules/poweraccent/PowerAccent.Core/Models/Rect.cs index fddc8235b5..c4f8941edb 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Models/Rect.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Models/Rect.cs @@ -6,14 +6,6 @@ namespace PowerAccent.Core; public struct Rect { - public Rect() - { - X = 0; - Y = 0; - Width = 0; - Height = 0; - } - public Rect(int x, int y, int width, int height) { X = x; @@ -22,14 +14,6 @@ public struct Rect Height = height; } - public Rect(double x, double y, double width, double height) - { - X = x; - Y = y; - Width = width; - Height = height; - } - public Rect(Point coord, Size size) { X = coord.X; @@ -45,24 +29,4 @@ public struct Rect public double Width { get; init; } public double Height { get; init; } - - public static Rect operator /(Rect rect, double divider) - { - if (divider == 0) - { - throw new DivideByZeroException(); - } - - return new Rect(rect.X / divider, rect.Y / divider, rect.Width / divider, rect.Height / divider); - } - - public static Rect operator /(Rect rect, Rect divider) - { - if (divider.X == 0 || divider.Y == 0) - { - throw new DivideByZeroException(); - } - - return new Rect(rect.X / divider.X, rect.Y / divider.Y, rect.Width / divider.Width, rect.Height / divider.Height); - } } diff --git a/src/modules/poweraccent/PowerAccent.Core/Models/Size.cs b/src/modules/poweraccent/PowerAccent.Core/Models/Size.cs index af1b553eef..f2e1510740 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Models/Size.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Models/Size.cs @@ -6,12 +6,6 @@ namespace PowerAccent.Core; public struct Size { - public Size() - { - Width = 0; - Height = 0; - } - public Size(double width, double height) { Width = width; @@ -27,26 +21,4 @@ public struct Size public double Width { get; init; } public double Height { get; init; } - - public static implicit operator Size(System.Drawing.Size size) => new Size(size.Width, size.Height); - - public static Size operator /(Size size, double divider) - { - if (divider == 0) - { - throw new DivideByZeroException(); - } - - return new Size(size.Width / divider, size.Height / divider); - } - - public static Size operator /(Size size, Size divider) - { - if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0) - { - throw new DivideByZeroException(); - } - - return new Size(size.Width / divider.Width, size.Height / divider.Height); - } } diff --git a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj index 6199ae3138..84157aeefc 100644 --- a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj +++ b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj @@ -8,8 +8,6 @@ enable disable True - true - true @@ -26,6 +24,13 @@ + + + + <_Parameter1>PowerToys.PowerAccent.Core.UnitTests + + + diff --git a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs index 50c27205a2..84916590e6 100644 --- a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs +++ b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs @@ -45,8 +45,12 @@ public partial class PowerAccent : IDisposable private readonly CharactersUsageInfo _usageInfo; - public PowerAccent() + private readonly Action _runOnUiThread; + + public PowerAccent(Action runOnUiThread) { + _runOnUiThread = runOnUiThread ?? throw new ArgumentNullException(nameof(runOnUiThread)); + Logger.InitializeLogger("\\QuickAccent\\Logs"); LoadUnicodeInfoCache(); @@ -68,7 +72,7 @@ public partial class PowerAccent : IDisposable { _keyboardListener.SetShowToolbarEvent(new PowerToys.PowerAccentKeyboardService.ShowToolbar((LetterKey letterKey) => { - System.Windows.Application.Current.Dispatcher.Invoke(() => + _runOnUiThread(() => { ShowToolbar(letterKey); }); @@ -76,7 +80,7 @@ public partial class PowerAccent : IDisposable _keyboardListener.SetHideToolbarEvent(new PowerToys.PowerAccentKeyboardService.HideToolbar((InputType inputType) => { - System.Windows.Application.Current.Dispatcher.Invoke(() => + _runOnUiThread(() => { SendInputAndHideToolbar(inputType); }); @@ -84,7 +88,7 @@ public partial class PowerAccent : IDisposable _keyboardListener.SetNextCharEvent(new PowerToys.PowerAccentKeyboardService.NextChar((TriggerKey triggerKey, bool shiftPressed) => { - System.Windows.Application.Current.Dispatcher.Invoke(() => + _runOnUiThread(() => { ProcessNextChar(triggerKey, shiftPressed); }); @@ -236,13 +240,13 @@ public partial class PowerAccent : IDisposable case InputType.Right: { - SendKeys.SendWait("{RIGHT}"); + WindowsFunctions.SendArrowKey(left: false); break; } case InputType.Left: { - SendKeys.SendWait("{LEFT}"); + WindowsFunctions.SendArrowKey(left: true); break; } @@ -391,14 +395,13 @@ public partial class PowerAccent : IDisposable /// Gets the maximum width for the toolbar display based on the active screen /// dimensions. /// - /// The maximum width in logical pixels, accounting for screen padding. - /// + /// The maximum width in DIPs (device-independent pixels), accounting for + /// screen padding. public double GetDisplayMaxWidth() { - // Note: activeDisplay.Size.Width is in raw physical pixels. - // We divide by DPI to convert to WPF logical pixels (Device-Independent Pixels), - // because ScreenMinPadding is a logical pixel value and WPF MaxWidth expects - // logical pixels. + // activeDisplay.Size.Width is in raw physical pixels; divide by the DPI scale to + // convert to DIPs (device-independent pixels), since ScreenMinPadding and the + // consuming window width are both expressed in DIPs. var activeDisplay = WindowsFunctions.GetActiveDisplay(); return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding; } diff --git a/src/modules/poweraccent/PowerAccent.Core/Tools/WindowsFunctions.cs b/src/modules/poweraccent/PowerAccent.Core/Tools/WindowsFunctions.cs index c4f479c4d2..3e8385afa6 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Tools/WindowsFunctions.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Tools/WindowsFunctions.cs @@ -88,6 +88,40 @@ internal static class WindowsFunctions } } + public static void SendArrowKey(bool left) + { + var key = left ? VIRTUAL_KEY.VK_LEFT : VIRTUAL_KEY.VK_RIGHT; + var inputs = new INPUT[] + { + new INPUT + { + type = INPUT_TYPE.INPUT_KEYBOARD, + Anonymous = new INPUT._Anonymous_e__Union + { + ki = new KEYBDINPUT + { + wVk = key, + dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY, + }, + }, + }, + new INPUT + { + type = INPUT_TYPE.INPUT_KEYBOARD, + Anonymous = new INPUT._Anonymous_e__Union + { + ki = new KEYBDINPUT + { + wVk = key, + dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY | KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP, + }, + }, + }, + }; + + _ = PInvoke.SendInput(inputs, Marshal.SizeOf()); + } + public static (Point Location, Size Size, double Dpi) GetActiveDisplay() { GUITHREADINFO guiInfo = default; @@ -107,7 +141,8 @@ internal static class WindowsFunctions double dpi = dpiRaw / 96d; var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top); - return (location, monitorInfo.rcWork.Size, dpi); + var size = new Size(monitorInfo.rcWork.right - monitorInfo.rcWork.left, monitorInfo.rcWork.bottom - monitorInfo.rcWork.top); + return (location, size, dpi); } public static bool IsCapsLockState() diff --git a/src/modules/poweraccent/PowerAccent.UI/App.xaml b/src/modules/poweraccent/PowerAccent.UI/App.xaml deleted file mode 100644 index e67322f371..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/App.xaml +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccent.UI/App.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/App.xaml.cs deleted file mode 100644 index 297b5f2f7b..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/App.xaml.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Windows; - -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace PowerAccent.UI -{ - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application, IDisposable - { - private static Mutex _mutex; - private bool _disposed; - private ETWTrace _etwTrace = new ETWTrace(); - - protected override void OnStartup(StartupEventArgs e) - { - _mutex = new Mutex(true, "QuickAccent", out bool createdNew); - - if (!createdNew) - { - Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent"); - Application.Current.Shutdown(); - } - - base.OnStartup(e); - } - - protected override void OnExit(ExitEventArgs e) - { - _mutex?.ReleaseMutex(); - base.OnExit(e); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _mutex?.Dispose(); - _etwTrace?.Dispose(); - } - - _disposed = true; - } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/modules/poweraccent/PowerAccent.UI/AssemblyInfo.cs b/src/modules/poweraccent/PowerAccent.UI/AssemblyInfo.cs deleted file mode 100644 index bcac370d7d..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/AssemblyInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Windows; - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located (used if a resource is not found in the page, or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly) // where the generic resource dictionary is locate (used if a resource is not found in the page, app, or any theme specific resource dictionaries) -] diff --git a/src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt b/src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt deleted file mode 100644 index bff40be3a3..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt +++ /dev/null @@ -1,2 +0,0 @@ -SetWindowPos -GetSystemMetrics diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj b/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj index f800bd1290..03d8fcae33 100644 --- a/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj @@ -2,39 +2,110 @@ - - - WinExe - disable - true - True - icon.ico - app.manifest - PowerToys.PowerAccent - True - PowerAccent.UI.Program - $(RepoRoot)$(Platform)\$(Configuration) - false - false - + - - - PreserveNewest - - + + WinExe + PowerAccent.UI + PowerToys.PowerAccent + disable + True + + preview + true + + false + true + None + true + icon.ico + app.manifest + PowerAccent.UI.Program + DISABLE_XAML_GENERATED_MAIN + false + false + $(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps + PowerToys.PowerAccent.pri + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + false + false + true + false + false + - - - - - - + + + $(OutDir) + false + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true +
diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml new file mode 100644 index 0000000000..3c9b917503 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs new file mode 100644 index 0000000000..c325311038 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +using ManagedCommon; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace PowerAccent.UI; + +public partial class App : Application, IDisposable +{ + private readonly ETWTrace _etwTrace = new ETWTrace(); + private bool _disposed; + + public static new App Current => (App)Application.Current; + + public DispatcherQueue DispatcherQueueForApp { get; private set; } + + public static MainWindow Window { get; private set; } + + public App() + { + InitializeComponent(); + UnhandledException += (s, e) => Logger.LogError("Unhandled exception", e.Exception); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + DispatcherQueueForApp = DispatcherQueue.GetForCurrentThread(); + Window = new MainWindow(); + + // Quick Accent has no visible main window until summoned by the keyboard hook; + // the accent selector keeps itself hidden (TransparentWindow hides its AppWindow on init). + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _etwTrace?.Dispose(); + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml new file mode 100644 index 0000000000..7565abcbcf --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs new file mode 100644 index 0000000000..f31c7166fb --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +using Microsoft.PowerToys.Common.UI.Controls.Window; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Windows.Graphics; +using CoreSize = PowerAccent.Core.Size; + +namespace PowerAccent.UI; + +public sealed partial class MainWindow : TransparentWindow, IDisposable +{ + // Accent-bar geometry (DIP). Width is derived from the item count (count * ItemWidthDip), not + // measured from the ListView: its DesiredSize (wrapped in a ScrollViewer) is racy while item + // containers realize and intermittently reports 0, yielding a blank/clipped bar. The one-row bar + // hugs its content like the WPF original, capped at the monitor width; beyond that it scrolls + // and ScrollIntoView reveals the selected glyph. + private const double RowHeightDip = 92; // one row of accent pills (item Height=48 + card border) + private const double DescriptionHeightDip = 36; // extra row shown when the Unicode description is on + private const double ItemWidthDip = 48; // one accent cell (ListViewItem Grid MinWidth=48) + private const double DescriptionMinWidthDip = 648; // min bar width while the description row shows (WPF parity) + + private readonly Core.PowerAccent _powerAccent; + private int _selectedIndex = -1; + private bool _active; + + // The view model lives on the SelectorControl (the x:Bind target); expose it here for the + // PowerAccent event handlers that populate the accent list and description. + private SelectorViewModel ViewModel => Selector.ViewModel; + + public MainWindow() + { + InitializeComponent(); + + // Give the overlay a stable UIA identity (window name) for accessibility tools (Narrator, + // Accessibility Insights) and the release-verification harness. "Quick Accent" is the + // user-facing feature name. + AppWindow.Title = "Quick Accent"; + + // The accent popup is shown/hidden instantly (no slide/fade) for typing-aid + // responsiveness. TransientSurface defaults to Transition.None (no animation); + // SubscribeSurfaceTo forwards to the inner surface so it follows this window's Show/Hide. + Selector.SubscribeSurfaceTo(this); + + _powerAccent = new Core.PowerAccent(RunOnUiThread); + _powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay; + _powerAccent.OnSelectCharacter += PowerAccent_OnSelectCharacter; + + // No manual theme handling: App.xaml leaves RequestedTheme unset, so WinUI follows the system + // theme and re-resolves the {ThemeResource} brushes (and retints the acrylic) on a live + // light/dark switch, even for this never-activated SW_SHOWNA overlay. + } + + // Marshal keyboard-hook callbacks (ShowToolbar / HideToolbar / NextChar) onto the UI thread. The + // hook runs on this UI thread, so callbacks arrive here already; run them inline (not via + // TryEnqueue, which would defer) so the accent injection stays ordered before the hook returns + // and the trigger key-up propagates. Fall back to enqueueing if ever called off-thread. + private void RunOnUiThread(Action action) + { + if (DispatcherQueue.HasThreadAccess) + { + action(); + } + else + { + DispatcherQueue.TryEnqueue(() => action()); + } + } + + private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars) + { + if (!isActive) + { + _active = false; + + // Release always-on-top before hiding so the dormant overlay does not keep a discrete + // GPU awake on hybrid-graphics laptops (issue #34849 / PR #41044). IsAlwaysOnTop is the + // WinUIEx WindowEx property (same as the sibling PowerDisplay). + IsAlwaysOnTop = false; + Hide(); + ViewModel.Characters.Clear(); + _selectedIndex = -1; + return; + } + + _active = true; + ViewModel.ShowDescription = _powerAccent.ShowUnicodeDescription; + + ViewModel.Characters.Clear(); + foreach (var c in chars) + { + ViewModel.Characters.Add(c); + } + + Selector.SetSelectedIndex(_selectedIndex); + ViewModel.Description = (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length) + ? _powerAccent.CharacterDescriptions[_selectedIndex] + : string.Empty; + + // Always-on-top only while shown, so the overlay sits above the foreground app (Show uses + // SW_SHOWNA and never activates it); released on hide (see above). Then size and show. + IsAlwaysOnTop = true; + SizeAndPosition(); + Show(); + + DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => + { + if (_active) + { + Selector.ScrollSelectedIntoView(_selectedIndex); + } + }); + + Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Core.Telemetry.PowerAccentShowAccentMenuEvent()); + } + + private void PowerAccent_OnSelectCharacter(int index, string character) + { + _selectedIndex = index; + Selector.SetSelectedIndex(index); + + if (index >= 0 && index < _powerAccent.CharacterDescriptions.Length) + { + ViewModel.Description = _powerAccent.CharacterDescriptions[index]; + } + + Selector.ScrollSelectedIntoView(index); + } + + private void SizeAndPosition() + { + // Width hugs the content: item count * ItemWidthDip (see the class-level note on why the + // ListView is not measured), capped at the monitor's max usable width so long lists scroll. + double maxWidthDip = _powerAccent.GetDisplayMaxWidth(); + double contentWidthDip = ViewModel.Characters.Count * ItemWidthDip; + + // The Unicode description row needs room for a readable line; the WPF original gave it a + // 600px MinWidth. Widen a short accent bar to match when the row is shown (the accent bar + // itself stays centered within the wider window). + if (ViewModel.ShowDescription) + { + contentWidthDip = Math.Max(contentWidthDip, DescriptionMinWidthDip); + } + + double widthDip = Math.Clamp(contentWidthDip, ItemWidthDip, maxWidthDip); + double heightDip = RowHeightDip + (ViewModel.ShowDescription ? DescriptionHeightDip : 0); + + // Calculation works in physical pixels; GetDisplayCoordinates multiplies the DIP size by + // the active monitor's DPI internally and returns the physical top-left for the anchor. + var coordinates = _powerAccent.GetDisplayCoordinates(new CoreSize(widthDip, heightDip)); + + var display = DisplayArea.GetFromPoint( + new PointInt32((int)Math.Round(coordinates.X), (int)Math.Round(coordinates.Y)), + DisplayAreaFallback.Nearest); + + double dpiScale = FlyoutWindowHelper.GetDpiScale(display); + + var rect = new RectInt32( + (int)Math.Round(coordinates.X), + (int)Math.Round(coordinates.Y), + (int)Math.Ceiling(widthDip * dpiScale), + (int)Math.Ceiling(heightDip * dpiScale)); + + FlyoutWindowHelper.MoveAndResizeOnDisplay(this, display, rect); + } + + public void Dispose() + { + _powerAccent.SaveUsageInfo(); + _powerAccent.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml new file mode 100644 index 0000000000..5f87e76fd4 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs new file mode 100644 index 0000000000..e4924c1dfc --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Common.UI.Controls.Window; +using Microsoft.UI.Xaml.Controls; + +namespace PowerAccent.UI; + +/// +/// The accent selector content. Hosting it in a UserControl (rather than directly in the +/// TransparentWindow) lets x:Bind initialize on the control's Loading pass - which fires when the +/// SW_SHOWNA overlay is first laid out - instead of on Window.Activated (which never fires for a +/// never-activated overlay). That removes the need to call Bindings.Update() by hand. +/// +public sealed partial class SelectorControl : UserControl +{ + public SelectorViewModel ViewModel { get; } = new(); + + public SelectorControl() + { + InitializeComponent(); + } + + // Number of items currently in the accent bar (mirrors the bound ObservableCollection). + public int ItemCount => CharactersList.Items.Count; + + // Wire the inner TransientSurface to the hosting window's Show/Hide so it animates in/out. + // TransientSurface.SubscribeTo explicitly supports being "placed within" the window content. + public void SubscribeSurfaceTo(TransparentWindow host) => Surface.SubscribeTo(host); + + public void SetSelectedIndex(int index) => CharactersList.SelectedIndex = index; + + public void ScrollSelectedIntoView(int index) + { + if (index >= 0 && index < CharactersList.Items.Count) + { + CharactersList.ScrollIntoView(CharactersList.Items[index]); + } + } +} diff --git a/src/modules/poweraccent/PowerAccent.UI/Program.cs b/src/modules/poweraccent/PowerAccent.UI/Program.cs index e9a416f7e6..57348d719f 100644 --- a/src/modules/poweraccent/PowerAccent.UI/Program.cs +++ b/src/modules/poweraccent/PowerAccent.UI/Program.cs @@ -1,14 +1,13 @@ -// Copyright (c) Microsoft Corporation +// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using System.Windows; using ManagedCommon; +using Microsoft.UI.Dispatching; using PowerToys.Interop; namespace PowerAccent.UI; @@ -16,13 +15,14 @@ namespace PowerAccent.UI; internal static class Program { private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource(); - private static App _application; + private static Mutex _mutex; private static int _powerToysRunnerPid; [STAThread] public static void Main(string[] args) { Logger.InitializeLogger("\\QuickAccent\\Logs"); + WinRT.ComWrappersSupport.InitializeComWrappers(); if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredQuickAccentEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) { @@ -30,21 +30,32 @@ internal static class Program return; } + _mutex = new Mutex(true, "QuickAccent", out bool createdNew); + if (!createdNew) + { + Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent"); + return; + } + Arguments(args); + InitExitListener(); - InitEvents(); + Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _ = new App(); + }); - _application = new App(); - _application.InitializeComponent(); - _application.Run(); + _mutex?.ReleaseMutex(); } - private static void InitEvents() + private static void InitExitListener() { Task.Run( () => { - EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent()); + using EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent()); if (eventHandle.WaitOne()) { Terminate(); @@ -55,39 +66,41 @@ internal static class Program private static void Arguments(string[] args) { - if (args?.Length > 0) + if (args?.Length > 0 && int.TryParse(args[0], out _powerToysRunnerPid)) { - try + Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}"); + RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () => { - if (int.TryParse(args[0], out _powerToysRunnerPid)) - { - Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}"); - - RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () => - { - Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent"); - Terminate(); - }); - } - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - } + Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent"); + Terminate(); + }); } else { - Logger.LogInfo($"QuickAccent started detached from PowerToys Runner."); + Logger.LogInfo("QuickAccent started detached from PowerToys Runner."); _powerToysRunnerPid = -1; } } private static void Terminate() { - Application.Current.Dispatcher.BeginInvoke(() => + var app = App.Current; + var queue = app?.DispatcherQueueForApp; + + // If the exit signal arrives during the brief startup window before OnLaunched has set + // DispatcherQueueForApp (e.g. the runner dies, or disable() is called, right after launch), + // or the queue is already draining, TryEnqueue can't run our cleanup. Fall back to a hard + // exit so we never orphan the process with the low-level keyboard hook still installed. The + // OS releases the hook on process termination; usage stats are simply not saved on this path. + if (queue is null || !queue.TryEnqueue(() => { _tokenSource.Cancel(); - Application.Current.Shutdown(); - }); + App.Window?.Dispose(); // MainWindow.SaveUsageInfo + Core.PowerAccent.Dispose on the UI thread + app.Dispose(); // disposes ETWTrace (idempotent via _disposed guard) + app.Exit(); + })) + { + Environment.Exit(0); + } } } diff --git a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml b/src/modules/poweraccent/PowerAccent.UI/Selector.xaml deleted file mode 100644 index 0059e1efe5..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs deleted file mode 100644 index b928d9bb00..0000000000 --- a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.ComponentModel; -using System.Windows; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.UI.WindowsAndMessaging; -using Point = PowerAccent.Core.Point; -using Size = PowerAccent.Core.Size; - -namespace PowerAccent.UI; - -public partial class Selector : Window, IDisposable, INotifyPropertyChanged -{ - // When setting the position for the selector window, we do not alter the z-order, - // activation status, or size. - private const SET_WINDOW_POS_FLAGS WindowPosFlags = - SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE; - - private readonly Core.PowerAccent _powerAccent = new(); - - private Visibility _characterNameVisibility = Visibility.Visible; - - private int _selectedIndex = -1; - - public event PropertyChangedEventHandler PropertyChanged; - - public Visibility CharacterNameVisibility - { - get - { - return _characterNameVisibility; - } - - set - { - _characterNameVisibility = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CharacterNameVisibility))); - } - } - - public Selector() - { - InitializeComponent(); - - Application.Current.MainWindow.ShowActivated = false; - } - - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - _powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay; - _powerAccent.OnSelectCharacter += PowerAccent_OnSelectionCharacter; - this.Visibility = Visibility.Hidden; - } - - private void PowerAccent_OnSelectionCharacter(int index, string character) - { - _selectedIndex = index; - characters.SelectedIndex = _selectedIndex; - - if (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length) - { - characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex]; - } - - if (characters.Items.Count > _selectedIndex && _selectedIndex >= 0) - { - characters.ScrollIntoView(characters.Items[_selectedIndex]); - } - } - - private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars) - { - // Topmost is conditionally set here to address hybrid graphics issues on laptops. - this.Topmost = isActive; - - CharacterNameVisibility = _powerAccent.ShowUnicodeDescription ? Visibility.Visible : Visibility.Collapsed; - - if (isActive) - { - int offscreenX = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_XVIRTUALSCREEN) - 1000; - int offscreenY = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_YVIRTUALSCREEN) - 1000; - - var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; - if (hwnd != IntPtr.Zero) - { - // Move off-screen to avoid flicker on previous monitor before Show() and - // UpdateLayout(). - PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, offscreenX, offscreenY, 0, 0, WindowPosFlags); - } - else - { - this.Left = offscreenX; - this.Top = offscreenY; - } - - Show(); - SetWindowsSize(); - characters.ItemsSource = chars; - characters.SelectedIndex = -1; // Reset before setting dynamically to avoid flashing - - this.UpdateLayout(); // Required for filling the actual width/height before positioning. - - characters.SelectedIndex = _selectedIndex; - - if (_selectedIndex >= 0 && _selectedIndex < chars.Length) - { - characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex]; - characters.ScrollIntoView(characters.Items[_selectedIndex]); - this.UpdateLayout(); // Re-layout after scrolling - } - else - { - characterName.Text = string.Empty; - } - - SetWindowPosition(); - Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new PowerAccent.Core.Telemetry.PowerAccentShowAccentMenuEvent()); - } - else - { - Hide(); - characters.ItemsSource = null; - _selectedIndex = -1; - } - } - - private void MenuExit_Click(object sender, RoutedEventArgs e) - { - Application.Current.Shutdown(); - } - - private void SetWindowPosition() - { - Size windowSize = new(((FrameworkElement)Application.Current.MainWindow.Content).ActualWidth, ((FrameworkElement)Application.Current.MainWindow.Content).ActualHeight); - Point physicalPosition = _powerAccent.GetDisplayCoordinates(windowSize); - - var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle; - if (hwnd != IntPtr.Zero) - { - PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, (int)Math.Round(physicalPosition.X), (int)Math.Round(physicalPosition.Y), 0, 0, WindowPosFlags); - } - } - - protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) - { - base.OnDpiChanged(oldDpi, newDpi); - if (this.Visibility == Visibility.Visible) - { - SetWindowsSize(); - SetWindowPosition(); - } - } - - private void SetWindowsSize() - { - double maxWidth = _powerAccent.GetDisplayMaxWidth(); - this.characters.MaxWidth = maxWidth; - this.MaxWidth = maxWidth; - } - - private void Window_SizeChanged(object sender, SizeChangedEventArgs e) - { - if (this.Visibility == Visibility.Visible) - { - SetWindowPosition(); - } - } - - protected override void OnClosed(EventArgs e) - { - _powerAccent.SaveUsageInfo(); - _powerAccent.Dispose(); - base.OnClosed(e); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - } -} diff --git a/src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs b/src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs new file mode 100644 index 0000000000..6800a5e9ee --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml; + +namespace PowerAccent.UI; + +public partial class SelectorViewModel : ObservableObject +{ + // Partial properties (not [ObservableProperty] fields): the CsWinRT generators need partial + // properties to emit correct WinRT marshalling for a WinUI 3 app (otherwise MVVMTK0045). + // Partial properties cannot carry field initializers, so initial values are set in the ctor. + [ObservableProperty] + public partial ObservableCollection Characters { get; set; } + + [ObservableProperty] + public partial string Description { get; set; } + + // Exposed directly as a Visibility (rather than binding the bool through a + // BoolToVisibilityConverter) so the description row's visibility needs no converter resource. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DescriptionVisibility))] + public partial bool ShowDescription { get; set; } + + public SelectorViewModel() + { + Characters = new ObservableCollection(); + Description = string.Empty; + } + + public Visibility DescriptionVisibility => ShowDescription ? Visibility.Visible : Visibility.Collapsed; +} diff --git a/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj b/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj index 6685359d5b..45af5aec7a 100644 --- a/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj +++ b/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj @@ -46,7 +46,7 @@ PowerToys.PowerAccentKeyboardService - $(RepoRoot)$(Platform)\$(Configuration)\ + $(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\ diff --git a/src/modules/poweraccent/PowerAccentModuleInterface/dllmain.cpp b/src/modules/poweraccent/PowerAccentModuleInterface/dllmain.cpp index 59a275b0d0..1ad8de83ba 100644 --- a/src/modules/poweraccent/PowerAccentModuleInterface/dllmain.cpp +++ b/src/modules/poweraccent/PowerAccentModuleInterface/dllmain.cpp @@ -59,7 +59,7 @@ private: unsigned long powertoys_pid = GetCurrentProcessId(); std::wstring executable_args = L"" + std::to_wstring(powertoys_pid); - std::wstring application_path = L"PowerToys.PowerAccent.exe"; + std::wstring application_path = L"WinUI3Apps\\PowerToys.PowerAccent.exe"; std::wstring full_command_path = application_path + L" " + executable_args.data(); Logger::trace(L"PowerToys QuickAccent launching: " + full_command_path); diff --git a/src/settings-ui/Settings.UI.Library/KeysDataModel.cs b/src/settings-ui/Settings.UI.Library/KeysDataModel.cs index a5a21a02d2..ac438fdf73 100644 --- a/src/settings-ui/Settings.UI.Library/KeysDataModel.cs +++ b/src/settings-ui/Settings.UI.Library/KeysDataModel.cs @@ -9,7 +9,6 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Management; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; diff --git a/tools/Verification scripts/verify-installation-script.ps1 b/tools/Verification scripts/verify-installation-script.ps1 index d617aca9a7..87986b005f 100644 --- a/tools/Verification scripts/verify-installation-script.ps1 +++ b/tools/Verification scripts/verify-installation-script.ps1 @@ -432,12 +432,9 @@ function Test-CoreFiles { 'PowerToys.MouseWithoutBordersHelper.dll', 'PowerToys.MouseWithoutBordersHelper.exe', - # PowerAccent - 'PowerAccent.Core.dll', - 'PowerToys.PowerAccent.dll', - 'PowerToys.PowerAccent.exe', + # PowerAccent - only the runner-loaded module interface ships in the install root. + # The app, core, common and keyboard-service binaries moved to WinUI3Apps (see $winUI3SignedFiles). 'PowerToys.PowerAccentModuleInterface.dll', - 'PowerToys.PowerAccentKeyboardService.dll', # Workspaces 'PowerToys.WorkspacesSnapshotTool.exe', @@ -500,7 +497,14 @@ function Test-CoreFiles { 'PowerToys.RegistryPreviewExt.dll', 'PowerToys.RegistryPreviewUILib.dll', 'PowerToys.RegistryPreview.dll', - 'PowerToys.RegistryPreview.exe' + 'PowerToys.RegistryPreview.exe', + + # PowerAccent (Quick Accent) - moved from the install root to WinUI3Apps + 'PowerAccent.Core.dll', + 'PowerAccent.Common.dll', + 'PowerToys.PowerAccent.dll', + 'PowerToys.PowerAccent.exe', + 'PowerToys.PowerAccentKeyboardService.dll' ) # Tools signed files (in Tools subdirectory)