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
4 changes: 4 additions & 0 deletions Cursorial.Gallery/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Cursorial.Gallery.ViewModels;
using Cursorial.UI;
using Cursorial.UI.Bars;
using Cursorial.UI.Bars.Input;
using Cursorial.UI.Configuration;
using Cursorial.UI.Controls;
using Cursorial.UI.Data;
Expand Down Expand Up @@ -49,11 +50,13 @@ static string FormatElement(UIObject? element)
void OnLayoutDiagnosticsDiagnosticRaised(LayoutDiagnosticEvent d) => vm?.AddDiagnostic($"[Layout ] {d.Kind}: {d.Message} ({FormatElement(d.Element)})");
void OnAnimationDiagnosticsTrackError(StoryboardTrackError e) => vm?.AddDiagnostic($"[Animation] {FormatElement(e.Scope)}: {e.Message} " + $"({e.Track.TargetProperty?.Name})");
void OnUIDiagnosticsRejectedValue(UIObject t, UIProperty p, object? v) => vm?.AddDiagnostic($"[Rejected ] {FormatElement(t)}.{p.Name} = {v}");
void OnKeyTipDiagnosticsMessageLogged(string s) => vm?.AddDiagnostic($"[KeyTips ] {s}");

// TODO: RE-ENABLE THESE BEFORE COMMITTING!
// StyleDebugDiagnostics.DiagnosticEmitted += OnStyleDebugDiagnosticsDiagnosticEmitted;
// ControlDiagnostics.DiagnosticRaised += OnControlDiagnosticsDiagnosticRaised;
BindingDiagnostics.TraceEmitted += OnBindingDiagnosticsTraceEmitted;
KeyTipDiagnostics.MessageLogged += OnKeyTipDiagnosticsMessageLogged; // dropped/suffixed badge letters show in the diagnostics pane
// LayoutDiagnostics.DiagnosticRaised += OnLayoutDiagnosticsDiagnosticRaised;
// AnimationDiagnostics.TrackError += OnAnimationDiagnosticsTrackError;
// UIDiagnostics.RejectedValue += OnUIDiagnosticsRejectedValue;
Expand All @@ -72,6 +75,7 @@ void OnBeginShutdown(object? o, EventArgs eventArgs)
LayoutDiagnostics.DiagnosticRaised -= OnLayoutDiagnosticsDiagnosticRaised;
AnimationDiagnostics.TrackError -= OnAnimationDiagnosticsTrackError;
UIDiagnostics.RejectedValue -= OnUIDiagnosticsRejectedValue;
KeyTipDiagnostics.MessageLogged -= OnKeyTipDiagnosticsMessageLogged;
}

app.BeginShutdown += OnBeginShutdown;
Expand Down
185 changes: 176 additions & 9 deletions Cursorial.UI.Bars.Tests/KeyTipTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
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 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.

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.
var pasted = false;
paste.Click += (_, _) => pasted = true;
host.ShowRoot(ribbon);
Expand All @@ -237,7 +237,7 @@
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 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.

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.
var pasted = false;
paste.Click += (_, _) => pasted = true;
var box = new TextBox();
Expand Down Expand Up @@ -764,26 +764,193 @@
Assert.Null(KeyTip.GetHopSequence(disabled)); // …so no (unreachable) hop hint
}

