diff --git a/AGENTS.md b/AGENTS.md
index fe44e6f..9caca4e 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, under `Command` / `Binding` / `Mnemonic` headings (`ActionRow.Header`, laid out like the rows and drawn in the palette's colours inverted, below 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
new file mode 100644
index 0000000..37c7b97
--- /dev/null
+++ b/src/TuiCode.Workbench/Actions/ActionRow.cs
@@ -0,0 +1,58 @@
+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 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);
+
+ /// Label, keys and mnemonic in columns. A narrow list shortens the label first; the mnemonic is never cut.
+ 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 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..0ab0a4d 100644
--- a/src/TuiCode.Workbench/Actions/ActionView.cs
+++ b/src/TuiCode.Workbench/Actions/ActionView.cs
@@ -16,8 +16,11 @@ namespace TuiCode.Workbench.Actions;
///
public sealed class ActionView : Window
{
+ private const int PreferredWidth = 76;
+
private readonly Action _execute;
private readonly TextField _search;
+ private readonly Label _header;
private readonly ListView _list;
private readonly ICommandService _scopeCommands;
@@ -51,14 +54,16 @@ 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;
+ 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,
@@ -66,16 +71,30 @@ 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,
+ };
+ _header.GettingAttributeForRole += (_, e) =>
+ {
+ var attribute = e.Result ?? GetAttributeForRole(e.Role);
+ e.Result = attribute with { Foreground = attribute.Background, Background = attribute.Foreground };
+ e.Handled = true;
+ };
+
+ _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);
@@ -88,6 +107,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
@@ -99,7 +129,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 +142,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);
+ || r.CommandId.Contains(needle, StringComparison.OrdinalIgnoreCase)
+ || r.Mnemonic.StartsWith(needle, StringComparison.OrdinalIgnoreCase);
- private static string FormatRow(ActionRow r) =>
- $"{Truncate(r.Label, 48).PadRight(50)}{r.Bindings}";
-
- 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 +173,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..a6a3a6c 100644
--- a/tests/TuiCode.Tests/ActionViewTests.cs
+++ b/tests/TuiCode.Tests/ActionViewTests.cs
@@ -1,3 +1,4 @@
+using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using TuiCode.Abstractions;
using TuiCode.Workbench.Actions;
@@ -11,7 +12,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 +23,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 +58,91 @@ 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("Quit", view.Row("Quit", 72).TrimEnd());
+ }
+
+ [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", 37);
+
+ Assert.Equal(72, wide.Length);
+ Assert.Equal(view.Row("Save active editor", 72).Length, wide.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_column_headings_are_drawn_in_the_palettes_colours_inverted()
+ {
+ using var view = Build(CommandScope.Global);
+ view.Layout(new System.Drawing.Size(76, 22));
+ var header = view.SubViews.OfType