From ffb606e5cd8dd16efbe8240686d639c4674355ed Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:53:37 +1300 Subject: [PATCH 1/3] Show each command's mnemonic in the command palette and filter on it (#341) Rows are laid out at the list's width by ActionRow.Display, like the Keyboard Shortcuts pane: label, keys, then the mnemonic right-aligned. A narrow list shortens the label first and never the mnemonic, and the palette shrinks to fit a terminal narrower than its usual 76 columns. The filter matches mnemonics by prefix, alongside the existing substring match on label, keys and id. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Workbench/Actions/ActionRow.cs | 53 ++++++++++++++++++++ src/TuiCode.Workbench/Actions/ActionView.cs | 22 ++++----- tests/TuiCode.Tests/ActionViewTests.cs | 55 +++++++++++++++++++-- 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 src/TuiCode.Workbench/Actions/ActionRow.cs diff --git a/AGENTS.md b/AGENTS.md index fe44e6f..454a29f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ What follows is how TuiCode implements it today. 1. Constant in `TuiCode.Abstractions.CommandIds`. 2. Register handler in `WorkbenchHost.RegisterDefaultCommands` via the labelled `Register(id, label, handler, scope)` overload. 3. Bind in `WorkbenchHost.BindDefaults` (called by `ApplyKeybindings`). -- **Command scopes (#132).** Every command has a fixed `CommandScope` from registration: `Global` (the default), `Editor`, `Explorer`, `Find` or `Diff`. A binding lands in its command's scope, user overrides and `-` removals included, so the keybindings file carries no scope. The store is one trie per scope; `Handle` tries the focused scope first (`WorkbenchHost.FocusedScope`: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with `isEnabled` that says no is skipped, so its key falls through to Global: explorer `Esc` cancels only a pending cut and editor `Esc` only removes extra cursors, otherwise it focuses the editor. **The document-changing commands are `Editor`** (#283): move/duplicate line up/down, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay `Global` — they act on the tab as a whole and are useful from the file tree. `ScopedKeybindingsHostTests` pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. **The command palette (`Ctrl+E`) lists Global plus the scope the keys were in when it opened, less whatever `isEnabled` says no to** (#284): `ActionView` filters `ICommandService.Registered` on the `CommandScope` `WorkbenchHost.OpenActions` captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on `IsEnabled`, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: `Ctrl+F`/`Ctrl+H` with no tab open the Find pane, and `Esc` says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. **Scope governs the keys, the palette's list and the leader's list** (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and `isEnabled` alike. +- **Command scopes (#132).** Every command has a fixed `CommandScope` from registration: `Global` (the default), `Editor`, `Explorer`, `Find` or `Diff`. A binding lands in its command's scope, user overrides and `-` removals included, so the keybindings file carries no scope. The store is one trie per scope; `Handle` tries the focused scope first (`WorkbenchHost.FocusedScope`: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with `isEnabled` that says no is skipped, so its key falls through to Global: explorer `Esc` cancels only a pending cut and editor `Esc` only removes extra cursors, otherwise it focuses the editor. **The document-changing commands are `Editor`** (#283): move/duplicate line up/down, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay `Global` — they act on the tab as a whole and are useful from the file tree. `ScopedKeybindingsHostTests` pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. **The command palette (`Ctrl+E`) lists Global plus the scope the keys were in when it opened, less whatever `isEnabled` says no to** (#284): `ActionView` filters `ICommandService.Registered` on the `CommandScope` `WorkbenchHost.OpenActions` captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on `IsEnabled`, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: `Ctrl+F`/`Ctrl+H` with no tab open the Find pane, and `Esc` says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. Each palette row ends with the command's mnemonic from `CommandMnemonics` (#341), right-aligned so the column lines up; `ActionRow.Display` lays the row out at the list's width and shortens the label first, never the mnemonic. The filter matches label, keys and id by substring and mnemonics by prefix, so `s` lists the `s…` family as the leader would. **Scope governs the keys, the palette's list and the leader's list** (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and `isEnabled` alike. - Chord trie keyed on `Key`; `"Ctrl+W X"` is a single binding. Esc cancels in-flight chords; stray keys abort and are consumed silently. - **Showing a key to the user goes through `KeyChord.Display`**, which renders the cursor keys as arrows (`Alt+↓`, not TG's `Alt+CursorDown`, #194) and everything else as `Key.ToString()`. It's display-only and never parsed back, so identity stays `KeyChord.Canonical`. The status-bar hints, the keybindings picker, the command palette and the in-flight chord all use it. - Bare letters drop Shift and lowercase: `"x"`, `"X"`, `"shift+x"` collide. `KeybindingService.Bindings` emits the lowercased form. Casefold in UI if needed. diff --git a/src/TuiCode.Workbench/Actions/ActionRow.cs b/src/TuiCode.Workbench/Actions/ActionRow.cs new file mode 100644 index 0000000..f65380c --- /dev/null +++ b/src/TuiCode.Workbench/Actions/ActionRow.cs @@ -0,0 +1,53 @@ +using System.Collections; +using System.Collections.Specialized; +using TuiCode.Abstractions; + +namespace TuiCode.Workbench.Actions; + +internal sealed record ActionRow(string CommandId, string Label, string Bindings, string Mnemonic) +{ + private const int Gap = 2; + private const int KeysWidth = 18; + private const int MinLabelWidth = 10; + private static readonly int MnemonicWidth = CommandMnemonics.All.Max(m => m.Value.Length); + + // Keys longer than their column take the room from their own row's label. + /// Label, keys and mnemonic in columns. A narrow list shortens the label first; the mnemonic is never cut. + public string Display(int width) + { + var available = Math.Max(width - MnemonicWidth - 2 * Gap, MinLabelWidth); + var keysColumn = Math.Min(KeysWidth, Math.Max(available - MinLabelWidth, 0)); + var keys = Truncate(Bindings, Math.Max(available - MinLabelWidth, 0)); + var labelWidth = available - Math.Max(keysColumn, keys.Length); + return $"{Truncate(Label, labelWidth).PadRight(labelWidth + Gap)}{keys.PadRight(available - labelWidth + Gap)}{Mnemonic.PadLeft(MnemonicWidth)}"; + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : max <= 0 ? "" : s[..(max - 1)] + "…"; +} + +internal sealed class ActionListSource(IReadOnlyList rows) : IListDataSource +{ + public event NotifyCollectionChangedEventHandler? CollectionChanged { add { } remove { } } + + public int Count => rows.Count; + + // Rows never exceed the viewport, so there's nothing to scroll sideways to. + public int MaxItemLength => 0; + + public bool SuspendCollectionChangedEvent { get; set; } + + public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) + { + listView.Move(col, row); + listView.AddStr(rows[item].Display(width).PadRight(width)); + } + + public bool IsMarked(int item) => false; + + public void SetMark(int item, bool value) { } + + public IList ToList() => rows.Select(r => r.Label).ToList(); + + public void Dispose() { } +} diff --git a/src/TuiCode.Workbench/Actions/ActionView.cs b/src/TuiCode.Workbench/Actions/ActionView.cs index 82629f3..12f57e9 100644 --- a/src/TuiCode.Workbench/Actions/ActionView.cs +++ b/src/TuiCode.Workbench/Actions/ActionView.cs @@ -16,6 +16,8 @@ namespace TuiCode.Workbench.Actions; /// public sealed class ActionView : Window { + private const int PreferredWidth = 76; + private readonly Action _execute; private readonly TextField _search; private readonly ListView _list; @@ -51,7 +53,7 @@ public ActionView( BorderStyle = LineStyle.Single; X = Pos.Center(); Y = Pos.Center(); - Width = 76; + Width = Dim.Func(_ => Math.Min(PreferredWidth, SuperView?.Viewport.Width ?? PreferredWidth)); Height = 22; // Required for descendant focus — same reason as KeybindingsPickerView. CanFocus = true; @@ -99,7 +101,8 @@ private static List BuildRows(ICommandService commands, IKeybindingSe .Select(c => new ActionRow( c.Id, c.Label, - bindingsByCommand.TryGetValue(c.Id, out var seqs) ? string.Join(", ", seqs) : "")) + bindingsByCommand.TryGetValue(c.Id, out var seqs) ? string.Join(", ", seqs) : "", + CommandMnemonics.For(c.Id) ?? "")) .OrderBy(r => r.Label, StringComparer.OrdinalIgnoreCase) .ToList(); } @@ -111,21 +114,18 @@ private void RebuildVisible() ? _allRows : _allRows.Where(r => Matches(r, query)).ToList(); - var lines = _visibleRows.Select(FormatRow).ToList(); - _list.Source = new ListWrapper(new(lines)); + _list.Source = new ActionListSource(_visibleRows); _list.SelectedItem = _visibleRows.Count > 0 ? 0 : null; } private static bool Matches(ActionRow r, string needle) => r.Label.Contains(needle, StringComparison.OrdinalIgnoreCase) || r.Bindings.Contains(needle, StringComparison.OrdinalIgnoreCase) - || r.CommandId.Contains(needle, StringComparison.OrdinalIgnoreCase); - - private static string FormatRow(ActionRow r) => - $"{Truncate(r.Label, 48).PadRight(50)}{r.Bindings}"; + || r.CommandId.Contains(needle, StringComparison.OrdinalIgnoreCase) + || r.Mnemonic.StartsWith(needle, StringComparison.OrdinalIgnoreCase); - private static string Truncate(string s, int max) => - s.Length <= max ? s : s[..(max - 1)] + "…"; + /// The row for as the list draws it columns wide. + internal string Row(string label, int width) => _visibleRows.Single(r => r.Label == label).Display(width); private void RegisterScopeBindings() { @@ -145,5 +145,5 @@ private void ExecuteSelected() _execute(commandId); } - private sealed record ActionRow(string CommandId, string Label, string Bindings); } + diff --git a/tests/TuiCode.Tests/ActionViewTests.cs b/tests/TuiCode.Tests/ActionViewTests.cs index 7d9699d..35b2e38 100644 --- a/tests/TuiCode.Tests/ActionViewTests.cs +++ b/tests/TuiCode.Tests/ActionViewTests.cs @@ -11,7 +11,8 @@ public class ActionViewTests { private static readonly (string Id, string Label, CommandScope Scope)[] Commands = [ - ("save", "Save active editor", CommandScope.Global), + (CommandIds.SaveActiveEditor, "Save active editor", CommandScope.Global), + (CommandIds.SelectAllOccurrences, "Select all occurrences", CommandScope.Editor), ("quit", "Quit", CommandScope.Global), ("move", "Move line up", CommandScope.Editor), ("delete", "Delete file or folder", CommandScope.Explorer), @@ -21,7 +22,7 @@ private static readonly (string Id, string Label, CommandScope Scope)[] Commands [Theory] [InlineData(CommandScope.Global, "Quit", "Save active editor")] - [InlineData(CommandScope.Editor, "Move line up", "Quit", "Save active editor")] + [InlineData(CommandScope.Editor, "Move line up", "Quit", "Save active editor", "Select all occurrences")] [InlineData(CommandScope.Explorer, "Delete file or folder", "Quit", "Save active editor")] [InlineData(CommandScope.Find, "Focus find results", "Quit", "Save active editor")] [InlineData(CommandScope.Diff, "Next change", "Quit", "Save active editor")] @@ -56,6 +57,52 @@ public void Typing_filters_within_the_scope_the_palette_captured() Assert.Equal(["Move line up"], editorPalette.Labels); } + [Fact] + public void Each_row_ends_with_the_commands_mnemonic_or_nothing_if_it_has_none() + { + using var view = Build(CommandScope.Editor); + + Assert.EndsWith($" {CommandMnemonics.For(CommandIds.SaveActiveEditor)}", view.Row("Save active editor", 72)); + Assert.EndsWith($" {CommandMnemonics.For(CommandIds.SelectAllOccurrences)}", view.Row("Select all occurrences", 72)); + Assert.Equal(view.Row("Quit", 72).TrimEnd(), "Quit"); + } + + [Theory] + [InlineData("sf", "Save active editor")] + [InlineData("SAO", "Select all occurrences")] + [InlineData("occurr", "Select all occurrences")] + [InlineData("Alt+", "Move line up")] + public void Typing_a_mnemonic_label_or_key_finds_the_command(string query, string expected) + { + var commands = Registered(); + var keybindings = new KeybindingService(commands); + keybindings.Bind("Alt+CursorUp", "move"); + using var view = new ActionView(commands, keybindings, CommandScope.Editor, _ => { }); + + Type(view, query); + + Assert.Equal([expected], view.Labels); + } + + [Fact] + public void The_mnemonics_line_up_and_a_narrow_list_shortens_the_label_rather_than_the_mnemonic() + { + var commands = Registered(); + var keybindings = new KeybindingService(commands); + keybindings.Bind("Ctrl+Shift+L", CommandIds.SelectAllOccurrences); + using var view = new ActionView(commands, keybindings, CommandScope.Editor, _ => { }); + + var wide = view.Row("Select all occurrences", 72); + var narrow = view.Row("Select all occurrences", 32); + + Assert.Equal(72, wide.Length); + Assert.Equal(view.Row("Save active editor", 72).Length, wide.Length); + Assert.Equal(32, narrow.Length); + Assert.EndsWith("sao", narrow); + Assert.Contains("…", narrow); + Assert.Contains("Ctrl+Shift+L", narrow); + } + private static ActionView Build(CommandScope scope) { var commands = Registered(); @@ -72,7 +119,5 @@ private static CommandService Registered() private static void Type(ActionView view, string query) => view.SubViews.OfType().Single().Text = query; - private static string Row(ActionView view, string label) => - view.SubViews.OfType().Single().Source!.ToList().Cast() - .Single(r => r.StartsWith(label, StringComparison.Ordinal)); + private static string Row(ActionView view, string label) => view.Row(label, 72); } From 06f7ad643408f3d025f85f9e10bfa2da88764fcc Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:50:03 +1300 Subject: [PATCH 2/3] Head the palette's columns and label its filter (#341) The palette now shows Command / Binding / Mnemonic headings above the list, laid out by the same column code as the rows, and a "Filter:" label before the search field. The mnemonic column widens to fit its heading. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Workbench/Actions/ActionRow.cs | 15 ++++++---- src/TuiCode.Workbench/Actions/ActionView.cs | 28 +++++++++++++++++-- tests/TuiCode.Tests/ActionViewTests.cs | 31 +++++++++++++++++++-- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 454a29f..7c7a245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ What follows is how TuiCode implements it today. 1. Constant in `TuiCode.Abstractions.CommandIds`. 2. Register handler in `WorkbenchHost.RegisterDefaultCommands` via the labelled `Register(id, label, handler, scope)` overload. 3. Bind in `WorkbenchHost.BindDefaults` (called by `ApplyKeybindings`). -- **Command scopes (#132).** Every command has a fixed `CommandScope` from registration: `Global` (the default), `Editor`, `Explorer`, `Find` or `Diff`. A binding lands in its command's scope, user overrides and `-` removals included, so the keybindings file carries no scope. The store is one trie per scope; `Handle` tries the focused scope first (`WorkbenchHost.FocusedScope`: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with `isEnabled` that says no is skipped, so its key falls through to Global: explorer `Esc` cancels only a pending cut and editor `Esc` only removes extra cursors, otherwise it focuses the editor. **The document-changing commands are `Editor`** (#283): move/duplicate line up/down, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay `Global` — they act on the tab as a whole and are useful from the file tree. `ScopedKeybindingsHostTests` pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. **The command palette (`Ctrl+E`) lists Global plus the scope the keys were in when it opened, less whatever `isEnabled` says no to** (#284): `ActionView` filters `ICommandService.Registered` on the `CommandScope` `WorkbenchHost.OpenActions` captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on `IsEnabled`, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: `Ctrl+F`/`Ctrl+H` with no tab open the Find pane, and `Esc` says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. Each palette row ends with the command's mnemonic from `CommandMnemonics` (#341), right-aligned so the column lines up; `ActionRow.Display` lays the row out at the list's width and shortens the label first, never the mnemonic. The filter matches label, keys and id by substring and mnemonics by prefix, so `s` lists the `s…` family as the leader would. **Scope governs the keys, the palette's list and the leader's list** (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and `isEnabled` alike. +- **Command scopes (#132).** Every command has a fixed `CommandScope` from registration: `Global` (the default), `Editor`, `Explorer`, `Find` or `Diff`. A binding lands in its command's scope, user overrides and `-` removals included, so the keybindings file carries no scope. The store is one trie per scope; `Handle` tries the focused scope first (`WorkbenchHost.FocusedScope`: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with `isEnabled` that says no is skipped, so its key falls through to Global: explorer `Esc` cancels only a pending cut and editor `Esc` only removes extra cursors, otherwise it focuses the editor. **The document-changing commands are `Editor`** (#283): move/duplicate line up/down, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay `Global` — they act on the tab as a whole and are useful from the file tree. `ScopedKeybindingsHostTests` pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. **The command palette (`Ctrl+E`) lists Global plus the scope the keys were in when it opened, less whatever `isEnabled` says no to** (#284): `ActionView` filters `ICommandService.Registered` on the `CommandScope` `WorkbenchHost.OpenActions` captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on `IsEnabled`, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: `Ctrl+F`/`Ctrl+H` with no tab open the Find pane, and `Esc` says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. Each palette row ends with the command's mnemonic from `CommandMnemonics` (#341), right-aligned so the column lines up, under `Command` / `Binding` / `Mnemonic` headings (`ActionRow.Header`, laid out like the rows and next to a `Filter:` label); `ActionRow.Display` lays the row out at the list's width and shortens the label first, never the mnemonic. The filter matches label, keys and id by substring and mnemonics by prefix, so `s` lists the `s…` family as the leader would. **Scope governs the keys, the palette's list and the leader's list** (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and `isEnabled` alike. - Chord trie keyed on `Key`; `"Ctrl+W X"` is a single binding. Esc cancels in-flight chords; stray keys abort and are consumed silently. - **Showing a key to the user goes through `KeyChord.Display`**, which renders the cursor keys as arrows (`Alt+↓`, not TG's `Alt+CursorDown`, #194) and everything else as `Key.ToString()`. It's display-only and never parsed back, so identity stays `KeyChord.Canonical`. The status-bar hints, the keybindings picker, the command palette and the in-flight chord all use it. - Bare letters drop Shift and lowercase: `"x"`, `"X"`, `"shift+x"` collide. `KeybindingService.Bindings` emits the lowercased form. Casefold in UI if needed. diff --git a/src/TuiCode.Workbench/Actions/ActionRow.cs b/src/TuiCode.Workbench/Actions/ActionRow.cs index f65380c..37c7b97 100644 --- a/src/TuiCode.Workbench/Actions/ActionRow.cs +++ b/src/TuiCode.Workbench/Actions/ActionRow.cs @@ -9,17 +9,22 @@ internal sealed record ActionRow(string CommandId, string Label, string Bindings private const int Gap = 2; private const int KeysWidth = 18; private const int MinLabelWidth = 10; - private static readonly int MnemonicWidth = CommandMnemonics.All.Max(m => m.Value.Length); + private const string MnemonicHeading = "Mnemonic"; + private static readonly int MnemonicWidth = Math.Max(CommandMnemonics.All.Max(m => m.Value.Length), MnemonicHeading.Length); + + public static string Header(int width) => Columns("Command", "Binding", MnemonicHeading, width); - // Keys longer than their column take the room from their own row's label. /// Label, keys and mnemonic in columns. A narrow list shortens the label first; the mnemonic is never cut. - public string Display(int width) + public string Display(int width) => Columns(Label, Bindings, Mnemonic, width); + + // Keys longer than their column take the room from their own row's label. + private static string Columns(string label, string bindings, string mnemonic, int width) { var available = Math.Max(width - MnemonicWidth - 2 * Gap, MinLabelWidth); var keysColumn = Math.Min(KeysWidth, Math.Max(available - MinLabelWidth, 0)); - var keys = Truncate(Bindings, Math.Max(available - MinLabelWidth, 0)); + var keys = Truncate(bindings, Math.Max(available - MinLabelWidth, 0)); var labelWidth = available - Math.Max(keysColumn, keys.Length); - return $"{Truncate(Label, labelWidth).PadRight(labelWidth + Gap)}{keys.PadRight(available - labelWidth + Gap)}{Mnemonic.PadLeft(MnemonicWidth)}"; + return $"{Truncate(label, labelWidth).PadRight(labelWidth + Gap)}{keys.PadRight(available - labelWidth + Gap)}{mnemonic.PadLeft(MnemonicWidth)}"; } private static string Truncate(string s, int max) => diff --git a/src/TuiCode.Workbench/Actions/ActionView.cs b/src/TuiCode.Workbench/Actions/ActionView.cs index 12f57e9..2b0098e 100644 --- a/src/TuiCode.Workbench/Actions/ActionView.cs +++ b/src/TuiCode.Workbench/Actions/ActionView.cs @@ -20,6 +20,7 @@ public sealed class ActionView : Window private readonly Action _execute; private readonly TextField _search; + private readonly Label _header; private readonly ListView _list; private readonly ICommandService _scopeCommands; @@ -58,9 +59,11 @@ public ActionView( // Required for descendant focus — same reason as KeybindingsPickerView. CanFocus = true; + var filterLabel = new Label { X = 1, Y = 0, Text = "Filter:" }; + _search = new TextField { - X = 1, + X = Pos.Right(filterLabel) + 1, Y = 0, Width = Dim.Fill(1), Height = 1, @@ -68,16 +71,24 @@ public ActionView( _search.TextChanged += (_, _) => RebuildVisible(); _search.MouseEvent += (_, _) => _search.SetFocus(); - _list = new ListView + _header = new Label { X = 1, Y = Pos.Bottom(_search) + 1, Width = Dim.Fill(1), + Height = 1, + }; + + _list = new ListView + { + X = 1, + Y = Pos.Bottom(_header), + Width = Dim.Fill(1), Height = Dim.Fill(1), }; _list.MouseEvent += (_, _) => _list.SetFocus(); - Add(_search, _list); + Add(filterLabel, _search, _header, _list); _scopeCommands = new CommandService(); _scopeKeybindings = new KeybindingService(_scopeCommands); @@ -90,6 +101,17 @@ public ActionView( public bool FocusSearch() => _search.SetFocus(); + /// The column headings over the list. + public string Header => _header.Text; + + protected override void OnSubViewsLaidOut(LayoutEventArgs args) + { + base.OnSubViewsLaidOut(args); + var header = ActionRow.Header(_list.Viewport.Width); + if (_header.Text != header) + _header.Text = header; + } + private static List BuildRows(ICommandService commands, IKeybindingService keybindings, CommandScope scope) { var bindingsByCommand = keybindings.Bindings diff --git a/tests/TuiCode.Tests/ActionViewTests.cs b/tests/TuiCode.Tests/ActionViewTests.cs index 35b2e38..dfd55f2 100644 --- a/tests/TuiCode.Tests/ActionViewTests.cs +++ b/tests/TuiCode.Tests/ActionViewTests.cs @@ -64,7 +64,7 @@ public void Each_row_ends_with_the_commands_mnemonic_or_nothing_if_it_has_none() Assert.EndsWith($" {CommandMnemonics.For(CommandIds.SaveActiveEditor)}", view.Row("Save active editor", 72)); Assert.EndsWith($" {CommandMnemonics.For(CommandIds.SelectAllOccurrences)}", view.Row("Select all occurrences", 72)); - Assert.Equal(view.Row("Quit", 72).TrimEnd(), "Quit"); + Assert.Equal("Quit", view.Row("Quit", 72).TrimEnd()); } [Theory] @@ -93,16 +93,41 @@ public void The_mnemonics_line_up_and_a_narrow_list_shortens_the_label_rather_th using var view = new ActionView(commands, keybindings, CommandScope.Editor, _ => { }); var wide = view.Row("Select all occurrences", 72); - var narrow = view.Row("Select all occurrences", 32); + var narrow = view.Row("Select all occurrences", 37); Assert.Equal(72, wide.Length); Assert.Equal(view.Row("Save active editor", 72).Length, wide.Length); - Assert.Equal(32, narrow.Length); + Assert.Equal(37, narrow.Length); Assert.EndsWith("sao", narrow); Assert.Contains("…", narrow); Assert.Contains("Ctrl+Shift+L", narrow); } + [Fact] + public void The_columns_are_headed_Command_Binding_and_Mnemonic_over_the_values_they_name() + { + var commands = Registered(); + var keybindings = new KeybindingService(commands); + keybindings.Bind("Ctrl+S", CommandIds.SaveActiveEditor); + using var view = new ActionView(commands, keybindings, CommandScope.Editor, _ => { }); + view.Layout(new System.Drawing.Size(76, 22)); + + var row = view.Row("Save active editor", view.Header.Length); + + Assert.StartsWith("Command", view.Header); + Assert.Equal(row.IndexOf("Ctrl+S", StringComparison.Ordinal), view.Header.IndexOf("Binding", StringComparison.Ordinal)); + Assert.EndsWith("Mnemonic", view.Header); + Assert.EndsWith(CommandMnemonics.For(CommandIds.SaveActiveEditor)!, row); + } + + [Fact] + public void The_filter_field_is_labelled() + { + using var view = Build(CommandScope.Global); + + Assert.Contains(view.SubViews.OfType