[Fact] // Audit: a control whose badge was DROPPED by a same-letter collision gets no hop; the survivor keeps it.
public void HopSequence_CollisionDropped_NullForLoser_SurvivorKeeps()
[Fact] // Auto letters that collide ALL survive, suffixed in document order (B → B0, B1); an explicit key still
// beats an auto letter (the auto badge is dropped, no hop). Maintainer, 2026-09-12.
public void Collision_AutoLetters_AllSurviveWithDigitSuffixes_ExplicitStillWins()
{
using var host = NewHost(HeadlessCapabilities.KittyTruecolor);
_ = host.Application.EnableKeyTips();
var controller = host.Application.EnableKeyTips();

var ribbon = new Ribbon();
var home = new RibbonTab { Header = "Home" };
var font = new RibbonGroup { Header = "Font" };
var bold = new BarButton { Content = "Bold" }; // 'B' — first-in-order wins
var border = new BarButton { Content = "Border" }; // 'B' — collides → dropped
font.Items.Add(bold);
font.Items.Add(border);
var bold = new BarButton { Content = "Bold" }; // 'B' → B0
var border = new BarButton { Content = "Border" }; // 'B' → B1
var bullets = new BarButton { Content = "Bullets" }; // 'B' → B2
var italic = new BarButton { Content = "Italic" }; // auto 'I'…
var inset = new BarButton { Content = "Inset" }; // …explicit 'I' reserves the letter: Italic's auto badge is dropped
KeyTip.SetKey(inset, "I");
foreach (var b in new[] { bold, border, bullets, italic, inset })
font.Items.Add(b);
home.Groups.Add(font);
ribbon.Items.Add(home);
host.ShowRoot(ribbon);
host.RunUntilIdle();

Assert.Equal("Alt␣H␣F␣B", KeyTip.GetHopSequence(bold)); // the survivor's real hop
Assert.Null(KeyTip.GetHopSequence(border)); // the dropped collider — no hop
Assert.Equal("Alt␣H␣F␣B0", KeyTip.GetHopSequence(bold));
Assert.Equal("Alt␣H␣F␣B1", KeyTip.GetHopSequence(border));
Assert.Equal("Alt␣H␣F␣B2", KeyTip.GetHopSequence(bullets));
Assert.Equal("Alt␣H␣F␣I", KeyTip.GetHopSequence(inset));
Assert.Null(KeyTip.GetHopSequence(italic));

// The drill: B is a shared prefix (all three stay viable, dimmed), the digit commits one.
var clicked = false;
border.Click += (_, _) => clicked = true;
AltDown(host);
TypeKeyTip(host, 'H');
host.RunFrame();
TypeKeyTip(host, 'F');
host.RunFrame();
TypeKeyTip(host, 'B');
Assert.True(controller.IsActive);
Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(bold)!.Visibility);
Assert.Equal(Visibility.Visible, controller.BadgeForTargetForTests(border)!.Visibility);
Assert.Equal(Visibility.Collapsed, controller.BadgeForTargetForTests(inset)!.Visibility);
TypeKeyTip(host, '1');
host.RunUntilIdle();
Assert.True(clicked);
}

// ───────────────────────────── inline badge placement (maintainer, 2026-09-12) ─────────────────────────────

// A badge sits where the access-key cue would: over the mnemonic's cluster, else over the first cluster spelling
// the badge letter — and shows that letter in the label's case.
private static (int Column, int Row) BadgeCell(KeyTipController controller, UIElement target)
{
var badge = controller.BadgeForTargetForTests(target)!;
Assert.Equal(Visibility.Visible, badge.Visibility);
return (Canvas.GetLeft(badge) ?? -1, Canvas.GetTop(badge) ?? -1);
}

private static (int Column, int Row) LabelCell(UIElement target, int cell)
{
// The presenter's origin is the control's text origin (padding, an icon cell, a check glyph …).
var presenter = FindPresenter(target)!;
return presenter.TranslateToScreen(cell, 0);
}

private static AccessTextPresenter? FindPresenter(UIElement root)
{
if (root is AccessTextPresenter p) return p;
for (var i = 0; i < root.VisualChildrenCount; i++)
if (FindPresenter(root.GetVisualChild(i)) is { } found) return found;
return null;
}

