From 8f75e1ebb45592210352a2eb1928984e3fb56c5f 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:29:25 +1300 Subject: [PATCH 1/6] Add a menu bar that lists every command by subject A one-row MenuBar above the sidebar and editor holds eight menus (File, Edit, Selection, View, Go, Diff, Review, Help), with every command that has a mnemonic in exactly one of them. Each item runs its command by id through RunLaunched, as the palette does, and shows its current key from the live bindings. F10 opens it through a new Show menu command, so it can be rebound; TG's own F10 hot key and the menus' bare-letter hot keys are removed so they can't open a menu behind the keybinding service. While a menu is open an empty key scope sits on top so arrows, Enter and Esc reach the menu. The region is captured as it opens and a picked command only runs where its key would. The close is handled on the next loop iteration: App.Invoke runs inline on the UI thread, and moving focus while TG is still shutting the popover switched the editor group to another tab. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 + src/TuiCode.Abstractions/CommandIds.cs | 1 + src/TuiCode.Workbench/Menus/CommandMenu.cs | 194 +++++++++++ src/TuiCode.Workbench/Workbench.cs | 10 +- src/TuiCode.Workbench/WorkbenchHost.cs | 31 ++ tests/TuiCode.Tests/FocusHostTests.cs | 2 +- tests/TuiCode.Tests/MenuBarHostTests.cs | 322 ++++++++++++++++++ .../ScopedKeybindingsHostTests.cs | 2 +- 8 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 src/TuiCode.Workbench/Menus/CommandMenu.cs create mode 100644 tests/TuiCode.Tests/MenuBarHostTests.cs diff --git a/AGENTS.md b/AGENTS.md index fe44e6f..cf4e02f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **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. @@ -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*. 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+` 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 a picked command runs only where its key would: in its scope and enabled, else nothing. 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. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index 5bafd15..9930356 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -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"; diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs new file mode 100644 index 0000000..9ae8124 --- /dev/null +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -0,0 +1,194 @@ +using TuiCode.Abstractions; + +namespace TuiCode.Workbench.Menus; + +/// +/// 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. +/// +public sealed class CommandMenu +{ + public const string Separator = "-"; + public const string FocusEditorTab = "Focus editor tab"; + + 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, CommandIds.Quit, + ]), + ("_Edit", + [ + CommandIds.MoveLinesUp, CommandIds.MoveLinesDown, CommandIds.DuplicateLinesUp, CommandIds.DuplicateLinesDown, + Separator, + CommandIds.FindInFile, CommandIds.ReplaceInFile, CommandIds.FindGlobally, CommandIds.ReplaceGlobally, + Separator, + CommandIds.ChangeGrammar, + ]), + ("_Selection", + [ + CommandIds.AddCursorAbove, CommandIds.AddCursorBelow, CommandIds.RemoveSecondaryCursors, + Separator, + CommandIds.ToggleColumnSelect, + Separator, + CommandIds.SelectNextOccurrence, CommandIds.SelectPreviousOccurrence, CommandIds.SelectAllOccurrences, + ]), + ("_View", + [ + CommandIds.ToggleSidebar, CommandIds.ShowExplorer, CommandIds.RefreshExplorer, CommandIds.FocusSidebar, + Separator, + CommandIds.WidenSidebar, CommandIds.NarrowSidebar, + Separator, + CommandIds.ToggleGutter, + Separator, + CommandIds.FocusEditorBody, CommandIds.FocusEditorTabStrip, + ]), + ("_Go", + [ + CommandIds.GoToLine, CommandIds.GoToSymbol, + Separator, + CommandIds.NavigateBack, CommandIds.NavigateForward, + Separator, + CommandIds.NextEditor, CommandIds.PreviousEditor, FocusEditorTab, + ]), + ("_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, CommandIds.FocusReview, + Separator, + CommandIds.CreateComment, CommandIds.SubmitReview, + ]), + ("_Help", + [ + CommandIds.ShowHelp, CommandIds.ShowActions, CommandIds.ShowMnemonics, + Separator, + CommandIds.ShowDiagnostics, CommandIds.ShowAbout, + ]), + ]; + + public static IEnumerable FocusEditorTabIds => + Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex); + + private readonly MenuBar _bar; + private readonly ICommandService _commands; + private readonly IKeybindingService _keybindings; + private readonly Func _canOpen; + private readonly List<(string Id, MenuItem Item)> _items = []; + private string? _picked; + + public CommandMenu(MenuBar bar, ICommandService commands, IKeybindingService keybindings, Func canOpen) + { + _bar = bar; + _commands = commands; + _keybindings = keybindings; + _canOpen = canOpen; + // 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; + + /// The menu has shut, carrying the command picked from it, if any. + public event EventHandler? Closed; + + public void Open() + { + if (!IsOpen) _bar.InvokeCommand(Command.HotKey); + } + + /// Shows each item's current key, e.g. after a rebind in Settings. + 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(); + } + } + + private MenuBarItem Build((string Title, string[] Ids) entry) + { + var menu = new MenuBarItem(entry.Title, entry.Ids.Select(Entry).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 switch + { + Separator => new Line(), + FocusEditorTab => new MenuItem(FocusEditorTab, string.Empty, new Menu(FocusEditorTabIds.Select(Item))), + _ => Item(id), + }; + + private MenuItem Item(string id) + { + var item = new MenuItem + { + Title = _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); + 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; + }); + } +} diff --git a/src/TuiCode.Workbench/Workbench.cs b/src/TuiCode.Workbench/Workbench.cs index 9867001..425a420 100644 --- a/src/TuiCode.Workbench/Workbench.cs +++ b/src/TuiCode.Workbench/Workbench.cs @@ -14,6 +14,10 @@ public sealed class Workbench : Window public SidebarPart Sidebar { get; } public EditorPart Editor { get; } public StatusBarPart StatusBar { get; } + public MenuBar MenuBar { get; } = new(); + + /// A dialog is open over the workbench: every view the host adds beside the four parts is one. + public bool HasDialog => SubViews.Any(view => view != MenuBar && view != Sidebar && view != Editor && view != StatusBar); public bool IsSidebarVisible { get; private set; } = true; @@ -39,12 +43,12 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar BorderStyle = LineStyle.None; sidebar.X = 0; - sidebar.Y = 0; + sidebar.Y = Pos.Bottom(MenuBar); sidebar.Width = SidebarSizing.Default; sidebar.Height = Dim.Fill(1); editor.X = Pos.Right(sidebar); - editor.Y = 0; + editor.Y = Pos.Bottom(MenuBar); editor.Width = Dim.Fill(); editor.Height = Dim.Fill(1); @@ -53,7 +57,7 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar statusBar.Width = Dim.Fill(); statusBar.Height = 1; - Add(sidebar, editor, statusBar); + Add(MenuBar, sidebar, editor, statusBar); sidebar.Explorer.FileActivated += (_, file) => OpenFile(file); diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index b25ec6f..d1b026f 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -17,6 +17,7 @@ using TuiCode.Workbench.Git; using TuiCode.Workbench.Grammars; using TuiCode.Workbench.Help; +using TuiCode.Workbench.Menus; using TuiCode.Workbench.Mnemonics; using TuiCode.Workbench.Navigation; using TuiCode.Workbench.Parts; @@ -57,6 +58,10 @@ public sealed class WorkbenchHost : IDisposable private readonly IGitCli _git; private readonly IGitHubCli _gitHub; private readonly TerminalCursors _terminalCursors; + private readonly CommandMenu _menu; + // Open menus take arrows, Enter and Esc for themselves, so no workbench key fires under one. + private readonly KeybindingService _menuKeys = new(new CommandService()); + private FocusRegion _menuOpenedFrom; private readonly FindController _find; private readonly FocusService _focus; private readonly DiskChanges _diskChanges; @@ -145,6 +150,9 @@ public WorkbenchHost( _focus = new FocusService(FocusedView); RegisterDefaultCommands(); + _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog); + _menu.Opened += (_, _) => OnMenuOpened(); + _menu.Closed += (_, picked) => OnMenuClosed(picked); ApplyKeybindings(_settings.KeybindingOverrides); _workbench.Editor.Group.Settings = _settings.Editor; _workbench.SetSidebarWidth(_settings.SidebarWidth); @@ -299,6 +307,8 @@ private static void WriteToTerminal(string sequence) } public IApplication App => _app; + + internal CommandMenu Menu => _menu; public Workbench Workbench => _workbench; public void Run() => _app.Run(_workbench, errorHandler: null!); @@ -472,6 +482,7 @@ private void RegisterDefaultCommands() () => { if (search.ReplaceVisible) search.RequestReplaceAll(); }, CommandScope.Find, () => search.InputsHaveFocus); _commands.Register(CommandIds.ShowActions, "Show all commands", OpenActions); _commands.Register(CommandIds.ShowMnemonics, "Show mnemonics", OpenMnemonics); + _commands.Register(CommandIds.ShowMenu, "Show menu", () => _menu.Open()); _commands.Register(CommandIds.ShowHelp, "Getting Started (help)", OpenHelp); _commands.Register(CommandIds.GoToLine, "Go to line:column", OpenGoToLine, CommandScope.Editor, FileOpen); // No default key (#137): VS Code's Ctrl+Shift+O collapses onto Ctrl+O in Terminal.app. @@ -548,6 +559,7 @@ public void ApplyKeybindings(IEnumerable overrides) } } + _menu.Refresh(); var help = _keybindings.Bindings.FirstOrDefault(b => b.CommandId == CommandIds.ShowHelp); _workbench.StatusBar.SetIdleHint(help is null ? null : $"Press {help.Display} for help"); _workbench.DiffKeysHint = DiffKeys("revert"); @@ -625,6 +637,7 @@ private static void BindDefaults(IKeybindingService keybindings) // the mnemonics it dispatches are fixed (CommandMnemonics). keybindings.Bind("Ctrl+Space", CommandIds.ShowMnemonics); keybindings.Bind("F1", CommandIds.ShowHelp); + keybindings.Bind("F10", CommandIds.ShowMenu); // Ctrl+G is a chord family (#35): L = go-to-line, P/N = previous/next cursor location, B = blame (#330). keybindings.Bind("Ctrl+G L", CommandIds.GoToLine); keybindings.Bind("Ctrl+G P", CommandIds.NavigateBack); @@ -931,6 +944,24 @@ private void RunLaunched(string commandId, bool fromExplorer) finally { _launchedFromExplorer = false; } } + private void OnMenuOpened() + { + _menuOpenedFrom = _focus.Region; + _scopes.Push(_menuKeys); + } + + // A picked command runs as its key would where the menu opened: not at all out of scope or disabled. + private void OnMenuClosed(string? picked) + { + _scopes.Pop(_menuKeys); + FocusCallingRegion(); + if (picked is null) return; + var scope = _commands.ScopeOf(picked); + if (scope != CommandScope.Global && scope != FocusService.ScopeOf(_menuOpenedFrom)) return; + if (!_commands.IsEnabled(picked)) return; + RunLaunched(picked, _menuOpenedFrom == FocusRegion.Explorer); + } + private void OpenMnemonics() { if (_activeMnemonics is not null) return; diff --git a/tests/TuiCode.Tests/FocusHostTests.cs b/tests/TuiCode.Tests/FocusHostTests.cs index 6a813a5..9678363 100644 --- a/tests/TuiCode.Tests/FocusHostTests.cs +++ b/tests/TuiCode.Tests/FocusHostTests.cs @@ -281,7 +281,7 @@ await HostSteps.Run(host, } // The workbench's own three parts are the sidebar, the editor and the status bar; anything else is a modal. - private static int Modals(Workbench.Workbench workbench) => workbench.SubViews.Count - 3; + private static int Modals(Workbench.Workbench workbench) => workbench.SubViews.Count - 4; [Fact] public async Task Saving_leaves_the_keys_in_the_diff() diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs new file mode 100644 index 0000000..d5a5af7 --- /dev/null +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -0,0 +1,322 @@ +using System.Drawing; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using TuiCode.Abstractions; +using TuiCode.Explorer; +using TuiCode.Workbench; +using TuiCode.Workbench.Menus; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Review; +using TuiCode.Workbench.Services; +using TuiCode.Workbench.Themes; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace TuiCode.Tests; + +// The menu bar (#340). Boots a TG Application — serialised (#77). +public class MenuBarHostTests : StaticConfigurationTest +{ + private readonly MockFileSystem _fs = new(); + private readonly FakeGitCli _git = new() { Root = "/work" }; + private readonly FakeGitHubCli _gitHub = new(); + + public MenuBarHostTests() + { + _fs.AddDirectory("/work/.git"); + _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo\nthree\n")); + } + + [Fact] + public void Every_command_with_a_mnemonic_has_exactly_one_menu_item() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + var mapped = MenuIds().ToList(); + + Assert.All(CommandMnemonics.All, pair => Assert.Single(mapped, id => id == pair.Key)); + Assert.Contains(CommandIds.ShowActions, mapped); + Assert.Contains(CommandIds.ShowMnemonics, mapped); + Assert.Equal(mapped.Count, mapped.Distinct().Count()); + Assert.All(mapped, id => Assert.True(commands.IsRegistered(id), $"{id} isn't a registered command")); + } + + [Fact] + public void The_menus_read_across_the_top_with_distinct_hot_letters() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var menus = workbench.MenuBar.SubViews.OfType().ToList(); + + Assert.Equal(["File", "Edit", "Selection", "View", "Go", "Diff", "Review", "Help"], + menus.Select(m => m.Title.Replace("_", ""))); + Assert.Equal(menus.Count, menus.Select(m => m.HotKey).Distinct().Count()); + Assert.All(menus, m => Assert.Equal(m.Title[1], (char)m.HotKey.KeyCode)); + } + + [Fact] + public async Task The_bar_sits_above_the_sidebar_and_the_editor() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var (bar, sidebar, editor, width) = (default(Rectangle), default(Rectangle), default(Rectangle), 0); + + await HostSteps.Run(host, + () => workbench.MenuBar.Frame.Width > 0, + () => { (bar, sidebar, editor, width) = (workbench.MenuBar.FrameToScreen(), workbench.Sidebar.FrameToScreen(), + workbench.Editor.FrameToScreen(), workbench.FrameToScreen().Width); }); + + Assert.Equal(new Rectangle(0, 0, width, 1), bar); + Assert.Equal(1, sidebar.Y); + Assert.Equal(1, editor.Y); + Assert.Equal(workbench.StatusBar.FrameToScreen().Y, sidebar.Bottom); + } + + [Fact] + public async Task F10_opens_File_and_Esc_hands_the_keys_back() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string? first = null; + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.F10), + () => workbench.MenuBar.IsOpen(), + () => { first = Focused(workbench); host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveTab!.HasFocus); + + Assert.Equal("New file or folder", first); + Assert.Equal("Editor", workbench.StatusBar.DisplayedFocus); + } + + [Fact] + public async Task Show_menu_can_be_rebound() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => host.ApplyKeybindings( + [ + new KeybindingOverride(TestKeys.Chord("F10"), "-" + CommandIds.ShowMenu), + new KeybindingOverride(TestKeys.Chord("F9"), CommandIds.ShowMenu), + ]), + () => host.App.InjectKey(Key.F10), + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.F9), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.Esc), + () => !workbench.MenuBar.IsOpen()); + } + + // Arrows walk the menu, and Enter runs the item once: a second binding would move the line twice. + [Fact] + public async Task Edit_Move_line_down_moves_the_editors_line_once() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.F10), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.CursorRight), + () => host.App.InjectKey(Key.CursorDown), + () => Focused(workbench) == "Move line down", + () => host.App.InjectKey(Key.Enter), + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveTab!.Content != "one\ntwo\nthree\n"); + + Assert.Equal("two\none\nthree\n", workbench.Editor.Group.ActiveTab!.Content.ReplaceLineEndings("\n")); + Assert.Equal("Editor", workbench.StatusBar.DisplayedFocus); + } + + [Fact] + public async Task Diff_Next_change_moves_the_diff_the_menu_was_opened_from() + { + _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo\nthree\nfour\nfive\nsix\n")); + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => { workbench.Editor.Group.ActiveTab!.Content = "one\ntwo\nthree\nFOUR\nfive\nSIX\n"; }, + () => commands.TryExecute(CommandIds.CompareToSaved), + () => workbench.StatusBar.DisplayedFocus == "Diff", + () => host.App.InjectKey(Key.D.WithAlt), + () => Focused(workbench) == "Compare to saved", + () => host.App.InjectKey(Key.CursorDown), + () => host.App.InjectKey(Key.CursorDown), + () => host.App.InjectKey(Key.CursorDown), + () => Focused(workbench) == "Next change", + () => host.App.InjectKey(Key.Enter), + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveDiffTab?.CurrentChange == 1); + + Assert.Equal("Diff", workbench.StatusBar.DisplayedFocus); + } + + // Move line down's key does nothing in the explorer, so neither does picking it there. + [Fact] + public async Task An_editor_command_picked_from_the_explorer_does_nothing() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => commands.TryExecute(CommandIds.FocusSidebar), + () => workbench.StatusBar.DisplayedFocus == "Explorer", + () => host.App.InjectKey(Key.E.WithAlt), + () => Focused(workbench) == "Move line up", + () => host.App.InjectKey(Key.CursorDown), + () => Focused(workbench) == "Move line down", + () => host.App.InjectKey(Key.Enter), + () => !workbench.MenuBar.IsOpen()); + + Assert.Equal("one\ntwo\nthree\n", workbench.Editor.Group.ActiveTab!.Content.ReplaceLineEndings("\n")); + Assert.Equal("Explorer", workbench.StatusBar.DisplayedFocus); + } + + [Fact] + public async Task Clicking_a_title_opens_it_clicking_an_item_runs_it_and_clicking_outside_closes_it() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + Click(host, () => Title(workbench, "View").FrameToScreen().Location), + () => workbench.MenuBar.IsOpen(), + Click(host, () => new Point(workbench.FrameToScreen().Width - 2, workbench.FrameToScreen().Height - 3)), + () => !workbench.MenuBar.IsOpen(), + Click(host, () => Title(workbench, "View").FrameToScreen().Location), + () => workbench.MenuBar.IsOpen(), + Click(host, () => Item(host, CommandIds.ToggleSidebar).FrameToScreen().Location), + () => !workbench.MenuBar.IsOpen() && !workbench.IsSidebarVisible); + } + + // Running a command under a dialog would take the keys out from under it. + [Fact] + public async Task The_menu_stays_shut_while_a_dialog_is_open() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => host.App.InjectKey(Key.E.WithCtrl), + () => workbench.HasDialog, + Click(host, () => Title(workbench, "File").FrameToScreen().Location), + () => { }, + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.Esc), + () => !workbench.HasDialog); + } + + [Fact] + public void Each_item_shows_its_current_key_and_follows_a_rebind() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + Assert.Equal("Alt+↓", KeyShown(host, CommandIds.MoveLinesDown)); + Assert.Equal("Ctrl+G l", KeyShown(host, CommandIds.GoToLine)); + Assert.Equal("", KeyShown(host, CommandIds.ToggleSidebar)); + + host.ApplyKeybindings( + [ + new KeybindingOverride(TestKeys.Chord("Alt+CursorDown"), "-" + CommandIds.MoveLinesDown), + new KeybindingOverride(TestKeys.Chord("F7"), CommandIds.MoveLinesDown), + new KeybindingOverride(TestKeys.Chord("Ctrl+G L"), "-" + CommandIds.GoToLine), + ]); + + Assert.Equal("F7", KeyShown(host, CommandIds.MoveLinesDown)); + Assert.Equal("", KeyShown(host, CommandIds.GoToLine)); + } + + [Fact] + public void Focus_editor_tab_opens_a_submenu_of_tabs_one_to_nine() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var go = Title(workbench, "Go").PopoverMenu!.Root!; + var parent = go.SubViews.OfType().Single(i => i.Title == CommandMenu.FocusEditorTab); + + Assert.Equal(CommandMenu.FocusEditorTabIds, + parent.SubMenu!.SubViews.OfType().Select(i => Id(host, i))); + } + + [Fact] + public void The_bar_takes_the_themes_menu_colours_and_follows_a_switch() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + ConfigurationManager.Enable(ConfigLocations.None); + try + { + ConfigurationManager.RuntimeConfig = BundledThemes.Config; + ConfigurationManager.Load(ConfigLocations.LibraryResources | ConfigLocations.Runtime); + var drawn = new List(); + foreach (var theme in new[] { BundledThemes.Default, BundledThemes.Daylight }) + { + ThemeManager.Theme = theme; + ConfigurationManager.Apply(); + var menu = SchemeManager.GetScheme(Schemes.Menu).GetAttributeForRole(VisualRole.Normal, null); + Assert.Equal(menu, workbench.MenuBar.GetAttributeForRole(VisualRole.Normal)); + drawn.Add(menu); + } + Assert.NotEqual(drawn[0], drawn[1]); + } + finally + { + ThemeManager.Theme = "Default"; + ConfigurationManager.Disable(resetToHardCodedDefaults: true); + } + } + + private static IEnumerable MenuIds() => + CommandMenu.Layout.SelectMany(menu => menu.Ids) + .Where(id => id != CommandMenu.Separator) + .SelectMany(id => id == CommandMenu.FocusEditorTab ? CommandMenu.FocusEditorTabIds : [id]); + + private static string? Focused(Workbench.Workbench workbench) => + workbench.MenuBar.SubViews.OfType().FirstOrDefault(m => m.PopoverMenuOpen)?.PopoverMenu?.Root?.Focused is MenuItem item + ? item.Title + : null; + + private static MenuBarItem Title(Workbench.Workbench workbench, string title) => + workbench.MenuBar.SubViews.OfType().Single(m => m.Title.Replace("_", "") == title); + + private static MenuItem Item(WorkbenchHost host, string id) => host.Menu.Items.Single(i => i.Id == id).Item; + + private static string Id(WorkbenchHost host, MenuItem item) => host.Menu.Items.Single(i => i.Item == item).Id; + + private static string KeyShown(WorkbenchHost host, string id) => Item(host, id).KeyView.Text; + + private static Action Click(WorkbenchHost host, Func at) => () => + { + var point = at(); + host.App.InjectMouse(new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = point }); + host.App.InjectMouse(new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = point }); + }; + + private Workbench.Workbench BuildWorkbench(string root = "/work") + { + var sidebar = new SidebarPart(new FileExplorerView(), review: new ReviewView(_git, _gitHub)); + var workbench = new Workbench.Workbench(sidebar, new EditorPart(), new StatusBarPart()); + workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New(root)); + return workbench; + } + + private WorkbenchHost BuildHost(Workbench.Workbench workbench, out CommandService commands) + { + commands = new CommandService(); + return new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, git: _git, gitHub: _gitHub); + } +} diff --git a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs index 4e42f65..c12b64d 100644 --- a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs +++ b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs @@ -347,7 +347,7 @@ public void Every_command_that_keeps_no_scope_is_one_whose_key_belongs_in_every_ CommandIds.OpenSettings, CommandIds.PreviousEditor, CommandIds.PullRequestOverview, CommandIds.Quit, CommandIds.RefreshExplorer, CommandIds.ReplaceGlobally, CommandIds.ReplaceInFile, CommandIds.SaveActiveEditor, CommandIds.ShowAbout, CommandIds.ShowActions, CommandIds.ShowDiagnostics, - CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMnemonics, + CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMenu, CommandIds.ShowMnemonics, CommandIds.SubmitReview, CommandIds.ToggleGutter, CommandIds.ToggleSidebar, CommandIds.WidenSidebar, .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), ]; From 4d198188fbfe8dcaf2bf3d1383488eda9c39594e Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 30 Sep 2026 00:10:06 +1300 Subject: [PATCH 2/6] Keep a host test's screen size when the Windows console reports its own TG 2.1.0's ANSI driver sends CSI 18 t every 500 ms and applies the answer through SetScreenSize. On the Windows runner the console answers 120x30, which overwrote the 80x20 the sideways-bar test had just set, so the bar never showed (failed twice on windows-latest). The test now pins its size: any other size the driver takes is put straight back. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/TuiCode.Tests/EditorScrollBarTests.cs | 2 +- tests/TuiCode.Tests/HostSteps.cs | 24 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/TuiCode.Tests/EditorScrollBarTests.cs b/tests/TuiCode.Tests/EditorScrollBarTests.cs index f29e263..b584051 100644 --- a/tests/TuiCode.Tests/EditorScrollBarTests.cs +++ b/tests/TuiCode.Tests/EditorScrollBarTests.cs @@ -321,7 +321,7 @@ private static MockFileSystem LongFile() } private static Action Size(WorkbenchHost host, int width, int height) => - () => host.App.Driver!.SetScreenSize(width, height); + () => HostSteps.PinScreenSize(host, width, height); private static EditorTab Tab(Workbench.Workbench workbench) => workbench.Editor.Group.ActiveTab!; diff --git a/tests/TuiCode.Tests/HostSteps.cs b/tests/TuiCode.Tests/HostSteps.cs index c816e2b..00c35ff 100644 --- a/tests/TuiCode.Tests/HostSteps.cs +++ b/tests/TuiCode.Tests/HostSteps.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; +using Terminal.Gui.Drivers; using TuiCode.Workbench; namespace TuiCode.Tests; @@ -37,4 +39,26 @@ void OnIteration(object? sender, EventArgs e) static bool Execute(Action act) { act(); return true; } } + + private static readonly ConditionalWeakTable> PinnedSizes = new(); + + // The Windows console answers TG's periodic size query with its own size, which would undo a test's resize. + public static void PinScreenSize(WorkbenchHost host, int width, int height) + { + var driver = host.App.Driver!; + var size = new System.Drawing.Size(width, height); + if (PinnedSizes.TryGetValue(driver, out var pinned)) + pinned.Value = size; + else + { + pinned = new StrongBox(size); + PinnedSizes.Add(driver, pinned); + driver.SizeChanged += (_, e) => + { + if (e.Size is { } actual && actual != pinned.Value) + driver.SetScreenSize(pinned.Value.Width, pinned.Value.Height); + }; + } + driver.SetScreenSize(width, height); + } } From 7990422c09aae95b142edb9c7b23a7ab0dff42c6 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:22:58 +1300 Subject: [PATCH 3/6] Set the menu bar apart from the explorer in every theme Midnight, Daylight, Turbo Pascal and Modern Borland take the a-team dashboard's Menu scheme, hotkeys underlined. Modern Borland's bar there is the same cyan as TuiCode's explorer, so it's grey here instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/TuiCode.Workbench/Themes/themes.json | 54 ++++++++++++------------ tests/TuiCode.Tests/MenuBarHostTests.cs | 8 ++-- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/TuiCode.Workbench/Themes/themes.json b/src/TuiCode.Workbench/Themes/themes.json index f33e54e..5b9e699 100644 --- a/src/TuiCode.Workbench/Themes/themes.json +++ b/src/TuiCode.Workbench/Themes/themes.json @@ -69,10 +69,10 @@ "Menu": { "Normal": { "Foreground": "#000000", "Background": "#AAAAAA" }, "Focus": { "Foreground": "#000000", "Background": "#00AA00" }, - "HotNormal": { "Foreground": "#AA0000", "Background": "#AAAAAA" }, - "HotFocus": { "Foreground": "#AA0000", "Background": "#00AA00" }, + "HotNormal": { "Foreground": "#AA0000", "Background": "#AAAAAA", "Style": "Underline" }, + "HotFocus": { "Foreground": "#AA0000", "Background": "#00AA00", "Style": "Underline" }, "Active": { "Foreground": "#000000", "Background": "#00AA00" }, - "HotActive": { "Foreground": "#AA0000", "Background": "#00AA00" }, + "HotActive": { "Foreground": "#AA0000", "Background": "#00AA00", "Style": "Underline" }, "Highlight": { "Foreground": "#000000", "Background": "#00AAAA" }, "Editable": { "Foreground": "#FFFF55", "Background": "#0000AA" }, "ReadOnly": { "Foreground": "#555555", "Background": "#AAAAAA" }, @@ -175,16 +175,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#000000", "Background": "#00A8A8" }, + "Normal": { "Foreground": "#000000", "Background": "#A8A8A8" }, "Focus": { "Foreground": "#FFFFFF", "Background": "#308800" }, - "HotNormal": { "Foreground": "#FFFFFF", "Background": "#00A8A8" }, - "HotFocus": { "Foreground": "#FFFF57", "Background": "#308800" }, + "HotNormal": { "Foreground": "#A80000", "Background": "#A8A8A8", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFFF57", "Background": "#308800", "Style": "Underline" }, "Active": { "Foreground": "#FFFFFF", "Background": "#308800" }, - "HotActive": { "Foreground": "#FFFF57", "Background": "#308800" }, + "HotActive": { "Foreground": "#FFFF57", "Background": "#308800", "Style": "Underline" }, "Highlight": { "Foreground": "#FFFFFF", "Background": "#006C90" }, "Editable": { "Foreground": "#FFFF57", "Background": "#003078" }, - "ReadOnly": { "Foreground": "#004E84", "Background": "#00A8A8" }, - "Disabled": { "Foreground": "#004E84", "Background": "#00A8A8" } + "ReadOnly": { "Foreground": "#545454", "Background": "#A8A8A8" }, + "Disabled": { "Foreground": "#545454", "Background": "#A8A8A8" } } }, { @@ -283,16 +283,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#C5CAD3", "Background": "#262A33" }, - "Focus": { "Foreground": "#E6E9EF", "Background": "#2F4A6D" }, - "HotNormal": { "Foreground": "#6CB6FF", "Background": "#262A33" }, - "HotFocus": { "Foreground": "#6CB6FF", "Background": "#2F4A6D" }, - "Active": { "Foreground": "#E6E9EF", "Background": "#2F4A6D" }, - "HotActive": { "Foreground": "#6CB6FF", "Background": "#2F4A6D" }, - "Highlight": { "Foreground": "#E6C07B", "Background": "#262A33" }, + "Normal": { "Foreground": "#FFFFFF", "Background": "#2B5A8A" }, + "Focus": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotNormal": { "Foreground": "#FFD866", "Background": "#2B5A8A", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Active": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotActive": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Highlight": { "Foreground": "#FFD866", "Background": "#2B5A8A" }, "Editable": { "Foreground": "#D4D7DD", "Background": "#1B1E24" }, - "ReadOnly": { "Foreground": "#7F8794", "Background": "#262A33" }, - "Disabled": { "Foreground": "#4B5263", "Background": "#262A33" } + "ReadOnly": { "Foreground": "#C7D4E2", "Background": "#2B5A8A" }, + "Disabled": { "Foreground": "#9CB4CC", "Background": "#2B5A8A" } } }, { @@ -391,16 +391,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#383A42", "Background": "#EDEEF1" }, - "Focus": { "Foreground": "#1F2328", "Background": "#CFE0F5" }, - "HotNormal": { "Foreground": "#0969DA", "Background": "#EDEEF1" }, - "HotFocus": { "Foreground": "#0969DA", "Background": "#CFE0F5" }, - "Active": { "Foreground": "#1F2328", "Background": "#CFE0F5" }, - "HotActive": { "Foreground": "#0969DA", "Background": "#CFE0F5" }, - "Highlight": { "Foreground": "#9A6700", "Background": "#EDEEF1" }, + "Normal": { "Foreground": "#FFFFFF", "Background": "#2B5A8A" }, + "Focus": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotNormal": { "Foreground": "#FFD866", "Background": "#2B5A8A", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Active": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotActive": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Highlight": { "Foreground": "#FFD866", "Background": "#2B5A8A" }, "Editable": { "Foreground": "#383A42", "Background": "#FFFFFF" }, - "ReadOnly": { "Foreground": "#6A6F78", "Background": "#EDEEF1" }, - "Disabled": { "Foreground": "#A0A4AC", "Background": "#EDEEF1" } + "ReadOnly": { "Foreground": "#C7D4E2", "Background": "#2B5A8A" }, + "Disabled": { "Foreground": "#9CB4CC", "Background": "#2B5A8A" } } }, { diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index d5a5af7..9389327 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -252,7 +252,7 @@ public void Focus_editor_tab_opens_a_submenu_of_tabs_one_to_nine() } [Fact] - public void The_bar_takes_the_themes_menu_colours_and_follows_a_switch() + public void The_bar_takes_the_themes_menu_colours_and_stands_apart_from_the_explorer() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out _); @@ -262,15 +262,17 @@ public void The_bar_takes_the_themes_menu_colours_and_follows_a_switch() ConfigurationManager.RuntimeConfig = BundledThemes.Config; ConfigurationManager.Load(ConfigLocations.LibraryResources | ConfigLocations.Runtime); var drawn = new List(); - foreach (var theme in new[] { BundledThemes.Default, BundledThemes.Daylight }) + foreach (var theme in BundledThemes.Names) { ThemeManager.Theme = theme; ConfigurationManager.Apply(); var menu = SchemeManager.GetScheme(Schemes.Menu).GetAttributeForRole(VisualRole.Normal, null); + var sidebar = SchemeManager.GetScheme("Sidebar").GetAttributeForRole(VisualRole.Normal, null); Assert.Equal(menu, workbench.MenuBar.GetAttributeForRole(VisualRole.Normal)); + Assert.NotEqual(sidebar.Background, menu.Background); drawn.Add(menu); } - Assert.NotEqual(drawn[0], drawn[1]); + Assert.True(drawn.Distinct().Count() > 1); } finally { From 4baf895feaa08a5e97cc549ca806b8d16f923473 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:33:09 +1300 Subject: [PATCH 4/6] Grey out menu items whose command can't run where the menu opened The menus now follow the palette's rule: an item is live only in its command's scope, or Global, and when its isEnabled says yes. Disabled rather than hidden so menus keep a stable shape and never go empty. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Workbench/Menus/CommandMenu.cs | 6 ++- src/TuiCode.Workbench/WorkbenchHost.cs | 15 ++++--- tests/TuiCode.Tests/MenuBarHostTests.cs | 47 ++++++++++++++++------ 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf4e02f..68ded2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ What follows is how TuiCode implements it today. - `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*. 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+` 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 a picked command runs only where its key would: in its scope and enabled, else nothing. The menu won't open over a dialog (`Workbench.HasDialog`). +- 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. Greyed rather than hidden, so the menus keep their shape. 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) diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index 9ae8124..7fe5bdd 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -88,15 +88,18 @@ public static readonly (string Title, string[] Ids)[] Layout = private readonly ICommandService _commands; private readonly IKeybindingService _keybindings; private readonly Func _canOpen; + private readonly Func _isAvailable; private readonly List<(string Id, MenuItem Item)> _items = []; private string? _picked; - public CommandMenu(MenuBar bar, ICommandService commands, IKeybindingService keybindings, Func canOpen) + public CommandMenu(MenuBar bar, ICommandService commands, IKeybindingService keybindings, Func canOpen, + Func 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)]; @@ -179,6 +182,7 @@ private void OnOpenChanged(bool open) IsOpen = true; _picked = null; Opened?.Invoke(this, EventArgs.Empty); + foreach (var (id, item) in _items) item.Enabled = _isAvailable(id); return; } _bar.App?.AddTimeout(TimeSpan.Zero, () => diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index d1b026f..6f6df37 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -150,7 +150,7 @@ public WorkbenchHost( _focus = new FocusService(FocusedView); RegisterDefaultCommands(); - _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog); + _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog, IsAvailableFromMenu); _menu.Opened += (_, _) => OnMenuOpened(); _menu.Closed += (_, picked) => OnMenuClosed(picked); ApplyKeybindings(_settings.KeybindingOverrides); @@ -950,15 +950,18 @@ private void OnMenuOpened() _scopes.Push(_menuKeys); } - // A picked command runs as its key would where the menu opened: not at all out of scope or disabled. + // The palette's rule: a command is offered where its key would run, in its scope and enabled. + private bool IsAvailableFromMenu(string id) + { + var scope = _commands.ScopeOf(id); + return (scope == CommandScope.Global || scope == FocusService.ScopeOf(_menuOpenedFrom)) && _commands.IsEnabled(id); + } + private void OnMenuClosed(string? picked) { _scopes.Pop(_menuKeys); FocusCallingRegion(); - if (picked is null) return; - var scope = _commands.ScopeOf(picked); - if (scope != CommandScope.Global && scope != FocusService.ScopeOf(_menuOpenedFrom)) return; - if (!_commands.IsEnabled(picked)) return; + if (picked is null || !IsAvailableFromMenu(picked)) return; RunLaunched(picked, _menuOpenedFrom == FocusRegion.Explorer); } diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index 9389327..54e1684 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -148,10 +148,6 @@ await HostSteps.Run(host, () => commands.TryExecute(CommandIds.CompareToSaved), () => workbench.StatusBar.DisplayedFocus == "Diff", () => host.App.InjectKey(Key.D.WithAlt), - () => Focused(workbench) == "Compare to saved", - () => host.App.InjectKey(Key.CursorDown), - () => host.App.InjectKey(Key.CursorDown), - () => host.App.InjectKey(Key.CursorDown), () => Focused(workbench) == "Next change", () => host.App.InjectKey(Key.Enter), () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveDiffTab?.CurrentChange == 1); @@ -159,12 +155,35 @@ await HostSteps.Run(host, Assert.Equal("Diff", workbench.StatusBar.DisplayedFocus); } - // Move line down's key does nothing in the explorer, so neither does picking it there. + // The palette's rule: an item is live only where its key would run. + [Fact] + public async Task With_no_editor_open_the_editor_commands_are_greyed_out() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string? first = null; + + await HostSteps.Run(host, + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => { first = Focused(workbench); host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen()); + + Assert.False(Item(host, CommandIds.MoveLinesUp).Enabled); + Assert.False(Item(host, CommandIds.ChangeGrammar).Enabled); + Assert.False(Item(host, CommandIds.AddCursorAbove).Enabled); + Assert.False(Item(host, CommandIds.NextChange).Enabled); + Assert.True(Item(host, CommandIds.FindInFile).Enabled); + Assert.True(Item(host, CommandIds.ToggleSidebar).Enabled); + Assert.Equal("Find in file", first); + } + [Fact] - public async Task An_editor_command_picked_from_the_explorer_does_nothing() + public async Task The_items_follow_where_the_menu_was_opened_from() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); + bool fromExplorer = true, fromEditor = false; await HostSteps.Run(host, () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), @@ -172,14 +191,18 @@ await HostSteps.Run(host, () => commands.TryExecute(CommandIds.FocusSidebar), () => workbench.StatusBar.DisplayedFocus == "Explorer", () => host.App.InjectKey(Key.E.WithAlt), - () => Focused(workbench) == "Move line up", - () => host.App.InjectKey(Key.CursorDown), - () => Focused(workbench) == "Move line down", - () => host.App.InjectKey(Key.Enter), + () => workbench.MenuBar.IsOpen(), + () => { fromExplorer = Item(host, CommandIds.MoveLinesDown).Enabled; host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen() && workbench.StatusBar.DisplayedFocus == "Explorer", + () => commands.TryExecute(CommandIds.FocusEditorBody), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => { fromEditor = Item(host, CommandIds.MoveLinesDown).Enabled; host.App.InjectKey(Key.Esc); }, () => !workbench.MenuBar.IsOpen()); - Assert.Equal("one\ntwo\nthree\n", workbench.Editor.Group.ActiveTab!.Content.ReplaceLineEndings("\n")); - Assert.Equal("Explorer", workbench.StatusBar.DisplayedFocus); + Assert.False(fromExplorer); + Assert.True(fromEditor); } [Fact] From 19a9d2f98993c20115e7b70d1059025c57266d6c 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 22:50:10 +1300 Subject: [PATCH 5/6] Take a menu with nothing that can run off the bar A menu whose items are all greyed out where the keys are, such as Selection or Diff with no file open, now leaves the bar and the titles after it close up. It comes back as soon as one of its commands could run. TG's Bar lines up a hidden item with Pos.Align and keeps its place, so the menu is removed rather than hidden. Availability is re-checked on each focus move and every 250ms, since some isEnabled checks stat the disk. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Workbench/Menus/CommandMenu.cs | 19 ++++++++++- src/TuiCode.Workbench/WorkbenchHost.cs | 19 ++++++++--- tests/TuiCode.Tests/MenuBarHostTests.cs | 37 ++++++++++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68ded2a..96f467f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ What follows is how TuiCode implements it today. - `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*. 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+` 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. Greyed rather than hidden, so the menus keep their shape. A picked command is checked again before it runs. The menu won't open over a dialog (`Workbench.HasDialog`). +- 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) diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index 7fe5bdd..7b3ee37 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -90,6 +90,7 @@ public static readonly (string Title, string[] Ids)[] Layout = private readonly Func _canOpen; private readonly Func _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 canOpen, @@ -135,9 +136,25 @@ public void Refresh() } } + /// Greys out each item that can't run, and takes a menu with none that can off the bar. + 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())) _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); @@ -182,7 +199,7 @@ private void OnOpenChanged(bool open) IsOpen = true; _picked = null; Opened?.Invoke(this, EventArgs.Empty); - foreach (var (id, item) in _items) item.Enabled = _isAvailable(id); + GreyOutUnavailable(); return; } _bar.App?.AddTimeout(TimeSpan.Zero, () => diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 6f6df37..a450100 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -62,6 +62,7 @@ public sealed class WorkbenchHost : IDisposable // Open menus take arrows, Enter and Esc for themselves, so no workbench key fires under one. private readonly KeybindingService _menuKeys = new(new CommandService()); private FocusRegion _menuOpenedFrom; + private long _menuShownAt; private readonly FindController _find; private readonly FocusService _focus; private readonly DiskChanges _diskChanges; @@ -150,7 +151,8 @@ public WorkbenchHost( _focus = new FocusService(FocusedView); RegisterDefaultCommands(); - _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog, IsAvailableFromMenu); + _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog, + id => IsAvailableFrom(id, _menu.IsOpen ? _menuOpenedFrom : _focus.Region)); _menu.Opened += (_, _) => OnMenuOpened(); _menu.Closed += (_, picked) => OnMenuClosed(picked); ApplyKeybindings(_settings.KeybindingOverrides); @@ -237,6 +239,7 @@ private void RegisterFocusRegions() { var inSidebar = region is FocusRegion.Explorer or FocusRegion.Find or FocusRegion.Review; _workbench.StatusBar.SetFocusRegion(FocusService.Label(region)); + ShowAvailableMenus(); sidebarBorder.Show(inSidebar); editorBorder.Show(!inSidebar); }; @@ -951,17 +954,23 @@ private void OnMenuOpened() } // The palette's rule: a command is offered where its key would run, in its scope and enabled. - private bool IsAvailableFromMenu(string id) + private bool IsAvailableFrom(string id, FocusRegion region) { var scope = _commands.ScopeOf(id); - return (scope == CommandScope.Global || scope == FocusService.ScopeOf(_menuOpenedFrom)) && _commands.IsEnabled(id); + return (scope == CommandScope.Global || scope == FocusService.ScopeOf(region)) && _commands.IsEnabled(id); + } + + private void ShowAvailableMenus() + { + _menuShownAt = Environment.TickCount64; + if (!_menu.IsOpen) _menu.ShowAvailable(); } private void OnMenuClosed(string? picked) { _scopes.Pop(_menuKeys); FocusCallingRegion(); - if (picked is null || !IsAvailableFromMenu(picked)) return; + if (picked is null || !IsAvailableFrom(picked, _menuOpenedFrom)) return; RunLaunched(picked, _menuOpenedFrom == FocusRegion.Explorer); } @@ -1096,6 +1105,8 @@ private void OnIteration(object? sender, EventArgs e) // Picks up focus Terminal.Gui moved on its own, and any move that didn't land where it was asked to. _focus.Reconcile(); _activeSymbolPicker?.Advance(); + // Some isEnabled checks touch the disk, so not on every iteration. + if (Environment.TickCount64 - _menuShownAt >= 250) ShowAvailableMenus(); } private void OnEditorCursorMoved(object? sender, (IFileInfo File, int Row, int Column) e) diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index 54e1684..2e2c0e0 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -178,6 +178,40 @@ await HostSteps.Run(host, Assert.Equal("Find in file", first); } + [Fact] + public async Task A_menu_with_nothing_that_can_run_is_hidden_and_the_rest_close_up() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string[] before = [], after = []; + var viewX = 0; + + await HostSteps.Run(host, + () => !Titles(workbench).Contains("Selection"), + () => + { + before = Titles(workbench); + viewX = Title(workbench, "View").Frame.X; + host.App.InjectKey(Key.S.WithAlt); + }, + () => { }, + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.CursorRight), + () => Title(workbench, "View").PopoverMenuOpen, + () => host.App.InjectKey(Key.Esc), + () => !workbench.MenuBar.IsOpen(), + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor" && Titles(workbench).Contains("Selection"), + () => { after = Titles(workbench); }); + + Assert.DoesNotContain("Selection", before); + Assert.Contains("Selection", after); + Assert.Equal(Title(workbench, "Edit").Frame.Right, viewX); + Assert.Equal(Title(workbench, "Selection").Frame.Right, Title(workbench, "View").Frame.X); + } + [Fact] public async Task The_items_follow_where_the_menu_was_opened_from() { @@ -314,6 +348,9 @@ private static IEnumerable MenuIds() => ? item.Title : null; + private static string[] Titles(Workbench.Workbench workbench) => + [.. workbench.MenuBar.SubViews.OfType().Select(m => m.Title.Replace("_", ""))]; + private static MenuBarItem Title(Workbench.Workbench workbench, string title) => workbench.MenuBar.SubViews.OfType().Single(m => m.Title.Replace("_", "") == title); From b8231e0c84590cbf31bcff9a909eacb896e97807 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:51:04 +1300 Subject: [PATCH 6/6] Tidy the menus after review Drop the focus moves from the menu, since opening it moves focus. View now leads with Explorer, Find and Review, then groups Toggle sidebar with Widen/Narrow. Quit gets its own separator on File. Closes #340 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 +- src/TuiCode.Workbench/Menus/CommandMenu.cs | 46 +++++++++++++--------- tests/TuiCode.Tests/MenuBarHostTests.cs | 33 ++++++++++------ 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96f467f..c6d3b5e 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`). - 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. + 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. @@ -104,7 +104,7 @@ What follows is how TuiCode implements it today. ## 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*. 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. +- `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+` 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*). diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index 7b3ee37..aedb61f 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -9,7 +9,21 @@ namespace TuiCode.Workbench.Menus; public sealed class CommandMenu { public const string Separator = "-"; - public const string FocusEditorTab = "Focus editor tab"; + + /// Commands with a mnemonic but no menu item: opening the menu moves focus itself. + public static readonly string[] Unlisted = + [ + CommandIds.FocusSidebar, CommandIds.FocusEditorBody, CommandIds.FocusEditorTabStrip, + .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), + ]; + + /// Items titled for their menu rather than with the command's label. + public static readonly IReadOnlyDictionary Titles = new Dictionary + { + [CommandIds.ShowExplorer] = "Explorer", + [CommandIds.FindGlobally] = "Find", + [CommandIds.FocusReview] = "Review", + }; public static readonly (string Title, string[] Ids)[] Layout = [ @@ -21,13 +35,15 @@ public static readonly (string Title, string[] Ids)[] Layout = Separator, CommandIds.ShowDocumentInfo, Separator, - CommandIds.OpenSettings, CommandIds.Quit, + CommandIds.OpenSettings, + Separator, + CommandIds.Quit, ]), ("_Edit", [ CommandIds.MoveLinesUp, CommandIds.MoveLinesDown, CommandIds.DuplicateLinesUp, CommandIds.DuplicateLinesDown, Separator, - CommandIds.FindInFile, CommandIds.ReplaceInFile, CommandIds.FindGlobally, CommandIds.ReplaceGlobally, + CommandIds.FindInFile, CommandIds.ReplaceInFile, CommandIds.ReplaceGlobally, Separator, CommandIds.ChangeGrammar, ]), @@ -41,13 +57,13 @@ public static readonly (string Title, string[] Ids)[] Layout = ]), ("_View", [ - CommandIds.ToggleSidebar, CommandIds.ShowExplorer, CommandIds.RefreshExplorer, CommandIds.FocusSidebar, + CommandIds.ShowExplorer, CommandIds.FindGlobally, CommandIds.FocusReview, Separator, - CommandIds.WidenSidebar, CommandIds.NarrowSidebar, + CommandIds.ToggleSidebar, CommandIds.WidenSidebar, CommandIds.NarrowSidebar, Separator, - CommandIds.ToggleGutter, + CommandIds.RefreshExplorer, Separator, - CommandIds.FocusEditorBody, CommandIds.FocusEditorTabStrip, + CommandIds.ToggleGutter, ]), ("_Go", [ @@ -55,7 +71,7 @@ public static readonly (string Title, string[] Ids)[] Layout = Separator, CommandIds.NavigateBack, CommandIds.NavigateForward, Separator, - CommandIds.NextEditor, CommandIds.PreviousEditor, FocusEditorTab, + CommandIds.NextEditor, CommandIds.PreviousEditor, ]), ("_Diff", [ @@ -69,7 +85,7 @@ public static readonly (string Title, string[] Ids)[] Layout = ]), ("_Review", [ - CommandIds.OpenPullRequest, CommandIds.PullRequestOverview, CommandIds.FocusReview, + CommandIds.OpenPullRequest, CommandIds.PullRequestOverview, Separator, CommandIds.CreateComment, CommandIds.SubmitReview, ]), @@ -81,9 +97,6 @@ public static readonly (string Title, string[] Ids)[] Layout = ]), ]; - public static IEnumerable FocusEditorTabIds => - Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex); - private readonly MenuBar _bar; private readonly ICommandService _commands; private readonly IKeybindingService _keybindings; @@ -164,18 +177,13 @@ private MenuBarItem Build((string Title, string[] Ids) entry) return menu; } - private View Entry(string id) => id switch - { - Separator => new Line(), - FocusEditorTab => new MenuItem(FocusEditorTab, string.Empty, new Menu(FocusEditorTabIds.Select(Item))), - _ => Item(id), - }; + private View Entry(string id) => id == Separator ? new Line() : Item(id); private MenuItem Item(string id) { var item = new MenuItem { - Title = _commands.Registered.FirstOrDefault(c => c.Id == id)?.Label ?? id, + 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, diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index 2e2c0e0..cd6332c 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -29,13 +29,15 @@ public MenuBarHostTests() } [Fact] - public void Every_command_with_a_mnemonic_has_exactly_one_menu_item() + public void Every_command_with_a_mnemonic_but_the_focus_moves_has_exactly_one_menu_item() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); var mapped = MenuIds().ToList(); - Assert.All(CommandMnemonics.All, pair => Assert.Single(mapped, id => id == pair.Key)); + Assert.All(CommandMnemonics.All.Where(pair => !CommandMenu.Unlisted.Contains(pair.Key)), + pair => Assert.Single(mapped, id => id == pair.Key)); + Assert.All(CommandMenu.Unlisted, id => Assert.DoesNotContain(id, mapped)); Assert.Contains(CommandIds.ShowActions, mapped); Assert.Contains(CommandIds.ShowMnemonics, mapped); Assert.Equal(mapped.Count, mapped.Distinct().Count()); @@ -297,15 +299,26 @@ public void Each_item_shows_its_current_key_and_follows_a_rebind() } [Fact] - public void Focus_editor_tab_opens_a_submenu_of_tabs_one_to_nine() + public void View_opens_with_the_sidebar_panels_by_name_then_toggle_widen_and_narrow() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out _); - var go = Title(workbench, "Go").PopoverMenu!.Root!; - var parent = go.SubViews.OfType().Single(i => i.Title == CommandMenu.FocusEditorTab); + var view = Title(workbench, "View").PopoverMenu!.Root!.SubViews.ToList(); - Assert.Equal(CommandMenu.FocusEditorTabIds, - parent.SubMenu!.SubViews.OfType().Select(i => Id(host, i))); + Assert.Equal(["Explorer", "Find", "Review", "-", "Toggle sidebar", "Widen sidebar", "Narrow sidebar"], + view.Take(7).Select(v => v is MenuItem item ? item.Title : "-")); + } + + [Fact] + public void Quit_stands_apart_from_Open_settings() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var file = Title(workbench, "File").PopoverMenu!.Root!.SubViews.ToList(); + var quit = file.IndexOf(Item(host, CommandIds.Quit)); + + Assert.IsType(file[quit - 1]); + Assert.Same(Item(host, CommandIds.OpenSettings), file[quit - 2]); } [Fact] @@ -339,9 +352,7 @@ public void The_bar_takes_the_themes_menu_colours_and_stands_apart_from_the_expl } private static IEnumerable MenuIds() => - CommandMenu.Layout.SelectMany(menu => menu.Ids) - .Where(id => id != CommandMenu.Separator) - .SelectMany(id => id == CommandMenu.FocusEditorTab ? CommandMenu.FocusEditorTabIds : [id]); + CommandMenu.Layout.SelectMany(menu => menu.Ids).Where(id => id != CommandMenu.Separator); private static string? Focused(Workbench.Workbench workbench) => workbench.MenuBar.SubViews.OfType().FirstOrDefault(m => m.PopoverMenuOpen)?.PopoverMenu?.Root?.Focused is MenuItem item @@ -356,8 +367,6 @@ private static MenuBarItem Title(Workbench.Workbench workbench, string title) => private static MenuItem Item(WorkbenchHost host, string id) => host.Menu.Items.Single(i => i.Id == id).Item; - private static string Id(WorkbenchHost host, MenuItem item) => host.Menu.Items.Single(i => i.Item == item).Id; - private static string KeyShown(WorkbenchHost host, string id) => Item(host, id).KeyView.Text; private static Action Click(WorkbenchHost host, Func at) => () =>