From fe4a86a54d5d69f19009c1c7ec2954e02c7660f4 Mon Sep 17 00:00:00 2001 From: moooyo <42196638+moooyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:06:19 +0800 Subject: [PATCH] [Quick Accent] Cloak the accent bar instead of hiding it (#49655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request Follow-up to #49633: replace the way the accent bar's first frame is protected. #49633 fixed the blank/stale first frame (#49489) by masking it — `Selector.Opacity = 0`, unveil after two `CompositionTarget.Rendering` ticks, backed by a 150 ms watchdog. This PR removes the cause instead, using the technique the Command Palette and Quick Access already ship: **DWM-cloak the overlay instead of hiding it**, so it never stops rendering and there is no stale frame to put back on screen. No user-visible behaviour change is intended beyond removing the fixed two-render-tick reveal delay; this is a mechanism swap plus the cleanup it enables. ## PR Checklist - [x] **Closes:** N/A — #49489 was already closed by #49633; this replaces that fix's mechanism - [x] **Communication:** follow-up to a merged PR in the same module, no new feature surface - [x] **Tests:** `PowerAccent.Core.UnitTests` 32/32 still pass (the pure width logic from #49633 is untouched). The current-head CI status is tracked in the PR checks; the compositor-specific manual measurements below were captured before the final commit-fence follow-up and are labeled accordingly - [x] **Localization:** no new end-user-facing strings - [x] **Dev docs:** N/A - [x] **New binaries:** none - [x] **Documentation updated:** N/A ## Detailed Description of the Pull Request / Additional comments ### Why cloaking A hidden WinUI 3 window renders nothing. Its composition surface therefore still holds the frame it was showing when it was hidden, and `ShowWindow` puts that stale frame back on screen before the rebuilt accent list has been laid out — that is #49489. Everything downstream of that follows from "the window does not render while hidden": * the bar cannot be measured before it is shown (a `Collapsed` subtree is never measured), hence #49633's measure-twice workaround; * #49633 deferred reveal using rendering ticks because it did not have a composition-commit fence; * this PR calls `Microsoft.UI.Composition.Compositor.RequestCommitAsync()` after the bar has been laid out, sized, positioned and scrolled, and only reveals after that commit completes. This replaces the frame counter and watchdog with an explicit compositor fence. A cloaked window is equally invisible to the user but stays `SW_SHOWNA`-shown, so XAML keeps laying it out and painting it. This is exactly what `Microsoft.CmdPal.UI\MainWindow.xaml.cs` does, and its comment names the same symptom: ```csharp // TRICKY: show our HWND again. This will trick XAML into painting our // HWND again, so that we avoid the "flicker" caused by a WinUI3 app // window being first shown ``` `QuickAccess.UI\QuickAccessXAML\MainWindow.xaml.cs` uses the same pattern, including the "warm up the window while cloaked" prewarm that this PR also picks up — which is what removes the *first summon of the process* case that #49633's second measurement existed for. ### What the summon looks like now `Show()` still raises `Showing`, so the surface leaves `Collapsed` and the bar lays out — but the window is still cloaked, so nothing reaches the screen. The bar is then measured **once** (on a templated, laid-out, non-collapsed subtree), sized, positioned and scrolled to the selection. The compositor commit is then awaited, and only after it completes does `Reveal()` uncloak the window. The first visible frame is a finished bar by construction rather than by timing. Removed as a result: `RevealTimeoutMs`, `FramesBeforeReveal`, `_revealTimer`, `_revealGeneration`, `_renderedFrames`, `_measuredContentWidthDip`, `ArmRevealTimeout`, `CancelPendingReveal`, `WaitForFirstFrameThenReveal`, `OnRenderingBeforeReveal`, the local `Reveal`, and the `Selector.Opacity` dance — 87 net lines out of `MainWindow`. `_showGeneration` stays: a layout callback queued by a dismissed summon still has to be dropped. ### `TransparentWindow` The cloak lives in the shared window because `Hide()` owns the `AppWindow.Hide()` that has to be replaced. It is **opt-in** (`EnableCloakedHide()`), so Shortcut Guide's overlay and CmdPal's toast keep hiding exactly as they do today; only Quick Accent enables it. `Reveal()` is a no-op for them. Two details worth review attention: * **Hit-testing.** Cloaking takes a window out of composition but *not* out of hit-testing, and this HWND sits exactly where the user is typing. While cloaked the window is therefore made click-through (`WS_EX_TRANSPARENT`), restored on reveal. Without this, an invisible accent bar would swallow clicks meant for the app underneath. * **`SW_HIDE` then `SW_SHOWNA`.** Same order as CmdPal: the hide is what hands the foreground back to whatever window should own it, and the show that follows leaves the window "shown" — which is what keeps XAML painting — while the cloak keeps it off screen. If DWM refuses to cloak, the HWND remains hidden; a later `Show()` retries instead of exposing an un-laid-out frame. ### Relationship to #34849 / #41044 Always-on-top is still released on hide, so the dormant overlay is `WS_EX_TOPMOST=False` exactly as before — verified below. Cloaking is orthogonal to topmost. The one honest trade-off is that the HWND is now permanently `WS_VISIBLE` (cloaked), so it keeps participating in composition while dormant, the same as CmdPal and Quick Access already do; it stays out of Alt-Tab and the taskbar via a hidden owner plus `WS_EX_TOOLWINDOW`. ## Validation Steps Performed > The build/test/live-state results below were recorded at `e372cdf`. Current head `ea658bb` adds the explicit `RequestCommitAsync` fence after that validation. Current-head CI is tracked by the PR checks, and the live window-state/frame-capture checks should be repeated before merge. * `build-essentials`, `Common.UI.Controls`, `PowerAccent.UI` and `PowerAccent.Core.UnitTests` all build clean (Debug|x64), 0 warnings. * `PowerAccent.Core.UnitTests`: 32/32 pass. * Live window-state measurement against the built `PowerToys.PowerAccent.exe` (`DwmGetWindowAttribute(DWMWA_CLOAKED)` + `IsWindowVisible` + ex-styles), driving a real summon of the R bar with **All languages** selected: | phase | state | |---|---| | dormant (prewarmed, before any summon) | `visible=True cloaked=1 topmost=False clickThrough=True` | | summoned | `visible=True cloaked=0 topmost=True clickThrough=False` | | dismissed | `visible=True cloaked=1 topmost=False clickThrough=True` | i.e. the window is shown-and-painting the whole time, invisible and click-through while dormant, and topmost/interactive only while summoned. * Screen-captured the summoned bar: all 22 characters for R render (including the wide `₹ ៛ ﷼`), leading and trailing padding are symmetric, nothing is clipped and the selection is on the first cell. --------- Co-authored-by: Yu Leng Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TransparentWindow/TransparentWindow.cs | 176 ++++++++++++++++- .../PowerAccentXAML/App.xaml.cs | 4 +- .../PowerAccentXAML/MainWindow.xaml.cs | 181 +++++------------- .../PowerAccentXAML/SelectorControl.xaml.cs | 9 +- 4 files changed, 224 insertions(+), 146 deletions(-) diff --git a/src/common/Common.UI.Controls/Window/TransparentWindow/TransparentWindow.cs b/src/common/Common.UI.Controls/Window/TransparentWindow/TransparentWindow.cs index ed66427e12..b503809621 100644 --- a/src/common/Common.UI.Controls/Window/TransparentWindow/TransparentWindow.cs +++ b/src/common/Common.UI.Controls/Window/TransparentWindow/TransparentWindow.cs @@ -56,16 +56,20 @@ public partial class TransparentWindow : WinUIEx.WindowEx { private const uint DwmwaColorNone = 0xFFFFFFFE; private const int DwmwaNcRenderingPolicy = 2; + private const int DwmwaCloak = 13; private const int DwmwaWindowCornerPreference = 33; private const int DwmwaBorderColor = 34; private const int DwmwcpDoNotRound = 1; private const int DwmncrpDisabled = 2; + private const int GwlpHwndParent = -8; private const int GwlExStyle = -20; private const int WsExDlgModalFrame = 0x00000001; + private const int WsExTransparent = 0x00000020; private const int WsExToolWindow = 0x00000080; private const int WsExWindowEdge = 0x00000100; private const int WsExClientEdge = 0x00000200; + private const int WsExAppWindow = 0x00040000; private const uint SwpNoSize = 0x0001; private const uint SwpNoMove = 0x0002; @@ -73,12 +77,16 @@ public partial class TransparentWindow : WinUIEx.WindowEx private const uint SwpNoActivate = 0x0010; private const uint SwpFrameChanged = 0x0020; + private const int SwHide = 0; private const int SwShowNa = 8; private readonly nint _hwnd; + private Microsoft.UI.Xaml.Window? _hiddenOwnerWindow; private bool _inputHooked; private bool _seenActivated; + private bool _cloakWhenHidden; + private bool _cloaked; public TransparentWindow() { @@ -211,7 +219,9 @@ public partial class TransparentWindow : WinUIEx.WindowEx /// /// Shows the window without activation (SW_SHOWNA) and raises /// without a transition, so subscribed content animates - /// in using its own configured show transition. + /// in using its own configured show transition. After + /// the window stays cloaked here and only + /// becomes visible on . /// public void Show() => RaiseShow(null); @@ -219,27 +229,50 @@ public partial class TransparentWindow : WinUIEx.WindowEx /// Shows the window without activation (SW_SHOWNA) and raises /// so subscribed content animates in using /// , overriding its configured show transition. + /// After the window stays cloaked here and only + /// becomes visible on . /// /// The transition the content should play. public void Show(Transition transition) => RaiseShow(transition); private void RaiseShow(Transition? transition) { + // A new show can interrupt a deferred hide. In that case HideCore never runs, so the + // previous Reveal left the HWND uncloaked. Cloak synchronously before the caller returns to + // the dispatcher; otherwise content rebuilt for this summon can render at the old bounds. + if (DispatcherQueue.HasThreadAccess) + { + // CloakAndKeepShown uses SW_HIDE, which can raise Deactivated. Reset this first so an + // internal show transition is not mistaken for a user-initiated focus loss. + _seenActivated = false; + EnsureCloakedBeforeShow(); + } + DispatcherQueue.TryEnqueue( DispatcherQueuePriority.Low, () => { _seenActivated = false; + + // Also cover callers that entered Show from another thread. + EnsureCloakedBeforeShow(); EnsureInputHooks(); - _ = ShowWindow(_hwnd, SwShowNa); + + // Cloaked mode is made SW_SHOWNA-visible only after cloaking succeeds. + if (!_cloakWhenHidden) + { + _ = ShowWindow(_hwnd, SwShowNa); + } + Showing?.Invoke(this, new ShowingEventArgs(transition)); }); } /// /// Raises so subscribed content animates out, then hides - /// the underlying once every - /// deferral taken by a handler has completed (immediately if none were taken). + /// the underlying - or cloaks the + /// window when was called - once every deferral + /// taken by a handler has completed (immediately if none were taken). /// public void Hide() { @@ -249,10 +282,143 @@ public partial class TransparentWindow : WinUIEx.WindowEx { var args = new HidingEventArgs(); Hiding?.Invoke(this, args); - args.RunWhenComplete(AppWindow.Hide); + args.RunWhenComplete(HideCore); }); } + /// + /// Switches this window from hiding to cloaking, and immediately puts it + /// into that state. + /// + /// + /// A hidden WinUI 3 window renders nothing, so its composition surface keeps + /// whatever frame it was showing when it was hidden, and the next + /// puts that stale frame back on screen before the new content has been laid out. + /// A cloaked window is equally invisible but stays SW_SHOWNA-shown, so XAML + /// keeps laying it out and painting it and there is no stale frame to put back. + /// This changes what the show sequence means: still raises + /// so the content lays out and animates in, but the window stays + /// cloaked - is what puts it on screen. A consumer that rebuilds + /// its content on every summon can therefore lay that content out while still invisible + /// and reveal a window that is correct in its first visible frame. + /// Enabling it also warms the window up: the XAML tree is built, templated and + /// painted right away rather than on the first summon. + /// Call this once, from the consumer's constructor after its content has been + /// set. Cloaking is a DWM feature; if DWM refuses, the window remains hidden and the + /// next retries. + /// + protected void EnableCloakedHide() + { + // Unlike a hidden HWND, a cloaked HWND remains WS_VISIBLE. Give it a hidden owner before + // the first SW_SHOWNA so Explorer reliably keeps it out of the taskbar on every virtual- + // desktop taskbar configuration; WS_EX_TOOLWINDOW alone is not sufficient there. + EnsureHiddenOwner(); + _cloakWhenHidden = true; + HideCore(); + } + + /// + /// Puts a cloaked window on screen. Consumers call this once the content that + /// laid out is ready to be seen. Does nothing unless + /// was called and the window is currently cloaked. + /// + public void Reveal() + { + if (!_cloaked) + { + return; + } + + // Keep the state and click-through style when DWM refuses to uncloak. A later Reveal can + // then retry instead of returning early while the HWND is still invisible. + if (!SetCloak(false)) + { + return; + } + + _cloaked = false; + + // Restore hit-testing: the window is on screen again, so it must behave like any + // other window (see CloakAndKeepShown for why it is click-through while cloaked). + ApplyExStyleBit(WsExTransparent, false); + } + + private void HideCore() + { + if (_cloakWhenHidden) + { + CloakAndKeepShown(); + return; + } + + AppWindow.Hide(); + } + + private void EnsureCloakedBeforeShow() + { + if (_cloakWhenHidden && !_cloaked) + { + CloakAndKeepShown(); + } + } + + private void EnsureHiddenOwner() + { + if (_hiddenOwnerWindow is not null || _hwnd == 0) + { + return; + } + + _hiddenOwnerWindow = new Microsoft.UI.Xaml.Window(); + nint hiddenOwnerHwnd = WinRT.Interop.WindowNative.GetWindowHandle(_hiddenOwnerWindow); + _ = SetWindowLongPtr(_hwnd, GwlpHwndParent, hiddenOwnerHwnd); + + // WS_EX_APPWINDOW overrides normal owner-based taskbar suppression. + ApplyExStyleBit(WsExAppWindow, false); + } + + private void CloakAndKeepShown() + { + if (_cloaked) + { + return; + } + + // Hide first so a DWM failure cannot leave an uncloaked overlay on screen. The next Show + // retries cloaking; only a successfully cloaked HWND is made SW_SHOWNA-visible again. + _ = ShowWindow(_hwnd, SwHide); + if (!SetCloak(true)) + { + return; + } + + _cloaked = true; + + // Cloaking only takes the window out of composition, not out of hit-testing, and + // this HWND sits exactly where the user is working. Make it click-through so the + // invisible window cannot swallow input meant for the app underneath it. + ApplyExStyleBit(WsExTransparent, true); + + // SW_HIDE above hands the foreground back to whatever window should own it (only the OS + // can pick the right one). Now that cloaking succeeded, SW_SHOWNA leaves this window + // "shown", which keeps XAML painting it, while the cloak keeps it off screen. + _ = ShowWindow(_hwnd, SwShowNa); + } + + private bool SetCloak(bool cloak) + { + if (_hwnd == 0) + { + return false; + } + + unsafe + { + int value = cloak ? 1 : 0; + return DwmSetWindowAttribute(_hwnd, DwmwaCloak, &value, sizeof(int)) == 0; + } + } + private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args) { if (args.WindowActivationState == WindowActivationState.Deactivated) diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs index c325311038..d997335255 100644 --- a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/App.xaml.cs @@ -33,8 +33,8 @@ public partial class App : Application, IDisposable 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). + // Quick Accent has no user-visible main window until summoned by the keyboard hook; + // the accent selector stays DWM-cloaked while its shown HWND keeps XAML warm. } protected virtual void Dispose(bool disposing) diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs index c27f4eaee7..dfeb00036f 100644 --- a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/MainWindow.xaml.cs @@ -4,10 +4,11 @@ using System; +using ManagedCommon; using Microsoft.PowerToys.Common.UI.Controls.Window; using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; -using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Hosting; using Windows.Graphics; using CoreSize = PowerAccent.Core.Size; @@ -20,9 +21,9 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable // glyph. Its width is measured from the character list (SelectorControl.MeasureContentWidthDip) // plus the space outside the list, NOT derived from the item count: an accent cell is a MINIMUM // of 48 DIP, so a glyph wider than that grows its cell and a count * 48 estimate would size the - // window narrower than its own content (issue #49488). The bar is sized twice per summon - once - // before Show, then again after the first real layout pass, because only the second measurement - // is taken on a templated, non-collapsed subtree. + // window narrower than its own content (issue #49488). The measurement is taken after the + // surface has been laid out, which the cloaked window (see the constructor) makes possible + // before anything is on screen. 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 MinItemWidthDip = 48; // one accent cell's minimum (ListViewItem MinWidth=48) @@ -31,30 +32,9 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable // Prevents the fractional pixels that may occur with scaled displays from truncating the character list. private const double LayoutRoundingDip = 1; - // Upper bound on how long the bar may stay invisible while waiting for its first composed frame - // (see PowerAccent_OnChangeDisplay). A CompositionTarget.Rendering handler forces the UI thread - // to run every frame, so this timer is NOT the normal path - that is FramesBeforeReveal refresh - // intervals. It exists because the tick cadence carries no guarantee (microsoft-ui-xaml#11048) - // and because ticking can stop for a locked or fully occluded session. Treat it as a floor, not - // a deadline: DispatcherQueueTimer tasks run at a priority lower than idle. On that path the bar - // simply appears the way it used to. - private const int RevealTimeoutMs = 150; - - // Composed frames to wait before unveiling. The bar is transparent until Reveal(), so neither of - // these frames draws it; they buy settling time for the resize below and for the surface's - // Collapsed -> Visible flip, so that the frame which first rasterizes the bar (the one after - // Opacity = 1) already has the correct client area. Rendering is not tied to any specific - // element, so a tick is evidence that a frame elapsed - not that this subtree was composed. - private const int FramesBeforeReveal = 2; - private readonly Core.PowerAccent _powerAccent; - private readonly DispatcherQueueTimer _revealTimer; private int _selectedIndex = -1; private int _showGeneration; - private int _revealGeneration = -1; - private double _measuredContentWidthDip = -1; - private int _renderedFrames; - 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. @@ -74,10 +54,13 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable // SubscribeSurfaceTo forwards to the inner surface so it follows this window's Show/Hide. Selector.SubscribeSurfaceTo(this); - _revealTimer = DispatcherQueue.CreateTimer(); - _revealTimer.IsRepeating = false; - _revealTimer.Interval = TimeSpan.FromMilliseconds(RevealTimeoutMs); - _revealTimer.Tick += (_, _) => Reveal(); + // Cloak the overlay instead of hiding it. A hidden WinUI 3 window renders nothing, so the + // bar would become visible while the characters of the new summon are still un-laid-out and + // the first frames would show the previous summon's content - issue #49489. Cloaked, the + // window stays shown (and therefore laid out and painted) while invisible, so every summon + // builds its bar out of sight and Reveal() never has a wrong frame to put on screen. This + // also warms the XAML tree up now rather than on the first summon. + EnableCloakedHide(); _powerAccent = new Core.PowerAccent(RunOnUiThread); _powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay; @@ -108,14 +91,11 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable { if (!isActive) { - _active = false; + // Invalidate any layout callback still queued for the summon being dismissed, so it + // cannot reveal a bar that is on its way out. Every dismissal bumps the counter and + // every summon captures it, so this is the only liveness check the callback needs. _showGeneration++; - // Drop any reveal still pending for the summon being dismissed. Same motivation as - // releasing always-on-top below: the Rendering handler forces the UI thread to run every - // frame, so it must not outlive the visible bar. - CancelPendingReveal(); - // 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). @@ -130,7 +110,6 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable return; } - _active = true; int generation = ++_showGeneration; ViewModel.ShowDescription = _powerAccent.ShowUnicodeDescription; @@ -145,52 +124,52 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable ? _powerAccent.CharacterDescriptions[_selectedIndex] : string.Empty; - // Show the bar transparent and unveil it once it has actually been drawn. A hidden WinUI 3 - // window renders nothing, so the HWND would otherwise become visible while the new - // characters are still un-laid-out, and the first frames would show the previous bar at its - // previous size, clipped by the new (already correct) client area - issue #49489. This is - // the WinUI 3 counterpart of the WPF fix in #46593, which rendered the toolbar off screen - // and only then moved it into view. - Selector.Opacity = 0; - // 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. + // SW_SHOWNA and never activates it); released on hide (see above). The window is cloaked at + // this point, so Show() flips the surface out of Collapsed and lets the new bar lay out + // without anything reaching the screen. IsAlwaysOnTop = true; - SizeAndPosition(Selector.MeasureContentWidthDip()); Show(); - // Arm the fallback deadline synchronously: the bar is transparent from here on, so the - // timeout has to be running even if the callback below never gets to run - otherwise a - // dropped callback leaves the bar invisible for the whole summon instead of merely late. - ArmRevealTimeout(generation); - - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => + DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () => { - if (!_active || generation != _showGeneration) + if (generation != _showGeneration) { return; } - // Runs after TransparentWindow.Show has made the window visible and flipped the surface - // out of Collapsed, so this is the first point at which the bar can lay out at all - - // and ScrollIntoView needs realized containers to land on the right offset. + // Runs after TransparentWindow.Show has flipped the surface out of Collapsed, so this is + // the first point at which the bar can lay out at all - a Collapsed subtree is never + // measured, which is why the size is taken here and not before Show. Selector.UpdateLayout(); + SizeAndPosition(Selector.MeasureContentWidthDip()); - // The measurement above ran before the surface left Collapsed and, on the first summon - // of the process, before its template had ever been applied, so it can report less than - // the items really need - in which case GetToolbarWidth silently falls back to the - // item-count estimate this whole change exists to replace. Now that a real layout pass - // has run, re-measure and re-size when the two disagree. The bar is still at Opacity 0, - // so the correction is never seen as a resize. - double laidOutContentWidthDip = Selector.MeasureContentWidthDip(); - if (Math.Abs(laidOutContentWidthDip - _measuredContentWidthDip) > LayoutRoundingDip) + // Lay out again at the new window size: ScrollIntoView needs realized containers and the + // final viewport to land on the right offset. + Selector.UpdateLayout(); + Selector.ScrollSelectedIntoView(_selectedIndex); + + // UpdateLayout only completes XAML measure/arrange. Wait for the composition commit so + // uncloaking cannot expose the previous summon's redirection surface at the new bounds. + try { - SizeAndPosition(laidOutContentWidthDip); - Selector.UpdateLayout(); + await ElementCompositionPreview.GetElementVisual(Selector).Compositor.RequestCommitAsync(); + } + catch (Exception ex) + { + Logger.LogError("Failed to commit the Quick Accent layout before reveal", ex); + return; } - Selector.ScrollSelectedIntoView(_selectedIndex); - WaitForFirstFrameThenReveal(); + if (generation != _showGeneration) + { + return; + } + + // Everything above happened on a window that was shown but cloaked. The commit ensures + // DWM's redirection surface already contains this summon's final layout before it is + // exposed, so Reveal cannot flash the previous bar at the new bounds (issue #49489). + Reveal(); }); Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Core.Telemetry.PowerAccentShowAccentMenuEvent()); @@ -216,8 +195,6 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable // max usable width so long lists scroll. The Unicode description row needs room for a // readable line, so it widens a short bar to the WPF original's minimum (the accent bar // itself stays centered within the wider window). - _measuredContentWidthDip = measuredContentWidthDip; - double widthDip = _powerAccent.GetDisplayWidth( measuredContentWidthDip, ViewModel.Characters.Count, @@ -246,72 +223,8 @@ public sealed partial class MainWindow : TransparentWindow, IDisposable FlyoutWindowHelper.MoveAndResizeOnDisplay(this, display, rect); } - // Starts the fallback deadline for the reveal, tagged with the summon that armed it. - private void ArmRevealTimeout(int generation) - { - // Cancel before re-tagging, not after. The Stop() inside CancelPendingReveal is what kills - // the previous summon's watchdog, and that watchdog was the only remaining path that would - // have driven its still-attached Rendering handler through Reveal(). Leaving the handler on - // would let it unveil THIS summon after FramesBeforeReveal ticks - before the layout - // callback below has run - and the generation guard in Reveal() cannot reject it, because - // the two assignments underneath put _revealGeneration and _showGeneration back in sync. - CancelPendingReveal(); - - _revealGeneration = generation; - _renderedFrames = 0; - - // Restart rather than extend: DispatcherQueueTimer does not document what Start() does to a - // timer that is already running, so the deadline is reset explicitly. - _revealTimer.Start(); - } - - // Drops a pending reveal. The watchdog and the per-frame handler always come off together: the - // handler forces the UI thread to run every frame, and the watchdog is what guarantees it is - // detached on a session where no frames arrive at all. - private void CancelPendingReveal() - { - _revealTimer.Stop(); - CompositionTarget.Rendering -= OnRenderingBeforeReveal; - } - - // Unveils the bar once the compositor has drawn it, or after RevealTimeoutMs if it never does. - private void WaitForFirstFrameThenReveal() - { - _renderedFrames = 0; - - // Re-arms rather than stacks: a summon that lands while an earlier one is still waiting - // reuses the same handler, so there is only ever one pending reveal. - CompositionTarget.Rendering -= OnRenderingBeforeReveal; - CompositionTarget.Rendering += OnRenderingBeforeReveal; - } - - private void OnRenderingBeforeReveal(object sender, object e) - { - if (++_renderedFrames < FramesBeforeReveal) - { - return; - } - - Reveal(); - } - - private void Reveal() - { - // Unconditional: the handler forces the UI thread to run every frame, so it has to come off - // even when the reveal itself is dropped as stale just below. - CancelPendingReveal(); - - // Same guard as the layout callback. A reveal armed by an earlier summon must not unveil a - // newer one before its own layout pass has run - that is exactly the stale frame of #49489. - if (_active && _revealGeneration == _showGeneration) - { - Selector.Opacity = 1; - } - } - public void Dispose() { - CancelPendingReveal(); _powerAccent.SaveUsageInfo(); _powerAccent.Dispose(); GC.SuppressFinalize(this); diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs index d7c8791f0d..05b8bc41cf 100644 --- a/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccentXAML/SelectorControl.xaml.cs @@ -41,11 +41,10 @@ public sealed partial class SelectorControl : UserControl /// The cell is a MinWidth of 48, not a fixed 48: a glyph wider than that (₹, ‰, ﷼, a CJK /// fallback) grows its cell, so the bar has to be measured rather than derived from the item /// count. The measurement is taken explicitly instead of read from the last layout pass because - /// the bar is rebuilt on every summon while the window is still hidden, so no pass has run for - /// the new items; measuring against an infinite width also yields the true content width rather - /// than whatever the ScrollViewer inside the ListView's own template would have clipped it to. - /// A caller that measures before the surface has been templated and laid out may get less than - /// the items need, so MainWindow measures again after its first layout pass. + /// the bar is rebuilt on every summon while its shown window is still cloaked, so the caller + /// first lays out the newly visible surface off screen. Measuring against an infinite width also + /// yields the true content width rather than whatever the ScrollViewer inside the ListView's own + /// template would have clipped it to. /// internal double MeasureContentWidthDip() {