Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions Cursorial.UI.Bars.Tests/KeyTipMenuNavigationTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
114 changes: 113 additions & 1 deletion Cursorial.UI.Bars/Input/KeyTipController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -263,6 +264,7 @@ private void Commit(KeyTipEntry entry)
entry.Reveal?.Invoke();
_parkedBuild = entry.BuildNext;
_parkedRetract = entry.Retract;
_parkedGiveUpRetracts = true;
_parkedGeneration = ++_levelGeneration;
_parkedRetries = 0;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
Expand Down
6 changes: 6 additions & 0 deletions Cursorial.UI.Bars/Input/KeyTipLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.</summary>
public Action? Retract { get; set; }

/// <summary>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).</summary>
public TopLevelSurface? Surface { get; set; }

/// <summary>The SURVIVING KeyTip letter for <paramref name="target"/> in this level (post collision-resolution +
/// eligibility filter), or <see langword="null"/> when it has no badge here — dropped by a collision, or filtered
/// as ineligible/hidden. Lets <see cref="KeyTip.GetHopSequence"/> report a hop that exactly matches the badges.</summary>
Expand Down
14 changes: 14 additions & 0 deletions Cursorial.UI.Bars/Input/RibbonKeyTipHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
{
Expand Down
5 changes: 5 additions & 0 deletions Cursorial.UI/Controls/ButtonBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <summary>A KeyTip badge activation: the access-key click, unconditionally — a <see cref="CheckBox"/>'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).</summary>
internal void InvokeKeyTipClick() => InvokeClickRetaining(InvokeMethod.AccessKey);

private void InvokeClickRetaining(InvokeMethod method = InvokeMethod.Programmatic)
{
var focus = UIApplication.Current?.FocusManager;
Expand Down
Loading
Loading