Skip to content
Merged
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +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`).
4. Give it a home in the menu bar: its place in `CommandMenu.Layout`. `MenuBarHostTests` fails for a command with a mnemonic and no menu item, unless it's in `CommandMenu.Unlisted`.
- **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.
- 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.
Expand All @@ -101,6 +102,13 @@ What follows is how TuiCode implements it today.
- **Narrowing the list can't make a mnemonic fire early**, so there's no second, unfiltered copy of the table to keep in step (#285). `ResolveExact` fires only on a prefix that is itself a complete mnemonic, and the table is prefix-free, so a complete mnemonic has nothing under it that hiding could reveal. The guard is `MnemonicScopeHostTests.Narrowing_the_list_never_makes_a_mnemonic_fire_a_keystroke_earlier`, over the real commands; break the prefix-free invariant and it says so. A keystroke no *in-scope* mnemonic could complete is ignored by the same rejection `OnKey` always did, over the smaller set — so there's no abort path and no message, which is what makes an out-of-scope command seem not to exist.
- The matching logic is the pure, TG-free `MnemonicResolver` (`Matching` for the hint list, `ResolveExact` for the fire-now decision) so it's unit-tested directly. `MnemonicView` is a thin capture-scope shell over it — it reads keys via `KeyCaptureScope` rather than a focused TextField precisely so a command that disposes the view executes on the app key-dispatch path, not inside a TextChanged callback.

## Menu bar (#340)

- `CommandMenu` (`Workbench/Menus/`) fills TG's `MenuBar` across the top of the workbench from `CommandMenu.Layout`: eight menus, every command with a mnemonic in exactly one, plus *Show all commands* and *Show mnemonics*. The exceptions are the focus moves in `CommandMenu.Unlisted`, since opening the menu moves focus itself. View lists the sidebar panels by name (`CommandMenu.Titles`), as VS Code does. An item runs its command by id, through `RunLaunched` like the palette. Its key column is `KeyView.Text`, set from the live bindings by `ApplyKeybindings`, not `MenuItem.Key`: that holds one `Key`, not a chord, and the workbench already dispatches the key, so `BindKeyToApplication` is off.
- `F10` is `ShowMenu`, rebindable like any other; TG's own F10 hot key is removed so an unbound key can't open it. `Alt+<letter>` opens a menu through TG's hot keys; the bare letters are removed, since TG would otherwise open a menu on a typed `f` wherever the keys are. Esc is re-added to each popover's Quit, which `NeutralizeBuiltinQuitKey` took away.
- While a menu is open the host pushes an empty key scope, so arrows, Enter and Esc reach the menu rather than workbench bindings. The region is captured as it opens, and each item is greyed out unless its key would run there, in its scope and enabled: the palette's rule. A menu with no item that could run leaves the bar (`CommandMenu.ShowAvailable`), re-checked on each focus move and a few times a second, since some `isEnabled` checks touch the disk. It's taken off rather than hidden because TG's bar keeps a hidden item's place. A picked command is checked again before it runs. The menu won't open over a dialog (`Workbench.HasDialog`).
- `CommandMenu.Closed` fires on the loop iteration after the last popover shuts: moving between menus shuts one before opening the next, and TG is still moving focus as it shuts. `App.Invoke` runs inline on the UI thread, so it doesn't defer; `AddTimeout(TimeSpan.Zero, ...)` does. Moving focus any earlier knocked the active tab over to another (see *Terminal.Gui v2*).

## Getting Started dialog (F1)

- `HelpView` is onboarding, not a shortcut reference — the command palette (`Ctrl+E`) lists the commands that apply where you are with their keys, and Settings › Keyboard Shortcuts every one of them. It shows only how to find everything else (palette, mnemonics) plus the handful of keys needed in the first few minutes (open, new, save, close, find, quit). Don't add a row per feature; a new row has to displace one.
Expand Down
1 change: 1 addition & 0 deletions src/TuiCode.Abstractions/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public static class CommandIds

public const string ShowActions = "workbench.action.showActions";
public const string ShowMnemonics = "workbench.action.showMnemonics";
public const string ShowMenu = "workbench.action.showMenu";
public const string ShowHelp = "workbench.action.showHelp";
public const string ShowDiagnostics = "workbench.action.showDiagnostics";
public const string ShowAbout = "workbench.action.showAbout";
Expand Down
223 changes: 223 additions & 0 deletions src/TuiCode.Workbench/Menus/CommandMenu.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using TuiCode.Abstractions;

namespace TuiCode.Workbench.Menus;

/// <summary>
/// The menu bar's eight menus (#340). Every item runs a registered command by id through the same path as the
/// palette, so the menu, the key, the mnemonic and the palette are four doors into one list.
/// </summary>
public sealed class CommandMenu
{
public const string Separator = "-";

/// <summary>Commands with a mnemonic but no menu item: opening the menu moves focus itself.</summary>
public static readonly string[] Unlisted =
[
CommandIds.FocusSidebar, CommandIds.FocusEditorBody, CommandIds.FocusEditorTabStrip,
.. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex),
];

/// <summary>Items titled for their menu rather than with the command's label.</summary>
public static readonly IReadOnlyDictionary<string, string> Titles = new Dictionary<string, string>
{
[CommandIds.ShowExplorer] = "Explorer",
[CommandIds.FindGlobally] = "Find",
[CommandIds.FocusReview] = "Review",
};

public static readonly (string Title, string[] Ids)[] Layout =
[
("_File",
[
CommandIds.New, CommandIds.Open, CommandIds.SaveActiveEditor, CommandIds.ReloadFromDisk, CommandIds.CloseActiveEditor,
Separator,
CommandIds.RenameFile, CommandIds.CutFile, CommandIds.PasteFile, CommandIds.DeleteFile,
Separator,
CommandIds.ShowDocumentInfo,
Separator,
CommandIds.OpenSettings,
Separator,
CommandIds.Quit,
]),
("_Edit",
[
CommandIds.MoveLinesUp, CommandIds.MoveLinesDown, CommandIds.DuplicateLinesUp, CommandIds.DuplicateLinesDown,
Separator,
CommandIds.FindInFile, CommandIds.ReplaceInFile, CommandIds.ReplaceGlobally,
Separator,
CommandIds.ChangeGrammar,
]),
("_Selection",
[
CommandIds.AddCursorAbove, CommandIds.AddCursorBelow, CommandIds.RemoveSecondaryCursors,
Separator,
CommandIds.ToggleColumnSelect,
Separator,
CommandIds.SelectNextOccurrence, CommandIds.SelectPreviousOccurrence, CommandIds.SelectAllOccurrences,
]),
("_View",
[
CommandIds.ShowExplorer, CommandIds.FindGlobally, CommandIds.FocusReview,
Separator,
CommandIds.ToggleSidebar, CommandIds.WidenSidebar, CommandIds.NarrowSidebar,
Separator,
CommandIds.RefreshExplorer,
Separator,
CommandIds.ToggleGutter,
]),
("_Go",
[
CommandIds.GoToLine, CommandIds.GoToSymbol,
Separator,
CommandIds.NavigateBack, CommandIds.NavigateForward,
Separator,
CommandIds.NextEditor, CommandIds.PreviousEditor,
]),
("_Diff",
[
CommandIds.CompareToSaved, CommandIds.CompareToRevision, CommandIds.CompareToOtherFile,
Separator,
CommandIds.NextChange, CommandIds.PreviousChange, CommandIds.GoToChangeLine,
Separator,
CommandIds.RevertChange, CommandIds.RevertAllChanges,
Separator,
CommandIds.GitBlame,
]),
("_Review",
[
CommandIds.OpenPullRequest, CommandIds.PullRequestOverview,
Separator,
CommandIds.CreateComment, CommandIds.SubmitReview,
]),
("_Help",
[
CommandIds.ShowHelp, CommandIds.ShowActions, CommandIds.ShowMnemonics,
Separator,
CommandIds.ShowDiagnostics, CommandIds.ShowAbout,
]),
];

private readonly MenuBar _bar;
private readonly ICommandService _commands;
private readonly IKeybindingService _keybindings;
private readonly Func<bool> _canOpen;
private readonly Func<string, bool> _isAvailable;
private readonly List<(string Id, MenuItem Item)> _items = [];
private readonly List<(MenuBarItem Menu, (string Id, MenuItem Item)[] Items)> _menus = [];
private string? _picked;

public CommandMenu(MenuBar bar, ICommandService commands, IKeybindingService keybindings, Func<bool> canOpen,
Func<string, bool> isAvailable)
{
_bar = bar;
_commands = commands;
_keybindings = keybindings;
_canOpen = canOpen;
_isAvailable = isAvailable;
// Our own keybinding service opens it, so the key can be rebound.
_bar.HotKeyBindings.Remove(_bar.Key);
_bar.Menus = [.. Layout.Select(Build)];
Refresh();
}

public bool IsOpen { get; private set; }

public IReadOnlyList<(string Id, MenuItem Item)> Items => _items;

public event EventHandler? Opened;

/// <summary>The menu has shut, carrying the command picked from it, if any.</summary>
public event EventHandler<string?>? Closed;

public void Open()
{
if (!IsOpen) _bar.InvokeCommand(Command.HotKey);
}

/// <summary>Shows each item's current key, e.g. after a rebind in Settings.</summary>
public void Refresh()
{
var keys = _keybindings.Bindings
.GroupBy(b => b.CommandId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Display, StringComparer.Ordinal);
foreach (var (id, item) in _items)
{
var key = keys.GetValueOrDefault(id, string.Empty);
if (item.KeyView.Text == key) continue;
item.KeyView.Text = key;
item.SetNeedsLayout();
}
}

/// <summary>Greys out each item that can't run, and takes a menu with none that can off the bar.</summary>
public void ShowAvailable()
{
GreyOutUnavailable();
var shown = _menus.Where(m => m.Items.Any(i => i.Item.Enabled)).Select(m => m.Menu).ToArray();
// TG's bar keeps a hidden item's place, so an empty menu leaves the bar rather than hiding.
if (!shown.SequenceEqual(_bar.SubViews.OfType<MenuBarItem>())) _bar.Menus = shown;
}

private void GreyOutUnavailable()
{
foreach (var (id, item) in _items) item.Enabled = _isAvailable(id);
}

private MenuBarItem Build((string Title, string[] Ids) entry)
{
var first = _items.Count;
var menu = new MenuBarItem(entry.Title, entry.Ids.Select(Entry).ToArray());
_menus.Add((menu, _items[first..].ToArray()));
// A title's bare letter would open its menu from anywhere and swallow typing; keep only Alt+letter.
menu.HotKeyBindings.Remove(menu.HotKey);
menu.HotKeyBindings.Remove(menu.HotKey.WithShift);
// NeutralizeBuiltinQuitKey took Esc off TG's Quit command, and the menu's close with it.
menu.PopoverMenu?.KeyBindings.Add(Key.Esc, Command.Quit);
menu.PopoverMenuOpenChanged += (_, e) => OnOpenChanged(e.NewValue);
return menu;
}

private View Entry(string id) => id == Separator ? new Line() : Item(id);

private MenuItem Item(string id)
{
var item = new MenuItem
{
Title = Titles.GetValueOrDefault(id) ?? _commands.Registered.FirstOrDefault(c => c.Id == id)?.Label ?? id,
// The workbench already dispatches the key; binding it here too would run the command twice.
BindKeyToApplication = false,
Action = () => _picked = id,
};
_items.Add((id, item));
return item;
}

// Moving between menus closes one before opening the next, and TG is still moving focus as a menu
// shuts, so decide it has shut on the next loop iteration.
private void OnOpenChanged(bool open)
{
if (open)
{
if (IsOpen) return;
if (!_canOpen())
{
_bar.HideActiveItem();
return;
}
IsOpen = true;
_picked = null;
Opened?.Invoke(this, EventArgs.Empty);
GreyOutUnavailable();
return;
}
_bar.App?.AddTimeout(TimeSpan.Zero, () =>
{
if (!IsOpen || _bar.IsOpen()) return false;
IsOpen = false;
var picked = _picked;
_picked = null;
Closed?.Invoke(this, picked);
return false;
});
}
}
Loading
Loading