diff --git a/Cursorial.Gallery/Program.cs b/Cursorial.Gallery/Program.cs
index 8741f575..8d8ffc40 100644
--- a/Cursorial.Gallery/Program.cs
+++ b/Cursorial.Gallery/Program.cs
@@ -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;
@@ -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;
@@ -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;
diff --git a/Cursorial.UI.Bars.Tests/KeyTipTests.cs b/Cursorial.UI.Bars.Tests/KeyTipTests.cs
index 12649fe6..5af78ef6 100644
--- a/Cursorial.UI.Bars.Tests/KeyTipTests.cs
+++ b/Cursorial.UI.Bars.Tests/KeyTipTests.cs
@@ -764,26 +764,193 @@ public void HopSequence_Null_ForDisabledControl()
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.
diff --git a/Cursorial.UI.Bars/Input/KeyTipController.cs b/Cursorial.UI.Bars/Input/KeyTipController.cs
index 4df04b8a..d4be46a4 100644
--- a/Cursorial.UI.Bars/Input/KeyTipController.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipController.cs
@@ -1,4 +1,7 @@
+using System.Globalization;
+
using Cursorial.Input;
+using Cursorial.Text;
using Cursorial.UI.Controls;
using Cursorial.UI.Input;
@@ -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;
@@ -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 —
diff --git a/Cursorial.UI.Bars/Input/KeyTipEntry.cs b/Cursorial.UI.Bars/Input/KeyTipEntry.cs
index ea74aea0..400f0ce1 100644
--- a/Cursorial.UI.Bars/Input/KeyTipEntry.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipEntry.cs
@@ -12,8 +12,10 @@ public sealed class KeyTipEntry
/// The control the badge sits over (positioned via ).
public required UIElement Target { get; init; }
- /// The badge letters (uppercased; 1–2 chars in v1). Matched case-insensitively against typed input.
- public required string KeyTip { get; init; }
+ /// 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 (B →
+ /// B0, B1 …), which renames an entry already built; nothing else writes it.
+ public required string KeyTip { get; set; }
/// What choosing this entry does.
public required KeyTipTargetKind Kind { get; init; }
diff --git a/Cursorial.UI.Bars/Input/KeyTipLevelBuilder.cs b/Cursorial.UI.Bars/Input/KeyTipLevelBuilder.cs
index afadaeda..32e2f325 100644
--- a/Cursorial.UI.Bars/Input/KeyTipLevelBuilder.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipLevelBuilder.cs
@@ -61,11 +61,12 @@ public void AddExplicit(
public KeyTipLevel Build(Action? retract = null)
{
var entries = new List(_pending.Count);
- var claimed = new HashSet(StringComparer.OrdinalIgnoreCase); // survivors (explicit + auto)
- // Explicit keys always beat auto — even a later-in-order explicit — so reserve every explicit letter up front,
- // then a single document-order walk keeps: an explicit (first-wins on an explicit-vs-explicit clash) and an
- // auto letter only when no explicit reserved it and no earlier survivor took it.
+ // Explicit keys always beat auto — even a later-in-order explicit — so reserve every explicit letter up front.
+ // Then one document-order walk: an explicit letter is kept first-wins (a duplicate explicit is dropped); an
+ // auto letter an explicit reserved is dropped; auto letters that collide with EACH OTHER all survive with a
+ // digit suffix in document order — `B`, `B` → `B0`, `B1` (the earlier survivor is renamed when the second
+ // arrives), so no control loses its badge to a same-letter sibling (maintainer, 2026-09-12).
var reservedExplicit = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var p in _pending)
{
@@ -73,21 +74,44 @@ public KeyTipLevel Build(Action? retract = null)
reservedExplicit.Add(p.KeyTip);
}
+ var claimedExplicit = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var autoByLetter = new Dictionary>(StringComparer.OrdinalIgnoreCase); // letter → ENTRY indices
+
foreach (var p in _pending)
{
- var collides = p.Explicit ? !claimed.Add(p.KeyTip)
- : reservedExplicit.Contains(p.KeyTip) || !claimed.Add(p.KeyTip);
- if (collides)
+ var keyTip = p.KeyTip;
+
+ if (p.Explicit)
+ {
+ if (!claimedExplicit.Add(keyTip))
+ {
+ KeyTipDiagnostics.Warning($"KeyTip '{keyTip}' collides in this level; dropping the duplicate explicit badge on {p.Target.GetType().Name}.");
+ continue;
+ }
+ }
+ else if (reservedExplicit.Contains(keyTip))
{
- KeyTipDiagnostics.Warning(
- $"KeyTip letter '{p.KeyTip}' collides in this level; dropping the {(p.Explicit ? "duplicate explicit" : "auto-assigned")} badge on {p.Target.GetType().Name}.");
+ KeyTipDiagnostics.Warning($"KeyTip letter '{keyTip}' is claimed by an explicit key in this level; dropping the auto-assigned badge on {p.Target.GetType().Name}.");
continue;
}
+ else
+ {
+ if (!autoByLetter.TryGetValue(keyTip, out var siblings))
+ autoByLetter[keyTip] = siblings = [];
+
+ if (siblings.Count == 1)
+ entries[siblings[0]].KeyTip = keyTip + "0"; // the first of a now-colliding pair takes suffix 0
+
+ if (siblings.Count > 0)
+ keyTip += siblings.Count.ToString();
+
+ siblings.Add(entries.Count);
+ }
entries.Add(new KeyTipEntry
{
Target = p.Target,
- KeyTip = p.KeyTip,
+ KeyTip = keyTip,
Kind = p.Kind,
ExplicitKey = p.Explicit,
Anchor = p.Anchor,
diff --git a/Cursorial.UI.Bars/Input/KeyTipModel.cs b/Cursorial.UI.Bars/Input/KeyTipModel.cs
index dbbc358f..e746c626 100644
--- a/Cursorial.UI.Bars/Input/KeyTipModel.cs
+++ b/Cursorial.UI.Bars/Input/KeyTipModel.cs
@@ -37,13 +37,10 @@ public static (string? KeyTip, bool Explicit) Resolve(UIElement target)
if (!KeyTip.GetAutoAssign(target))
return (null, false);
- // 4 — the control's access-key mnemonic.
- if (target is ContentControl content)
- {
- var access = content.GetAccessTextInternal();
- if (access.HasKey)
- return (char.ToUpperInvariant(access.Key).ToString(), false);
- }
+ // 4 — the control's access-key mnemonic: a ContentControl's content label, or a headered items control's
+ // header (a MenuItem's `_File` — so a menu row badges the letter its underline shows, not its first letter).
+ if (AccessTextOf(target) is { HasKey: true } access)
+ return (char.ToUpperInvariant(access.Key).ToString(), false);
// 5 — the bound BarCommand's text mnemonic, else its first letter.
if (CommandOf(target) is BarCommand { Text: { Length: > 0 } commandText })
@@ -64,6 +61,14 @@ public static (string? KeyTip, bool Explicit) Resolve(UIElement target)
return (null, false);
}
+ /// The access-key label a control derives its mnemonic from (content or header), or default when it has none.
+ internal static AccessText AccessTextOf(UIElement target) => target switch
+ {
+ ContentControl content => content.GetAccessTextInternal(),
+ HeaderedItemsControl headered => headered.GetAccessText(),
+ _ => default,
+ };
+
private static string? Clean(string? raw)
=> string.IsNullOrWhiteSpace(raw) ? null : raw.Trim().ToUpperInvariant();
diff --git a/Cursorial.UI.Tests/AccessKeyTestHelpers.cs b/Cursorial.UI.Tests/AccessKeyTestHelpers.cs
new file mode 100644
index 00000000..dd3c9dc6
--- /dev/null
+++ b/Cursorial.UI.Tests/AccessKeyTestHelpers.cs
@@ -0,0 +1,168 @@
+using Cursorial.Output;
+using Cursorial.Rendering;
+using Cursorial.Rendering.Media;
+using Cursorial.Text;
+using Cursorial.UI;
+using Cursorial.UI.Controls;
+using Cursorial.UI.Hosting.Headless;
+using Cursorial.UI.Input;
+using Cursorial.UI.Themes;
+
+namespace Cursorial.Tests.UI;
+
+internal static class AccessKeyTestHelpers
+{
+ internal static void AssertCue(bool active, UIHeadlessHost host, int col, int row, UIElement cueOwner)
+ {
+ AssertCueMatch(true, active, host, col, row, cueOwner);
+ AssertCueMatch(false, !active, host, col, row, cueOwner);
+ }
+
+ internal static void AssertNoCue(UIHeadlessHost host, int col, int row, UIElement cueOwner)
+ {
+ AssertCueMatch(expectMatch: false, active: true, host, col, row, cueOwner);
+
+ var inactiveStyle = ResolveCueStyleCore(host, cueOwner, active: false, FindPresenter(cueOwner));
+ if (inactiveStyle.IsIdentity) return;
+
+ AssertCueMatch(expectMatch: false, active: false, host, col, row, cueOwner);
+ }
+
+ private static void AssertCueMatch(bool expectMatch, bool active, UIHeadlessHost host, int col, int row,
+ UIElement? cueOwner = null)
+ {
+ var root = host.Application.RootElement;
+
+ cueOwner ??= root;
+
+ Assert.NotNull(cueOwner);
+
+ var atp = FindPresenter(cueOwner);
+
+ if (atp is not null)
+ {
+ if (expectMatch)
+ Assert.True(active == AccessKeyManager.GetShowUnderline(atp));
+ }
+
+ var expectedStyle = ResolveCueStyle(expectMatch, host, cueOwner, active);
+ var otherStyle = ResolveCueStyle(expectMatch, host, cueOwner, !active);
+
+ AssertCueMatchCore(expectMatch, expectedStyle, host, col, row);
+
+ // If matching active AND inactive is identity, skip; we can't prove an identity style wasn't applied.
+ if (active && otherStyle.IsIdentity)
+ return;
+
+ AssertCueMatchCore(!expectMatch, otherStyle, host, col, row);
+ }
+
+ private static void AssertCueMatchCore(bool expectMatch, BrushedStyle effectiveCueStyle, UIHeadlessHost host,
+ int col, int row)
+ {
+ var resolved = effectiveCueStyle.Resolve(col, row, new Rect(col, row, 1, 1));
+
+ var actualStyle = host.GetCell(col, row).Style;
+ var defaultStyle = host.FrameBuffer.DefaultStyle;
+ var expectedStyle = defaultStyle;
+
+ if (expectMatch) expectedStyle = resolved.ApplyTo(expectedStyle);
+
+ if (expectedStyle.Foreground == actualStyle.Foreground ^ expectMatch)
+ {
+ Fail(nameof(CellStyle.Foreground),
+ expectMatch,
+ expectedStyle.Foreground.ToString(),
+ actualStyle.Foreground.ToString());
+ }
+
+ if (expectedStyle.Background == actualStyle.Background ^ expectMatch)
+ {
+ Fail(nameof(CellStyle.Background),
+ expectMatch,
+ expectedStyle.Background.ToString(),
+ actualStyle.Background.ToString());
+ }
+
+ var expectAttr = expectedStyle.Attributes;
+ var actualAttr = actualStyle.Attributes;
+
+ if (((expectMatch
+ ? expectAttr.HasFlag(TextAttributes.Underline)
+ : resolved.AppliedAttributes.HasFlag(TextAttributes.Underline)) ||
+ actualAttr.HasFlag(TextAttributes.Underline)) &&
+ expectedStyle.UnderlineColor != defaultStyle.UnderlineColor &&
+ expectedStyle.UnderlineColor == actualStyle.UnderlineColor ^ expectMatch)
+ {
+ Fail(nameof(CellStyle.UnderlineColor),
+ expectMatch,
+ expectedStyle.UnderlineColor.ToString(),
+ actualStyle.UnderlineColor.ToString());
+ }
+
+ if ((expectAttr != TextAttributes.None || actualAttr != TextAttributes.None) &&
+ expectAttr == actualAttr ^ expectMatch)
+ {
+ Fail(nameof(CellStyle.Attributes),
+ expectMatch,
+ expectAttr.ToString(),
+ actualAttr.ToString());
+ }
+
+ static void Fail(string attribute, bool expectedMatch, string expected, string actual)
+ {
+ Assert.Fail($"Expected {attribute} to {(expectedMatch ? "MATCH" : "NOT MATCH")}; " +
+ $"expected <{expected}> but found <{actual}>.");
+ }
+ }
+
+ private static BrushedStyle ResolveCueStyle(bool expectMatch, UIHeadlessHost host, UIElement? cueOwner,
+ bool active = true)
+ {
+ Assert.NotNull(cueOwner ??= host.Application.RootElement);
+
+ var atp = FindPresenter(cueOwner!);
+ var cue = ResolveCueStyleCore(host, atp ?? cueOwner, active, atp);
+
+ if (expectMatch is false && active is false && cue.IsIdentity)
+ return cue; // we can only prove a mismatch against an identity style if it remains thus; don't compose.
+
+ var baseStyle = BrushedStyle.FromElement(cueOwner!.VisualRoot!);
+ var ownerStyle = BrushedStyle.FromElement(cueOwner);
+
+ if (atp is not null && BrushedStyle.FromElement(atp) is { IsIdentity: false } atpStyle)
+ ownerStyle = ownerStyle.Then(atpStyle);
+
+ return baseStyle.Then(ownerStyle).Then(cue);
+ }
+
+ private static BrushedStyle ResolveCueStyleCore(UIHeadlessHost host, UIElement? cueOwner, bool active,
+ AccessTextPresenter? atp)
+ {
+ Assert.NotNull(cueOwner ??= host.Application.RootElement);
+
+ if (atp is not null) return active ? atp.ActiveCueStyle : atp.InactiveCueStyle;
+
+ var key = active ? ThemeKeys.InteractiveCueActiveStyle : ThemeKeys.InteractiveCueInactiveStyle;
+
+ Assert.True(cueOwner!.TryFindResource(key, out var cs));
+ return Assert.IsType(cs);
+ }
+
+ private static AccessTextPresenter? FindPresenter(UIElement? cueOwner)
+ {
+ if (cueOwner is AccessTextPresenter presenter)
+ return presenter;
+
+ if (cueOwner?.VisualChildrenList is {} children)
+ {
+ foreach (var child in children)
+ {
+ if (FindPresenter(child) is {} found)
+ return found;
+ }
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Cursorial.UI.Tests/ControlMatrix/Section09_TextBorderAccessKey.cs b/Cursorial.UI.Tests/ControlMatrix/Section09_TextBorderAccessKey.cs
index cf992377..85ba7c89 100644
--- a/Cursorial.UI.Tests/ControlMatrix/Section09_TextBorderAccessKey.cs
+++ b/Cursorial.UI.Tests/ControlMatrix/Section09_TextBorderAccessKey.cs
@@ -2,7 +2,6 @@
using Cursorial.Input;
using Cursorial.Input.Events;
using Cursorial.Media;
-using Cursorial.Output;
using Cursorial.Rendering;
using Cursorial.Rendering.Media;
using Cursorial.Rendering.Text;
@@ -13,6 +12,8 @@
using Cursorial.UI.Input;
using Cursorial.UI.Themes;
+using static Cursorial.Tests.UI.AccessKeyTestHelpers;
+
// ReSharper disable InconsistentNaming
namespace Cursorial.Tests.UI.ControlMatrix;
@@ -305,93 +306,24 @@ public void C175_AccessTextPresenterUnderlinesWhenCueShown()
using var host = Attach(atp);
- var rootStyle = BrushedStyle.FromElement(atp.VisualRoot!);
- var baseStyle = BrushedStyle.FromElement(atp);
- var on = rootStyle.Then(baseStyle).Then(atp.ActiveCueStyle);
- var off = rootStyle.Then(baseStyle).Then(atp.InactiveCueStyle);
-
// No cue: no underline on the mnemonic.
// Assert.False(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
- AssertCueMatch(true, off, host, 0, 0);
+ AssertCue(active: false, host, 0, 0, atp);
// Cue shown: the KeyIndex grapheme is underlined.
AccessKeyManager.SetShowUnderline(atp, true);
host.RunFrame();
- AssertCueMatch(true, on, host, 0, 0);
- AssertCueMatch(false, on, host, 1, 0);
-
- if (atp.InactiveCueStyle.IsIdentity is false)
- AssertCueMatch(false, off, host, 1, 0);
- // Assert.True(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'F' at index 0
- // Assert.False(host.GetCell(1, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'i' not underlined
+ AssertCue(active: true, host, 0, 0, atp);
+ AssertNoCue(host, 1, 0, atp);
var defaultActive = atp.TryFindResource(ThemeKeys.InteractiveCueActiveStyle, out var a) ? (BrushedStyle) a! : default;
var defaultInactive = atp.TryFindResource(ThemeKeys.InteractiveCueInactiveStyle, out var i) ? (BrushedStyle) i! : default;
Assert.True(AccessTextPresenter.TextProperty.GetEffects(typeof(AccessTextPresenter)).HasFlag(PropertyEffects.AffectsMeasure));
- // Assert.Equal(UnderlineStyle.Single, new AccessTextPresenter().KeyUnderline); // default
Assert.Equal(defaultActive, atp.ActiveCueStyle);
Assert.Equal(defaultInactive, atp.InactiveCueStyle);
}
- private void AssertCueMatch(bool expectMatch, BrushedStyle cue, UIHeadlessHost host, int col, int row)
- {
- var resolved = cue.Resolve(col, row, new Rect(col, row, 1, 1));
-
- var actualStyle = host.GetCell(col, row).Style;
- var defaultStyle = host.FrameBuffer.DefaultStyle;
- var expectedStyle = defaultStyle;
-
- if (expectMatch) expectedStyle = resolved.ApplyTo(expectedStyle);
-
- if (expectedStyle.Foreground == actualStyle.Foreground ^ expectMatch)
- {
- Fail(nameof(CellStyle.Foreground),
- expectMatch,
- expectedStyle.Foreground.ToString(),
- actualStyle.Foreground.ToString());
- }
-
- if (expectedStyle.Background == actualStyle.Background ^ expectMatch)
- {
- Fail(nameof(CellStyle.Background),
- expectMatch,
- expectedStyle.Background.ToString(),
- actualStyle.Background.ToString());
- }
-
- var expectAttr = expectedStyle.Attributes;
- var actualAttr = actualStyle.Attributes;
-
- if (((expectMatch
- ? expectAttr.HasFlag(TextAttributes.Underline)
- : resolved.AppliedAttributes.HasFlag(TextAttributes.Underline)) ||
- actualAttr.HasFlag(TextAttributes.Underline)) &&
- expectedStyle.UnderlineColor != defaultStyle.UnderlineColor &&
- expectedStyle.UnderlineColor == actualStyle.UnderlineColor ^ expectMatch)
- {
- Fail(nameof(CellStyle.UnderlineColor),
- expectMatch,
- expectedStyle.UnderlineColor.ToString(),
- actualStyle.UnderlineColor.ToString());
- }
-
- if ((expectAttr != TextAttributes.None || actualAttr != TextAttributes.None) &&
- expectAttr == actualAttr ^ expectMatch)
- {
- Fail(nameof(CellStyle.Attributes),
- expectMatch,
- expectAttr.ToString(),
- actualAttr.ToString());
- }
-
- static void Fail(string attribute, bool expectedMatch, string expected, string actual)
- {
- Assert.Fail($"Expected {attribute} to {(expectedMatch ? "MATCH" : "NOT MATCH")}; " +
- $"expected <{expected}> but found <{actual}>.");
- }
- }
-
[Fact] // C176
public void C176_ParsesAccessKeyLiteralsFlagResolution()
{
@@ -508,8 +440,10 @@ public void C181_AccessKeyCue_PermanentUnderscore_OnLegacyTerminal()
// The 'O' mnemonic cell carries TextAttributes.Underline; the 'K' next to it does not.
var (oc, orow) = presenter.TranslateToWindow(0, 0);
Assert.Equal("O", host.GetCell(oc, orow).Grapheme);
- Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
- Assert.False(host.GetCell(oc + 1, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: true, host, oc, orow, button);
+ AssertNoCue(host, oc + 1, orow, button);
+ // Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.False(host.GetCell(oc + 1, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
using (host) { }
}
@@ -530,7 +464,8 @@ public void C182_AccessKeyCue_AltToggle_OnCapableTerminal()
// At rest: no cue, no underline.
Assert.False(AccessKeyManager.GetShowUnderline(presenter));
- Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: false, host, oc, orow, button);
// Alt down → cue up → the theme rule underlines the mnemonic in the same frame.
host.SendKey(Key.LeftAlt, KeyModifiers.Alt);
@@ -538,14 +473,16 @@ public void C182_AccessKeyCue_AltToggle_OnCapableTerminal()
Assert.True(host.Application.AccessKeys.IsCueActive);
Assert.True(AccessKeyManager.GetShowUnderline(presenter));
Assert.Equal("O", host.GetCell(oc, orow).Grapheme);
- Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: true, host, oc, orow, button);
// A clean Alt down/up is an Alt TAP (N171): the cue goes sticky (menu mode) and stays up — so
// the underscore persists across the release. This is the framework's pinned behavior.
host.SendInput(new KeyEvent { Key = Key.LeftAlt, Modifiers = KeyModifiers.None, Kind = KeyEventKind.Up, Timestamp = host.Time.GetUtcNow() });
host.RunFrame();
Assert.True(host.Application.AccessKeys.IsCueActive); // sticky tap holds the cue
- Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: true, host, oc, orow, button);
// A SECOND Alt tap toggles the sticky cue off (N178) → cue down → the underline reverts.
host.SendKey(Key.LeftAlt, KeyModifiers.Alt);
@@ -554,7 +491,8 @@ public void C182_AccessKeyCue_AltToggle_OnCapableTerminal()
host.RunFrame();
Assert.False(host.Application.AccessKeys.IsCueActive);
Assert.False(AccessKeyManager.GetShowUnderline(presenter));
- Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: false, host, oc, orow, button);
}
[Fact] // C182b — terminal focus-out clears the cue and the underline (ND24 ①).
@@ -569,13 +507,15 @@ public void C182b_AccessKeyCue_TerminalFocusOut_ClearsUnderline()
host.SendKey(Key.LeftAlt, KeyModifiers.Alt);
host.RunFrame();
- Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.True(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: true, host, oc, orow, button);
// A terminal focus-out (Alt+Tab swallows the Up) must clear the cue unconditionally.
host.SendInput(new FocusEvent { HasFocus = false, Timestamp = host.Time.GetUtcNow() });
host.RunFrame();
Assert.False(host.Application.AccessKeys.IsCueActive);
- Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ // Assert.False(host.GetCell(oc, orow).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(active: false, host, oc, orow, button);
}
// ─── registration THROUGH the manager (not OnAccessKey directly) — C177/C178 producer ③ ───
diff --git a/Cursorial.UI.Tests/ControlMatrix/Section20_ContextMenu.cs b/Cursorial.UI.Tests/ControlMatrix/Section20_ContextMenu.cs
index a853895c..a19709c3 100644
--- a/Cursorial.UI.Tests/ControlMatrix/Section20_ContextMenu.cs
+++ b/Cursorial.UI.Tests/ControlMatrix/Section20_ContextMenu.cs
@@ -163,6 +163,33 @@ public void C7_7_LeafInvokeDismisses()
Assert.Equal(0, Popups(host)); // the popup surface was released
}
+ [Fact] // C7.7b: keyboard focus moving OUT of the open menu (to an element elsewhere) closes it — a menu left open
+ // behind focus that has moved on would answer the next Escape/arrow for nobody (maintainer, 2026-09-12).
+ public void C7_7b_FocusLeavingTheMenu_Closes()
+ {
+ var leaf = new MenuItem { Header = "Cut" };
+ var menu = new ContextMenu();
+ menu.Items.Add(leaf);
+ using var host = Host();
+ var owner = new Border { Width = 30, Height = 6 };
+ ContextMenu.SetMenu(owner, menu);
+ var elsewhere = new TextBox();
+ host.ShowRoot(new StackPanel { Orientation = Orientation.Vertical, Children = { owner, elsewhere } });
+ host.RunUntilIdle();
+
+ menu.Open(owner);
+ host.RunUntilIdle();
+ Assert.True(leaf.IsFocused);
+ Assert.Equal(1, Popups(host));
+
+ elsewhere.Focus();
+ host.RunUntilIdle();
+
+ Assert.False(menu.IsOpen);
+ Assert.Equal(0, Popups(host));
+ Assert.True(elsewhere.IsFocused);
+ }
+
[Fact] // C7.8: Escape closes the open menu
public void C7_8_EscapeCloses()
{
diff --git a/Cursorial.UI.Tests/Controls/AccessTextPipelineTests.cs b/Cursorial.UI.Tests/Controls/AccessTextPipelineTests.cs
index a5231fe5..a9d446b9 100644
--- a/Cursorial.UI.Tests/Controls/AccessTextPipelineTests.cs
+++ b/Cursorial.UI.Tests/Controls/AccessTextPipelineTests.cs
@@ -15,6 +15,10 @@
using Cursorial.UI.Input;
using Cursorial.UI.Hosting.Headless;
+using static Cursorial.Tests.UI.AccessKeyTestHelpers;
+
+// ReSharper disable MemberCanBePrivate.Local
+
namespace Cursorial.Tests.UI.Controls;
///
@@ -146,7 +150,7 @@ public void FastPath_WithIndicator_PaintsCellForCellIdenticallyToTheFullPipeline
var full = FullPipeline(f.Cache, text, in request, in carrier);
- Assert.Equal(full.Size, fast!.Size);
+ Assert.Equal(full.Size, fast.Size);
Assert.Equal(full.ProvidedColumns, fast.ProvidedColumns);
Assert.Equal(full.HasTrimmedLines, fast.HasTrimmedLines);
@@ -154,11 +158,11 @@ public void FastPath_WithIndicator_PaintsCellForCellIdenticallyToTheFullPipeline
$"columns={columns} carrier={(carrier == PlainCarrier ? "plain" : "gradient")}";
AssertTwinBuffers(fast, full, columns, 1, label + " lane=paint",
- (FormattedText ft, in CellBufferView view, in Rect rect)
+ (ft, in view, in rect)
=> ft.Paint(view, rect, OutputCapabilities.None));
AssertTwinBuffers(fast, full, columns, 1, label + " lane=preference",
- (FormattedText ft, in CellBufferView view, in Rect rect) =>
+ (ft, in view, in rect) =>
{
var document = ft; // copies: `in` parameters cannot be
var paintRect = rect; // captured by the scene-draw closure
@@ -189,16 +193,16 @@ public void FastPath_OutOfRangeIndicatorCluster_FormatsAsPlainText_InBothLanes()
var full = FullPipeline(f.Cache, "Save", in request, PlainCarrier);
- AssertTwinBuffers(fast!, full, 10, 1, "out-of-range cluster",
- (FormattedText ft, in CellBufferView view, in Rect rect)
+ AssertTwinBuffers(fast, full, 10, 1, "out-of-range cluster",
+ (ft, in view, in rect)
=> ft.Paint(view, rect, OutputCapabilities.None));
// ...and both equal the indicator-free layout (the declaration didn't land anywhere).
var plainRequest = Request("Save", columns: 10, indicator: null);
var plain = f.Cache.TryFormatPlainTextFast(in plainRequest, PlainCarrier);
Assert.NotNull(plain);
- AssertTwinBuffers(fast!, plain!, 10, 1, "out-of-range vs plain",
- (FormattedText ft, in CellBufferView view, in Rect rect)
+ AssertTwinBuffers(fast, plain, 10, 1, "out-of-range vs plain",
+ (ft, in view, in rect)
=> ft.Paint(view, rect, OutputCapabilities.None));
}
@@ -230,6 +234,7 @@ private static void AssertTwinBuffers(FormattedText fast, FormattedText full,
// ─────────────────────────────── presenter routing wiring ───────────────────────────────
+ // ReSharper disable once UnusedTupleComponentInReturnValue
private static (UIHeadlessHost Host, SlotHost Slot) Show(UIElement child, int columns, int rows)
{
var slot = new SlotHost(child) { SlotRect = new Rect(0, 0, columns, rows) };
@@ -260,8 +265,8 @@ public void AccessTextPresenter_FittingLabel_RoutesThroughTheFastPath()
// The frame is the ordinary cue'd render…
Assert.Equal("S", host.GetCell(0, 0).Grapheme);
- Assert.True(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
- Assert.False(host.GetCell(1, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(true, host, 0, 0, presenter);
+ AssertNoCue(host, 1, 0, presenter);
// …and it was built by the fast path, never the RichText pipeline — the indicator is part
// of the fast subset now (M2).
@@ -286,12 +291,13 @@ public void AccessTextPresenter_OverflowingLabel_RoutesThroughTheFullPipeline_An
var (host, _) = Show(presenter, columns: 8, rows: 1);
using var _1 = host;
+ // ReSharper disable once StringLiteralTypo
Assert.StartsWith("Documen…", host.GetRowText(0));
Assert.True(presenter.GetValue(TextElement.IsTrimmedProperty));
// The mnemonic survives the trim and wears the cue — through the FULL pipeline.
- Assert.True(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
- Assert.False(host.GetCell(1, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(true, host, 0, 0, presenter);
+ AssertNoCue(host, 1, 0, presenter);
Assert.Equal(0, presenter.Cache.FastPathFormatCount);
Assert.True(presenter.Cache.FullFormatCount >= 1,
@@ -309,9 +315,9 @@ public void AccessTextPresenter_WideGlyphMnemonic_CarriesTheCueOnTheWideCluster(
Assert.Equal("日", host.GetCell(0, 0).Grapheme);
Assert.Equal("本", host.GetCell(2, 0).Grapheme);
- Assert.False(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
- Assert.True(host.GetCell(2, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
- Assert.False(host.GetCell(4, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertNoCue(host, 0, 0, presenter);
+ AssertCue(true, host, 2, 0, presenter);
+ AssertNoCue(host, 4, 0, presenter);
}
// ─────────────────────────────── cue / carrier freshness ───────────────────────────────
@@ -329,11 +335,11 @@ public void AccessTextPresenter_ShowUnderlineFlip_TogglesTheCueThroughTheCache()
// the repaint re-formats by key miss — no measure pass needed, no stale layout served.
AccessKeyManager.SetShowUnderline(presenter, true);
host.RunFrame();
- Assert.True(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(true, host, 0, 0, presenter);
AccessKeyManager.SetShowUnderline(presenter, false);
host.RunFrame();
- Assert.False(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline));
+ AssertCue(false, host, 0, 0, presenter);
}
[Fact]
@@ -399,7 +405,7 @@ public void AccessTextPresenter_EllipsisCutAtTheMnemonic_TheEllipsisStaysPlain()
Assert.Equal("Sa…", host.GetRowText(0).TrimEnd());
Assert.False(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'S'
- Assert.True(host.GetCell(1, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'a' — the cue
+ AssertCue(true, host, 1, 0, presenter); // 'a' — the cue
Assert.False(host.GetCell(2, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // '…' — plain, no cue
}
@@ -419,7 +425,7 @@ public void AccessTextPresenter_WordEllipsisCutAtTheMnemonic_TheEllipsisStaysPla
Assert.Equal("Sa…", host.GetRowText(0).TrimEnd());
Assert.False(host.GetCell(0, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'S'
- Assert.True(host.GetCell(1, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // 'a' — the cue
+ AssertCue(true, host, 1, 0, presenter); // 'a' — the cue
Assert.False(host.GetCell(2, 0).Style.Attributes.HasFlag(TextAttributes.Underline)); // '…' — plain, no cue
}
}
diff --git a/Cursorial.UI/Controls/AccessText.cs b/Cursorial.UI/Controls/AccessText.cs
index 72955fcb..a1fc9eb8 100644
--- a/Cursorial.UI/Controls/AccessText.cs
+++ b/Cursorial.UI/Controls/AccessText.cs
@@ -12,8 +12,8 @@ namespace Cursorial.UI.Controls;
/// The grapheme index of the mnemonic in ; -1 when there is none.
public readonly record struct AccessText(string Text, char Key, int KeyIndex)
{
- /// Whether the label carries a mnemonic.
- public bool HasKey => KeyIndex >= 0;
+ /// Whether the label carries a mnemonic — a key character AND its index (a default-constructed value has index 0 but no key).
+ public bool HasKey => Key is not '\0' && KeyIndex >= 0;
///
/// Parses an access-key label (design doc §12.5): "_File" → ("File",'F',0);
diff --git a/Cursorial.UI/Controls/ContextMenu.cs b/Cursorial.UI/Controls/ContextMenu.cs
index f539ef9c..9031b7d6 100644
--- a/Cursorial.UI/Controls/ContextMenu.cs
+++ b/Cursorial.UI/Controls/ContextMenu.cs
@@ -205,6 +205,7 @@ private Popup CreatePopup()
popup.SetBinding(MaxWidthProperty, CompiledBinding.For(MaxWidthProperty, source: this));
popup.SetBinding(MaxHeightProperty, CompiledBinding.For(MaxHeightProperty, source: this));
popup.Closed += OnPopupClosed;
+ popup.LostFocus += OnPopupLostFocus;
return popup;
}
@@ -219,6 +220,12 @@ private void OnPopupClosed(object? sender, PopupClosedEventArgs e)
RaiseEvent(closed); // every close path funnels here — exactly one Closed per close
}
+ private void OnPopupLostFocus(object? sender, FocusChangedEventArgs e)
+ {
+ if (e.OldFocus is { IsAttachedToTree: true } old && IsAncestorOf(old) && IsKeyboardFocusWithin is false)
+ Close();
+ }
+
private void FocusFirstItem()
{
for (var i = 0; i < ItemContainerGenerator.ContainerCount; i++)
diff --git a/Cursorial.UI/Controls/HeaderedItemsControl.cs b/Cursorial.UI/Controls/HeaderedItemsControl.cs
index ebaeff88..f718d6ed 100644
--- a/Cursorial.UI/Controls/HeaderedItemsControl.cs
+++ b/Cursorial.UI/Controls/HeaderedItemsControl.cs
@@ -33,6 +33,10 @@ private static void OnHeaderChanged(UIObject sender, object? oldValue, object? n
control.AddLogicalChild(newElement);
}
+ /// The header's parsed access-key label when the header is a string and this runtime type folds access-key literals (a 's _File); default otherwise.
+ internal AccessText GetAccessText()
+ => Header is string s && HeaderProperty.GetMetadata(GetType()).ParsesAccessKeyLiterals == true ? AccessText.Parse(s) : default;
+
///
public object? Header { get => GetValue(HeaderProperty); set => SetValue(HeaderProperty, value); }
diff --git a/Cursorial.UI/Controls/MenuItem.cs b/Cursorial.UI/Controls/MenuItem.cs
index 2669716f..1da4a257 100644
--- a/Cursorial.UI/Controls/MenuItem.cs
+++ b/Cursorial.UI/Controls/MenuItem.cs
@@ -843,9 +843,6 @@ protected virtual void OnAccessKey(AccessKeyEventArgs e)
OnClick(InvokeMethod.AccessKey);
}
- private AccessText GetAccessText()
- => Header is string s && HeaderProperty.GetMetadata(GetType()).ParsesAccessKeyLiterals == true ? AccessText.Parse(s) : default;
-
private void RegisterAccessKey()
{
if (UIApplication.Current?.AccessKeys is not { } manager)
diff --git a/Cursorial.UI/Controls/TextElement.cs b/Cursorial.UI/Controls/TextElement.cs
index 50ec7b91..55971034 100644
--- a/Cursorial.UI/Controls/TextElement.cs
+++ b/Cursorial.UI/Controls/TextElement.cs
@@ -170,6 +170,13 @@ public static TextSizing GetSizing(UIElement element)
return element.GetValue(SizingProperty);
}
+ /// The element's effective glyph source — its and as one .
+ public static GlyphSource GetGlyphSource(UIElement element)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+ return new GlyphSource(element.GetValue(FontProperty), element.GetValue(SizingProperty));
+ }
+
/// Sets the text sizing on .
public static void SetSizing(UIElement element, TextSizing value)
{
diff --git a/Cursorial.UI/Themes/CursorialTheme.cs b/Cursorial.UI/Themes/CursorialTheme.cs
index c609531d..65fe06a4 100644
--- a/Cursorial.UI/Themes/CursorialTheme.cs
+++ b/Cursorial.UI/Themes/CursorialTheme.cs
@@ -719,8 +719,9 @@ private static void AddTierPalette(ResourceDictionary dict, ThemeBase @base)
// Ansi16 indices, or they collapse to the NoColor floor (no fill) on 16-color terminals.
ansi16[ThemeKeys.RibbonTabStripBrush] = Palette(dark ? 0 : 7); // recess, tracks Surface/Panel
ansi16[ThemeKeys.RibbonTabActiveBrush] = Palette(dark ? 0 : 15); // dropped active fill, tracks the band
- ansi16[ThemeKeys.KeyTipBrush] = Palette(3); // amber → yellow
- ansi16[ThemeKeys.KeyTipMatchedBrush] = Palette(8); // dimmed matched → bright-black
+ ansi16[ThemeKeys.KeyTipBrush] = Palette(3); // amber → yellow
+ ansi16[ThemeKeys.KeyTipMatchedBrush] = Palette(8); // dimmed matched → bright-black
+ ansi16[ThemeKeys.KeyTipTextWeight] = TextWeight.Bold;
ansi16[ThemeKeys.RibbonContextualFillBrush] = Palette(dark ? 0 : 7); // tinted well, tracks the recess
ansi16[ThemeKeys.RibbonContextualUnderlinePen] = Pens.Heavy.WithColor(Color.FromPalette(dark ? (byte)13 : (byte)5)); // purple
@@ -770,37 +771,43 @@ private static void AddTierPalette(ResourceDictionary dict, ThemeBase @base)
private static void AddCueStyle(ResourceDictionary root, ThemeVariant v, ResourceDictionary d, bool noColor = false)
{
- // TextAttributes a = Resolve(root, v, ThemeKeys.KeyTipTextWeight, out var wv) && wv is TextWeight w
- // ? w switch
- // {
- // TextWeight.Bold => TextAttributes.Bold,
- // TextWeight.Faint => TextAttributes.Faint,
- // _ => TextAttributes.None
- // }
- // : TextAttributes.None;
-
- TextAttributes a = v.Tier switch
- {
- ColorDepth.NoColor => TextAttributes.Faint,
- ColorDepth.Ansi16 => TextAttributes.Bold,
- _ => TextAttributes.None
- };
+ TextAttributes a = Resolve(root, v, ThemeKeys.KeyTipTextWeight, out var wv) && wv is TextWeight w
+ ? w switch
+ {
+ TextWeight.Bold => TextAttributes.Bold,
+ TextWeight.Faint => TextAttributes.Faint,
+ _ => TextAttributes.None
+ }
+ : v.Tier switch
+ {
+ ColorDepth.NoColor => TextAttributes.Faint,
+ ColorDepth.Ansi16 => TextAttributes.Bold,
+ _ => TextAttributes.None
+ };
+
+ // TextAttributes a = v.Tier switch
+ // {
+ // ColorDepth.NoColor => TextAttributes.Faint,
+ // ColorDepth.Ansi16 => TextAttributes.Bold,
+ // _ => TextAttributes.None
+ // };
TextAttributes i = noColor ? TextAttributes.Inverse : TextAttributes.None;
TextAttributes u = TextAttributes.Underline;
- // Add(root, v, d, ThemeKeys.InteractiveCueInactiveStyle, null, null,
- // applied: noColor ? a : u, toggled: i, ulStyle: UnderlineStyle.Dotted);
- //
- // Add(root, v, d, ThemeKeys.InteractiveCueActiveStyle, ThemeKeys.KeyTipUnmatchedBrush, ThemeKeys.KeyTipBrush,
- // applied: a, toggled: i, removed: u, ulKey: ThemeKeys.KeyTipUnmatchedBrush);
-
Add(root, v, d, ThemeKeys.InteractiveCueInactiveStyle, null, null,
- applied: a, toggled: i, removed: u);
+ applied: noColor ? a : TextAttributes.None, toggled: i/*, ulStyle: UnderlineStyle.Dotted*/);
- Add(root, v, d, ThemeKeys.InteractiveCueActiveStyle, null, null,
- applied: a | u, toggled: i);
+ Add(root, v, d, ThemeKeys.InteractiveCueActiveStyle, ThemeKeys.KeyTipUnmatchedBrush, ThemeKeys.KeyTipBrush,
+ applied: a, toggled: i, removed: u/*, ulKey: ThemeKeys.KeyTipUnmatchedBrush*/);
+
+ Add(root, v, d, ThemeKeys.InteractiveCueActiveAlternateStyle, null, null, toggled: TextAttributes.Inverse);
+ // Add(root, v, d, ThemeKeys.InteractiveCueInactiveStyle, null, null,
+ // applied: a, toggled: i, removed: u);
+ //
+ // Add(root, v, d, ThemeKeys.InteractiveCueActiveStyle, null, null,
+ // applied: a | u, toggled: i);
}
private static void AddInputTextStyles(ResourceDictionary root, ThemeVariant v, ResourceDictionary d,
diff --git a/Cursorial.UI/Themes/CursorialThemeStyles.cs b/Cursorial.UI/Themes/CursorialThemeStyles.cs
index 1d4df273..e699c5e7 100644
--- a/Cursorial.UI/Themes/CursorialThemeStyles.cs
+++ b/Cursorial.UI/Themes/CursorialThemeStyles.cs
@@ -434,9 +434,7 @@ internal static Style WarningStyle()
.SetResource(Control.BorderPenProperty, ThemeKeys.WarningBorderPen)
.SetResource(TextElement.ForegroundProperty, ThemeKeys.WarningBrush),
new Style("^Button")
- //.Set(AccessTextPresenter.ActiveCueStyleProperty, BrushedStyle.Identity
- // .Applying(TextAttributes.Inverse)
- // .RemovingUnderline())
+ .SetResource(AccessTextPresenter.ActiveCueStyleProperty, ThemeKeys.InteractiveCueActiveAlternateStyle)
.SetResource(TextElement.ForegroundProperty, ThemeKeys.OnAccentBrush)
.SetResource(Panel.BackgroundProperty, ThemeKeys.WarningBrush),
new Style("^Button:pointerover")
diff --git a/Cursorial.UI/Themes/ThemeKeys.cs b/Cursorial.UI/Themes/ThemeKeys.cs
index 6204798f..d4dc7c5e 100644
--- a/Cursorial.UI/Themes/ThemeKeys.cs
+++ b/Cursorial.UI/Themes/ThemeKeys.cs
@@ -350,6 +350,13 @@ public static class ThemeKeys
/// The base text style applied to the active mnemonic grapheme of the access key cue.
public const string InteractiveCueActiveStyle = "Theme.InteractiveCueActiveStyle";
+ /// An alternate base text style applied to the active mnemonic grapheme of the access key cue.
+ ///
+ /// Use this when has a color clash with the target control or otherwise
+ /// lacks visual distinction.
+ ///
+ public const string InteractiveCueActiveAlternateStyle = "Theme.InteractiveCueActiveAlternateStyle";
+
/// The base text style applied to the inactive mnemonic grapheme of the access key cue.
public const string InteractiveCueInactiveStyle = "Theme.InteractiveCueInactiveStyle";
diff --git a/Cursorial.UI/UIElement.cs b/Cursorial.UI/UIElement.cs
index 57029b78..4b912c36 100644
--- a/Cursorial.UI/UIElement.cs
+++ b/Cursorial.UI/UIElement.cs
@@ -828,6 +828,21 @@ public bool IsEffectivelyVisible
// ───────────────────────────── coordinate translation ─────────────────────────────
+ ///
+ /// Translates this element's local (, ) into
+ /// 's local coordinates — through screen space, so the two may sit on different
+ /// surfaces (a badge overlay locating a cell of a control inside a popup).
+ ///
+ public (int Column, int Row) TranslateTo(UIElement target, int column, int row)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+
+ var (screenColumn, screenRow) = TranslateToScreen(column, row);
+ var (targetColumn, targetRow) = target.TranslateToScreen(0, 0);
+
+ return (screenColumn - targetColumn, screenRow - targetRow);
+ }
+
///
/// Translates element-local coordinates to window (visual-root) coordinates — the inverse of
/// .