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.