diff --git a/Cursorial.UI.Bars.Tests/KeyTipPopupDrillTests.cs b/Cursorial.UI.Bars.Tests/KeyTipPopupDrillTests.cs new file mode 100644 index 00000000..8f4ddffc --- /dev/null +++ b/Cursorial.UI.Bars.Tests/KeyTipPopupDrillTests.cs @@ -0,0 +1,222 @@ +using Cursorial.Input; +using Cursorial.Input.Events; +using Cursorial.Rendering; +using Cursorial.Terminal; +using Cursorial.UI; +using Cursorial.UI.Bars; +using Cursorial.UI.Bars.Input; +using Cursorial.UI.Controls; +using Cursorial.UI.Hosting.Headless; +using Cursorial.UI.Input; + +namespace Cursorial.Tests.UI.Bars; + +// KeyTip drills INTO popups (keytips-design §6's deferred v2 leg, built 2026-09-09 at the maintainer's ask): a menu +// header opens its submenu and badges the rows; a dropdown-bearing bar control opens its dropdown and badges its +// controls; the toolbar's overflow chevron (badge 0) opens the overflow and badges the pocketed controls. The overlay +// re-stacks directly ABOVE the popup it annotates; Esc pops the level and closes the popup it opened. +public sealed class KeyTipPopupDrillTests +{ + private static UIHeadlessHost NewHost(int w = 80, int h = 16) => + UIHeadlessHost.Create(new UIHeadlessHostOptions { InitialSize = new Size(w, h), Capabilities = HeadlessCapabilities.KittyTruecolor }); + + private static KeyEvent Key_(Key key, KeyModifiers modifiers = KeyModifiers.None, string? text = null, KeyEventKind kind = KeyEventKind.Down) + => new() { Key = key, Modifiers = modifiers, Kind = kind, Text = (text ?? string.Empty).AsMemory(), Timestamp = DateTimeOffset.UnixEpoch }; + + private static void AltDown(UIHeadlessHost host) => host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.Alt)); + private static void TypeKeyTip(UIHeadlessHost host, char c) => host.Application.InputDispatcher.ProcessEvent(Key_(Key.Character, KeyModifiers.Alt, c.ToString())); + private static void Escape(UIHeadlessHost host) => host.Application.InputDispatcher.ProcessEvent(Key_(Key.Escape)); + + // Runs frames until the parked level lands (bounded). + private static void SettleLevel(UIHeadlessHost host, KeyTipController controller, int depth) + { + for (var i = 0; i < 8 && controller.LevelDepthForTests < depth; i++) + host.RunFrame(); + Assert.Equal(depth, controller.LevelDepthForTests); + } + + // The topmost popup surface's index (the drilled popup is the last one opened). + private static int TopPopupIndex(UIHeadlessHost host) + { + var surfaces = host.Application.WindowManager!.Surfaces; + for (var i = surfaces.Count - 1; i >= 0; i--) + if (surfaces[i].IsPopup) + return i; + return -1; + } + + private static int OverlayIndex(UIHeadlessHost host) + { + var surfaces = host.Application.WindowManager!.Surfaces; + for (var i = 0; i < surfaces.Count; i++) + if (surfaces[i].IsHitTestTransparent) + return i; + return -1; + } + + [Fact] // Menu: Alt → F (opens File, badges its rows) → N activates New; the level's badges sit ABOVE the submenu popup. + public void Menu_HeaderDrillsIntoSubmenu_RowActivates() + { + using var host = NewHost(); + var controller = host.Application.EnableKeyTips(); + + var menu = new Menu(); + var file = new MenuItem { Header = "_File" }; + var newItem = new MenuItem { Header = "_New" }; + var openItem = new MenuItem { Header = "_Open" }; + file.Items.Add(newItem); + file.Items.Add(openItem); + menu.Items.Add(file); + menu.Items.Add(new MenuItem { Header = "_Edit" }); + var invoked = false; + newItem.Click += (_, _) => invoked = true; + + var root = new StackPanel { Orientation = Orientation.Vertical, Children = { menu, new TextBox() } }; + host.ShowRoot(root); + host.RunUntilIdle(); + + AltDown(host); + Assert.Equal(1, controller.LevelDepthForTests); + TypeKeyTip(host, 'F'); // drill File: the submenu opens + SettleLevel(host, controller, 2); + Assert.True(file.IsSubmenuOpen); + + var badge = controller.BadgeForTargetForTests(newItem); + Assert.NotNull(badge); + Assert.Equal(Visibility.Visible, badge!.Visibility); + Assert.True(TopPopupIndex(host) >= 0 && OverlayIndex(host) > TopPopupIndex(host), "the badge overlay stacks above the submenu popup"); + + TypeKeyTip(host, 'N'); // activate New + host.RunUntilIdle(); + Assert.True(invoked); + Assert.False(controller.IsActive); + } + + [Fact] // Menu: Esc inside the submenu level pops it AND closes the submenu; the header level is matchable again. + public void Menu_EscInSubmenuLevel_ClosesTheSubmenu_AndReDrills() + { + using var host = NewHost(); + var controller = host.Application.EnableKeyTips(); + + var menu = new Menu(); + var file = new MenuItem { Header = "_File" }; + file.Items.Add(new MenuItem { Header = "_New" }); + var edit = new MenuItem { Header = "_Edit" }; + var undo = new MenuItem { Header = "_Undo" }; + edit.Items.Add(undo); + menu.Items.Add(file); + menu.Items.Add(edit); + host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { menu, new TextBox() } }); + host.RunUntilIdle(); + + AltDown(host); + TypeKeyTip(host, 'F'); + SettleLevel(host, controller, 2); + Assert.True(file.IsSubmenuOpen); + + Escape(host); // pop: back to the headers, File closed + host.RunUntilIdle(); + Assert.Equal(1, controller.LevelDepthForTests); + Assert.False(file.IsSubmenuOpen); + Assert.True(controller.IsActive); + + TypeKeyTip(host, 'E'); // a sibling header drills fine afterwards + SettleLevel(host, controller, 2); + Assert.True(edit.IsSubmenuOpen); + Assert.NotNull(controller.BadgeForTargetForTests(undo)); + } + + [Fact] // Ribbon: Alt → tab → group → a dropdown control's letter OPENS its dropdown and badges the dropdown's controls. + public void Ribbon_DropDownControl_DrillsIntoItsDropdown() + { + using var host = NewHost(); + var controller = host.Application.EnableKeyTips(); + + var pasteSpecial = new BarButton { Content = "_Special" }; + var pasteValues = new BarButton { Content = "_Values" }; + var dropContent = new StackPanel { Orientation = Orientation.Vertical, Children = { pasteSpecial, pasteValues } }; + var paste = new BarPopupButton { Content = "_Paste", DropDownContent = dropContent }; + var ran = false; + pasteValues.Click += (_, _) => ran = true; + + var ribbon = new Ribbon(); + var home = new RibbonTab { Header = "Home" }; + var clipboard = new RibbonGroup { Header = "Clipboard" }; + clipboard.Items.Add(paste); + home.Groups.Add(clipboard); + ribbon.Items.Add(home); + host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { ribbon, new TextBox() } }); + host.RunUntilIdle(); + + AltDown(host); + TypeKeyTip(host, 'H'); + SettleLevel(host, controller, 2); + TypeKeyTip(host, 'C'); + SettleLevel(host, controller, 3); + TypeKeyTip(host, 'P'); // the dropdown opens, its controls get badges + SettleLevel(host, controller, 4); + Assert.True(paste.IsDropDownOpen); + Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(pasteValues)!.Visibility); + Assert.True(TopPopupIndex(host) >= 0 && OverlayIndex(host) > TopPopupIndex(host), "the badge overlay stacks above the dropdown popup"); + + TypeKeyTip(host, 'V'); + host.RunUntilIdle(); + Assert.True(ran); + Assert.False(controller.IsActive); + } + + [Fact] // Toolbar: the overflow chevron carries badge 0; it opens the overflow and the pocketed control gets its badge. + public void Toolbar_OverflowChevron_DrillsIntoTheOverflow() + { + using var host = NewHost(); + var controller = host.Application.EnableKeyTips(); + + var visible = new BarButton { Content = "Xut" }; + var overflowed = new BarButton { Content = "Zettings" }; + Toolbar.SetOverflowMode(overflowed, ToolbarOverflowMode.Always); + var toolbar = new Toolbar(); + toolbar.Items.Add(visible); + toolbar.Items.Add(overflowed); + var ran = false; + overflowed.Click += (_, _) => ran = true; + host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { toolbar, new TextBox() } }); + host.RunUntilIdle(); + Assert.True(toolbar.HasOverflow); + + AltDown(host); + Assert.NotNull(controller.BadgeForTargetForTests(toolbar.OverflowToggleForTests!)); + TypeKeyTip(host, '0'); // open the overflow + SettleLevel(host, controller, 2); + Assert.True(toolbar.IsOverflowOpen); + Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(overflowed)!.Visibility); + + TypeKeyTip(host, 'Z'); + host.RunUntilIdle(); + Assert.True(ran); + Assert.False(controller.IsActive); + } + + [Fact] // Scoping: with a window ACTIVE over the root ribbon, Alt badges the window's bar, not the ribbon behind it. + public void ActiveWindow_ScopesTheOverlay_ToItsOwnBars() + { + using var host = NewHost(); + var controller = host.Application.EnableKeyTips(); + + var ribbon = new Ribbon(); + var home = new RibbonTab { Header = "Home" }; + home.Groups.Add(new RibbonGroup { Header = "Font", Items = { new Button { Content = "Bold" } } }); + ribbon.Items.Add(home); + host.ShowRoot(ribbon); + host.RunUntilIdle(); + + var dialogButton = new BarButton { Content = "Xut" }; + var window = new Window { Content = new StackPanel { Orientation = Orientation.Vertical, Children = { new Toolbar { Items = { dialogButton } }, new TextBox() } } }; + window.Show(host.Application.WindowManager!); + host.RunUntilIdle(); + + AltDown(host); + Assert.True(controller.IsActive); + Assert.NotNull(controller.BadgeForTargetForTests(dialogButton)); // the dialog's bar is badged… + Assert.Null(controller.BadgeForTargetForTests(home)); // …the ribbon behind it is not + } +} diff --git a/Cursorial.UI.Bars.Tests/KeyTipTests.cs b/Cursorial.UI.Bars.Tests/KeyTipTests.cs index 38d61a07..12649fe6 100644 --- a/Cursorial.UI.Bars.Tests/KeyTipTests.cs +++ b/Cursorial.UI.Bars.Tests/KeyTipTests.cs @@ -205,6 +205,84 @@ public void Bonk_ConsumesChar_NoLeak() Assert.Equal("", box.Text ?? ""); // the char never reached the focused TextBox } + [Fact] // Regression (maintainer, 2026-09-09): a letter with no badge at the CURRENT level must not fall through to the + // inline access-key stage, which matches `_x` access keys on every surface whatever level the overlay shows — + // "Alt, P" fired a `_Paste` button two levels down, "Alt, H, B" fired `_Bold` instead of drilling a group. + public void UnmatchedLetter_DoesNotFireAnInlineAccessKey_ElsewhereOnTheRibbon() + { + using var host = NewHost(HeadlessCapabilities.KittyTruecolor); + var controller = host.Application.EnableKeyTips(); + var (ribbon, _, _) = NewRibbon(); + var paste = new Button { Content = "_Paste" }; // access key P, in Home ▸ Font — no P badge at the ROOT level + ((RibbonGroup)((RibbonTab)ribbon.Items[0]).Groups[0]).Items.Add(paste); + var pasted = false; + paste.Click += (_, _) => pasted = true; + host.ShowRoot(ribbon); + host.RunUntilIdle(); + + AltDown(host); + TypeKeyTip(host, 'P'); // bonk at the root (tabs H / I) + host.RunUntilIdle(); + + Assert.False(pasted); // the access key did NOT fire + Assert.True(controller.IsActive); // the overlay is still up, at the root + Assert.Equal(1, controller.LevelDepthForTests); + } + + [Fact] // The same in STICKY mode (Alt tapped, then plain letters): an unmatched letter neither fires a hidden-level + // access key nor leaks into the focused TextBox — it is the menu-mode bonk. + public void UnmatchedLetter_InStickyMode_NoAccessKey_NoLeak() + { + using var host = NewHost(HeadlessCapabilities.KittyTruecolor); + var controller = host.Application.EnableKeyTips(); + var (ribbon, _, _) = NewRibbon(); + var paste = new Button { Content = "_Paste" }; + ((RibbonGroup)((RibbonTab)ribbon.Items[0]).Groups[0]).Items.Add(paste); + var pasted = false; + paste.Click += (_, _) => pasted = true; + var box = new TextBox(); + var root = new StackPanel { Orientation = Orientation.Vertical, Children = { ribbon, box } }; + host.ShowRoot(root); + host.RunUntilIdle(); + host.Application.FocusManager.SetFocus(box); + + host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.Alt)); // tap… + host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.None, kind: KeyEventKind.Up)); // …sticky + Assert.True(controller.IsActive); + host.Application.InputDispatcher.ProcessEvent(Key_(Key.Character, KeyModifiers.None, "p")); // plain 'p' + host.RunUntilIdle(); + + Assert.False(pasted); + Assert.Equal("", box.Text ?? ""); + Assert.True(controller.IsActive); + } + + [Fact] // Regression (maintainer, 2026-09-09): "Alt, H, B" typed FAST — the group letter arrives before the tab's group + // level has been built at the post-layout hook. It must be queued for THAT level (and drill it), not matched + // against the root level it was never meant for. + public void LetterTypedBeforeTheParkedLevelBuilds_IsReplayedIntoIt() + { + using var host = NewHost(HeadlessCapabilities.KittyTruecolor); + var controller = host.Application.EnableKeyTips(); + var (ribbon, _, table) = NewRibbon(); + var clicked = false; + table.Click += (_, _) => clicked = true; + host.ShowRoot(ribbon); + host.RunUntilIdle(); + + AltDown(host); + TypeKeyTip(host, 'I'); // drill Insert — its group level is PARKED until the next layout hook + TypeKeyTip(host, 'A'); // typed ahead: the "Absble" group's letter + TypeKeyTip(host, 'E'); // typed ahead: the "Edd" control's letter + Assert.Equal(1, controller.LevelDepthForTests); + host.RunFrame(); // L1 builds → 'A' replays (drills, parks L2) + host.RunFrame(); // L2 builds → 'E' replays (activates) + host.RunUntilIdle(); + + Assert.True(clicked); + Assert.False(controller.IsActive); + } + [Fact] // A global gesture survives while KeyTips is active (a Ctrl chord falls through PreProcessInput). public void GlobalGesture_SurvivesWhileActive() { @@ -268,6 +346,47 @@ public void AltGesture_OpensWindow_KeepsFocusInNewWindow() Assert.Same(newButton, host.Application.FocusManager.FocusedElement); // …and KEEPS it (not restored to box) } + [Fact] // Regression (maintainer, 2026-09-09): an Alt-bearing gesture that moves focus WITHIN the active root — a file + // dialog's Alt+P focusing its Places rail — must not be undone by the overlay's focus-restore on Alt-release. + // The snapshot is only put back when focus is still there or parked on a bar surface by menu mode. (The + // reported dialog had no bar of its own: the overlay armed on the RIBBON BEHIND it — badges now scope to the + // active root, so that case no longer arms at all; this pins the restore rule for a dialog that has a bar.) + public void AltGesture_MovesFocusInTheActiveRoot_FocusStaysThere() + { + using var host = NewHost(HeadlessCapabilities.KittyTruecolor); + var controller = host.Application.EnableKeyTips(); + + var (ribbon, _, _) = NewRibbon(); // root badges H / I — 'P' is not one + host.ShowRoot(ribbon); + host.RunUntilIdle(); + + // A dialog window with its own toolbar (so the overlay arms IN the dialog — badges scope to the active root), + // a list-like TextBox (focused) and a Places button; Alt+P focuses the Places button. + var list = new TextBox(); + var places = new Button { Content = "Places" }; + var toolbar = new Toolbar { Items = { new BarButton { Content = "Xut" } } }; // 'X' — 'P' is not a badge + var window = new Window { Content = new StackPanel { Orientation = Orientation.Vertical, Children = { toolbar, list, places } } }; + window.InputBindings.Add(new KeyBinding( + new KeyGesture(Key.Character, KeyModifiers.Alt, "P"), + new BarCommand(() => places.Focus(FocusNavigationMethod.AccessKey)))); + window.Show(host.Application.WindowManager!); + host.RunUntilIdle(); + host.Application.FocusManager.SetFocus(list); + Assert.Same(list, host.Application.FocusManager.FocusedElement); + + AltDown(host); // arms KeyTips on the dialog's toolbar; snapshot = list + Assert.True(controller.IsActive); + TypeKeyTip(host, 'P'); // no 'P' badge → falls through → the dialog's binding focuses Places + host.RunUntilIdle(); + Assert.Same(places, host.Application.FocusManager.FocusedElement); + + host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.None, kind: KeyEventKind.Up)); // cue off → exit + host.RunUntilIdle(); + + Assert.False(controller.IsActive); + Assert.Same(places, host.Application.FocusManager.FocusedElement); // NOT bounced back to the list + } + [Fact] // Badges stay glued to their targets when the ribbon SCROLLS inside a ScrollViewer (an in-band composite // slide moves the tabs; the badges must follow via TranslateToScreen's scroll-offset walk). public void Badges_TrackTarget_WhenRibbonScrolls() diff --git a/Cursorial.UI.Bars/Input/KeyTipController.cs b/Cursorial.UI.Bars/Input/KeyTipController.cs index 8f250f28..68589c96 100644 --- a/Cursorial.UI.Bars/Input/KeyTipController.cs +++ b/Cursorial.UI.Bars/Input/KeyTipController.cs @@ -39,6 +39,11 @@ public sealed class KeyTipController : IKeyTipController, IKeyTipLayoutHook private int _parkedGeneration; private int _parkedRetries; + // Drill letters typed while a next level is still parked (a fast "Alt, H, B" lands 'B' before the Home band's + // group level has been built at the post-layout hook). They are consumed at once and replayed, in order, the + // moment the level exists — never dropped, never matched against the level they were NOT typed for. + private readonly Queue _pendingChars = new(); + internal KeyTipController(UIApplication app) { _app = app; @@ -76,9 +81,13 @@ private void OnPreProcessInput(object? sender, InputEventArgs e) if (!_isActive || e is not KeyEventArgs k || k.Device.Kind != KeyEventKind.Down) return; - // Consume drill letters + digits while active; Ctrl/Super/… chords fall through so global gestures (Ctrl+S) - // still fire. Escape is handled EARLIER by AccessKeyManager's Alt pre-stage via TryPopLevel (it runs before - // this seam), so there is no Escape branch here. + // Consume a drill letter/digit the current level ACCEPTS (a match, a still-ambiguous prefix, or a letter typed + // ahead of a parked level). A bonk (no badge starts with it) falls through so an Alt-bearing app gesture + // (a KeyBinding on Alt+N, Alt+/) still fires while Alt is held; the inline access-key stage, which would + // otherwise match `_x` access keys on every surface whatever level the overlay shows, stands down while the + // overlay is active (AccessKeyManager.ProcessKeyDown) — the badges are the only accelerators then. Ctrl/Super/… + // chords fall through untouched so global gestures (Ctrl+S) fire. Escape is handled EARLIER by + // AccessKeyManager's Alt pre-stage via TryPopLevel (it runs before this seam), so there is no Escape branch here. if (TryGetDrillChar(k, out var c) && TypeChar(c)) e.Handled = true; } @@ -142,6 +151,7 @@ public void Exit() _isActive = false; _parkedBuild = null; _parkedRetract = null; + _pendingChars.Clear(); _levelGeneration++; // orphan any parked build/pop in flight _app.WindowManager?.HideKeyTipOverlay(); @@ -163,12 +173,19 @@ public void Exit() _exitViaActivation = false; } - // Typing a badge letter: filter the current level by the case-folded prefix. + // Typing a badge letter: filter the current level by the case-folded prefix. A letter typed while the next level + // is still parked belongs to THAT level — queue it (replayed by CompletePendingLayout once the level is built). private bool TypeChar(char c) { if (_stack.Count == 0) return false; + if (_parkedBuild is not null) + { + _pendingChars.Enqueue(c); + return true; + } + var level = _stack[^1]; level.Typed.Append(char.ToUpperInvariant(c)); var prefix = level.Typed.ToString(); @@ -197,7 +214,8 @@ private bool TypeChar(char c) return true; } - // Still ambiguous (multi-char keytips): dim the matched prefix on the viable badges, hide the rest. + // Still ambiguous (multi-char keytips): dim the matched prefix on the viable badges, hide the rest. The letter + // was accepted into the prefix — consumed (it is not a bonk). foreach (var entry in level.Entries) { var isViable = entry.KeyTip.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); @@ -209,8 +227,17 @@ private bool TypeChar(char c) if (isViable) badge.MatchedPrefixLength = prefix.Length; } - - return false; + + return true; + } + + // Feeds the letters typed while the just-pushed level was parked. A replayed letter can itself commit a drill + // (parking the NEXT level) or activate a leaf (exiting): either stops the replay — a later letter then waits for + // that level, or was typed for nothing. + private void ReplayPendingChars() + { + while (_isActive && _parkedBuild is null && _pendingChars.TryDequeue(out var c)) + TypeChar(c); } private static void Bonk(KeyTipLevel level) @@ -252,6 +279,7 @@ public bool TryPopLevel() _parkedBuild = null; // cancel any parked deeper build _parkedRetract = null; + _pendingChars.Clear(); _levelGeneration++; ShowLevel(_stack[^1]); // re-place the parent level's badges @@ -277,6 +305,7 @@ public void CompletePendingLayout() _parkedBuild = null; _parkedRetract = null; ShowLevel(level); + ReplayPendingChars(); // the letters typed ahead of this level, in order } else if (++_parkedRetries >= MaxParkRetries) { @@ -286,6 +315,7 @@ public void CompletePendingLayout() _parkedBuild = null; _parkedRetract?.Invoke(); _parkedRetract = null; + _pendingChars.Clear(); // typed for a level that never came — dropped, not mis-matched ShowLevel(_stack[^1]); } } @@ -296,6 +326,7 @@ public void CompletePendingLayout() // leave the badges trailing their targets by a frame. if (_stack.Count > 0) { + AnchorOverlay(_stack[^1]); PlaceBadges(_stack[^1]); _app.WindowManager?.RunKeyTipOverlayLayout(); } @@ -320,9 +351,47 @@ private void ShowLevel(KeyTipLevel level) _layer.Children.Add(badge); } + AnchorOverlay(level); PlaceBadges(level); } + // Re-stacks the overlay directly above the surface the level's targets live on — the root for a root bar, a + // window for a window's bar, the popup for a drilled submenu / dropdown / flyout / overflow — so the badges paint + // over what they annotate and stay under anything opened on top of it (keytips-design §8, the v2 popup drill). + // The TOPMOST of the targets' surfaces wins when a level straddles surfaces. + private void AnchorOverlay(KeyTipLevel level) + { + if (_app.WindowManager is not { } wm) + return; + + TopLevelSurface? topmost = null; + var topmostIndex = -1; + var surfaces = wm.Surfaces; + foreach (var entry in level.Entries) + { + if (wm.SurfaceForElement(entry.Target) is not { } surface) + continue; + + var index = -1; + for (var i = 0; i < surfaces.Count; i++) + { + if (ReferenceEquals(surfaces[i], surface)) + { + index = i; + break; + } + } + + if (index > topmostIndex) + { + topmostIndex = index; + topmost = surface; + } + } + + wm.AnchorKeyTipOverlay(topmost); + } + // Positions each visible badge at its target's screen cell. A badge is HIDDEN (not stranded at some bogus cell) // when: the prefix filter dropped it (entry.Hidden); its target is no longer on a live rendered surface — it // detached (a page navigation) or moved into a closed popup (a toolbar-overflowed control), so it has no real @@ -337,13 +406,12 @@ private void PlaceBadges(KeyTipLevel level) if (entry.Badge is not { } badge) continue; - // The target must be visible AND on a live, NON-popup surface. v1 badges only the root/window bar - // surfaces (a ribbon/toolbar/menu bar); it does not drill INTO popups, so a target that has moved into a - // popup — an OPEN overflow menu / dropdown / submenu — is not badged. (The single overlay sits just above - // the root, so a popup-surface badge would render BENEATH its own translucent menu and bleed through; the - // proper fix for v2 popup-drilling is a per-surface badge layer positioned above each target's surface.) + // The target must be visible AND on a live surface (root, window, or an opened popup — the overlay is + // re-stacked above the level's surface by AnchorOverlay, so a popup-level badge paints over its popup). A + // target off every surface — detached by a page navigation, or pocketed into a CLOSED overflow popup — has + // no real position and would otherwise land at the bar origin. var onBadgeableSurface = entry.Target.IsEffectivelyVisible - && (_app.WindowManager is not { } wm || wm.SurfaceForElement(entry.Target) is { IsPopup: false }); + && (_app.WindowManager is not { } wm || wm.SurfaceForElement(entry.Target) is not null); var (anchorColumn, anchorRow) = AnchorCell(entry); var (column, row) = entry.Target.TranslateToScreen(anchorColumn, anchorRow); @@ -377,8 +445,9 @@ private static (int Column, int Row) AnchorCell(KeyTipEntry entry) // ───────────────────────────── discovery / activation / focus ───────────────────────────── - // Level 0: discover the bar-surface hosts under every non-overlay surface root (the app root surface AND any - // window — a ribbon may live in a top-level root, not only a Window) and aggregate their root entries. + // Level 0: discover the bar-surface hosts under the ACTIVE root (the active window's root, else the app root — + // a ribbon may live in either) and aggregate their root entries. Bars behind a modal window are not badged: the + // overlay scopes to the surface the user is working in, like the access keys do. private KeyTipLevel BuildRootLevel() { var hosts = new List(); @@ -394,6 +463,12 @@ private KeyTipLevel BuildRootLevel() private IEnumerable ActiveSurfaceRoots() { + if (_app.FocusManager.ActiveRoot is { } activeRoot) + { + yield return activeRoot; + yield break; + } + if (_app.WindowManager is not { } wm) { if (_app.RootElement is { } root) @@ -457,6 +532,31 @@ private void RestoreFocus() if (_app.FocusManager.ActiveRoot is { } activeRoot && !activeRoot.IsAncestorOf(snapshot)) return; + // …and only if focus is still where the overlay left it, or parked on a bar surface by menu mode (the Alt + // tap focuses the menu bar's first item — that is the move this restore undoes). Focus that an app gesture + // moved ELSEWHERE while the overlay was up is the app's decision and stands: a file dialog's Alt+P puts + // focus on its Places rail, and restoring the snapshot on Alt-release bounced it straight back to the file + // list (maintainer, 2026-09-09). + var focused = _app.FocusManager.FocusedElement; + if (focused is not null && !ReferenceEquals(focused, snapshot) && !IsOnBarSurface(focused)) + return; + _app.FocusManager.SetFocus(snapshot, FocusNavigationMethod.Restore); } + + // Whether the element sits inside a KeyTip host surface (a ribbon / toolbar / menu bar) under the active roots. + private bool IsOnBarSurface(UIElement element) + { + var hosts = new List(); + foreach (var root in ActiveSurfaceRoots()) + KeyTipTree.CollectHosts(root, hosts); + + foreach (var host in hosts) + { + if (ReferenceEquals(host.SurfaceElement, element) || host.SurfaceElement.IsAncestorOf(element)) + return true; + } + + return false; + } } diff --git a/Cursorial.UI.Bars/Input/KeyTipPopupLevels.cs b/Cursorial.UI.Bars/Input/KeyTipPopupLevels.cs new file mode 100644 index 00000000..74056330 --- /dev/null +++ b/Cursorial.UI.Bars/Input/KeyTipPopupLevels.cs @@ -0,0 +1,80 @@ +using Cursorial.UI.Controls; + +namespace Cursorial.UI.Bars.Input; + +/// +/// KeyTip levels over OPENED popup surfaces (keytips-design §6's deferred v2 leg, built 2026-09-09): a menu's +/// submenu, a bar dropdown / split / popup button's dropdown, a collapsed ribbon group's flyout, the collapsed QAT, +/// the toolbar overflow. One rule serves them all: the popup's content is walked for its accelerator-eligible +/// controls (); a control that opens a further popup (a submenu +/// header, a nested dropdown) is a whose next level is built the same +/// way, everything else activates. A level built before the popup's content has realized is null, so the controller +/// retries at the next post-layout hook (the park-until-popup-surface leg). Esc pops a level and its retract closes +/// the popup it opened. +/// +internal static class KeyTipPopupLevels +{ + /// Adds to as a leaf or, when it opens a popup, as a drill into that popup. + public static void Add(KeyTipLevelBuilder into, UIElement target) + { + switch (target) + { + case MenuItem { HasItems: true } header: + into.AddDrill( + header, KeyTipTargetKind.DrillPopup, + reveal: () => KeyTipController.ActivateLeaf(header), // the access-key open: submenu + focus into it + buildNext: () => BuildOverItems(header), + retract: () => header.IsSubmenuOpen = false); + break; + + case BarDropDownButton opener: + into.AddDrill( + opener, KeyTipTargetKind.DrillPopup, + reveal: () => opener.IsDropDownOpen = true, + buildNext: () => BuildOver(opener.DropDownContent as UIElement), + retract: () => opener.IsDropDownOpen = false); + break; + + default: + into.AddActivate(target, () => KeyTipController.ActivateLeaf(target)); + break; + } + } + + /// The level over an opened submenu's rows (null until the rows have realized). + public static KeyTipLevel? BuildOverItems(MenuItem header) + { + var builder = new KeyTipLevelBuilder(); + var items = header.ItemContainerGenerator; + for (var i = 0; i < items.ContainerCount; i++) + { + if (items.ContainerFromIndex(i) is { } row && row.IsEffectivelyVisible && HasSurface(row)) + Add(builder, row); + } + + return builder.HasPending ? builder.Build() : null; + } + + /// The level over an opened popup's (null when there is no content, or none of it has realized on a surface yet). + public static KeyTipLevel? BuildOver(UIElement? content) + { + if (content is null) + return null; + + var targets = new List(); + KeyTipTree.CollectAccessKeyTargets(content, targets); + + var builder = new KeyTipLevelBuilder(); + foreach (var target in targets) + { + if (HasSurface(target)) + Add(builder, target); + } + + return builder.HasPending ? builder.Build() : null; + } + + // A popup's content has a screen position only once its surface exists; before that a badge could not be placed. + private static bool HasSurface(UIElement element) + => UIApplication.Current?.WindowManager is not { } wm || wm.SurfaceForElement(element) is not null; +} diff --git a/Cursorial.UI.Bars/Input/KeyTipTree.cs b/Cursorial.UI.Bars/Input/KeyTipTree.cs index fe0c9391..28706cc6 100644 --- a/Cursorial.UI.Bars/Input/KeyTipTree.cs +++ b/Cursorial.UI.Bars/Input/KeyTipTree.cs @@ -1,4 +1,5 @@ using Cursorial.UI.Controls; +using Cursorial.UI.Input; namespace Cursorial.UI.Bars.Input; @@ -31,6 +32,22 @@ public static void CollectHosts(UIElement root, List into) CollectHosts(root.GetVisualChild(i), into); } + /// Collects the accelerator-eligible controls under — every visible + /// that would answer an access key (a menu row, a bar button, a plain button) — + /// shallow-stopping at each (its own subtree is not re-scanned). The popup-level walk: an opened submenu, + /// dropdown, flyout or overflow popup hosts arbitrary content, and these are the things a badge can drive. + public static void CollectAccessKeyTargets(UIElement root, List into) + { + if (root is IAccessKeyTarget { IsAccessKeyEligible: true } && root.IsEffectivelyVisible) + { + into.Add(root); + return; + } + + for (var i = 0; i < root.VisualChildrenCount; i++) + CollectAccessKeyTargets(root.GetVisualChild(i), into); + } + /// Collects the realized s under (only the selected /// tab's band is realized in a TabControl, so this yields exactly the current tab's groups). public static void CollectGroups(UIElement root, List into) diff --git a/Cursorial.UI.Bars/Input/MenuKeyTipHost.cs b/Cursorial.UI.Bars/Input/MenuKeyTipHost.cs index 7966cbab..f2512ea5 100644 --- a/Cursorial.UI.Bars/Input/MenuKeyTipHost.cs +++ b/Cursorial.UI.Bars/Input/MenuKeyTipHost.cs @@ -4,11 +4,10 @@ namespace Cursorial.UI.Bars.Input; /// -/// The menu-bar KeyTip host (keytips-design §6) — single level in v1: one -/// badge per top-level . Activating opens the item's submenu (its -/// , matching the current access-key behavior), then exits — the user -/// walks the open submenu with arrows. Submenu KeyTip drill is deferred to v2 (the same park-until-popup-surface -/// dependency as the toolbar overflow). +/// The menu-bar KeyTip host (keytips-design §6): one badge per top-level . A header with a +/// submenu is a — choosing it opens the submenu (its access-key open, +/// focus into it) and pushes a level over the submenu's rows, nested submenus drilling the same way and a leaf row +/// activating (, 2026-09-09); a top-level item without a submenu activates. /// internal sealed class MenuKeyTipHost(Menu menu) : IKeyTipHost { @@ -24,8 +23,7 @@ public void BuildRootLevel(KeyTipLevelBuilder into) if (items.ContainerFromIndex(i) is not MenuItem item || item is not IAccessKeyTarget { IsAccessKeyEligible: true }) continue; - var leaf = item; - into.AddActivate(item, () => KeyTipController.ActivateLeaf(leaf)); + KeyTipPopupLevels.Add(into, item); } } } diff --git a/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs b/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs index 6fafb39f..f09b99a4 100644 --- a/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs +++ b/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs @@ -10,10 +10,9 @@ namespace Cursorial.UI.Bars.Input; /// Level 2 badges the group's leaf controls + the ⋰ dialog launcher. /// /// -/// v1 scope: the bar-control dropdown drill, the collapsed-group flyout drill, and the collapsed-QAT (⋯▾) drill are -/// treated as (open the surface, then exit — the user navigates it with -/// arrows). Drilling a KeyTip level over an opened popup surface is the park-until-popup-surface leg deferred to v2 -/// (keytips-design §6/§14/Risk 3); the ribbon L0→L1→L2 drill already proves the parked-layout machinery. +/// Popup drills (2026-09-09): a dropdown-bearing bar control, a collapsed group's flyout opener and the collapsed +/// QAT's ⋯▾ opener are entries — choosing one opens its popup and pushes a +/// level over the popup's controls (), the badges re-anchored above that popup. /// internal sealed class RibbonKeyTipHost(Ribbon ribbon) : IKeyTipHost { @@ -67,9 +66,23 @@ public void BuildRootLevel(KeyTipLevelBuilder into) } } - // The collapsed-QAT opener (⋯▾): v1 opens the flyout then exits (drill deferred). + // The collapsed-QAT opener (⋯▾) drills into its flyout (the re-hosted QAT commands). if (ribbon is { IsQuickAccessCollapsedForTests: true, QatCollapsedButtonForTests: { } opener }) - into.AddExplicit(opener, "0", KeyTipTargetKind.Activate, activate: () => KeyTipController.ActivateLeaf(opener), reveal: null); + { + if (opener is BarDropDownButton dropOpener) + { + into.AddExplicit( + opener, "0", KeyTipTargetKind.DrillPopup, + activate: null, + reveal: () => dropOpener.IsDropDownOpen = true, + buildNext: () => KeyTipPopupLevels.BuildOver(dropOpener.DropDownContent as UIElement), + retract: () => dropOpener.IsDropDownOpen = false); + } + else + { + into.AddExplicit(opener, "0", KeyTipTargetKind.Activate, activate: () => KeyTipController.ActivateLeaf(opener), reveal: null); + } + } } // L1: the selected tab's groups (each drills to its controls). Returns null until the band's groups realize @@ -86,8 +99,12 @@ public void BuildRootLevel(KeyTipLevelBuilder into) { if (group is { Density: RibbonGroupDensity.Collapsed, KeyTipCollapsedButton: { } collapsedOpener }) { - // A collapsed group: v1 opens its flyout then exits (drill into the flyout deferred to v2). - builder.AddActivate(group, () => KeyTipController.ActivateLeaf(collapsedOpener)); + // A collapsed group: its badge (the group letter) opens the flyout and drills into the group's controls. + builder.AddDrill( + group, KeyTipTargetKind.DrillPopup, + reveal: () => collapsedOpener.IsDropDownOpen = true, + buildNext: () => KeyTipPopupLevels.BuildOver(collapsedOpener.DropDownContent as UIElement), + retract: () => collapsedOpener.IsDropDownOpen = false); } else { @@ -121,8 +138,7 @@ public void BuildRootLevel(KeyTipLevelBuilder into) /// A control's surviving KeyTip letter (from its group's control level), or null. internal static string? ResolveControlKeyTip(RibbonGroup group, UIElement control) => BuildControlLevel(group)?.KeyTipFor(control); - // L2: a group's leaf controls + its ⋰ dialog launcher. A dropdown/split control opens on activation (drill over - // its items deferred to v2). + // L2: a group's leaf controls + its ⋰ dialog launcher. A dropdown-bearing control drills into its dropdown. private static KeyTipLevel? BuildControlLevel(RibbonGroup group) { var builder = new KeyTipLevelBuilder(); @@ -132,8 +148,7 @@ public void BuildRootLevel(KeyTipLevelBuilder into) if (control is not IAccessKeyTarget { IsAccessKeyEligible: true }) continue; - var leaf = control; - builder.AddActivate(control, () => KeyTipController.ActivateLeaf(leaf)); + KeyTipPopupLevels.Add(builder, control); } if (group.KeyTipDialogLauncher is { } launcher) diff --git a/Cursorial.UI.Bars/Input/ToolbarKeyTipHost.cs b/Cursorial.UI.Bars/Input/ToolbarKeyTipHost.cs index 30608666..81958a9f 100644 --- a/Cursorial.UI.Bars/Input/ToolbarKeyTipHost.cs +++ b/Cursorial.UI.Bars/Input/ToolbarKeyTipHost.cs @@ -3,10 +3,11 @@ namespace Cursorial.UI.Bars.Input; /// -/// The toolbar KeyTip host (keytips-design §6) — single level in v1. One -/// badge per realized, eligible bar control in the visible row. An overflowed control (pocketed into the ⋯/» popup) -/// is not eligible (its is false while hidden), so it gets no -/// badge — the overflow-drill is deferred to v2 (documented). +/// The toolbar KeyTip host (keytips-design §6). One badge per realized, eligible bar control in the visible row — a +/// dropdown-bearing control drills into its dropdown. An overflowed control (pocketed into the ⋯/» popup) is not +/// eligible while hidden, so it gets no badge of its own; instead the overflow chevron carries the 0 badge +/// (the digit convention of the ribbon's collapsed QAT) and drills into the overflow popup, where the pocketed +/// controls are badged (, 2026-09-09). /// internal sealed class ToolbarKeyTipHost(Toolbar toolbar) : IKeyTipHost { @@ -22,8 +23,19 @@ public void BuildRootLevel(KeyTipLevelBuilder into) if (items.ContainerFromIndex(i) is not { } control || control is not IAccessKeyTarget { IsAccessKeyEligible: true }) continue; - var leaf = control; - into.AddActivate(control, () => KeyTipController.ActivateLeaf(leaf)); + KeyTipPopupLevels.Add(into, control); + } + + // The overflow chevron: present (visible) only when something is pocketed. Its popup hosts the overflowed + // controls; the level over them is built once the popup surface exists. + if (toolbar.OverflowToggleForTests is { IsEffectivelyVisible: true } chevron) + { + into.AddExplicit( + chevron, "0", KeyTipTargetKind.DrillPopup, + activate: null, + reveal: () => toolbar.IsOverflowOpen = true, + buildNext: () => KeyTipPopupLevels.BuildOver(toolbar.OverflowHostForTests), + retract: () => toolbar.IsOverflowOpen = false); } } diff --git a/Cursorial.UI/Input/AccessKeyManager.cs b/Cursorial.UI/Input/AccessKeyManager.cs index 74bbf3c4..91b582cc 100644 --- a/Cursorial.UI/Input/AccessKeyManager.cs +++ b/Cursorial.UI/Input/AccessKeyManager.cs @@ -648,6 +648,15 @@ internal bool ProcessKeyDown(KeyEvent key, bool cueWindowWasOpen) var folded = char.ToLowerInvariant(key.Text.Span[0]); + // While the KeyTip overlay is up, its badges are the ONLY accelerators: an inline access key must not + // activate on a letter the overlay's current level did not claim. Access keys live on every surface + // whatever level the overlay shows, so "Alt, P" with no P badge at the root fired a `_Paste` button two + // levels down, and "Alt, H, B" fired `_Bold` instead of drilling the B group (maintainer, 2026-09-09). The + // letter is otherwise treated as a menu-mode bonk: consumed in sticky mode (it must not leak into text + // input), left to the remaining tails while Alt is physically held (the Alt bit keeps it out of TextInput). + if (_keyTipController is { IsActive: true }) + return _stickyCue; + CollectEligibleMatches(folded); if (_matchScratch.Count == 0) diff --git a/Cursorial.UI/Windowing/WindowManager.cs b/Cursorial.UI/Windowing/WindowManager.cs index 82826a87..48290523 100644 --- a/Cursorial.UI/Windowing/WindowManager.cs +++ b/Cursorial.UI/Windowing/WindowManager.cs @@ -68,7 +68,8 @@ public sealed class WindowManager : ILayoutSystem, IRenderSystem, IWindowSystem, private string? _lastEmittedTitle; // the last title mirrored to the terminal (OSC 2 change detector) private TopLevelSurface? _fitBadgeSurface; // the WM-owned fit-to-viewport badge (top-right; §8.7) private bool _fitBadgeVisible; - private TopLevelSurface? _keyTipSurface; // the Bars KeyTip badge overlay (topmost; keytips-design §8) + private TopLevelSurface? _keyTipSurface; // the Bars KeyTip badge overlay (keytips-design §8) + private TopLevelSurface? _keyTipAnchorSurface; // the surface the overlay sits directly above (null = the root) /// /// Creates the window manager. is the user-code funnel routed to every @@ -1257,29 +1258,58 @@ private void RebuildSurfaceStack() if (_rootSurface is not null) _surfaces.Add(_rootSurface); - // The KeyTip overlay sits directly ABOVE the root surface (its badges annotate the root's bars) but BELOW - // windows and popups — so a modal Backstage window / File-anchored popup opened over the ribbon OCCLUDES the - // badges rather than the badges bleeding on top of it (keytips-design §8). v1 badges only root/window bar - // surfaces; a v2 dropdown-drill (badging an opened popup's items) will need a per-popup badge layer. - if (_keyTipSurface is {} keyTips) + // The KeyTip overlay sits directly ABOVE the surface whose bars it is badging — the root by default, so a + // modal Backstage window / File-anchored popup opened over the ribbon OCCLUDES the badges rather than the + // badges bleeding on top of it (keytips-design §8) — and, while a drill has descended into a window's bar or + // an opened popup (a menu's submenu, a dropdown, the toolbar overflow), directly above THAT surface, so its + // badges paint over the popup they annotate and stay below anything opened on top of it. + var anchor = _keyTipAnchorSurface is {} a && (ReferenceEquals(a, _rootSurface) || IsHosted(a)) ? a : _rootSurface; + if (_keyTipSurface is {} keyTips && ReferenceEquals(anchor, _rootSurface)) _surfaces.Add(keyTips); foreach (var window in _windows) { if (window.HostSurface is {} surface) + { _surfaces.Add(surface); + if (_keyTipSurface is {} k && ReferenceEquals(anchor, surface)) + _surfaces.Add(k); + } } foreach (var popup in _popups) { if (popup.PopupSurface is {} surface) + { _surfaces.Add(surface); + if (_keyTipSurface is {} k && ReferenceEquals(anchor, surface)) + _surfaces.Add(k); + } } if (_fitBadgeSurface is {} badge) // the fit badge sits above everything (§8.7) _surfaces.Add(badge); } + // Whether a surface is one of the window/popup surfaces this manager currently hosts (a stale anchor — its popup + // closed — falls back to the root). + private bool IsHosted(TopLevelSurface surface) + { + foreach (var window in _windows) + { + if (ReferenceEquals(window.HostSurface, surface)) + return true; + } + + foreach (var popup in _popups) + { + if (ReferenceEquals(popup.PopupSurface, surface)) + return true; + } + + return false; + } + // ── Popup hosting (P7-W4: light-dismiss surfaces in the band above every window) ────────────────── /// Hosts 's child on a new popup surface, places it relative to the target @@ -1681,6 +1711,25 @@ internal void ShowKeyTipOverlay(UIElement badgeRoot) SurfacesChanged?.Invoke(); } + /// Re-stacks the KeyTip overlay directly above (null = the root surface): the + /// controller anchors the overlay to the surface the shown level's targets live on, so badges over an opened + /// popup paint above that popup (keytips-design §8's v2 popup drill). A no-op when the anchor is unchanged or no + /// overlay is showing. + internal void AnchorKeyTipOverlay(TopLevelSurface? surface) + { + var anchor = ReferenceEquals(surface, _rootSurface) ? null : surface; + if (ReferenceEquals(_keyTipAnchorSurface, anchor)) + return; + + _keyTipAnchorSurface = anchor; + if (_keyTipSurface is null) + return; + + RebuildSurfaceStack(); + ResetCompositor(); + SurfacesChanged?.Invoke(); + } + /// Re-arranges the KeyTip overlay surface within the current frame (a no-op when none is showing). The /// controller calls this right after re-anchoring badges in the post-layout hook so a scroll/move that shifts a /// target's screen cell repositions its badge THIS frame — the main layout pass already ran, so without this the @@ -1696,6 +1745,7 @@ internal void HideKeyTipOverlay() surface.Detach(); _keyTipSurface = null; + _keyTipAnchorSurface = null; RebuildSurfaceStack(); ResetCompositor(); diff --git a/docs/ui-layer-design/keytips-design.md b/docs/ui-layer-design/keytips-design.md index f23cefe0..59a2b218 100644 --- a/docs/ui-layer-design/keytips-design.md +++ b/docs/ui-layer-design/keytips-design.md @@ -167,15 +167,19 @@ interface IKeyTipHost { Because minimized/floated bands and collapsed groups realize over later frames (`ScheduleEnterFloatedBody`, `ScheduleCollapsedFocusRepair` precedents), the pushed level's **badge placement is parked** to `CompletePendingKeyTipLayout` with the same `_floatGeneration`-style guard (§9, Risk 5). -### Toolbar (single-level in v1; overflow-drill deferred) +### Toolbar -`ToolbarKeyTipHost.BuildRootLevel` = one `Activate` entry per realized bar control (walk the `ItemsControl` containers). All flat, no tab step. **Overflow-drill (the ⋯/» popup) is DEFERRED to v2** — v1 badges only the visible row; overflowed items get no badge (documented). Rationale: the overflow popup needs the park-until-popup-surface leg, which v1 proves once on the ribbon dropdown path and can extend later. +`ToolbarKeyTipHost.BuildRootLevel` = one entry per realized bar control (walk the `ItemsControl` containers) — a dropdown-bearing control is a `DrillPopup` — plus, when something is pocketed, the overflow chevron with the explicit `0` badge (the ribbon's collapsed-QAT digit convention): a `DrillPopup` whose reveal sets `IsOverflowOpen` and whose next level is built over the overflow host's controls (**built 2026-09-09**; it was the deferred v2 leg). -### Menu bar (single-level top items in v1; submenu-drill deferred) +### Menu bar -`MenuKeyTipHost.BuildRootLevel` = top-level `MenuItem` headers as `Activate` (each opens its submenu via the existing menu activation, then Exit) — matching the current access-key behavior. **Submenu KeyTip drill is DEFERRED to v2** (same park-until-popup-surface dependency as toolbar overflow). +`MenuKeyTipHost.BuildRootLevel` = top-level `MenuItem` headers; a header with a submenu is a `DrillPopup` (reveal = the item's access-key open — submenu + focus into it; next level = the submenu's realized rows via its `ItemContainerGenerator`, a nested submenu drilling the same way, a leaf row activating; retract closes the submenu), a top-level leaf activates (**built 2026-09-09**). -The one `KeyTipLevel`/`KeyTipEntry` type serves ribbon tab→group→control, and (v2) toolbar→overflow→items and menu→submenu→items — one accelerator model. +### Popup levels — one rule (`KeyTipPopupLevels`) + +Every popup drill builds its level the same way: the popup's content is walked for its accelerator-eligible controls (`KeyTipTree.CollectAccessKeyTargets` — visible `IAccessKeyTarget`s, shallow-stopping), a `MenuItem` with items or a `BarDropDownButton` becomes a further `DrillPopup`, everything else an `Activate`. A level built before the popup's content has a surface is null, so the controller's parked build retries at the next post-layout hook (the park-until-popup-surface leg). The collapsed ribbon group's flyout and the collapsed QAT's ⋯▾ opener use the same builder over their `DropDownContent`. + +The one `KeyTipLevel`/`KeyTipEntry` type serves ribbon tab→group→control→dropdown, toolbar→overflow→items and menu→submenu→items — one accelerator model. --- @@ -191,7 +195,7 @@ The one `KeyTipLevel`/`KeyTipEntry` type serves ribbon tab→group→control, an ## 8. The badge overlay mechanism (exact) -**Surface, not adorner** (the engine has no adorner layer; the fit badge is the proven precedent). One dedicated KeyTip `TopLevelSurface` at the **top of the surface stack**, above windows AND popups. +**Surface, not adorner** (the engine has no adorner layer; the fit badge is the proven precedent). One dedicated KeyTip `TopLevelSurface`, stacked **directly above the surface the shown level's targets live on** (`WindowManager.AnchorKeyTipOverlay`, 2026-09-09): the root for a root bar (so a modal window or a popup opened over the ribbon occludes the badges), a window for a window's bar, and the opened popup for a drilled submenu / dropdown / flyout / overflow — so popup-level badges paint over the popup they annotate and under anything opened on top of it. The controller re-anchors on every level show and in the post-layout hook (a popup surface appears a frame after its reveal). Level-0 discovery scopes to the **active root** (the active window's root, else the app root), like the access keys. **WM seam** (`WindowManager`, mirroring `_fitBadgeSurface`): ``` @@ -303,7 +307,9 @@ All headless; the `KittyTruecolor` preset gates `AltHeld`. Drive Alt via `SendBy **V1 (ship):** the `KeyTipController` FSM; `KeyTip.Key`/`AutoAssign` attached properties + `KeyTipModel` derivation ladder (explicit → access-key via `GetAccessTextInternal` → `BarCommand.Text` → first letter), auto-assign only-when-unique (first-wins + DEBUG diagnostic on collision, explicit multi-char keys honored); **full multi-level Ribbon drill** (tab→group→control, File→Backstage, drilling an opened bar-control dropdown, minimized/floated/collapsed realization via the parked layout hook); **single-level Toolbar** and **single-level Menu bar** (top items); the dedicated topmost WM KeyTip surface (`IsHitTestTransparent`) with amber `ThemeKeys.KeyTipBrush` tint via the R2 palette spine; matched-prefix highlight; Esc back-out + all exit conditions; capability gate reusing `AccessKeyMode`; `SuspendCue` mutual-exclusion; focus snapshot/restore via the ribbon's non-retaining scope; the PreProcessInput interception seam; headless tests per §13 row. -**Deferred (v2):** WPF FN/FP two-letter auto-assignment resolver; toolbar-overflow and menu-submenu KeyTip drill (the park-until-popup-surface leg — v1 proves parking once on the ribbon dropdown); the compose-both `:keytip-active` cue (badges on bars + underlines on plain content); Backspace-to-untype within a level; badge fade/animation; KeyTip badges on non-bars plain controls; a per-scope registry surviving re-templating without recompute; culture-aware letter selection beyond invariant folding; an AlwaysVisible-mode badge variant. +**Built after v1 (2026-09-09):** toolbar-overflow, menu-submenu, bar-dropdown, collapsed-group and collapsed-QAT KeyTip drills (`KeyTipPopupLevels`; the overlay re-anchored above the drilled popup); the inline access-key stage standing down while the overlay is up (a bonked letter no longer fires a hidden-level access key); letters typed ahead of a parked level replayed into it; the focus-restore leaving focus an app gesture moved. + +**Deferred (v2):** WPF FN/FP two-letter auto-assignment resolver; the compose-both `:keytip-active` cue (badges on bars + underlines on plain content); Backspace-to-untype within a level; badge fade/animation; KeyTip badges on non-bars plain controls; a per-scope registry surviving re-templating without recompute; culture-aware letter selection beyond invariant folding; an AlwaysVisible-mode badge variant. ---