[Fact]
public void InlineBadge_SitsOverTheMnemonicCluster_InTheLabelsCase()
{
using var host = NewHost(HeadlessCapabilities.KittyTruecolor, w: 100);
var controller = host.Application.EnableKeyTips();

var toolbar = new Toolbar();
var bold = new BarButton { Content = "_Bold" }; // mnemonic at cluster 0 → cell 0
var save = new BarButton { Content = "Sa_ve" }; // mnemonic 'v' at cluster 2 → cell 2
var go = new BarButton { Content = "日本_Go" }; // two WIDE clusters before the mnemonic → cell 4, not 2
toolbar.Items.Add(bold);
toolbar.Items.Add(save);
toolbar.Items.Add(go);
host.ShowRoot(toolbar);
host.RunUntilIdle();

AltDown(host);
host.RunFrame();

Assert.Equal(LabelCell(bold, 0), BadgeCell(controller, bold));
Assert.Equal(LabelCell(save, 2), BadgeCell(controller, save));
Assert.Equal(LabelCell(go, 4), BadgeCell(controller, go));

Assert.Equal("B", controller.BadgeForTargetForTests(bold)!.KeyTipText);
Assert.Equal("v", controller.BadgeForTargetForTests(save)!.KeyTipText); // the label's own case
Assert.Equal("G", controller.BadgeForTargetForTests(go)!.KeyTipText);

// Matching stays case-insensitive whatever the badge shows.
var saved = false;
save.Click += (_, _) => saved = true;
TypeKeyTip(host, 'V');
host.RunUntilIdle();
Assert.True(saved);
}

[Fact]
public void InlineBadge_WithoutAMnemonic_SitsOverTheFirstMatchingLetter_ElseAtTheControlOrigin()
{
using var host = NewHost(HeadlessCapabilities.KittyTruecolor, w: 100);
var controller = host.Application.EnableKeyTips();

var toolbar = new Toolbar();
var italic = new BarButton { Content = "Italic" }; // auto 'I' → the first cluster spelling it: cell 0
var opener = new BarButton { Content = "▾" }; // no letter at all: explicit key, no inline match
KeyTip.SetKey(opener, "Q");
var later = new BarButton { Content = " Zoom" }; // leading spaces: 'Z' sits at cell 2
toolbar.Items.Add(italic);
toolbar.Items.Add(opener);
toolbar.Items.Add(later);
host.ShowRoot(toolbar);
host.RunUntilIdle();

AltDown(host);
host.RunFrame();

Assert.Equal(LabelCell(italic, 0), BadgeCell(controller, italic));
Assert.Equal(opener.TranslateToScreen(0, 0), BadgeCell(controller, opener)); // the anchor-rule fallback: the control's origin
Assert.Equal("Q", controller.BadgeForTargetForTests(opener)!.KeyTipText);
Assert.Equal(LabelCell(later, 2), BadgeCell(controller, later));
}

[Fact]
public void InlineBadge_SuffixedCollision_StartsAtTheLetter_AndRunsRight()
{
using var host = NewHost(HeadlessCapabilities.KittyTruecolor, w: 100);
var controller = host.Application.EnableKeyTips();

var toolbar = new Toolbar();
var bold = new BarButton { Content = "Bold" };
var border = new BarButton { Content = "Border" }; // both auto 'B' → B0 / B1, each inline over its own 'B'
toolbar.Items.Add(bold);
toolbar.Items.Add(border);
host.ShowRoot(toolbar);
host.RunUntilIdle();

AltDown(host);
host.RunFrame();

Assert.Equal("B0", controller.BadgeForTargetForTests(bold)!.KeyTipText);
Assert.Equal(LabelCell(bold, 0), BadgeCell(controller, bold));
Assert.Equal("B1", controller.BadgeForTargetForTests(border)!.KeyTipText);
Assert.Equal(LabelCell(border, 0), BadgeCell(controller, border));
}

[Fact] // A menu row badges the letter its underline shows (the header's mnemonic), placed over that letter.
public void MenuRow_BadgesItsHeaderMnemonic_InPlace()
{
using var host = NewHost(HeadlessCapabilities.KittyTruecolor, w: 100);
var controller = host.Application.EnableKeyTips();

var menu = new Menu();
var file = new MenuItem { Header = "_File" };
var exit = new MenuItem { Header = "E_xit" }; // mnemonic 'x' at cluster 1 — badge X, not E
file.Items.Add(new MenuItem { Header = "_New" });
file.Items.Add(exit);
menu.Items.Add(file);
host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { menu, new TextBox() } });
host.RunUntilIdle();

