Skip to content

[QuickAccent] Fix UI glitches, DPI-related issues, selection bugs, and add hardware shift key state fallback#46593

Open
daverayment wants to merge 2 commits intomicrosoft:mainfrom
daverayment:fix-quick-accent-ui
Open

[QuickAccent] Fix UI glitches, DPI-related issues, selection bugs, and add hardware shift key state fallback#46593
daverayment wants to merge 2 commits intomicrosoft:mainfrom
daverayment:fix-quick-accent-ui

Conversation

@daverayment
Copy link
Copy Markdown
Collaborator

Summary of the Pull Request

This PR fixes several issues around the popup selection window's size and position, selection-related issues which result in flashing or glitching, and includes more reliable detection of the Shift key.

PR Checklist

Detailed Description of the Pull Request / Additional comments

This PR includes fixes for the Quick Accent's selection window position, its width measurement, and letter selection-related issues. In addition, glitches such as the window flashing the selection colour and the window appearing blank should be reduced or eliminated entirely.

Popup width bug

When opening Quick Accent from a letter with many mappings, it would appear too wide for the display. Even though letters could be selected, they may be entirely off-screen:

image

This was because of this flaw in GetDisplayMaxWidth, which is used directly by the popup to set the maximum width of the characters area:

    // In Selector.xaml.cs
    private void SetWindowsSize()
    {
        this.characters.MaxWidth = _powerAccent.GetDisplayMaxWidth();
    }

...
    // In PowerAccent.cs
    public double GetDisplayMaxWidth()
    {
        return WindowsFunctions.GetActiveDisplay().Size.Width - ScreenMinPadding;
    }

GetActiveDisplay uses the GetMonitorInfo API, which exposes the working area of the display. It returns its values in raw unscaled pixel values:

    public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
    {
        ...
        var res = PInvoke.MonitorFromWindow(guiInfo.hwndActive, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST);
        MONITORINFO monitorInfo = default;
        monitorInfo.cbSize = (uint)Marshal.SizeOf(monitorInfo);
        PInvoke.GetMonitorInfo(res, ref monitorInfo);

        ...

        return (location, monitorInfo.rcWork.Size, dpi);
    }

However, the MaxWidth property must be a pre-scaled value, i.e. in logical WPF units not physical pixels. The fix is straightforward:

    public double GetDisplayMaxWidth()
    {
        var activeDisplay = WindowsFunctions.GetActiveDisplay();
        return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding;
    }

Popup positioning bug

This is related to a subtle DPI issue in GetActiveDisplay():

    public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
    {
        GUITHREADINFO guiInfo = default;
        guiInfo.cbSize = (uint)Marshal.SizeOf(guiInfo);
        PInvoke.GetGUIThreadInfo(0, ref guiInfo);
        var res = PInvoke.MonitorFromWindow(guiInfo.hwndActive, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST);

        MONITORINFO monitorInfo = default;
        monitorInfo.cbSize = (uint)Marshal.SizeOf(monitorInfo);
        PInvoke.GetMonitorInfo(res, ref monitorInfo);

        double dpi = PInvoke.GetDpiForWindow(guiInfo.hwndActive) / 96d;
        var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top);
        return (location, monitorInfo.rcWork.Size, dpi);
    }

Here, the application window's DPI is returned. Unfortunately, the window may report a value which is different from the monitor's own DPI value. This will consistently happen if the application is not Per-Monitor DPI-Aware, and the monitor is not at 100% Scale. The effects are that the Quick Accent popup can appear misaligned or even off-screen entirely. Quick Accent can still be used, but the user may not be able to see what they are selecting.

