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