AltDown(host);
TypeKeyTip(host, 'F');
for (var i = 0; i < 8 && controller.LevelDepthForTests < 2; i++)
host.RunFrame();
Assert.Equal(2, controller.LevelDepthForTests);

var badge = controller.BadgeForTargetForTests(exit)!;
Assert.Equal("x", badge.KeyTipText);
Assert.Equal(LabelCell(exit, 1), BadgeCell(controller, exit));

var exited = false;
exit.Click += (_, _) => exited = true;
TypeKeyTip(host, 'X');
host.RunUntilIdle();
Assert.True(exited);
}

[Fact] // Multi-char keytips: typing the shared prefix dims the matched letters + keeps only the viable badges.
Expand Down
113 changes: 104 additions & 9 deletions Cursorial.UI.Bars/Input/KeyTipController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Globalization;

using Cursorial.Input;
using Cursorial.Text;
using Cursorial.UI.Controls;
using Cursorial.UI.Input;

Expand Down Expand Up @@ -531,7 +534,7 @@ private void PlaceBadges(KeyTipLevel level)
var onBadgeableSurface = entry.Target.IsEffectivelyVisible
&& (_app.WindowManager is not { } wm || wm.SurfaceForElement(entry.Target) is not null);

var (anchorColumn, anchorRow) = AnchorCell(entry);
var (anchorColumn, anchorRow, inlineText) = AnchorCell(entry);
var (column, row) = entry.Target.TranslateToScreen(anchorColumn, anchorRow);
var onScreen = column >= 0 && row >= 0 && column < viewport.Columns && row < viewport.Rows;

Expand All @@ -540,27 +543,119 @@ private void PlaceBadges(KeyTipLevel level)
if (!show)
continue;

// An inline badge shows the letter in the case the label spells it (the badge stands where the access-key
// underline would be, so it reads as the label's own letter); matching stays case-insensitive.
badge.KeyTipText = inlineText ?? entry.KeyTip;

// Keep a wide (multi-letter) badge from overflowing the right edge; the anchor itself is on-screen.
Canvas.SetLeft(badge, Math.Min(column, Math.Max(0, viewport.Columns - 1)));
int width = GraphemeWidth.StringWidth(badge.KeyTipText);
Canvas.SetLeft(badge, Math.Max(0, Math.Min(column, viewport.Columns - width)));
Canvas.SetTop(badge, row);
}
}

// The target-local cell a badge anchors to (keytips-design §8). BottomLeading (the v1 default, "at the end of its
// command" on the bottom row) collapses to (0,0) for a single-row control; TopLeading pins the top-left corner;
// BottomCenter centers on the bottom edge.
private static (int Column, int Row) AnchorCell(KeyTipEntry entry)
// The target-local cell a badge anchors to (keytips-design §8), and the text it shows there.
//
// INLINE (maintainer, 2026-09-12): a control whose label is plain monospace text and whose badge is that label's
// own letter puts the badge where the access-key cue would sit — over the mnemonic's cluster when the label has
// one, else over the first cluster spelling the badge letter — so the badge reads like the underline it replaces
// and never hides the label's first letter for nothing. A multi-letter badge (a `B0`/`B1` collision suffix)
// starts at that cell and runs right. The text shown takes the label's own case for that letter.
//
// Otherwise the anchor rule: BottomLeading (the design's desktop default, "at the end of its command" on the
// bottom row) collapses to (0,0) for a single-row control; TopLeading pins the top-left corner; BottomCenter
// centers on the bottom edge.
private static (int Column, int Row, string? InlineText) AnchorCell(KeyTipEntry entry)
{
if (InlineAnchor(entry) is var (inlineColumn, inlineRow, inlineText))
return (inlineColumn, inlineRow, inlineText);

var bounds = entry.Target.Bounds;
var lastRow = Math.Max(0, bounds.Rows - 1);
return entry.Anchor switch
{
KeyTipAnchor.TopLeading => (0, 0),
KeyTipAnchor.BottomCenter => (Math.Max(0, bounds.Columns / 2), lastRow),
_ => (0, lastRow), // BottomLeading
KeyTipAnchor.TopLeading => (0, 0, null),
KeyTipAnchor.BottomCenter => (Math.Max(0, bounds.Columns / 2), lastRow, null),
_ => (0, lastRow, null), // BottomLeading
};
}

