Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions src/TuiCode.Workbench/Actions/ActionRow.cs
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>Label, keys and mnemonic in columns. A narrow list shortens the label first; the mnemonic is never cut.</summary>
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<ActionRow> 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() { }
}
56 changes: 42 additions & 14 deletions src/TuiCode.Workbench/Actions/ActionView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ namespace TuiCode.Workbench.Actions;
/// </summary>
public sealed class ActionView : Window
{
private const int PreferredWidth = 76;

private readonly Action<string> _execute;
private readonly TextField _search;
private readonly Label _header;
private readonly ListView _list;

private readonly ICommandService _scopeCommands;
Expand Down Expand Up @@ -51,31 +54,47 @@ 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,
};
_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);
Expand All @@ -88,6 +107,17 @@ public ActionView(

public bool FocusSearch() => _search.SetFocus();

/// <summary>The column headings over the list.</summary>
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<ActionRow> BuildRows(ICommandService commands, IKeybindingService keybindings, CommandScope scope)
{
var bindingsByCommand = keybindings.Bindings
Expand All @@ -99,7 +129,8 @@ private static List<ActionRow> 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();
}
Expand All @@ -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<string>(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)] + "…";
/// <summary>The row for <paramref name="label"/> as the list draws it <paramref name="width"/> columns wide.</summary>
internal string Row(string label, int width) => _visibleRows.Single(r => r.Label == label).Display(width);

private void RegisterScopeBindings()
{
Expand All @@ -145,5 +173,5 @@ private void ExecuteSelected()
_execute(commandId);
}

private sealed record ActionRow(string CommandId, string Label, string Bindings);
}

95 changes: 90 additions & 5 deletions tests/TuiCode.Tests/ActionViewTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using TuiCode.Abstractions;
using TuiCode.Workbench.Actions;
Expand All @@ -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),
Expand All @@ -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")]
Expand Down Expand Up @@ -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<Label>().Single(l => l.Text == view.Header);

var palette = view.GetAttributeForRole(VisualRole.Normal);
var heading = header.GetAttributeForRole(VisualRole.Normal);

Assert.Equal(palette.Background, heading.Foreground);
Assert.Equal(palette.Foreground, heading.Background);
}

[Fact]
public void The_filter_field_is_labelled()
{
using var view = Build(CommandScope.Global);

Assert.Contains(view.SubViews.OfType<Label>(), l => l.Text == "Filter:");
}

private static ActionView Build(CommandScope scope)
{
var commands = Registered();
Expand All @@ -72,7 +159,5 @@ private static CommandService Registered()
private static void Type(ActionView view, string query) =>
view.SubViews.OfType<TextField>().Single().Text = query;

private static string Row(ActionView view, string label) =>
view.SubViews.OfType<ListView>().Single().Source!.ToList().Cast<string>()
.Single(r => r.StartsWith(label, StringComparison.Ordinal));
private static string Row(ActionView view, string label) => view.Row(label, 72);
}
Loading