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
222 changes: 222 additions & 0 deletions Cursorial.UI.Bars.Tests/KeyTipPopupDrillTests.cs
Original file line number Diff line number Diff line change
@@ -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
}
}
119 changes: 119 additions & 0 deletions Cursorial.UI.Bars.Tests/KeyTipTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,84 @@
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);

Check warning on line 217 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Dereference of a possibly null reference.

Check warning on line 217 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Converting null literal or possible null value to non-nullable type.

Check warning on line 217 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Dereference of a possibly null reference.

Check warning on line 217 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Converting null literal or possible null value to non-nullable type.
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);

Check warning on line 240 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Dereference of a possibly null reference.

Check warning on line 240 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Converting null literal or possible null value to non-nullable type.

Check warning on line 240 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Dereference of a possibly null reference.

Check warning on line 240 in Cursorial.UI.Bars.Tests/KeyTipTests.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Converting null literal or possible null value to non-nullable type.
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()
{
Expand Down Expand Up @@ -268,6 +346,47 @@
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()
Expand Down
Loading
Loading