diff --git a/Cursorial.UI.Bars.Tests/KeyTipMenuNavigationTests.cs b/Cursorial.UI.Bars.Tests/KeyTipMenuNavigationTests.cs
new file mode 100644
index 00000000..b3fe218d
--- /dev/null
+++ b/Cursorial.UI.Bars.Tests/KeyTipMenuNavigationTests.cs
@@ -0,0 +1,170 @@
+using Cursorial.Input;
+using Cursorial.Input.Events;
+using Cursorial.Rendering;
+using Cursorial.UI;
+using Cursorial.UI.Bars;
+using Cursorial.UI.Bars.Input;
+using Cursorial.UI.Controls;
+using Cursorial.UI.Hosting.Headless;
+
+namespace Cursorial.Tests.UI.Bars;
+
+// KeyTips FOLLOW the keyboard through menus (maintainer, 2026-09-12): with the overlay up, a submenu the arrow keys
+// open gets its rows badged, a submenu the arrow keys close pops its level, and a sibling top-level menu reached by
+// arrows gets badged too. Plus the QAT customize ▾: badge 00 drills into the checklist, whose check boxes toggle on
+// activation.
+public sealed class KeyTipMenuNavigationTests
+{
+ 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 };
+
+ // Sticky mode (Alt tap): the realistic "Alt, letter, arrows" flow — plain keys afterwards.
+ private static void AltTap(UIHeadlessHost host)
+ {
+ host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.Alt));
+ host.Application.InputDispatcher.ProcessEvent(Key_(Key.LeftAlt, KeyModifiers.None, kind: KeyEventKind.Up));
+ }
+
+ private static void Type(UIHeadlessHost host, char c) => host.Application.InputDispatcher.ProcessEvent(Key_(Key.Character, KeyModifiers.None, c.ToString()));
+
+ private static void Press(UIHeadlessHost host, Key key)
+ {
+ host.Application.InputDispatcher.ProcessEvent(Key_(key));
+ host.RunUntilIdle();
+ }
+
+ 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);
+ }
+
+ private static (Menu Menu, MenuItem File, MenuItem Recent, MenuItem RecentA, MenuItem Edit, MenuItem Undo) NewMenu()
+ {
+ var menu = new Menu();
+ var file = new MenuItem { Header = "_File" };
+ var recent = new MenuItem { Header = "_Recent" };
+ var recentA = new MenuItem { Header = "_Alpha.txt" };
+ recent.Items.Add(recentA);
+ file.Items.Add(new MenuItem { Header = "_New" });
+ file.Items.Add(recent);
+ var edit = new MenuItem { Header = "_Edit" };
+ var undo = new MenuItem { Header = "_Undo" };
+ edit.Items.Add(undo);
+ menu.Items.Add(file);
+ menu.Items.Add(edit);
+ return (menu, file, recent, recentA, edit, undo);
+ }
+
+ [Fact] // Alt, F badges File's rows; Right on the Recent row opens its submenu BY KEYBOARD → its rows are badged; Left closes it → back to File's rows.
+ public void ArrowKeys_OpenAndCloseANestedSubmenu_TheLevelFollows()
+ {
+ using var host = NewHost();
+ var controller = host.Application.EnableKeyTips();
+ var (menu, file, recent, recentA, _, _) = NewMenu();
+ host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { menu, new TextBox() } });
+ host.RunUntilIdle();
+
+ AltTap(host);
+ Type(host, 'F'); // drill File (submenu opens, focus on its first row)
+ SettleLevel(host, controller, 2);
+ Assert.True(file.IsSubmenuOpen);
+
+ Press(host, Key.DownArrow); // New → Recent
+ Assert.True(recent.IsFocused);
+ Press(host, Key.RightArrow); // opens Recent's submenu by keyboard
+ Assert.True(recent.IsSubmenuOpen);
+ SettleLevel(host, controller, 3); // the overlay followed: a level over Recent's rows
+ Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(recentA)!.Visibility);
+
+ Press(host, Key.LeftArrow); // closes Recent by keyboard
+ Assert.False(recent.IsSubmenuOpen);
+ SettleLevel(host, controller, 2); // popped back to File's rows
+ Assert.NotNull(controller.BadgeForTargetForTests(recent));
+ Assert.True(controller.IsActive);
+
+ var invoked = false;
+ recentA.Click += (_, _) => invoked = true;
+ Press(host, Key.RightArrow); // re-open Recent…
+ SettleLevel(host, controller, 3);
+ Type(host, 'A'); // …and its badge still activates
+ host.RunUntilIdle();
+ Assert.True(invoked);
+ Assert.False(controller.IsActive);
+ }
+
+ [Fact] // Alt, F, then Left (back to the File header) and over to Edit with Down: the sibling menu's rows get badged.
+ public void ArrowKeys_ReachASiblingTopLevelMenu_ItsRowsAreBadged()
+ {
+ using var host = NewHost();
+ var controller = host.Application.EnableKeyTips();
+ var (menu, file, _, _, edit, undo) = NewMenu();
+ host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { menu, new TextBox() } });
+ host.RunUntilIdle();
+
+ AltTap(host);
+ Type(host, 'F');
+ SettleLevel(host, controller, 2);
+
+ Press(host, Key.LeftArrow); // on a File row: ascend — closes File, focuses its header
+ Assert.False(file.IsSubmenuOpen);
+ SettleLevel(host, controller, 1); // File's level popped: the headers are badged again
+ Assert.NotNull(controller.BadgeForTargetForTests(edit));
+
+ Press(host, Key.RightArrow); // File header → Edit header
+ Assert.True(edit.IsFocused);
+ Press(host, Key.DownArrow); // opens Edit's submenu by keyboard
+ Assert.True(edit.IsSubmenuOpen);
+ SettleLevel(host, controller, 2); // the overlay followed into the sibling menu
+ Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(undo)!.Visibility);
+
+ var invoked = false;
+ undo.Click += (_, _) => invoked = true;
+ Type(host, 'U');
+ host.RunUntilIdle();
+ Assert.True(invoked);
+ }
+
+ [Fact] // The QAT customize ▾ carries badge 00 and drills into the checklist; a check box row TOGGLES on activation.
+ public void QatCustomize_Badge00_DrillsIntoTheChecklist_AndACheckBoxToggles()
+ {
+ 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 = "Clipboard", Items = { new BarButton { Content = "Paste" } } });
+ ribbon.Items.Add(home);
+ var save = new BarCommand(() => { }) { Text = "_Save" };
+ var print = new BarCommand(() => { }) { Text = "_Print" };
+ ribbon.QuickAccessCandidates.Add(save);
+ ribbon.QuickAccessCandidates.Add(print);
+ ribbon.QuickAccessCommands.Add(save); // Save ON, Print OFF
+ host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { ribbon, new TextBox() } });
+ host.RunUntilIdle();
+
+ AltTap(host);
+ var customize = ribbon.QatCustomizeForTests!;
+ var badge = controller.BadgeForTargetForTests(customize);
+ Assert.NotNull(badge);
+ Assert.Equal("00", badge!.KeyTipText);
+
+ Type(host, '0');
+ Type(host, '0'); // commits "00": the checklist opens
+ SettleLevel(host, controller, 2);
+ Assert.True(ribbon.QatPopupForTests!.IsOpen);
+ var printRow = (CheckBox)ribbon.QatChecklistForTests!.Children[1];
+ Assert.False(printRow.IsChecked == true);
+ Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(printRow)!.Visibility);
+
+ Type(host, 'P'); // activate the Print row: it TOGGLES (not focus-only)
+ host.RunUntilIdle();
+ Assert.True(printRow.IsChecked == true);
+ Assert.Contains(print, ribbon.QuickAccessCommands);
+ Assert.False(controller.IsActive);
+ }
+}
diff --git a/Cursorial.UI.Bars/Input/KeyTipController.cs b/Cursorial.UI.Bars/Input/KeyTipController.cs
index 3945f7e2..3e577dca 100644
--- a/Cursorial.UI.Bars/Input/KeyTipController.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipController.cs
@@ -38,6 +38,7 @@ public sealed class KeyTipController : IKeyTipController, IKeyTipLayoutHook
private Action? _parkedRetract;
private int _parkedGeneration;
private int _parkedRetries;
+ private bool _parkedGiveUpRetracts; // a drill's reveal is undone when its level never builds; a keyboard-opened popup is left alone
// 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
@@ -263,6 +264,7 @@ private void Commit(KeyTipEntry entry)
entry.Reveal?.Invoke();
_parkedBuild = entry.BuildNext;
_parkedRetract = entry.Retract;
+ _parkedGiveUpRetracts = true;
_parkedGeneration = ++_levelGeneration;
_parkedRetries = 0;
}
@@ -315,13 +317,18 @@ public void CompletePendingLayout()
// reveal and RE-SHOW the drilled-from level so it stays matchable — otherwise its Typed prefix
// (the drill letter) is left dirty and every sibling letter would bonk (the level would brick).
_parkedBuild = null;
- _parkedRetract?.Invoke();
+ if (_parkedGiveUpRetracts)
+ _parkedRetract?.Invoke();
_parkedRetract = null;
_pendingChars.Clear(); // typed for a level that never came — dropped, not mis-matched
ShowLevel(_stack[^1]);
}
}
+ // 1b) Follow popups the keyboard opened or closed under the overlay (arrow keys in a menu).
+ if (_isActive && _parkedBuild is null)
+ SyncWithPopups();
+
// 2) Re-anchor the shown level's badges to their targets' final screen cells — this is what keeps badges glued
// to a ribbon that MOVES (a panel above it grows) or SCROLLS (a ScrollViewer slide, reflected through
// TranslateToScreen's ChildScrollOffset walk). Re-arrange the overlay in the SAME frame so a scroll doesn't
@@ -391,9 +398,106 @@ private void AnchorOverlay(KeyTipLevel level)
}
}
+ level.Surface = topmost ?? wm.Surfaces.FirstOrDefault();
wm.AnchorKeyTipOverlay(topmost);
}
+ // ───────────────────────────── popup sync (keyboard-navigated menus) ─────────────────────────────
+
+ // Keeps the level stack in step with the popups the user opens and closes by other means while the overlay is
+ // up — the arrow keys in a menu (maintainer, 2026-09-12): Right/Enter on a submenu header opens it, Left closes
+ // it, Left/Right on the bar switches to the sibling top-level menu. A level whose popup surface has closed is
+ // popped (its popup is already gone — no retract); a popup opened FROM one of the shown level's targets (its
+ // placement target is that control, or sits inside it) gets a level pushed over it, so the badges follow the
+ // keyboard. Any other popup (unrelated to the drill) is left alone.
+ private void SyncWithPopups()
+ {
+ if (_app.WindowManager is not { } wm || _stack.Count == 0)
+ return;
+
+ var popped = false;
+ while (_stack.Count > 1 && _stack[^1].Surface is { IsPopup: true } surface && !wm.Surfaces.Contains(surface))
+ {
+ _stack.RemoveAt(_stack.Count - 1);
+ popped = true;
+ }
+
+ if (popped)
+ {
+ _pendingChars.Clear();
+ _levelGeneration++;
+ ShowLevel(_stack[^1]);
+ }
+
+ // The topmost open popup not already on the stack, opened from a control the shown level badges.
+ var top = _stack[^1];
+ var surfaces = wm.Surfaces;
+ for (var i = surfaces.Count - 1; i >= 0; i--)
+ {
+ var candidate = surfaces[i];
+ if (!candidate.IsPopup || OnStack(candidate))
+ continue;
+
+ if (PopupFor(wm, candidate) is not { } popup || popup.EffectiveTarget is not { } placement || !IsLevelTarget(top, placement))
+ continue;
+
+ var content = candidate.Root;
+ if (KeyTipPopupLevels.BuildOver(content) is { Entries.Count: > 0 } level)
+ {
+ level.Retract = () => popup.IsOpen = false;
+ _stack.Add(level);
+ _levelGeneration++;
+ ShowLevel(level);
+ ReplayPendingChars();
+ }
+ else
+ {
+ // Not realized yet (the popup opened this frame): park it like a drill's next level — but a popup
+ // the USER opened is never closed by a give-up (it may simply hold nothing badgeable).
+ _parkedBuild = () => KeyTipPopupLevels.BuildOver(content);
+ _parkedRetract = () => popup.IsOpen = false;
+ _parkedGiveUpRetracts = false;
+ _parkedGeneration = ++_levelGeneration;
+ _parkedRetries = 0;
+ }
+
+ return;
+ }
+ }
+
+ private bool OnStack(TopLevelSurface surface)
+ {
+ foreach (var level in _stack)
+ {
+ if (ReferenceEquals(level.Surface, surface))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static Popup? PopupFor(WindowManager wm, TopLevelSurface surface)
+ {
+ foreach (var popup in wm.Popups)
+ {
+ if (ReferenceEquals(popup.PopupSurface, surface))
+ return popup;
+ }
+
+ return null;
+ }
+
+ private static bool IsLevelTarget(KeyTipLevel level, UIElement element)
+ {
+ foreach (var entry in level.Entries)
+ {
+ if (ReferenceEquals(entry.Target, element) || entry.Target.IsAncestorOf(element))
+ return true;
+ }
+
+ return false;
+ }
+
// 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
@@ -496,6 +600,14 @@ internal static void ActivateLeaf(UIElement target)
if (target.Focusable && app is not null)
app.FocusManager.SetFocus(target, FocusNavigationMethod.AccessKey);
+ // A check box's access-key reaction is focus-first (the mnemonic that focused it does not toggle it); a badge
+ // names the box outright, so choosing it toggles — the click path, not the access-key reaction.
+ if (target is CheckBox { IsEffectivelyEnabled: true } box)
+ {
+ box.InvokeKeyTipClick();
+ return;
+ }
+
if (target is IAccessKeyTarget { IsAccessKeyEligible: true } accessKeyTarget)
accessKeyTarget.OnAccessKey(new AccessKeyEventArgs('\0', isMultiMatch: false, target));
}
diff --git a/Cursorial.UI.Bars/Input/KeyTipLevel.cs b/Cursorial.UI.Bars/Input/KeyTipLevel.cs
index 4aef1d96..6509a37d 100644
--- a/Cursorial.UI.Bars/Input/KeyTipLevel.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipLevel.cs
@@ -19,6 +19,12 @@ public sealed class KeyTipLevel
/// Settable so the controller can attach the committing drill's retract to the level built by its parked builder.
public Action? Retract { get; set; }
+ /// The surface the level's badges annotate (the topmost surface among its targets — the root, a window,
+ /// or the popup this level drilled into), recorded when the level is shown. The controller pops a level whose
+ /// popup surface has closed under it (a submenu the user backed out of with the keyboard) and pushes a level
+ /// over a popup opened from one of the shown level's targets (a submenu or sibling menu reached by arrow keys).
+ public TopLevelSurface? Surface { get; set; }
+
/// The SURVIVING KeyTip letter for in this level (post collision-resolution +
/// eligibility filter), or when it has no badge here — dropped by a collision, or filtered
/// as ineligible/hidden. Lets report a hop that exactly matches the badges.
diff --git a/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs b/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs
index 4d62aae4..daaee3e0 100644
--- a/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs
+++ b/Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs
@@ -109,6 +109,20 @@ public void BuildRootLevel(KeyTipLevelBuilder into)
}
}
+ // The QAT customize ▾ (maintainer, 2026-09-12: it had no badge — its face is a glyph, nothing to derive —
+ // and its checklist is a raw Popup, not a dropdown button): the explicit "00" badge, in the QAT's digit
+ // family beside the items' 1..9 and the collapsed opener's 0, drills into the checklist (the candidate
+ // check boxes, "More Commands…", "Show Below the Ribbon").
+ if (ribbon is { QatCustomizeForTests: { IsEffectivelyVisible: true } customize, QatPopupForTests: { } qatPopup })
+ {
+ into.AddExplicit(
+ customize, "00", KeyTipTargetKind.DrillPopup,
+ activate: null,
+ reveal: () => qatPopup.IsOpen = true,
+ buildNext: () => KeyTipPopupLevels.BuildOver(qatPopup.Child),
+ retract: () => qatPopup.IsOpen = false);
+ }
+
// The collapsed-QAT opener (⋯▾) drills into its flyout (the re-hosted QAT commands).
if (ribbon is { IsQuickAccessCollapsedForTests: true, QatCollapsedButtonForTests: { } opener })
{
diff --git a/Cursorial.UI/Controls/ButtonBase.cs b/Cursorial.UI/Controls/ButtonBase.cs
index 1b0e3cbb..83e5ee6a 100644
--- a/Cursorial.UI/Controls/ButtonBase.cs
+++ b/Cursorial.UI/Controls/ButtonBase.cs
@@ -116,6 +116,11 @@ protected override bool IsEnabledCore
// focus-visible ring (a Restore — see FocusManager.ReturnRetainedFocus), independent of the invoke modality.
// Skipped when the click itself moved focus elsewhere (a drop-down button that focused its popup). A cheap no-op
// outside a non-retaining scope.
+ /// A KeyTip badge activation: the access-key click, unconditionally — a 's own
+ /// access-key reaction is focus-first (a mnemonic that moved focus onto it does not toggle it, keeping single- and
+ /// multi-match consistent), but a badge names the box outright, so choosing it toggles (maintainer, 2026-09-12).
+ internal void InvokeKeyTipClick() => InvokeClickRetaining(InvokeMethod.AccessKey);
+
private void InvokeClickRetaining(InvokeMethod method = InvokeMethod.Programmatic)
{
var focus = UIApplication.Current?.FocusManager;
diff --git a/docs/ui-layer-design/keytips-design.md b/docs/ui-layer-design/keytips-design.md
index 59a2b218..cb90829d 100644
--- a/docs/ui-layer-design/keytips-design.md
+++ b/docs/ui-layer-design/keytips-design.md
@@ -307,6 +307,8 @@ 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.
+**Built after v1 (2026-09-12):** the level stack follows the keyboard through menus — a submenu or sibling top-level menu the arrow keys open (its popup's placement target is one of the shown level's badged controls) gets a level pushed over its rows, a popup the arrow keys close pops its level (`KeyTipController.SyncWithPopups`, run at the post-layout hook; a keyboard-opened popup is never closed by a give-up); the QAT customize ▾ carries the explicit `00` badge and drills into its checklist; a `CheckBox` badge activation toggles the box (the click path — its own access-key reaction stays focus-first, keeping single- and multi-match consistent).
+
**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.