// The inline anchor for a badge whose first letter the target's label spells: the target-local cell of that
// cluster (through screen space — the presenter is a template part, possibly on another surface) and the badge
// text with the label's case. Null when the target has no plain-monospace access-text presenter, or its label
// does not contain the letter.
private static (int Column, int Row, string Text)? InlineAnchor(KeyTipEntry entry)
{
var target = entry.Target;
if (entry.KeyTip.Length == 0 || !TextElement.GetGlyphSource(target).PaintsAsCells)
return null;

if (FindAccessTextPresenter(target) is not { Text.Text: { Length: > 0 } label } presenter)
return null;

char letter = entry.KeyTip[0];
int clusterIndex = ClusterIndexOf(presenter.Text, letter);
if (clusterIndex < 0)
return null;

// The cluster's cell column: the width of the clusters before it (a wide or multi-unit cluster shifts it).
int cell = 0;
var remaining = label.AsSpan();
string spelled = letter.ToString();
for (var i = 0; !remaining.IsEmpty; i++)
{
int len = StringInfo.GetNextTextElementLength(remaining);
if (len <= 0)
break;
if (i == clusterIndex)
{
spelled = remaining[..len].ToString();
break;
}

cell += GraphemeWidth.ClusterWidth(remaining[..len]);
remaining = remaining[len..];
}

var (column, row) = presenter.TranslateTo(target, cell, 0);
return (column, row, spelled + entry.KeyTip[1..]);
}

// The cluster index the badge letter sits on: the mnemonic's cluster when the label has one and it IS the badge
// letter; else the first cluster that spells the letter (case-insensitively); else -1.
private static int ClusterIndexOf(in AccessText text, char letter)
{
if (text.HasKey && char.ToUpperInvariant(text.Key) == char.ToUpperInvariant(letter))
return text.KeyIndex;

var remaining = text.Text.AsSpan();
for (var i = 0; !remaining.IsEmpty; i++)
{
int len = StringInfo.GetNextTextElementLength(remaining);
if (len <= 0)
break;
if (len == 1 && char.ToUpperInvariant(remaining[0]) == char.ToUpperInvariant(letter))
return i;
remaining = remaining[len..];
}

return -1;
}

private static AccessTextPresenter? FindAccessTextPresenter(UIElement root)
{
if (root is AccessTextPresenter presenter)
return presenter;

for (var i = 0; i < root.VisualChildrenCount; i++)
{
if (FindAccessTextPresenter(root.GetVisualChild(i)) is { } found)
return found;
}

return null;
}

// ───────────────────────────── discovery / activation / focus ─────────────────────────────

// Level 0: discover the bar-surface hosts under the ACTIVE root (the active window's root, else the app root —
Expand Down
6 changes: 4 additions & 2 deletions Cursorial.UI.Bars/Input/KeyTipEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ public sealed class KeyTipEntry
/// <summary>The control the badge sits over (positioned via <see cref="UIElement.TranslateToScreen(int, int)"/>).</summary>
public required UIElement Target { get; init; }

/// <summary>The badge letters (uppercased; 1–2 chars in v1). Matched case-insensitively against typed input.</summary>
public required string KeyTip { get; init; }
/// <summary>The badge letters (uppercased). Matched case-insensitively against typed input. Settable because the
/// level builder resolves an auto-assigned collision by suffixing a digit onto every collider (<c>B</c> →
/// <c>B0</c>, <c>B1</c> …), which renames an entry already built; nothing else writes it.</summary>
public required string KeyTip { get; set; }

/// <summary>What choosing this entry does.</summary>
public required KeyTipTargetKind Kind { get; init; }
Expand Down
Loading
Loading