From a131ac285c89fc64e68bca63e60864fa3762e004 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:45:05 +1300 Subject: [PATCH 1/4] F1 shows your live keys, grouped by menu F1 opens the library's KeysDialog over a KeySheet built from TuiCode's own commands and bindings, so Everywhere follows rebinds and newly bound commands instead of a fixed list of eight. The library's KeySheet.For reads its own CommandRegistry, so HelpSheet builds the sheet from ICommandService and IKeybindingService, and LibraryScope puts the dialog's IInputScope on TuiCode's scope stack. HelpView and its tests go. The status bar's help hint moves to its own slot at the right edge, so a message no longer replaces it, and the command is renamed Show keys. Closes #512 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 7 +- src/TuiCode.Abstractions/CommandIds.cs | 5 - src/TuiCode.Workbench/Find/FindController.cs | 9 +- src/TuiCode.Workbench/Help/HelpSheet.cs | 42 ++++ src/TuiCode.Workbench/Help/HelpView.cs | 142 -------------- src/TuiCode.Workbench/Menus/CommandMenu.cs | 2 +- src/TuiCode.Workbench/Parts/StatusBarPart.cs | 37 ++-- .../Services/LibraryScope.cs | 27 +++ src/TuiCode.Workbench/WorkbenchHost.cs | 133 ++++++------- tests/TuiCode.Tests/ActionScopeHostTests.cs | 2 +- tests/TuiCode.Tests/BlameHostTests.cs | 6 +- tests/TuiCode.Tests/DiffFindTests.cs | 3 +- tests/TuiCode.Tests/DiffRevertTests.cs | 8 +- tests/TuiCode.Tests/DiffTabTests.cs | 36 ++-- tests/TuiCode.Tests/FindAndSearchHostTests.cs | 20 +- tests/TuiCode.Tests/FindControllerTests.cs | 9 +- tests/TuiCode.Tests/FindOptionsHostTests.cs | 8 +- tests/TuiCode.Tests/FindUsagesHostTests.cs | 8 +- tests/TuiCode.Tests/GlobalUsings.cs | 3 + tests/TuiCode.Tests/HelpPlaceHostTests.cs | 180 +++++++++++++----- tests/TuiCode.Tests/HelpReviewHostTests.cs | 22 +-- tests/TuiCode.Tests/HelpSheetTests.cs | 115 +++++++++++ tests/TuiCode.Tests/HelpViewTests.cs | 74 ------- tests/TuiCode.Tests/SidebarHelp.cs | 2 +- tests/TuiCode.Tests/StatusBarPartTests.cs | 66 ++++--- .../TuiCode.Tests/WatchTutorialsHostTests.cs | 2 +- tests/TuiCode.Tests/WorkbenchHostTests.cs | 38 +--- 27 files changed, 531 insertions(+), 475 deletions(-) create mode 100644 src/TuiCode.Workbench/Help/HelpSheet.cs delete mode 100644 src/TuiCode.Workbench/Help/HelpView.cs create mode 100644 src/TuiCode.Workbench/Services/LibraryScope.cs create mode 100644 tests/TuiCode.Tests/HelpSheetTests.cs delete mode 100644 tests/TuiCode.Tests/HelpViewTests.cs diff --git a/AGENTS.md b/AGENTS.md index ef28ccbe..e7d16f9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,9 +120,10 @@ What follows is how TuiCode implements it today. ## Help dialog (F1) -- `HelpView`'s left column, **Everywhere**, 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. -- The right column lists the keys of the place you pressed F1 (#406), captured in `WorkbenchHost.OpenHelp` before the dialog's scope is pushed. A diff has its own hand-picked rows (`DiffHelp`); the Explorer, the Find pane and the editor list every command in their scope (`ScopeHelp`), after a fixed list of keys that aren't commands (`ExplorerKeys`, `FindResultKeys`). The rows come from the live bindings and leave out an unbound or disabled command, as the palette does. The find bar has its own (`FindController.Help`), whose keys aren't rebindable, listing those of the field the keys were in. Review (`ReviewHelp`) and the tab strip (`TabsHelp`) list their fixed keys, then the commands among theirs that are bound; a new Review or tab-strip key adds a row to its column. The Usages tab has only fixed keys (`UsagesKeys`). A sidebar tab's column ends with the keys that show the other sidebar tabs and `Ctrl+PgUp`/`Ctrl+PgDn` (`SidebarTabKeys`), and a panel tab's with the panel's (`PanelTabKeys`). Below the width both need, it sits above Everywhere instead, and when the columns are taller than the screen they scroll with `↑ ↓ PgUp PgDn`. -- The status bar holds status, not key hints. Its default message (`TuiCode • F1 help`) and, while no file is open, the position slot's `Press F1 for help` both read the live `ShowHelp` binding (`WorkbenchHost.ApplyKeybindings`), so a rebind updates them and an unbound help key leaves just `TuiCode`. +- F1 (*Show keys*) opens the library's `KeysDialog`, titled *Help*, over a `KeySheet` TuiCode builds itself (`HelpSheet`), since the library's `KeySheet.For` reads its own command registry, not ours. It's hosted like any other modal: added to the workbench, its scope pushed through `LibraryScope`, which adapts the library's `IInputScope` to our stack. It closes on `Esc` only, and swallows every other key while it's open. +- **Everywhere** is every `Global` command that has a key and can run now (`IsEnabled`, as the palette filters), read from the live bindings when F1 opens, under its `CommandMenu.Layout` heading in menu order, then *Other* for bound commands with no menu item. A command with several keys is one row (`Ctrl+O, F5`). It shows the command's label, not its menu title. Editor-scoped commands are the editor column's, never Everywhere's. +- The place column lists the keys of the place you pressed F1 (#406), captured in `WorkbenchHost.OpenHelp` before the dialog's scope is pushed. A diff has its own hand-picked rows (`DiffHelp`); the Explorer, the Find pane and the editor list every command in their scope (`ScopeHelp`), after a fixed list of keys that aren't commands (`ExplorerKeys`, `FindResultKeys`). The rows come from the live bindings and leave out an unbound or disabled command, as the palette does. The find bar has its own (`FindController.Help`), whose keys aren't rebindable, listing those of the field the keys were in. Review (`ReviewHelp`) and the tab strip (`TabsHelp`) list their fixed keys, then the commands among theirs that are bound; a new Review or tab-strip key adds a row to its column. The Usages tab has only fixed keys (`UsagesKeys`). A sidebar tab's column ends with the keys that show the other sidebar tabs and `Ctrl+PgUp`/`Ctrl+PgDn` (`SidebarTabKeys`), and a panel tab's with the panel's (`PanelTabKeys`). A place with none shows Everywhere alone. The library lays them out: side by side, Everywhere first; stacked with the place on top below the width both need; scrolling with `↑ ↓ PgUp PgDn` when taller than the screen. +- The status bar always ends with `Press F1 for help` (`StatusBarPart.SetHelpKey`), from the live `ShowHelp` binding (`WorkbenchHost.ApplyKeybindings`), so a rebind updates it and an unbound key leaves nothing there. The cursor position sits just before it, and the message on the left never replaces it; the default message is just `TuiCode`. ## Find & search (#33) diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index c85d5fca..e7243ff8 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -65,11 +65,6 @@ public static class CommandIds public const string ScrollDiffPageLeft = "diff.action.scrollPageLeft"; public const string ScrollDiffPageRight = "diff.action.scrollPageRight"; - public const string HelpClose = "help.action.close"; - public const string HelpScrollUp = "help.action.scrollUp"; - public const string HelpScrollDown = "help.action.scrollDown"; - public const string HelpPageUp = "help.action.pageUp"; - public const string HelpPageDown = "help.action.pageDown"; public const string DiagnosticsClose = "diagnostics.action.close"; public const string AboutClose = "about.action.close"; public const string DocumentInfoClose = "documentInfo.action.close"; diff --git a/src/TuiCode.Workbench/Find/FindController.cs b/src/TuiCode.Workbench/Find/FindController.cs index ef18c236..1115f865 100644 --- a/src/TuiCode.Workbench/Find/FindController.cs +++ b/src/TuiCode.Workbench/Find/FindController.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Keys; using TuiCode.Abstractions; using TuiCode.Editor; using TuiCode.Search; @@ -89,11 +90,11 @@ internal bool FocusInput() => _bar.ReplaceVisible && _bar.KeysInReplacement ? _bar.FocusReplacement() : _bar.FocusQuery(); /// The bar's keys for F1, for whichever field has them. - internal HelpColumn Help(IEnumerable options) + internal KeyColumn Help(IEnumerable options) { var inReplacement = _bar.ReplaceVisible && _bar.KeysInReplacement; - HelpRow[] replaceRows = [new("Ctrl+Enter", "Replace all"), new("Tab", inReplacement ? "Find field" : "Replace field")]; - HelpRow[] rows = + KeyRow[] replaceRows = [new("Ctrl+Enter", "Replace all"), new("Tab", inReplacement ? "Find field" : "Replace field")]; + KeyRow[] rows = [ new("Enter", inReplacement ? "Replace and go to next" : "Next match"), new("Shift+Enter", "Previous match"), @@ -101,7 +102,7 @@ internal HelpColumn Help(IEnumerable options) .. options, new("Esc", "Close"), ]; - return new HelpColumn("Find bar", rows); + return HelpSheet.Place("Find bar", rows); } public void Close() diff --git a/src/TuiCode.Workbench/Help/HelpSheet.cs b/src/TuiCode.Workbench/Help/HelpSheet.cs new file mode 100644 index 00000000..50634f39 --- /dev/null +++ b/src/TuiCode.Workbench/Help/HelpSheet.cs @@ -0,0 +1,42 @@ +using MentalDesk.Tui.Keys; +using TuiCode.Abstractions; +using TuiCode.Workbench.Menus; + +namespace TuiCode.Workbench.Help; + +/// What F1 shows: the place's keys, and every Global command that has a key and can run now under its menu's heading. +public static class HelpSheet +{ + public static KeySheet For(KeyColumn? here, ICommandService commands, IKeybindingService keybindings) => + new(here, Everywhere(commands, keybindings, CommandMenu.Layout)); + + internal static KeyColumn Everywhere(ICommandService commands, IKeybindingService keybindings, IEnumerable<(string Title, string[] Ids)> menu) + { + var bound = commands.Registered + .Where(command => command.Scope == CommandScope.Global && commands.IsEnabled(command.Id)) + .Select(command => Row(command, keybindings)) + .OfType<(string Id, KeyRow Row)>() + .ToList(); + var rows = bound.ToDictionary(command => command.Id, command => command.Row, StringComparer.Ordinal); + var listed = new HashSet(StringComparer.Ordinal); + var groups = new List(); + foreach (var (title, ids) in menu) + { + KeyRow[] items = [.. ids.Where(id => rows.ContainsKey(id) && listed.Add(id)).Select(id => rows[id])]; + if (items.Length > 0) groups.Add(new KeyGroup(title.Replace("_", "", StringComparison.Ordinal), items)); + } + KeyRow[] other = [.. bound.Where(command => !listed.Contains(command.Id)).Select(command => command.Row)]; + if (other.Length > 0) groups.Add(new KeyGroup(KeySheet.OtherHeading, other)); + return new KeyColumn("Everywhere", groups); + } + + public static KeyColumn Place(string heading, IEnumerable rows) => new(heading, [new KeyGroup(null, [.. rows])]); + + public static IReadOnlyList Rows(this KeyColumn column) => [.. column.Groups.SelectMany(group => group.Rows)]; + + private static (string Id, KeyRow Row)? Row(CommandDescriptor command, IKeybindingService keybindings) + { + var keys = keybindings.Bindings.Where(binding => binding.CommandId == command.Id).Select(binding => binding.Display).ToList(); + return keys.Count == 0 ? null : (command.Id, new KeyRow(string.Join(", ", keys), command.Label)); + } +} diff --git a/src/TuiCode.Workbench/Help/HelpView.cs b/src/TuiCode.Workbench/Help/HelpView.cs deleted file mode 100644 index 78cf6de6..00000000 --- a/src/TuiCode.Workbench/Help/HelpView.cs +++ /dev/null @@ -1,142 +0,0 @@ -using TuiCode.Abstractions; -using TuiCode.Workbench.Services; - -namespace TuiCode.Workbench.Help; - -public sealed record HelpRow(string Key, string Description); - -public sealed record HelpColumn(string Title, IReadOnlyList Rows); - -public sealed class HelpView : Window -{ - internal static readonly HelpColumn Everywhere = new("Everywhere", - [ - new("Ctrl+E", "Command palette"), - new("Ctrl+Space", "Run a command by its mnemonic"), - new("Ctrl+O", "Open file or folder"), - new("Ctrl+N", "New file or folder"), - new("Ctrl+S", "Save"), - new("Ctrl+W", "Close tab"), - new("Ctrl+F", "Find in file"), - new("Ctrl+Q", "Quit"), - ]); - - private const int Chrome = 4; - - private readonly ICommandService _scopeCommands; - private readonly IKeybindingService _scopeKeybindings; - private readonly View _body; - - public IKeybindingService Scope => _scopeKeybindings; - - /// The keys of the place F1 was pressed in, beside ; null when it has none. - internal HelpColumn? Place { get; } - - internal bool IsStacked { get; } - - internal bool IsScrollable { get; } - - internal View Body => _body; - - public event EventHandler? Closed; - - public HelpView(HelpColumn? place = null, int availableWidth = int.MaxValue, int availableHeight = int.MaxValue) - { - Place = place; - Title = "Help"; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - CanFocus = true; - _body = new View(); - - var (everywhere, leftWidth, leftHeight) = ColumnView(Everywhere); - everywhere.X = 1; - int innerWidth, columnsHeight; - if (place is null) - { - _body.Add(everywhere); - (innerWidth, columnsHeight) = (leftWidth + 2, leftHeight); - } - else - { - var (here, rightWidth, rightHeight) = ColumnView(place); - innerWidth = leftWidth + rightWidth + 5; - IsStacked = innerWidth + 2 > availableWidth; - if (IsStacked) - { - here.X = 1; - everywhere.Y = rightHeight + 1; - _body.Add(here, everywhere); - innerWidth = Math.Max(leftWidth, rightWidth) + 2; - columnsHeight = rightHeight + 1 + leftHeight; - } - else - { - columnsHeight = Math.Max(leftHeight, rightHeight); - var divider = new Line { Orientation = Orientation.Vertical, X = leftWidth + 2, Y = 0, Height = columnsHeight }; - here.X = leftWidth + 4; - _body.Add(everywhere, divider, here); - } - } - - var bodyHeight = Math.Clamp(availableHeight - Chrome, 1, columnsHeight); - IsScrollable = bodyHeight < columnsHeight; - var bodyWidth = IsScrollable ? innerWidth + 1 : innerWidth; - _body.Width = bodyWidth; - _body.Height = bodyHeight; - _body.SetContentSize(new System.Drawing.Size(innerWidth, columnsHeight)); - if (IsScrollable) _body.ViewportSettings |= ViewportSettingsFlags.HasScrollBars; - Add(_body); - Width = bodyWidth + 2; - Height = bodyHeight + Chrome; - - var footer = new VerbatimLabel - { - X = Pos.Center(), - Y = Pos.AnchorEnd(1), - Text = IsScrollable ? "↑ ↓ scroll · Esc · Enter close" : "Esc · Enter close", - }; - Add(footer); - - _scopeCommands = new CommandService(); - _scopeKeybindings = new KeybindingService(_scopeCommands); - RegisterScopeBindings(); - } - - private void RegisterScopeBindings() - { - _scopeCommands.Register(CommandIds.HelpClose, () => Closed?.Invoke(this, EventArgs.Empty)); - _scopeKeybindings.Bind("Esc", CommandIds.HelpClose); - _scopeKeybindings.Bind("Enter", CommandIds.HelpClose); - if (!IsScrollable) return; - _scopeCommands.Register(CommandIds.HelpScrollUp, () => ScrollBy(-1)); - _scopeCommands.Register(CommandIds.HelpScrollDown, () => ScrollBy(1)); - _scopeCommands.Register(CommandIds.HelpPageUp, () => ScrollBy(-_body.Viewport.Height)); - _scopeCommands.Register(CommandIds.HelpPageDown, () => ScrollBy(_body.Viewport.Height)); - _scopeKeybindings.Bind("CursorUp", CommandIds.HelpScrollUp); - _scopeKeybindings.Bind("CursorDown", CommandIds.HelpScrollDown); - _scopeKeybindings.Bind("PageUp", CommandIds.HelpPageUp); - _scopeKeybindings.Bind("PageDown", CommandIds.HelpPageDown); - } - - private void ScrollBy(int rows) - { - var bottom = Math.Max(0, _body.GetContentSize().Height - _body.Viewport.Height); - _body.Viewport = _body.Viewport with { Y = Math.Clamp(_body.Viewport.Y + rows, 0, bottom) }; - } - - private static (View View, int Width, int Height) ColumnView(HelpColumn column) - { - var keyWidth = column.Rows.Max(row => row.Key.Length) + 1; - var rows = column.Rows.Select(row => row.Key.PadRight(keyWidth) + row.Description).ToList(); - var width = rows.Append(column.Title).Max(text => text.Length); - var height = rows.Count + 2; - var view = new View { Width = width, Height = height }; - view.Add( - new VerbatimLabel { X = 0, Y = 0, Text = column.Title }, - new Line { X = 0, Y = 1, Width = width }, - new VerbatimLabel { X = 0, Y = 2, Width = width, Height = rows.Count, Text = string.Join("\n", rows) }); - return (view, width, height); - } -} diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index e7f66b38..7b0a994e 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -65,7 +65,7 @@ .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), [CommandIds.OpenPullRequest] = 'o', [CommandIds.PullRequestOverview] = 'p', [CommandIds.CreateComment] = 'c', [CommandIds.ToggleViewed] = 'v', [CommandIds.SubmitReview] = 's', - [CommandIds.ShowHelp] = 'g', [CommandIds.ShowActions] = 's', [CommandIds.ShowMnemonics] = 'm', + [CommandIds.ShowHelp] = 'k', [CommandIds.ShowActions] = 's', [CommandIds.ShowMnemonics] = 'm', [CommandIds.WatchTutorials] = 't', [CommandIds.ShowDiagnostics] = 'd', [CommandIds.ShowLog] = 'l', [CommandIds.ShowAbout] = 'a', }; diff --git a/src/TuiCode.Workbench/Parts/StatusBarPart.cs b/src/TuiCode.Workbench/Parts/StatusBarPart.cs index 8b6dbbdd..2cfcc247 100644 --- a/src/TuiCode.Workbench/Parts/StatusBarPart.cs +++ b/src/TuiCode.Workbench/Parts/StatusBarPart.cs @@ -8,6 +8,7 @@ public sealed class StatusBarPart : View { private readonly VerbatimLabel _label; private readonly VerbatimLabel _position; + private readonly VerbatimLabel _help; private readonly VerbatimLabel _focus; private readonly Button _warnings; private int _unseenWarnings; @@ -20,7 +21,6 @@ public sealed class StatusBarPart : View private string? _mode; private (int Row, int Column)? _cursor; private (int Carets, int? Selected) _selection = (1, null); - private string? _idleHint; public StatusBarPart() { @@ -29,7 +29,9 @@ public StatusBarPart() CanFocus = false; SchemeName = "StatusBar"; - _position = new VerbatimLabel { X = Pos.AnchorEnd() - 1, Y = 0 }; + _help = new VerbatimLabel { X = Pos.AnchorEnd() - 1, Y = 0, Text = HelpHintFor("F1") }; + _position = new VerbatimLabel { Y = 0 }; + PlacePosition(); _focus = new VerbatimLabel { X = 1, Y = 0 }; _warnings = new Button { @@ -60,7 +62,7 @@ public StatusBarPart() Width = Dim.Fill(2, _warnings), Text = _message }; - Add(_focus, _label, _warnings, _position); + Add(_focus, _label, _warnings, _position, _help); } /// The ⚠ n item was clicked. @@ -106,18 +108,19 @@ public void SetError(string message) internal string Message => _message; - /// What the bar says when nothing else has: the name and the help key, if it has one. - public string DefaultMessage { get; private set; } = DefaultMessageFor("F1"); + /// What the bar says when nothing else has. + public string DefaultMessage => "TuiCode"; - /// Name as the help key in the default message; null leaves the name alone. + /// Ends the bar with Press {key} for help; null leaves nothing there. public void SetHelpKey(string? key) { - var previous = DefaultMessage; - DefaultMessage = DefaultMessageFor(key); - if (_message == previous) SetMessage(DefaultMessage); + _help.Text = HelpHintFor(key); + PlacePosition(); } - private static string DefaultMessageFor(string? helpKey) => helpKey is null ? "TuiCode" : $"TuiCode • {helpKey} help"; + private static string HelpHintFor(string? key) => key is null ? string.Empty : $"Press {key} for help"; + + internal string DisplayedHelp => _help.Text; internal bool ShowsError => _label.SchemeName == "Error"; @@ -172,25 +175,23 @@ public void SetSelection(int carets, int? selected) ShowPosition(); } - /// Shown in the position's place while no file is open; null for nothing. - public void SetIdleHint(string? hint) - { - _idleHint = hint; - ShowPosition(); - } - private void ShowPosition() { if (_cursor is not var (row, column)) { - _position.Text = _idleHint ?? string.Empty; + _position.Text = string.Empty; + PlacePosition(); return; } var (carets, selected) = _selection; var where = carets > 1 ? $"{carets} selections" : $"Ln {row + 1}, Col {column + 1}"; _position.Text = selected is { } count ? $"{where} ({count:N0} selected)" : where; + PlacePosition(); } + private void PlacePosition() => + _position.X = Pos.Left(_help) - _position.Text.GetColumns(false) - (_help.Text.Length > 0 && _position.Text.Length > 0 ? 3 : 0); + internal string DisplayedText => _label.Text; internal string DisplayedPosition => _position.Text; diff --git a/src/TuiCode.Workbench/Services/LibraryScope.cs b/src/TuiCode.Workbench/Services/LibraryScope.cs new file mode 100644 index 00000000..95935445 --- /dev/null +++ b/src/TuiCode.Workbench/Services/LibraryScope.cs @@ -0,0 +1,27 @@ +using MentalDesk.Tui.Keys; +using TuiCode.Abstractions; + +namespace TuiCode.Workbench.Services; + +/// A library dialog's on TuiCode's scope stack; it binds nothing of its own. +internal sealed class LibraryScope(IInputScope scope) : IKeybindingService +{ + public KeyHandlingResult Handle(Key key) => scope.Handle(key) switch + { + KeyResult.Consumed => KeyHandlingResult.Consumed, + KeyResult.ChordInProgress => KeyHandlingResult.ChordInProgress, + _ => KeyHandlingResult.Pass, + }; + + public void Bind(string keySequence, string commandId) { } + public void Bind(IReadOnlyList chord, string commandId) { } + public bool Unbind(string keySequence, CommandScope scope = CommandScope.Global) => false; + public bool Unbind(IReadOnlyList chord, CommandScope scope = CommandScope.Global, string? commandId = null) => false; + public void Reset() { } + public KeybindingConflict? CheckConflict(string keySequence, CommandScope scope = CommandScope.Global) => null; + public KeybindingConflict? CheckConflict(IReadOnlyList chord, CommandScope scope = CommandScope.Global) => null; + public Func FocusedScope { get; set; } = () => CommandScope.Global; + public IEnumerable Bindings => []; + public string? CurrentChord => scope.PendingChord; + public event EventHandler? ChordChanged { add { } remove { } } +} diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 39e7a1b9..61b11daa 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using KeyColumn = MentalDesk.Tui.Keys.KeyColumn; +using KeyRow = MentalDesk.Tui.Keys.KeyRow; +using KeysDialog = MentalDesk.Tui.Keys.KeysDialog; using MentalDesk.Tui.Shell; using Terminal.Gui.Drivers; using Terminal.Gui.Time; @@ -89,7 +92,8 @@ public sealed class WorkbenchHost : IDisposable private bool _suppressHistory; private SettingsView? _activeSettings; private ActionView? _activeActions; - private HelpView? _activeHelp; + private KeysDialog? _activeHelp; + private LibraryScope? _helpScope; private GoToLineView? _activeGoToLine; private SymbolPickerView? _activeSymbolPicker; private DefinitionPickerView? _activeDefinitionPicker; @@ -681,7 +685,7 @@ private void RegisterDefaultCommands() _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.ShowHelp, "Show keys", 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. _commands.Register(CommandIds.GoToSymbol, "Go to symbol in file", OpenSymbolPicker, CommandScope.Editor); @@ -786,12 +790,10 @@ public void ApplyKeybindings(IEnumerable overrides) } _menu.Refresh(); - var help = KeyOf(CommandIds.ShowHelp); - _workbench.StatusBar.SetIdleHint(help is null ? null : $"Press {help} for help"); - _workbench.StatusBar.SetHelpKey(help); + _workbench.StatusBar.SetHelpKey(KeyOf(CommandIds.ShowHelp)); } - private HelpColumn? PlaceHelp() + private KeyColumn? PlaceHelp() { Reconcile(); var help = _focus.Region switch @@ -802,123 +804,122 @@ public void ApplyKeybindings(IEnumerable overrides) FocusRegion.Find => FindPaneHelp(), FocusRegion.Editor => _workbench.Editor.Group.Value is null ? null : ScopeHelp("Editor", CommandScope.Editor, GroupHelp()), FocusRegion.Review => ReviewHelp(), - FocusRegion.Usages => new HelpColumn("Usages", UsagesKeys), + FocusRegion.Usages => HelpSheet.Place("Usages", UsagesKeys), FocusRegion.Assistant => ScopeHelp("AI assistance", CommandScope.Assistant, AssistantKeys), - FocusRegion.History => new HelpColumn("History", HistoryKeys), + FocusRegion.History => HelpSheet.Place("History", HistoryKeys), FocusRegion.Tabs => TabsHelp(), _ => null, }; - HelpRow[] switching = InSidebar(_focus.Region) ? SidebarTabKeys() : InPanel(_focus.Region) ? PanelTabKeys() : []; - return help is null ? null : help with { Rows = [.. help.Rows, .. switching] }; + KeyRow[] switching = InSidebar(_focus.Region) ? SidebarTabKeys() : InPanel(_focus.Region) ? PanelTabKeys() : []; + return help is null ? null : HelpSheet.Place(help.Heading, [.. help.Rows(), .. switching]); } - private HelpRow[] SidebarTabKeys() => + private KeyRow[] SidebarTabKeys() => [ - .. HelpRowOf(CommandIds.ShowExplorer, "Show explorer"), - .. HelpRowOf(CommandIds.FindGlobally, "Show find"), - .. HelpRowOf(CommandIds.FocusUsages, "Show usages"), - .. HelpRowOf(CommandIds.FocusReview, "Show review"), - .. HelpRowOf(CommandIds.NextEditor, "Next sidebar tab"), - .. HelpRowOf(CommandIds.PreviousEditor, "Previous sidebar tab"), + .. KeyRowOf(CommandIds.ShowExplorer, "Show explorer"), + .. KeyRowOf(CommandIds.FindGlobally, "Show find"), + .. KeyRowOf(CommandIds.FocusUsages, "Show usages"), + .. KeyRowOf(CommandIds.FocusReview, "Show review"), + .. KeyRowOf(CommandIds.NextEditor, "Next sidebar tab"), + .. KeyRowOf(CommandIds.PreviousEditor, "Previous sidebar tab"), ]; - private HelpRow[] PanelTabKeys() => + private KeyRow[] PanelTabKeys() => [ - .. HelpRowOf(CommandIds.ToggleFileHistory, "Show or hide file history"), - .. HelpRowOf(CommandIds.AiAssistance, "AI assistance"), - .. HelpRowOf(CommandIds.NextEditor, "Next panel tab"), - .. HelpRowOf(CommandIds.PreviousEditor, "Previous panel tab"), + .. KeyRowOf(CommandIds.ToggleFileHistory, "Show or hide file history"), + .. KeyRowOf(CommandIds.AiAssistance, "AI assistance"), + .. KeyRowOf(CommandIds.NextEditor, "Next panel tab"), + .. KeyRowOf(CommandIds.PreviousEditor, "Previous panel tab"), ]; - private HelpColumn ReviewHelp() + private KeyColumn ReviewHelp() { var review = _workbench.Sidebar.Review; - HelpRow[] keys = review.OverviewHasFocus + KeyRow[] keys = review.OverviewHasFocus ? [new("Enter", "Open the Overview"), new("↓", "To the files")] : [ new("Enter", "Open the file's diff"), - .. review.HasOverview ? new HelpRow[] { new("↑", "To Overview") } : [], - .. HelpRowOf(CommandIds.ToggleViewed, "Mark viewed or not"), + .. review.HasOverview ? new KeyRow[] { new("↑", "To Overview") } : [], + .. KeyRowOf(CommandIds.ToggleViewed, "Mark viewed or not"), ]; return ScopeHelp("Review", CommandScope.Review, keys); } - private HelpColumn TabsHelp() => new("Tabs", + private KeyColumn TabsHelp() => HelpSheet.Place("Tabs", [ new("← →", "Switch tab"), new("Enter", "Go to the file"), - .. HelpRowOf(CommandIds.NextEditor, "Next tab"), - .. HelpRowOf(CommandIds.PreviousEditor, "Previous tab"), - .. HelpRowOf(CommandIds.CloseActiveEditor, "Close tab"), - .. HelpRowOf(CommandIds.CloseOtherTabs, "Close other tabs"), - .. HelpRowOf(CommandIds.CloseAllTabs, "Close all tabs"), + .. KeyRowOf(CommandIds.NextEditor, "Next tab"), + .. KeyRowOf(CommandIds.PreviousEditor, "Previous tab"), + .. KeyRowOf(CommandIds.CloseActiveEditor, "Close tab"), + .. KeyRowOf(CommandIds.CloseOtherTabs, "Close other tabs"), + .. KeyRowOf(CommandIds.CloseAllTabs, "Close all tabs"), .. GroupHelp(), ]); - private HelpRow[] GroupHelp() => + private KeyRow[] GroupHelp() => [ - .. HelpRowOf(CommandIds.MoveToOtherGroup, "Move to other group"), - .. HelpRowOf(CommandIds.FocusOtherGroup, "Focus other group"), + .. KeyRowOf(CommandIds.MoveToOtherGroup, "Move to other group"), + .. KeyRowOf(CommandIds.FocusOtherGroup, "Focus other group"), ]; - private IEnumerable HelpRowOf(string commandId, string description) => - _commands.IsEnabled(commandId) && KeyOf(commandId) is { } key ? [new HelpRow(key, description)] : []; + private IEnumerable KeyRowOf(string commandId, string description) => + _commands.IsEnabled(commandId) && KeyOf(commandId) is { } key ? [new KeyRow(key, description)] : []; - private static readonly HelpRow[] ExplorerKeys = [new("Enter", "Open"), new("→ ←", "Expand or collapse")]; + private static readonly KeyRow[] ExplorerKeys = [new("Enter", "Open"), new("→ ←", "Expand or collapse")]; - private HelpColumn FindPaneHelp() + private KeyColumn FindPaneHelp() { if (!_workbench.Sidebar.Search.InputsHaveFocus) return ScopeHelp("Find", CommandScope.Find, FindResultKeys); - var help = ScopeHelp("Find", CommandScope.Find, []); - return help with { Rows = [.. help.Rows, .. FindOptionKeys()] }; + return ScopeHelp("Find", CommandScope.Find, FindOptionKeys(), fixedFirst: false); } - private HelpRow[] FindOptionKeys() => + private KeyRow[] FindOptionKeys() => [ - .. HelpRowOf(CommandIds.ToggleMatchCase, "Match case on or off"), - .. HelpRowOf(CommandIds.ToggleWholeWord, "Match whole word on or off"), - .. HelpRowOf(CommandIds.ToggleIgnoredFiles, "Ignored files on or off"), + .. KeyRowOf(CommandIds.ToggleMatchCase, "Match case on or off"), + .. KeyRowOf(CommandIds.ToggleWholeWord, "Match whole word on or off"), + .. KeyRowOf(CommandIds.ToggleIgnoredFiles, "Ignored files on or off"), ]; - private static readonly HelpRow[] FindResultKeys = [new("Enter", "Open result")]; + private static readonly KeyRow[] FindResultKeys = [new("Enter", "Open result")]; - private static readonly HelpRow[] HistoryKeys = + private static readonly KeyRow[] HistoryKeys = [new("Enter", "Open the commit's change"), new("↑ ↓", "Choose a commit"), new("Type", "Filter the commits"), new("Esc", "Back to the editor")]; - private static readonly HelpRow[] AssistantKeys = [new("↑ ↓ PgUp PgDn", "Scroll the chat")]; + private static readonly KeyRow[] AssistantKeys = [new("↑ ↓ PgUp PgDn", "Scroll the chat")]; - private static readonly HelpRow[] UsagesKeys = [new("Enter", "Go to the usage"), new("↑ ↓", "Step through usages"), new("→ ←", "Expand or collapse")]; + private static readonly KeyRow[] UsagesKeys = [new("Enter", "Go to the usage"), new("↑ ↓", "Step through usages"), new("→ ←", "Expand or collapse")]; /// A region's keys for F1: its fixed keys, then each command in its scope that's bound and can run now. - private HelpColumn ScopeHelp(string title, CommandScope scope, IEnumerable fixedKeys) + private KeyColumn ScopeHelp(string title, CommandScope scope, IEnumerable fixedKeys, bool fixedFirst = true) { var commands = _commands.Registered .Where(command => command.Scope == scope && _commands.IsEnabled(command.Id)) .Select(command => (command.Label, Keys: _keybindings.Bindings.Where(b => b.CommandId == command.Id).Select(b => b.Display).ToList())) .Where(command => command.Keys.Count > 0) - .Select(command => new HelpRow(string.Join(", ", command.Keys), command.Label)); - return new HelpColumn(title, [.. fixedKeys, .. commands]); + .Select(command => new KeyRow(string.Join(", ", command.Keys), command.Label)); + return HelpSheet.Place(title, fixedFirst ? [.. fixedKeys, .. commands] : [.. commands, .. fixedKeys]); } /// The focused diff's keys for F1, from the live bindings, leaving out what's unbound or can't run there. - private HelpColumn? DiffHelp() + private KeyColumn? DiffHelp() { if (_workbench.Editor.Group.ActiveDiffTab is not { } diff) return null; - HelpRow[] rows = + KeyRow[] rows = [ - .. HelpRowOf(CommandIds.NextChange, "Next change"), - .. HelpRowOf(CommandIds.PreviousChange, "Previous change"), - .. HelpRowOf(CommandIds.RevertChange, diff.IsDeleted ? "Restore file" : "Revert change"), - .. HelpRowOf(CommandIds.GoToChangeLine, "Go to this line"), + .. KeyRowOf(CommandIds.NextChange, "Next change"), + .. KeyRowOf(CommandIds.PreviousChange, "Previous change"), + .. KeyRowOf(CommandIds.RevertChange, diff.IsDeleted ? "Restore file" : "Revert change"), + .. KeyRowOf(CommandIds.GoToChangeLine, "Go to this line"), .. PairRow(CommandIds.ScrollDiffLeft, CommandIds.ScrollDiffRight, "Scroll sideways"), .. PairRow(CommandIds.ScrollDiffPageLeft, CommandIds.ScrollDiffPageRight, "Page sideways"), .. GroupHelp(), ]; - return rows.Length == 0 ? null : new HelpColumn("Diff", rows); + return rows.Length == 0 ? null : HelpSheet.Place("Diff", rows); - IEnumerable PairRow(string backId, string forwardId, string description) => - KeyPair(backId, forwardId) is { } keys ? [new HelpRow(keys, description)] : []; + IEnumerable PairRow(string backId, string forwardId, string description) => + KeyPair(backId, forwardId) is { } keys ? [new KeyRow(keys, description)] : []; } /// Shift+← →: both keys, sharing their modifiers when they have the same ones. @@ -2486,21 +2487,23 @@ private void OpenHelp() { if (_activeHelp is not null) return; - var view = new HelpView(PlaceHelp(), _workbench.Frame.Width, _workbench.Viewport.Height); + var view = new KeysDialog(HelpSheet.For(PlaceHelp(), _commands, _keybindings), _workbench.Viewport.Size); view.Closed += (_, _) => CloseHelp(view); _activeHelp = view; + _helpScope = new LibraryScope(view.Scope); _workbench.Add(view); - _scopes.Push(view.Scope); + _scopes.Push(_helpScope); view.SetFocus(); } - private void CloseHelp(HelpView view) + private void CloseHelp(KeysDialog view) { if (!ReferenceEquals(_activeHelp, view)) return; - _scopes.Pop(view.Scope); + _scopes.Pop(_helpScope!); _workbench.Remove(view); view.Dispose(); _activeHelp = null; + _helpScope = null; FocusCallingRegion(); } diff --git a/tests/TuiCode.Tests/ActionScopeHostTests.cs b/tests/TuiCode.Tests/ActionScopeHostTests.cs index d0085f02..0eed17de 100644 --- a/tests/TuiCode.Tests/ActionScopeHostTests.cs +++ b/tests/TuiCode.Tests/ActionScopeHostTests.cs @@ -37,7 +37,7 @@ public class ActionScopeHostTests : StaticConfigurationTest private static readonly string[] Globals = [ "Save active editor", "Close active editor", "Next tab", "Previous tab", "Hide sidebar", - "Open settings", "Quit", "Getting Started (help)", + "Open settings", "Quit", "Show keys", ]; private readonly MockFileSystem _fs = new(); diff --git a/tests/TuiCode.Tests/BlameHostTests.cs b/tests/TuiCode.Tests/BlameHostTests.cs index 4679c276..ef327426 100644 --- a/tests/TuiCode.Tests/BlameHostTests.cs +++ b/tests/TuiCode.Tests/BlameHostTests.cs @@ -151,7 +151,7 @@ public async Task Enter_opens_the_change_that_introduced_the_line_as_a_diff_with var group = workbench.Editor.Group; string? status = null; bool? revert = null, revertAll = null, goToLine = null; - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => OpenFile(workbench, row: 1), @@ -169,7 +169,7 @@ await HostSteps.Run(host, goToLine = commands.IsEnabled(CommandIds.GoToChangeLine); }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); Assert.Null(Dialog(workbench)); @@ -182,7 +182,7 @@ await HostSteps.Run(host, Assert.False(revert); Assert.False(revertAll); Assert.False(goToLine); - Assert.Equal(["Next change", "Previous change", "Scroll sideways", "Page sideways", "Move to other group"], help!.Place!.Rows.Select(row => row.Description)); + Assert.Equal(["Next change", "Previous change", "Scroll sideways", "Page sideways", "Move to other group"], help!.Sheet.Here!.Rows().Select(row => row.Label)); Assert.False(group.Tabs.Single().IsDirty); Assert.Equal((null, null), (diff.Source, diff.Review)); } diff --git a/tests/TuiCode.Tests/DiffFindTests.cs b/tests/TuiCode.Tests/DiffFindTests.cs index 358c7c01..f3db1910 100644 --- a/tests/TuiCode.Tests/DiffFindTests.cs +++ b/tests/TuiCode.Tests/DiffFindTests.cs @@ -3,6 +3,7 @@ using TuiCode.Abstractions; using TuiCode.Editor; using TuiCode.Workbench.Find; +using TuiCode.Workbench.Help; using TuiCode.Workbench.Services; namespace TuiCode.Tests; @@ -123,7 +124,7 @@ public void Help_in_a_diff_lists_no_replace_keys_whichever_field_last_had_them() new("Enter", "Next match"), new("Shift+Enter", "Previous match"), new("Esc", "Close"), - ], _find.Help([]).Rows); + ], _find.Help([]).Rows()); } [Fact] diff --git a/tests/TuiCode.Tests/DiffRevertTests.cs b/tests/TuiCode.Tests/DiffRevertTests.cs index 32f9d421..55b59335 100644 --- a/tests/TuiCode.Tests/DiffRevertTests.cs +++ b/tests/TuiCode.Tests/DiffRevertTests.cs @@ -502,7 +502,7 @@ public async Task F1_in_a_deleted_files_diff_says_restore_rather_than_revert_and using var host = BuildHost(workbench, out var commands); var group = workbench.Editor.Group; string? status = null; - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => host.ApplyKeybindings( @@ -516,11 +516,11 @@ await HostSteps.Run(host, () => group.ActiveDiffTab is { IsFocused: true }, () => { status = workbench.StatusBar.DisplayedText; }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Contains(new HelpRow("F9", "Restore file"), help!.Place!.Rows); - Assert.DoesNotContain(help.Place.Rows, row => row.Description == "Revert change"); + Assert.Contains(new KeyRow("F9", "Restore file"), help!.Sheet.Here!.Rows()); + Assert.DoesNotContain(help.Sheet.Here!.Rows(), row => row.Label == "Revert change"); Assert.DoesNotContain("F9", status); Assert.DoesNotContain("Alt+↓", status); } diff --git a/tests/TuiCode.Tests/DiffTabTests.cs b/tests/TuiCode.Tests/DiffTabTests.cs index 7db8df73..9c8619ae 100644 --- a/tests/TuiCode.Tests/DiffTabTests.cs +++ b/tests/TuiCode.Tests/DiffTabTests.cs @@ -971,17 +971,17 @@ public async Task F1_in_a_diff_lists_the_diffs_keys_beside_everywhere() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => OpenThreeChanges(workbench, commands), () => workbench.Editor.Group.ActiveDiffTab is { IsFocused: true }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); Assert.Equal("Help", help!.Title); - Assert.Equal("Diff", help.Place!.Title); + Assert.Equal("Diff", help.Sheet.Here!.Heading); Assert.Equal( [ new("Alt+↓", "Next change"), @@ -991,7 +991,7 @@ await HostSteps.Run(host, new("← →", "Scroll sideways"), new("Shift+← →", "Page sideways"), new("Ctrl+\\", "Move to other group"), - ], help.Place.Rows); + ], help.Sheet.Here!.Rows()); } [Fact] @@ -999,7 +999,7 @@ public async Task The_help_column_follows_a_rebind_and_drops_an_unbound_key() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => @@ -1014,11 +1014,11 @@ await HostSteps.Run(host, }, () => workbench.Editor.Group.ActiveDiffTab is { IsFocused: true }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Equal(new HelpRow("F8", "Next change"), help!.Place!.Rows[0]); - Assert.DoesNotContain(help.Place.Rows, row => row.Description == "Revert change"); + Assert.Equal(new KeyRow("F8", "Next change"), help!.Sheet.Here!.Rows()[0]); + Assert.DoesNotContain(help.Sheet.Here!.Rows(), row => row.Label == "Revert change"); } [Theory] @@ -1028,7 +1028,7 @@ public async Task The_page_keys_in_the_help_column_follow_a_rebind(string? pageR { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); - HelpView? help = null; + KeysDialog? help = null; List overrides = [new(TestKeys.Chord("Shift+CursorRight"), "-" + CommandIds.ScrollDiffPageRight)]; if (pageRight is not null) overrides.Add(new(TestKeys.Chord(pageRight), CommandIds.ScrollDiffPageRight)); @@ -1040,10 +1040,10 @@ await HostSteps.Run(host, }, () => workbench.Editor.Group.ActiveDiffTab is { IsFocused: true }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Equal(expected, help!.Place!.Rows.Single(row => row.Description == "Page sideways").Key); + Assert.Equal(expected, help!.Sheet.Here!.Rows().Single(row => row.Label == "Page sideways").Keys); } [Fact] @@ -1051,7 +1051,7 @@ public async Task F1_in_a_diffs_find_bar_lists_the_find_bars_keys_and_the_status { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); - HelpView? help = null; + KeysDialog? help = null; var status = ""; await HostSteps.Run(host, @@ -1061,11 +1061,11 @@ await HostSteps.Run(host, () => { foreach (var c in "line") host.App.InjectKey(new Key(c)); }, () => workbench.Editor.Group.ActiveDiffTab!.CurrentMatch is not null, () => { status = workbench.StatusBar.DisplayedText; host.App.InjectKey(Key.F1); }, - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Equal("Find bar", help!.Place!.Title); - Assert.Equal(["Enter", "Shift+Enter", "Alt+C", "Alt+W", "Esc"], help.Place.Rows.Select(row => row.Key)); + Assert.Equal("Find bar", help!.Sheet.Here!.Heading); + Assert.Equal(["Enter", "Shift+Enter", "Alt+C", "Alt+W", "Esc"], help.Sheet.Here!.Rows().Select(row => row.Keys)); Assert.DoesNotContain("Enter", status); } @@ -1074,7 +1074,7 @@ public async Task F1_with_a_diff_showing_but_the_sidebar_focused_lists_the_sideb { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => OpenThreeChanges(workbench, commands), @@ -1082,10 +1082,10 @@ await HostSteps.Run(host, () => commands.TryExecute(CommandIds.FocusSidebar), () => workbench.Editor.Group.ActiveDiffTab is { IsFocused: false }, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Equal("Explorer", help!.Place!.Title); + Assert.Equal("Explorer", help!.Sheet.Here!.Heading); } // Changes at rows 4 (modified), 14 (line 15 removed) and 30 (modified). diff --git a/tests/TuiCode.Tests/FindAndSearchHostTests.cs b/tests/TuiCode.Tests/FindAndSearchHostTests.cs index 204ab8cc..c6a079ef 100644 --- a/tests/TuiCode.Tests/FindAndSearchHostTests.cs +++ b/tests/TuiCode.Tests/FindAndSearchHostTests.cs @@ -107,7 +107,7 @@ public async Task F1_in_the_find_field_lists_the_find_bars_keys_and_Esc_hands_th using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); FindBarView? bar = null; - HelpView? help = null; + KeysDialog? help = null; var status = ""; var queryAfterHelp = ""; @@ -118,17 +118,17 @@ await HostSteps.Run(host, () => { foreach (var c in "foo") host.App.InjectKey(new Key(c)); }, () => bar!.Query == "foo", () => { status = workbench.StatusBar.DisplayedText; host.App.InjectKey(Key.F1); }, - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc), - () => !workbench.SubViews.OfType().Any(), + () => !workbench.SubViews.OfType().Any(), () => { queryAfterHelp = bar!.Query; host.App.InjectKey(new Key('x')); }, () => bar!.Query == "x"); Assert.Equal(_fs.Path.GetFullPath("/work/a.txt"), status); Assert.Equal("foo", queryAfterHelp); - Assert.Equal("Find bar", help!.Place!.Title); + Assert.Equal("Find bar", help!.Sheet.Here!.Heading); Assert.Equal(["Next match", "Previous match", "Match case on or off", "Match whole word on or off", "Close"], - help.Place.Rows.Select(row => row.Description)); + help.Sheet.Here!.Rows().Select(row => row.Label)); Assert.Contains(bar!, workbench.SubViewsDeep()); } @@ -139,7 +139,7 @@ public async Task F1_in_the_replace_field_lists_its_keys_and_Enter_hands_the_fie using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); FindBarView? bar = null; - HelpView? help = null; + KeysDialog? help = null; var status = ""; await HostSteps.Run(host, @@ -150,15 +150,15 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.Tab), () => bar!.ReplacementHasFocus, () => { status = workbench.StatusBar.DisplayedText; host.App.InjectKey(Key.F1); }, - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, - () => host.App.InjectKey(Key.Enter), - () => !workbench.SubViews.OfType().Any(), + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => host.App.InjectKey(Key.Esc), + () => !workbench.SubViews.OfType().Any(), () => { foreach (var c in "dog") host.App.InjectKey(new Key(c)); }, () => bar!.Replacement == "dog"); Assert.Equal(_fs.Path.GetFullPath("/work/a.txt"), status); Assert.Equal(["Replace and go to next", "Previous match", "Replace all", "Find field", "Match case on or off", "Match whole word on or off", "Close"], - help!.Place!.Rows.Select(row => row.Description)); + help!.Sheet.Here!.Rows().Select(row => row.Label)); Assert.Equal("cat", bar!.Query); } diff --git a/tests/TuiCode.Tests/FindControllerTests.cs b/tests/TuiCode.Tests/FindControllerTests.cs index 33c81f20..f8fdeac0 100644 --- a/tests/TuiCode.Tests/FindControllerTests.cs +++ b/tests/TuiCode.Tests/FindControllerTests.cs @@ -1,6 +1,7 @@ using TuiCode.Abstractions; using TuiCode.Editor; using TuiCode.Workbench.Find; +using TuiCode.Workbench.Help; using TuiCode.Workbench.Services; namespace TuiCode.Tests; @@ -99,13 +100,13 @@ public void Help_in_the_find_field_lists_next_previous_and_close() var help = _find.Help([]); - Assert.Equal("Find bar", help.Title); + Assert.Equal("Find bar", help.Heading); Assert.Equal( [ new("Enter", "Next match"), new("Shift+Enter", "Previous match"), new("Esc", "Close"), - ], help.Rows); + ], help.Rows()); } [Fact] @@ -121,7 +122,7 @@ public void Help_in_the_find_field_adds_replace_all_and_the_replace_field_when_t new("Ctrl+Enter", "Replace all"), new("Tab", "Replace field"), new("Esc", "Close"), - ], _find.Help([]).Rows); + ], _find.Help([]).Rows()); } [Fact] @@ -138,7 +139,7 @@ public void Help_in_the_replace_field_lists_the_replace_fields_keys() new("Ctrl+Enter", "Replace all"), new("Tab", "Find field"), new("Esc", "Close"), - ], _find.Help([]).Rows); + ], _find.Help([]).Rows()); } [Fact] diff --git a/tests/TuiCode.Tests/FindOptionsHostTests.cs b/tests/TuiCode.Tests/FindOptionsHostTests.cs index 3f6bf0d6..1a3ce1f8 100644 --- a/tests/TuiCode.Tests/FindOptionsHostTests.cs +++ b/tests/TuiCode.Tests/FindOptionsHostTests.cs @@ -196,17 +196,17 @@ public async Task F1_in_the_find_pane_lists_the_option_keys() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => host.App.InjectKey(Key.F.WithCtrl.WithShift), () => workbench.Sidebar.Search.InputsHaveFocus, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Contains(new HelpRow("Alt+C", "Match case on or off"), help!.Place!.Rows); - Assert.Contains(new HelpRow("Alt+W", "Match whole word on or off"), help.Place.Rows); + Assert.Contains(new KeyRow("Alt+C", "Match case on or off"), help!.Sheet.Here!.Rows()); + Assert.Contains(new KeyRow("Alt+W", "Match whole word on or off"), help.Sheet.Here!.Rows()); } private IReadOnlyList Palette(string query) diff --git a/tests/TuiCode.Tests/FindUsagesHostTests.cs b/tests/TuiCode.Tests/FindUsagesHostTests.cs index a5f437c6..a32f069e 100644 --- a/tests/TuiCode.Tests/FindUsagesHostTests.cs +++ b/tests/TuiCode.Tests/FindUsagesHostTests.cs @@ -199,7 +199,7 @@ public async Task F1_in_the_Usages_tab_lists_its_keys() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); - HelpView? help = null; + KeysDialog? help = null; await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, @@ -207,17 +207,17 @@ await HostSteps.Run(host, () => { Tab(workbench).MoveCursor(4, 26); Execute(host); }, () => workbench.Sidebar.Usages.ResultsHaveFocus, () => host.App.InjectKey(Key.F1), - () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Equal("Usages", help!.Place!.Title); + Assert.Equal("Usages", help!.Sheet.Here!.Heading); Assert.Equal( [ new("Enter", "Go to the usage"), new("↑ ↓", "Step through usages"), new("→ ←", "Expand or collapse"), .. SidebarHelp.TabKeys, - ], help.Place.Rows); + ], help.Sheet.Here!.Rows()); } [Fact] diff --git a/tests/TuiCode.Tests/GlobalUsings.cs b/tests/TuiCode.Tests/GlobalUsings.cs index e76f7d8c..942a91cb 100644 --- a/tests/TuiCode.Tests/GlobalUsings.cs +++ b/tests/TuiCode.Tests/GlobalUsings.cs @@ -6,3 +6,6 @@ global using Terminal.Gui.Input; global using Terminal.Gui.Testing; global using Xunit; +global using KeyColumn = MentalDesk.Tui.Keys.KeyColumn; +global using KeyRow = MentalDesk.Tui.Keys.KeyRow; +global using KeysDialog = MentalDesk.Tui.Keys.KeysDialog; diff --git a/tests/TuiCode.Tests/HelpPlaceHostTests.cs b/tests/TuiCode.Tests/HelpPlaceHostTests.cs index 280632ba..b7266e43 100644 --- a/tests/TuiCode.Tests/HelpPlaceHostTests.cs +++ b/tests/TuiCode.Tests/HelpPlaceHostTests.cs @@ -25,7 +25,7 @@ public async Task F1_in_the_explorer_lists_its_keys_and_its_commands() { var help = await HelpFrom(() => _commands.TryExecute(CommandIds.FocusSidebar), "Explorer"); - Assert.Equal("Explorer", help.Title); + Assert.Equal("Explorer", help.Heading); Assert.Equal( [ new("Enter", "Open"), @@ -36,7 +36,7 @@ public async Task F1_in_the_explorer_lists_its_keys_and_its_commands() new("Ctrl+X", "Cut file or folder"), new("Ctrl+V", "Paste file or folder"), .. SidebarHelp.TabKeys, - ], help.Rows); + ], help.Rows()); } [Fact] @@ -47,8 +47,8 @@ public async Task The_explorer_column_follows_a_rebind_and_drops_an_unbound_comm Override("F6", CommandIds.RenameFile), Override("Ctrl+X", "-" + CommandIds.CutFile)); - Assert.Contains(new HelpRow("F6", "Move or rename file or folder"), help.Rows); - Assert.DoesNotContain(help.Rows, row => row.Description == "Cut file or folder"); + Assert.Contains(new KeyRow("F6", "Move or rename file or folder"), help.Rows()); + Assert.DoesNotContain(help.Rows(), row => row.Label == "Cut file or folder"); } [Fact] @@ -61,7 +61,7 @@ public async Task Cancel_cut_is_listed_only_while_something_is_cut() Built.Sidebar.Explorer.Cut(_fs.FileInfo.New("/work/a.txt")); }, "Explorer"); - Assert.Contains(new HelpRow("Esc", "Cancel cut"), help.Rows); + Assert.Contains(new KeyRow("Esc", "Cancel cut"), help.Rows()); } [Fact] @@ -69,7 +69,7 @@ public async Task F1_in_the_find_pane_inputs_lists_the_find_keys() { var help = await HelpFrom(() => _commands.TryExecute(CommandIds.FindGlobally), "Find"); - Assert.Equal("Find", help.Title); + Assert.Equal("Find", help.Heading); Assert.Equal( [ new("Enter, ↓", "Focus find results"), @@ -78,14 +78,14 @@ public async Task F1_in_the_find_pane_inputs_lists_the_find_keys() new("Alt+C", "Match case on or off"), new("Alt+W", "Match whole word on or off"), .. SidebarHelp.TabKeys, - ], help.Rows); + ], help.Rows()); } [Fact] public async Task F1_in_the_find_results_lists_how_to_open_one() { _fs.AddFile("/work/a.txt", new MockFileData("needle")); - HelpView? view = null; + KeysDialog? view = null; using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); @@ -95,13 +95,13 @@ await HostSteps.Run(host, () => workbench.Sidebar.Search.FocusResults(), () => !workbench.Sidebar.Search.InputsHaveFocus, () => host.App.InjectKey(Key.F1), - () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - var help = view!.Place!; + var help = view!.Sheet.Here!; - Assert.Equal("Find", help.Title); + Assert.Equal("Find", help.Heading); - Assert.Equal([new HelpRow("Enter", "Open result"), .. SidebarHelp.TabKeys], help.Rows); + Assert.Equal([new KeyRow("Enter", "Open result"), .. SidebarHelp.TabKeys], help.Rows()); } [Fact] @@ -115,7 +115,7 @@ public async Task F1_in_the_AI_assistance_tab_ends_with_the_keys_that_switch_the new("Ctrl+G a", "AI assistance"), new("Ctrl+PageDown", "Next panel tab"), new("Ctrl+PageUp", "Previous panel tab"), - ], help.Rows.TakeLast(4)); + ], help.Rows().TakeLast(4)); } [Fact] @@ -124,13 +124,13 @@ public async Task F1_in_the_editor_lists_the_editors_commands_that_can_run() _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo")); var help = await HelpFrom(() => Built.OpenFile(_fs.FileInfo.New("/work/a.txt")), "Editor"); - Assert.Equal("Editor", help.Title); - Assert.Contains(new HelpRow("Alt+↑", "Move line up"), help.Rows); - Assert.Contains(new HelpRow("Ctrl+Alt+↓", "Add cursor below"), help.Rows); - Assert.Contains(new HelpRow("Ctrl+G l", "Go to line:column"), help.Rows); - Assert.DoesNotContain(help.Rows, row => row.Description is "Clear selection" or "Remove secondary cursors"); - Assert.DoesNotContain(help.Rows, row => row.Description == "Go to symbol in file"); - Assert.DoesNotContain(help.Rows, row => row.Description == "Save active editor"); + Assert.Equal("Editor", help.Heading); + Assert.Contains(new KeyRow("Alt+↑", "Move line up"), help.Rows()); + Assert.Contains(new KeyRow("Ctrl+Alt+↓", "Add cursor below"), help.Rows()); + Assert.Contains(new KeyRow("Ctrl+G l", "Go to line:column"), help.Rows()); + Assert.DoesNotContain(help.Rows(), row => row.Label is "Clear selection" or "Remove secondary cursors"); + Assert.DoesNotContain(help.Rows(), row => row.Label == "Go to symbol in file"); + Assert.DoesNotContain(help.Rows(), row => row.Label == "Save active editor"); } [Fact] @@ -139,8 +139,8 @@ public async Task The_editor_column_names_column_select_and_word_wrap_both_ways( _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo")); var help = await HelpFrom(() => Built.OpenFile(_fs.FileInfo.New("/work/a.txt")), "Editor"); - Assert.Contains(help.Rows, row => row.Description == "Column select on or off"); - Assert.Contains(help.Rows, row => row.Description == "Word wrap on or off"); + Assert.Contains(help.Rows(), row => row.Label == "Column select on or off"); + Assert.Contains(help.Rows(), row => row.Label == "Word wrap on or off"); } [Fact] @@ -150,7 +150,7 @@ public async Task The_editor_column_lists_clear_selection_while_there_is_one() var help = await HelpFrom(() => Built.OpenFile(_fs.FileInfo.New("/work/a.txt")), "Editor", before: host => host.App.InjectKey(Key.CursorRight.WithShift)); - Assert.Contains(new HelpRow("Esc", "Clear selection"), help.Rows); + Assert.Contains(new KeyRow("Esc", "Clear selection"), help.Rows()); } [Fact] @@ -158,7 +158,7 @@ public async Task F1_on_the_tab_strip_lists_the_tab_keys() { var help = await TabStripHelp(); - Assert.Equal("Tabs", help.Title); + Assert.Equal("Tabs", help.Heading); Assert.Equal( [ new("← →", "Switch tab"), @@ -167,7 +167,7 @@ public async Task F1_on_the_tab_strip_lists_the_tab_keys() new("Ctrl+PageUp", "Previous tab"), new("Ctrl+W", "Close tab"), new("Ctrl+\\", "Move to other group"), - ], help.Rows); + ], help.Rows()); } [Fact] @@ -175,15 +175,15 @@ public async Task The_tabs_column_follows_a_rebind_of_next_tab() { var help = await TabStripHelp(Override("Ctrl+PageDown", "-" + CommandIds.NextEditor), Override("F6", CommandIds.NextEditor)); - Assert.Contains(new HelpRow("F6", "Next tab"), help.Rows); - Assert.DoesNotContain(new HelpRow("Ctrl+PageDown", "Next tab"), help.Rows); + Assert.Contains(new KeyRow("F6", "Next tab"), help.Rows()); + Assert.DoesNotContain(new KeyRow("Ctrl+PageDown", "Next tab"), help.Rows()); } [Fact] public async Task F1_with_no_file_open_shows_everywhere_alone() { _fs.AddFile("/work/a.txt", new MockFileData("one")); - HelpView? view = null; + KeysDialog? view = null; using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); @@ -193,13 +193,91 @@ await HostSteps.Run(host, () => _commands.TryExecute(CommandIds.CloseActiveEditor), () => workbench.Editor.Group.Value is null && workbench.StatusBar.DisplayedFocus == "Editor", () => host.App.InjectKey(Key.F1), - () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - Assert.Null(view!.Place); + Assert.Null(view!.Sheet.Here); } - private async Task TabStripHelp(params KeybindingOverride[] overrides) + [Fact] + public async Task F1_in_the_explorer_shows_help_with_everywhere_beside_the_explorer() + { + var view = await ExplorerDialog(); + + Assert.Equal("Help", view.Title); + Assert.False(view.Stacked); + Assert.Equal("Explorer", view.Sheet.Here!.Heading); + Assert.Equal("Everywhere", view.Sheet.Everywhere!.Heading); + var headings = view.Sheet.Everywhere.Groups.Select(group => group.Heading!).ToList(); + string[] menus = ["File", "Edit", "Selection", "View", "Go", "Diff", "Review", "Help", "Other"]; + Assert.Equal(headings, menus.Where(headings.Contains)); + Assert.Equal("File", headings[0]); + Assert.Contains(new KeyRow("Ctrl+O", "Open file or folder"), view.Sheet.Everywhere.Groups[0].Rows); + Assert.Contains(new KeyRow("F1", "Show keys"), view.Sheet.Everywhere.Groups.Single(group => group.Heading == "Help").Rows); + } + + [Fact] + public async Task Everywhere_follows_a_rebind_of_the_command_palette() + { + var view = await ExplorerDialog(Override("Ctrl+E", "-" + CommandIds.ShowActions), Override("F6", CommandIds.ShowActions)); + + Assert.Contains(new KeyRow("F6", "Show all commands"), Everywhere(view)); + Assert.DoesNotContain(Everywhere(view), row => row.Keys == "Ctrl+E"); + } + + [Fact] + public async Task A_command_given_a_key_appears_under_its_menu_and_goes_when_unbound() + { + var bound = await ExplorerDialog(Override("F7", CommandIds.OpenRecentFolder)); + var unbound = await ExplorerDialog(); + + Assert.Contains(new KeyRow("F7", "Open recent folder"), bound.Sheet.Everywhere!.Groups.Single(group => group.Heading == "File").Rows); + Assert.DoesNotContain(Everywhere(unbound), row => row.Label == "Open recent folder"); + } + + [Fact] + public async Task Everywhere_leaves_out_a_command_that_cannot_run_here() + { + var view = await ExplorerDialog(); + + Assert.DoesNotContain(Everywhere(view), row => row.Label == "Match case on or off"); + } + + [Fact] + public async Task Everywhere_leaves_editor_commands_to_the_editor_column() + { + _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo")); + var view = await DialogFrom(() => Built.OpenFile(_fs.FileInfo.New("/work/a.txt")), "Editor", null); + + Assert.Contains(view.Sheet.Here!.Rows(), row => row.Label == "Move line up"); + Assert.DoesNotContain(Everywhere(view), row => row.Label == "Move line up"); + } + + [Fact] + public async Task No_key_reaches_the_editor_while_help_is_open_and_Esc_hands_the_keys_back() + { + _fs.AddFile("/work/a.txt", new MockFileData("one")); + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var region = ""; + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.F1), + () => workbench.SubViews.OfType().Any(), + () => host.App.InjectKey(new Key('x')), + () => host.App.InjectKey(Key.Esc), + () => !workbench.SubViews.OfType().Any(), + () => { region = workbench.StatusBar.DisplayedFocus; }, + () => host.App.InjectKey(new Key('y')), + () => workbench.Editor.Group.ActiveTab!.Lines[0] != "one"); + + Assert.Equal("Editor", region); + Assert.Equal("yone", workbench.Editor.Group.ActiveTab!.Lines[0]); + } + + private async Task TabStripHelp(params KeybindingOverride[] overrides) { _fs.AddFile("/work/a.txt", new MockFileData("one")); return await HelpFrom(() => @@ -213,52 +291,64 @@ private async Task TabStripHelp(params KeybindingOverride[] override public async Task The_editor_column_scrolls_to_its_last_row_in_an_80_by_24_terminal() { _fs.AddFile("/work/a.txt", new MockFileData("one")); - HelpView? view = null; + KeysDialog? view = null; using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); - var fits = false; + bool fits = false, scrolls = false; await HostSteps.Run(host, () => HostSteps.PinScreenSize(host, 80, 24), () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), () => workbench.StatusBar.DisplayedFocus == "Editor", () => host.App.InjectKey(Key.F1), - () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, - () => fits = view!.Frame.Height <= workbench.Viewport.Height, - () => host.App.InjectKey(Key.PageDown), - () => host.App.InjectKey(Key.PageDown), - () => host.App.InjectKey(Key.CursorDown), - () => view!.Body.Viewport.Bottom == view.Body.GetContentSize().Height, + () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => { fits = view!.Frame.Height <= workbench.Viewport.Height; scrolls = view.Body.GetContentSize().Height > view.Body.Viewport.Height; }, + () => + { + host.App.InjectKey(Key.PageDown); + return view!.Body.Viewport.Bottom >= view.Body.GetContentSize().Height; + }, () => host.App.InjectKey(Key.Esc)); - Assert.True(view!.IsScrollable); + Assert.True(scrolls); Assert.True(fits); + Assert.True(view!.Stacked); + Assert.Equal("Editor", view.Sheet.Here!.Heading); } private Workbench.Workbench Built => _built!; private Workbench.Workbench? _built; - private async Task HelpFrom(Action focus, string region, params KeybindingOverride[] overrides) => + private async Task HelpFrom(Action focus, string region, params KeybindingOverride[] overrides) => await HelpFrom(focus, region, null, overrides); - private async Task HelpFrom(Action focus, string region, Action? before, params KeybindingOverride[] overrides) + private async Task HelpFrom(Action focus, string region, Action? before, params KeybindingOverride[] overrides) => + (await DialogFrom(focus, region, before, overrides)).Sheet.Here!; + + private async Task DialogFrom(Action focus, string region, Action? before, params KeybindingOverride[] overrides) { - HelpView? view = null; + KeysDialog? view = null; using var workbench = BuildWorkbench(); _built = workbench; using var host = BuildHost(workbench, overrides); await HostSteps.Run(host, + () => HostSteps.PinScreenSize(host, 120, 30), focus, () => workbench.StatusBar.DisplayedFocus == region, () => before?.Invoke(host), () => host.App.InjectKey(Key.F1), - () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - return view!.Place!; + return view!; } + private Task ExplorerDialog(params KeybindingOverride[] overrides) => + DialogFrom(() => _commands.TryExecute(CommandIds.FocusSidebar), "Explorer", null, overrides); + + private static IEnumerable Everywhere(KeysDialog view) => view.Sheet.Everywhere!.Rows(); + private Workbench.Workbench BuildWorkbench() { var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart()); diff --git a/tests/TuiCode.Tests/HelpReviewHostTests.cs b/tests/TuiCode.Tests/HelpReviewHostTests.cs index aac3f7d7..0c791a72 100644 --- a/tests/TuiCode.Tests/HelpReviewHostTests.cs +++ b/tests/TuiCode.Tests/HelpReviewHostTests.cs @@ -29,14 +29,14 @@ public async Task F1_in_the_review_list_lists_its_keys() { var help = await HelpFrom(review => review.ListHasFocus && review.ViewedText.Length > 0); - Assert.Equal("Review", help.Title); + Assert.Equal("Review", help.Heading); Assert.Equal( [ new("Enter", "Open the file's diff"), new("↑", "To Overview"), new("Space", "Mark viewed or not"), .. SidebarHelp.TabKeys, - ], help.Rows); + ], help.Rows()); } [Fact] @@ -45,8 +45,8 @@ public async Task Without_a_PR_up_to_Overview_isnt_listed() _gitHub.PullRequest = null; var help = await HelpFrom(review => review.ListHasFocus && review.ViewedText.Length > 0); - Assert.DoesNotContain(help.Rows, row => row.Description == "To Overview"); - Assert.Contains(new HelpRow("Enter", "Open the file's diff"), help.Rows); + Assert.DoesNotContain(help.Rows(), row => row.Label == "To Overview"); + Assert.Contains(new KeyRow("Enter", "Open the file's diff"), help.Rows()); } [Fact] @@ -58,7 +58,7 @@ public async Task The_review_column_follows_a_rebind_of_toggle_viewed() new(TestKeys.Chord("F6"), CommandIds.ToggleViewed), ])); - Assert.Contains(new HelpRow("F6", "Mark viewed or not"), help.Rows); + Assert.Contains(new KeyRow("F6", "Mark viewed or not"), help.Rows()); } [Fact] @@ -66,11 +66,11 @@ public async Task F1_on_the_Overview_row_lists_its_keys() { var help = await HelpFrom(review => review.TitleText.Length > 0, onOverview: true); - Assert.Equal("Review", help.Title); - Assert.Equal([new HelpRow("Enter", "Open the Overview"), new HelpRow("↓", "To the files"), .. SidebarHelp.TabKeys], help.Rows); + Assert.Equal("Review", help.Heading); + Assert.Equal([new KeyRow("Enter", "Open the Overview"), new KeyRow("↓", "To the files"), .. SidebarHelp.TabKeys], help.Rows()); } - private async Task HelpFrom(Func ready, bool onOverview = false, Action? settings = null) + private async Task HelpFrom(Func ready, bool onOverview = false, Action? settings = null) { var sidebar = new SidebarPart(new FileExplorerView(), review: new ReviewView(_git, _gitHub)); using var workbench = new Workbench.Workbench(sidebar, new EditorPart(), new StatusBarPart()); @@ -80,7 +80,7 @@ private async Task HelpFrom(Func ready, bool onOve using var host = new WorkbenchHost(workbench, _commands, new KeybindingService(_commands), new InputScopeStack(), inMemory, driverName: DriverRegistry.Names.ANSI, git: _git, gitHub: _gitHub); var review = workbench.Sidebar.Review; - HelpView? view = null; + KeysDialog? view = null; await HostSteps.Run(host, () => _commands.TryExecute(CommandIds.FocusReview), @@ -88,9 +88,9 @@ await HostSteps.Run(host, () => { if (onOverview) host.App.InjectKey(Key.CursorUp); }, () => review.OverviewHasFocus == onOverview, () => host.App.InjectKey(Key.F1), - () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, + () => (view = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); - return view!.Place!; + return view!.Sheet.Here!; } } diff --git a/tests/TuiCode.Tests/HelpSheetTests.cs b/tests/TuiCode.Tests/HelpSheetTests.cs new file mode 100644 index 00000000..5cb81c0a --- /dev/null +++ b/tests/TuiCode.Tests/HelpSheetTests.cs @@ -0,0 +1,115 @@ +using TuiCode.Abstractions; +using TuiCode.Workbench.Help; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +public class HelpSheetTests +{ + private static readonly (string Title, string[] Ids)[] Menu = + [ + ("_File", ["file.open", "file.save"]), + ("_Edit", ["edit.find"]), + ("_Go", ["go.back"]), + ]; + + private readonly CommandService _commands = new(); + private readonly KeybindingService _keybindings; + private bool _saveEnabled = true; + + public HelpSheetTests() + { + _keybindings = new KeybindingService(_commands); + _commands.Register("go.back", "Navigate back", () => { }); + _commands.Register("file.open", "Open", () => { }); + _commands.Register("file.save", "Save", () => { }, isEnabled: () => _saveEnabled); + _commands.Register("edit.find", "Find in file", () => { }); + _commands.Register("palette", "Show all commands", () => { }); + _commands.Register("editor.moveUp", "Move line up", () => { }, CommandScope.Editor); + _keybindings.Bind("Ctrl+O", "file.open"); + _keybindings.Bind("Ctrl+S", "file.save"); + _keybindings.Bind("Ctrl+G P", "go.back"); + _keybindings.Bind("Ctrl+E", "palette"); + _keybindings.Bind("Alt+CursorUp", "editor.moveUp"); + } + + private KeyColumn Everywhere() => HelpSheet.Everywhere(_commands, _keybindings, Menu); + + [Fact] + public void Everywhere_groups_bound_commands_under_their_menu_heading_in_menu_order_then_other() + { + var everywhere = Everywhere(); + + Assert.Equal("Everywhere", everywhere.Heading); + Assert.Equal(["File", "Go", "Other"], everywhere.Groups.Select(group => group.Heading)); + Assert.Equal([new KeyRow("Ctrl+O", "Open"), new KeyRow("Ctrl+S", "Save")], everywhere.Groups[0].Rows); + Assert.Equal([new KeyRow("Ctrl+G p", "Navigate back")], everywhere.Groups[1].Rows); + Assert.Equal([new KeyRow("Ctrl+E", "Show all commands")], everywhere.Groups[2].Rows); + } + + [Fact] + public void Everywhere_leaves_out_a_menu_whose_commands_have_no_key() + { + Assert.DoesNotContain(Everywhere().Groups, group => group.Heading == "Edit"); + } + + [Fact] + public void A_newly_bound_command_appears_under_its_menu_heading() + { + _keybindings.Bind("F3", "edit.find"); + + Assert.Equal([new KeyRow("F3", "Find in file")], Everywhere().Groups.Single(group => group.Heading == "Edit").Rows); + } + + [Fact] + public void A_rebind_replaces_the_old_key() + { + _keybindings.Unbind("Ctrl+E"); + _keybindings.Bind("F6", "palette"); + + var rows = Everywhere().Rows(); + + Assert.Contains(new KeyRow("F6", "Show all commands"), rows); + Assert.DoesNotContain(rows, row => row.Keys == "Ctrl+E"); + } + + [Fact] + public void An_unbound_command_is_left_out() + { + _keybindings.Unbind("Ctrl+O"); + + Assert.DoesNotContain(Everywhere().Rows(), row => row.Label == "Open"); + } + + [Fact] + public void A_command_that_cannot_run_now_is_left_out() + { + _saveEnabled = false; + + Assert.DoesNotContain(Everywhere().Rows(), row => row.Label == "Save"); + } + + [Fact] + public void A_command_with_two_keys_is_one_row() + { + _keybindings.Bind("F5", "file.open"); + + Assert.Contains(new KeyRow("Ctrl+O, F5", "Open"), Everywhere().Rows()); + } + + [Fact] + public void Editor_commands_are_left_to_the_editor_column() + { + Assert.DoesNotContain(Everywhere().Rows(), row => row.Label == "Move line up"); + } + + [Fact] + public void The_place_goes_beside_everywhere_and_keeps_its_rows_in_order() + { + var sheet = HelpSheet.For(HelpSheet.Place("Explorer", [new("Enter", "Open"), new("F2", "Move or rename")]), _commands, _keybindings); + + Assert.Equal("Explorer", sheet.Here!.Heading); + Assert.Equal([new KeyRow("Enter", "Open"), new KeyRow("F2", "Move or rename")], sheet.Here.Rows()); + Assert.Equal("Everywhere", sheet.Everywhere!.Heading); + } +} diff --git a/tests/TuiCode.Tests/HelpViewTests.cs b/tests/TuiCode.Tests/HelpViewTests.cs deleted file mode 100644 index e8a4be98..00000000 --- a/tests/TuiCode.Tests/HelpViewTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Terminal.Gui.Views; -using TuiCode.Workbench.Help; - -namespace TuiCode.Tests; - -public class HelpViewTests : StaticConfigurationTest -{ - private static readonly HelpColumn Diff = new("Diff", [new("Alt+↓", "Next change"), new("Shift+← →", "Page sideways")]); - - [Fact] - public void A_place_column_sits_beside_everywhere_when_both_fit() - { - using var help = new HelpView(Diff, availableWidth: 120); - - Assert.False(help.IsStacked); - Assert.Equal(["Everywhere", "Diff"], ColumnTitles(help)); - } - - [Fact] - public void A_place_column_sits_above_everywhere_in_a_narrow_terminal() - { - using var help = new HelpView(Diff, availableWidth: 60); - - Assert.True(help.IsStacked); - Assert.Equal(["Diff", "Everywhere"], ColumnTitles(help)); - help.Layout(new System.Drawing.Size(60, 40)); - Assert.True(help.Frame.Width <= 60); - } - - [Fact] - public void Without_a_place_it_shows_everywhere_alone() - { - using var help = new HelpView(); - - Assert.Equal("Help", help.Title); - Assert.Equal(["Everywhere"], ColumnTitles(help)); - } - - [Fact] - public void A_column_taller_than_the_room_scrolls_to_its_last_row() - { - var rows = Enumerable.Range(1, 30).Select(i => new HelpRow($"F{i}", $"Command {i}")).ToList(); - using var help = new HelpView(new HelpColumn("Editor", rows), availableWidth: 80, availableHeight: 22); - help.Layout(new System.Drawing.Size(80, 22)); - - Assert.True(help.IsScrollable); - Assert.Equal(22, help.Frame.Height); - Assert.Equal(0, help.Body.Viewport.Y); - Assert.True(help.Body.Viewport.Width >= help.Body.GetContentSize().Width); - - for (var i = 0; i < 10; i++) help.Scope.Handle(Key.PageDown); - - Assert.Equal(help.Body.GetContentSize().Height, help.Body.Viewport.Bottom); - help.Scope.Handle(Key.CursorUp); - Assert.Equal(help.Body.GetContentSize().Height - 1, help.Body.Viewport.Bottom); - } - - [Fact] - public void A_column_that_fits_does_not_scroll() - { - using var help = new HelpView(Diff, availableWidth: 120, availableHeight: 40); - - Assert.False(help.IsScrollable); - } - - private static IEnumerable ColumnTitles(HelpView help) - { - help.Layout(new System.Drawing.Size(200, 40)); - return help.Body.SubViews - .Where(view => view.SubViews.OfType