As Quick Accent is using monitor coordinates for setting its location, the solution is to use the monitor's own DPI value. The fix is to add this in place of the GetDpiForWindow line:

        uint dpiRaw = 96; // Safe default
        if (PInvoke.GetDpiForMonitor(res, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
        {
            dpiRaw = dpiX;
        }

        double dpi = dpiRaw / 96d;

Selection bugs

After dismissing the Quick Accent window, the _selectedIndex state was not properly reset. The next time the window opened, it could attempt to scroll to or highlight an index that was out-of-bounds for the new character set. This could result in glitching, such as the window flashing the selection colour or the initial selection being incorrect. In this fix, I:

  1. Explicitly set _selectedIndex to -1 when the UI hides.
  2. Reset the SelectedIndex inside Selector.xaml.cs before updating the ItemsSource.

Shift key activation

In certain cases, a quick press of Shift could fail to move back through the character list. In this fix, I:

  1. Added a native fallback usign GetAsyncKeyState(VK_SHIFT).
  2. Updated ProcessNextChar() to evaluate shiftPressed || WindowsFunctions.IsShiftState().
  3. Updated the multiple ifs in ProcessNextChar to be an if/else structure, to prevent bugs when more than one trigger key is pressed.

Support added for multi-codepoint graphemes

The current code loops through each char of a mapping, calling SendInput multiple times for multi-char sequences. This will fail for multi-codepoint graphemes, i.e. where the mapping 'letter' is more than one UTF-16 codepoint. Those characters may appear as []. The amended Insert() in WindowsFunctions appends all characters before calling SendInput.

Miscelleneous

  • Added an OnDpiChanged handler for the Selector control, so changing the DPI of the screen should be picked up automatically. (It's questionable whether this is essential, as the DPI would have to change while the control was displayed, but it's worth having for robustness.)
  • Now using SetWindowPos instead of setting the Left and Top of the popup control. Also now initialising the popup offscreen to attempt to reduce flicker and the occurrence of blank window flashes.
  • Changed the Focusable property of the characters ListBox to False, to attempt to reduce flicker and the window flashing the selector colour.
  • Removed Width and added MinWidth to the letter control in Selector.xaml. This allows for wider letters or longer multi-letter mappings.
  • Changed the VirtualizingStackPanel to a regular StackPanel. We do not have mappings with enough entries for a virtual control to be necessary, and using StackPanel seemed to have a positive effect on the appearance of blank window glitches.
  • Added TextTrimming, TextWrapping and MaxHeight to the unicode description TextBlock. This helps support extremely long unicode descriptions. Again, this will enable us to support longer multi-character mappings in the future.
  • Added CsWin32 to the PowerAccent.UI project, to support the SetWindowPos call.

Validation Steps Performed

See separate doc for full test details:

https://docs.google.com/document/d/19uClcUiv7RUDRlbFhazG-Cmu46oNmrAVoJf9bHSjSJU/edit?usp=sharing

@daverayment daverayment added the Product-Quick Accent Refers to the Quick Accent PowerToy label Mar 28, 2026
@niels9001 niels9001 requested a review from Copilot March 28, 2026 11:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR targets the Quick Accent (PowerAccent) popup UI reliability by addressing DPI scaling/positioning issues, selection-state glitches, and improving keyboard handling for navigation.

Changes:

  • Fix DPI-related sizing/positioning by using monitor DPI and switching popup placement to Win32 SetWindowPos with raw coordinates.
  • Reduce selection flashing/glitching via selection reset, ItemsSource clearing on hide, and UI layout adjustments (min widths, trimming/wrapping).
  • Add/adjust native interop usage (CsWin32 update, new SetWindowPos projection, GetDpiForMonitor, GetAsyncKeyState) and tweak navigation logic.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs Switches positioning to SetWindowPos, adds DPI/size-change repositioning, and improves selection reset/scroll behavior.
src/modules/poweraccent/PowerAccent.UI/Selector.xaml Adjusts ListBox focus/scroll behavior, sizing (MinWidth), panel type, and description TextBlock trimming/wrapping.
src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj Adds CsWin32 package reference for UI project Win32 interop.
src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt Declares SetWindowPos for CsWin32 generation in UI project.
src/modules/poweraccent/PowerAccent.Core/Tools/WindowsFunctions.cs Updates text insertion to send in a single SendInput call; uses GetDpiForMonitor and GetAsyncKeyState.
src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs Adjusts selection initialization/navigation (including shift behavior) and updates coordinate/max-width calculations for DPI correctness.
src/modules/poweraccent/PowerAccent.Core/NativeMethods.txt Updates CsWin32 native method list to match new P/Invoke usage.
Directory.Packages.props Bumps Microsoft.Windows.CsWin32 package version repo-wide and applies minor formatting cleanup.

Comment on lines +242 to +252
shiftPressed = shiftPressed || WindowsFunctions.IsShiftState();

if (_visible && _selectedIndex == -1)
{
if (triggerKey == TriggerKey.Left)
if (triggerKey == TriggerKey.Space)
{
_selectedIndex = (_characters.Length / 2) - 1;
_selectedIndex = shiftPressed ? (_characters.Length - 1) : 0;
}

if (triggerKey == TriggerKey.Right)
else if (_settingService.StartSelectionFromTheLeft)
{
_selectedIndex = _characters.Length / 2;
_selectedIndex = 0;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shiftPressed = shiftPressed || WindowsFunctions.IsShiftState() will treat any currently-held Shift as “navigate backwards”, including Shift that was already down to type an uppercase letter. This appears to contradict the keyboard hook’s existing behavior of only tracking Shift after the toolbar is visible (to avoid uppercase conflicts), and can cause Space navigation to unexpectedly go backwards when the user triggers Quick Accent while holding Shift for capitalization. Consider tracking the Shift state at toolbar-show time (and only applying the async-key fallback if Shift transitioned after the toolbar became visible), or moving the fallback into the hook where you can distinguish “Shift used for uppercase” vs “Shift used for navigation” reliably.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an excellent catch. I've implemented the suggested logic in commit 59261e0. The new code tracks the initial state of the shift key when the popup is summoned, i.e. the user is typing a capital letter, and checks against this when determining the subsequent shift state in ProcessNextChar, disregarding the current hardware shift state if the initial state was true, i.e. only relying on the keyboard hook in that case. This means we still get the better async shift state handling for lowercase letters and we don't accidentally move backwards when the user is typing a capital letter.

Comment on lines +90 to +101
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, -10000, -10000, 0, 0, WindowPosFlags);
}
else
{
this.Left = -10000;
this.Top = -10000;
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hard-coded off-screen move to (-10000, -10000) may still land on-screen for large multi-monitor layouts (e.g., several 4K monitors positioned to the left/top can extend beyond -10000 in virtual screen coordinates), which would reintroduce the flicker this is trying to avoid. Consider computing a guaranteed-offscreen position using the virtual screen bounds (e.g., via GetSystemMetrics(SM_XVIRTUALSCREEN/SM_CXVIRTUALSCREEN/SM_YVIRTUALSCREEN/SM_CYVIRTUALSCREEN)) and placing the window just outside that rectangle.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Updated the code to render offscreen 1000 pixels top-left of 0,0 in virtual screen space, in commit 59261e0

…ed. Ensure offscreen rendering is actually offscreen by using virtual display metrics.
@niels9001
Copy link
Copy Markdown
Collaborator

@daverayment Ah, looks like we are partying in the same app :)

#46604 replaces the WpfUi library with plain WPF and it's built-in Fluent Theming. Do you want us to review your PR first or merge that one first and sync yours then with main?

@daverayment
Copy link
Copy Markdown
Collaborator Author

@niels9001 Ooh, exciting! It looks like the controls that are changing back to pure WPF in #46604 are ones that I've also changed in this PR, so there's likely going to be a conflict whichever way around we do it :) I think that the properties I've added or updated are consistent with regular WPF.

It may be better to deal with this PR first before doing the WpfUI->WPF migration. Otherwise, the test document I wrote won't be valid.

After the migration to pure WPF, we probably need to ensure the control properties I've changed/added are still present and then run through the tests again.

@niels9001
Copy link
Copy Markdown
Collaborator

@niels9001 Ooh, exciting! It looks like the controls that are changing back to pure WPF in #46604 are ones that I've also changed in this PR, so there's likely going to be a conflict whichever way around we do it :) I think that the properties I've added or updated are consistent with regular WPF.

It may be better to deal with this PR first before doing the WpfUI->WPF migration. Otherwise, the test document I wrote won't be valid.

After the migration to pure WPF, we probably need to ensure the control properties I've changed/added are still present and then run through the tests again.

Sounds good! Let's get this one in first!

@daverayment
Copy link
Copy Markdown
Collaborator Author

@niels9001 Just in case you were waiting on me, I've made the Copilot-requested changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment