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 /// .