From 8635f5eea9192f90236a38baa252dbde1ef0ef1f Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:39:44 +1300 Subject: [PATCH] Move five pickers and prompts onto the library's PickerDialog and AppDialog Open recent folder, Definitions of, Choose grammar, Go to line and the language server dialog are now MentalDesk.Tui dialogs: their keys are on buttons, the hint rows are gone, and F1 opens Help with the dialog's keys. HostedDialog hosts any AppDialog on the scope stack and answers its F1 with a KeysDialog, putting the caret back where it was when Help closes. The three OptionSelectors call SelectOnArrows(), so the arrows choose and one Tab leaves. TuiCode's own filtering, hint text and per-dialog command ids for these dialogs are deleted. Closes #572 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +- src/TuiCode.Abstractions/CommandIds.cs | 18 -- .../Controls/HostedDialog.cs | 50 ++++ src/TuiCode.Workbench/Controls/PickerList.cs | 21 ++ .../Grammars/GrammarPickerView.cs | 91 ++----- .../Navigation/DefinitionPickerView.cs | 121 +-------- .../Navigation/GoToLineView.cs | 75 ++--- .../Review/SubmitReviewView.cs | 4 +- .../Settings/EditorSettingsView.cs | 4 +- .../Settings/GrammarAssociationsView.cs | 26 +- .../Settings/LanguageServerDialog.cs | 65 ++--- .../Settings/LanguageServersView.cs | 24 +- src/TuiCode.Workbench/WorkbenchHost.cs | 100 ++----- .../Workspace/RecentFolderList.cs | 12 +- .../Workspace/RecentFolderPickerView.cs | 123 +-------- .../TuiCode.Tests/EditorSettingsViewTests.cs | 1 - .../OpenRecentFolderHostTests.cs | 30 ++ tests/TuiCode.Tests/PickerDialogsTests.cs | 257 ++++++++++++++++++ tests/TuiCode.Tests/RecentFolderListTests.cs | 20 -- tests/TuiCode.Tests/SubmitReviewHostTests.cs | 25 +- 20 files changed, 515 insertions(+), 555 deletions(-) create mode 100644 src/TuiCode.Workbench/Controls/HostedDialog.cs create mode 100644 src/TuiCode.Workbench/Controls/PickerList.cs create mode 100644 tests/TuiCode.Tests/PickerDialogsTests.cs diff --git a/AGENTS.md b/AGENTS.md index 39a85aa8..0b61e54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ What follows is how TuiCode implements it today. - `AlertView` (`Workbench/Controls/`) is the guide's message block. It wraps the message and reports the rows it needs (`Lines`); the dialog grows by that many and moves its buttons up (`SubmitReviewView.Alert`). `AlertSeverity.Error` draws it in the theme's `Error` scheme, `Info` in `Accent`. `sr`, `gs` and `cc` use it — `PathPromptView`, `RevisionPickerView` and `PullRequestPickerView` still have the one-row `Label` (#208). - Text goes in a `VerbatimLabel` (Abstractions), never a plain `Label`, which takes the first `_` as a hotkey marker and hides it (#549). `AlertView` turns the hotkey off the same way. `VerbatimLabelTests` fails on any `new Label` in our assemblies. - `LoadStateView` (from the library) is the guide's **Loading and empty** rule: `opr`, `sr`, `ctr` and the Review pane open at once and say loading, empty or failed where their content goes, never on the status bar (#525). `R` retries a failed load through a scoped command enabled only while it's failed, so it still types into the filter otherwise. `LoadStateViews.ShowLoadFailed` cuts the message to one row, since the library doesn't wrap it, and leaves `R retry` off outside a git repository, which the library can only do by showing it as empty (dimmed). +- **Pickers and prompts** (#572): Open recent folder, *Definitions of*, Choose grammar are the library's `PickerDialog`, and Go to line and the language server dialog are `AppDialog`s, all shown with `HostedDialog.Show`, which pushes their keys on our scope stack and answers their `F1` with a `KeysDialog` of their keys, hosted beside them. `PickerList` reads the list `PickerDialog` keeps private. Open worktree, Open pull request, Compare to revision and Go to symbol still have their own views and hint rows until the library can host them. An `OptionSelector` calls `SelectOnArrows()`, so the arrows choose and one `Tab` leaves it. - `InputView` (`Workbench/Controls/`) is the guide's bordered multi-line field, used by `sr` and `cc`. It draws its box heavy while it has focus and single while it hasn't (#223), and leaves where typing goes to the terminal cursor, as the editor does — it paints no caret of its own, so nothing covers the character the cursor is on. A dialog hosting one is `InputView.Frame` rows and columns taller for the box. ## AOT @@ -403,7 +404,7 @@ What follows is how TuiCode implements it today. - **Workspace state (#13)** — the files open per folder and the active one — persists to `~/.tui/TuiCode.workspaces.json` via `WorkspaceStateStore`: a JSON array, most recently used folder first, capped at `MaxFolders` (30). The launch folder always wins (like `code .`); `Workbench.OpenFolder` (startup and the in-app Open) restores that folder's tabs, skipping files that are gone. Saves happen on every `ActiveTabChanged` (open, close, switch), so a crash loses nothing. `Workbench` nulls its folder while switching and on dispose so closing the old tabs isn't saved as that folder's state. - **Where you were in each file (#502)** persists to `~/.tui/TuiCode.positions.json` via `FilePositionStore`, keyed by full path across every folder, most recently used first, capped at `MaxFiles` (300); paths inside `.git` (`FilePaths.IsInGitDir`) are never kept. `Workbench` records a tab's `EditorTab.ViewPosition` (cursor, plus the file line at the top) as it closes (`EditorGroups.EditorClosing`), every tab's before a folder switch and on dispose, never per keystroke. A file's tab gets it back on `EditorGroups.EditorOpening`, before it's added, so anything that opens at a place (a Find result, a jump, `file:42`) just moves the cursor afterwards and wins. The scroll waits for the tab's first draw, since TG doesn't size the content to the text until then, and an explicit `MoveCursor`, `Select` or `RevealLines` before that drops it. - **Unsaved edits survive TuiCode ending unexpectedly (#579).** `UnsavedEdits` (`Workspace/`) keeps each dirty tab's text, `ViewPosition` and `OnDisk` snapshot in `RecoveryStore`, `~/.tui/recovery/-.json` (`0600`, written to a temp file and renamed), `UnsavedEdits.Delay` (2 s) after its last edit. Buffers are read on the UI thread and the writes queued in order on a background task. The copy goes when the tab is clean again (save, undo back, reload), closes (every in-app close of a dirty tab has asked or been asked for: Don't save, delete), on a folder switch, and on *Don't save* at quit (`DiscardAll`), but not on `Workbench.Dispose`, which flushes instead, since a crash out of the loop ends there too. `SIGHUP`/`SIGTERM` flush through `WorkbenchHost.KeepUnsavedEdits` and then take their default course. A file opens with a copy (`EditorGroup.Recovery`) when the process that wrote it has gone (`RecoveryOwner`: pid plus start time), as `EditorTab.Recovered`: dirty, the old snapshot as `OnDisk`, so `⚠` or `⊘` and #267's prompt follow, and undo starting there; it skips the saved position (#502), and `RestoreOpenFiles` brings back a deleted file that has one. The new owner writes its own copy before forgetting the old. Paths in `.git` are kept, unlike positions. The status bar counts the files a loop iteration recovered. -- **Open recent folder (#357)** (`or`, no default key) lists `WorkspaceStateStore.Folders()` as `RecentFolderPickerView`, less the folder open now and any that no longer exist; it only reads the file, so dead entries are hidden, not pruned. `RecentFolderList` (pure) builds the name + `~`-shortened parent rows and filters them. Enter is `Workbench.OpenFolder`. +- **Open recent folder (#357)** (`or`, no default key) lists `WorkspaceStateStore.Folders()` as `RecentFolderPickerView`, less the folder open now and any that no longer exist; it only reads the file, so dead entries are hidden, not pruned. `RecentFolderList` (pure) builds the name + `~`-shortened parent rows; the picker filters on both. Enter is `Workbench.OpenFolder`. - **Open file path (#358)** (`opa`, no default key; `op` would shadow `opr`) is a `PathPromptView` for a folder to open by its full path; `FolderPath.Expand` turns a leading `~` into the home folder and drops a trailing separator. A relative path, a missing folder or a file is `No such folder: ` in the prompt, which stays open; Enter otherwise is `Workbench.OpenFolder`. - Don't reach into `TuiConfigurationBuilder` / `ThemeManager` from feature code — go through `ISettingsService`. Tests use `InMemorySettingsService`. - Tests that mutate `ThemeManager.Theme` (e.g. `DefaultSettingsServiceTests`) derive from `StaticConfigurationTest`, which snapshots and restores it. Keybindings are instance state on the service, so keybinding-only tests need neither. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index cee7da1c..06c0108d 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -78,8 +78,6 @@ public static class CommandIds public const string ActionsFocusSearch = "actions.action.focusSearch"; public const string GoToLine = "workbench.action.goToLine"; - public const string GoToLineConfirm = "goToLine.action.confirm"; - public const string GoToLineCancel = "goToLine.action.cancel"; public const string GoToSymbol = "workbench.action.goToSymbol"; public const string GoToMatchingBracket = "workbench.action.goToMatchingBracket"; @@ -91,12 +89,6 @@ public static class CommandIds public const string SymbolPickerPageDown = "symbolPicker.action.pageDown"; public const string GoToDefinition = "workbench.action.goToDefinition"; - public const string DefinitionPickerConfirm = "definitionPicker.action.confirm"; - public const string DefinitionPickerCancel = "definitionPicker.action.cancel"; - public const string DefinitionPickerUp = "definitionPicker.action.up"; - public const string DefinitionPickerDown = "definitionPicker.action.down"; - public const string DefinitionPickerPageUp = "definitionPicker.action.pageUp"; - public const string DefinitionPickerPageDown = "definitionPicker.action.pageDown"; public const string FindUsages = "workbench.action.findUsages"; public const string AiAssistance = "workbench.action.aiAssistance"; public const string AssistantSend = "assistant.action.send"; @@ -144,12 +136,6 @@ public static class CommandIds public const string PullRequestPickerPageDown = "pullRequestPicker.action.pageDown"; public const string PullRequestPickerRetry = "pullRequestPicker.action.retry"; - public const string RecentFolderPickerConfirm = "recentFolderPicker.action.confirm"; - public const string RecentFolderPickerCancel = "recentFolderPicker.action.cancel"; - public const string RecentFolderPickerUp = "recentFolderPicker.action.up"; - public const string RecentFolderPickerDown = "recentFolderPicker.action.down"; - public const string RecentFolderPickerPageUp = "recentFolderPicker.action.pageUp"; - public const string RecentFolderPickerPageDown = "recentFolderPicker.action.pageDown"; public const string WorktreePickerConfirm = "worktreePicker.action.confirm"; public const string WorktreePickerCancel = "worktreePicker.action.cancel"; @@ -198,11 +184,7 @@ public static class CommandIds public const string ToggleWordWrap = "editor.action.toggleWordWrap"; public const string ChangeGrammar = "workbench.action.changeGrammar"; - public const string GrammarPickerConfirm = "grammarPicker.action.confirm"; - public const string GrammarPickerCancel = "grammarPicker.action.cancel"; - public const string LanguageServerSave = "languageServer.action.save"; - public const string LanguageServerCancel = "languageServer.action.cancel"; public static string FocusEditorByIndex(int oneBasedIndex) => $"workbench.action.focusEditor{oneBasedIndex}"; diff --git a/src/TuiCode.Workbench/Controls/HostedDialog.cs b/src/TuiCode.Workbench/Controls/HostedDialog.cs new file mode 100644 index 00000000..36e4a5e9 --- /dev/null +++ b/src/TuiCode.Workbench/Controls/HostedDialog.cs @@ -0,0 +1,50 @@ +using MentalDesk.Tui.Dialogs; +using MentalDesk.Tui.Keys; +using TuiCode.Abstractions; +using TuiCode.Workbench.Services; + +namespace TuiCode.Workbench.Controls; + +/// A library added to host with its keys on our scope stack, and F1 showing its keys. +internal static class HostedDialog +{ + /// runs once the dialog has left , before it's disposed. + public static void Show(View host, IInputScopeStack scopes, AppDialog dialog, Action closed) + { + var scope = new LibraryScope(dialog.Scope); + dialog.KeysRequested += (_, sheet) => ShowKeys(host, scopes, dialog, sheet); + dialog.Closed += (_, _) => + { + scopes.Pop(scope); + host.Remove(dialog); + closed(); + dialog.Dispose(); + }; + host.Add(dialog); + scopes.Push(scope); + } + + private static void ShowKeys(View host, IInputScopeStack scopes, AppDialog dialog, KeySheet sheet) + { + var focused = dialog.MostFocused; + var caret = (focused as TextField)?.InsertionPoint; + var keys = new KeysDialog(sheet, host.Viewport.Size); + var scope = new LibraryScope(keys.Scope); + keys.Closed += (_, _) => + { + scopes.Pop(scope); + host.Remove(keys); + keys.Dispose(); + focused?.SetFocus(); + // A TextField selects all as it takes focus, so the next key would replace what was typed. + if (focused is TextField field && caret is { } at) + { + field.ClearAllSelection(); + field.InsertionPoint = at; + } + }; + host.Add(keys); + scopes.Push(scope); + keys.SetFocus(); + } +} diff --git a/src/TuiCode.Workbench/Controls/PickerList.cs b/src/TuiCode.Workbench/Controls/PickerList.cs new file mode 100644 index 00000000..33b5f2b9 --- /dev/null +++ b/src/TuiCode.Workbench/Controls/PickerList.cs @@ -0,0 +1,21 @@ +using MentalDesk.Tui.Dialogs; + +namespace TuiCode.Workbench.Controls; + +/// What doesn't expose of its list. +internal static class PickerList +{ + private const int Chrome = 4; + + /// The columns a row has in a picker wide. + public static int Width(int dialogWidth) => dialogWidth - Chrome; + + public static int? SelectedIndex(this PickerDialog picker) where T : class => + List(picker).SelectedItem; + + public static void Select(this PickerDialog picker, int index) where T : class => + List(picker).SelectedItem = index; + + private static ListView List(PickerDialog picker) where T : class => + picker.SubViews.OfType().Single(); +} diff --git a/src/TuiCode.Workbench/Grammars/GrammarPickerView.cs b/src/TuiCode.Workbench/Grammars/GrammarPickerView.cs index f9c99c87..20191a04 100644 --- a/src/TuiCode.Workbench/Grammars/GrammarPickerView.cs +++ b/src/TuiCode.Workbench/Grammars/GrammarPickerView.cs @@ -1,84 +1,35 @@ -using TuiCode.Abstractions; +using MentalDesk.Tui.Dialogs; using TuiCode.Syntax; -using TuiCode.Workbench.Services; +using TuiCode.Workbench.Controls; namespace TuiCode.Workbench.Grammars; -/// Modal grammar list with a filter box, shared by cg and the Grammars settings pane. The owner pushes and removes it on . -public sealed class GrammarPickerView : Window -{ - private readonly TextField _search; - private readonly ListView _list; - private readonly IReadOnlyList _allRows; - private readonly ICommandService _scopeCommands = new CommandService(); - private List _visibleRows = []; - - public IKeybindingService Scope { get; } - - /// Raised with the chosen grammar (null for Plain Text). - public event EventHandler? Chosen; - - /// Raised when the picker should be removed: after a choice, or on Esc. - public event EventHandler? Closed; +/// A row of the grammar picker: null is Plain Text. +internal sealed record GrammarRow(SyntaxLanguage? Grammar, string Name, bool IsCurrent); +/// Modal grammar list with a filter box, shared by cg and the Grammars settings pane. +internal sealed class GrammarPickerView : PickerDialog +{ public GrammarPickerView(IEnumerable grammars, string title, SyntaxLanguage? current) + : this(title, Rows(grammars, current)) { - Title = title; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - Width = 60; - Height = 22; - CanFocus = true; - - _search = new TextField { X = 1, Y = 0, Width = Dim.Fill(1), Height = 1 }; - _search.TextChanged += (_, _) => RebuildVisible(); - _search.MouseEvent += (_, _) => _search.SetFocus(); - - _list = new ListView { X = 1, Y = Pos.Bottom(_search) + 1, Width = Dim.Fill(1), Height = Dim.Fill(1) }; - _list.MouseEvent += (_, _) => _list.SetFocus(); - - Add(_search, _list); - - _allRows = - [ - new Row(null, Workbench.PlainTextName, current is null), - .. grammars - .OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase) - .Select(g => new Row(g, g.Name, g.Id == current?.Id)), - ]; - - Scope = new KeybindingService(_scopeCommands); - _scopeCommands.Register(CommandIds.GrammarPickerCancel, () => Closed?.Invoke(this, EventArgs.Empty)); - _scopeCommands.Register(CommandIds.GrammarPickerConfirm, ChooseSelected); - Scope.Bind("Esc", CommandIds.GrammarPickerCancel); - Scope.Bind("Enter", CommandIds.GrammarPickerConfirm); - - RebuildVisible(); } - public bool FocusSearch() => _search.SetFocus(); - - private void RebuildVisible() + private GrammarPickerView(string title, IReadOnlyList rows) + : base(title, 60, 20, rows, r => r.Grammar is null ? r.Name : $"{r.Name} {r.Grammar.Id}", "Choose", + r => (r.IsCurrent ? "● " : " ") + r.Name) { - var query = _search.Text ?? ""; - _visibleRows = _allRows - .Where(r => r.Name.Contains(query, StringComparison.OrdinalIgnoreCase) - || (r.Grammar?.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)) - .ToList(); - - _list.Source = new ListWrapper(new(_visibleRows.Select(r => (r.IsCurrent ? "● " : " ") + r.Name))); - var current = string.IsNullOrEmpty(query) ? _visibleRows.FindIndex(r => r.IsCurrent) : -1; - _list.SelectedItem = _visibleRows.Count == 0 ? null : Math.Max(current, 0); + this.Select(Math.Max(0, rows.ToList().FindIndex(r => r.IsCurrent))); } - private void ChooseSelected() - { - var i = _list.SelectedItem ?? -1; - if (i < 0 || i >= _visibleRows.Count) return; - Chosen?.Invoke(this, _visibleRows[i].Grammar); - Closed?.Invoke(this, EventArgs.Empty); - } + /// The grammar chosen, null for Plain Text; only meaningful once . + public SyntaxLanguage? Grammar => Chosen?.Grammar; - private sealed record Row(SyntaxLanguage? Grammar, string Name, bool IsCurrent); + private static IReadOnlyList Rows(IEnumerable grammars, SyntaxLanguage? current) => + [ + new GrammarRow(null, Workbench.PlainTextName, current is null), + .. grammars + .OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => new GrammarRow(g, g.Name, g.Id == current?.Id)), + ]; } diff --git a/src/TuiCode.Workbench/Navigation/DefinitionPickerView.cs b/src/TuiCode.Workbench/Navigation/DefinitionPickerView.cs index 89b7b214..d87eaa00 100644 --- a/src/TuiCode.Workbench/Navigation/DefinitionPickerView.cs +++ b/src/TuiCode.Workbench/Navigation/DefinitionPickerView.cs @@ -1,8 +1,7 @@ -using System.Collections; -using System.Collections.Specialized; +using MentalDesk.Tui.Dialogs; using TuiCode.Abstractions; +using TuiCode.Workbench.Controls; using TuiCode.Workbench.Languages; -using TuiCode.Workbench.Services; namespace TuiCode.Workbench.Navigation; @@ -16,119 +15,23 @@ public string Display(int placeWidth, int width) } } -/// Picks one of several definitions, such as a partial class's; the host does the move and closes it. -public sealed class DefinitionPickerView : Window +/// Picks one of several definitions, such as a partial class's; the host does the move once it closes. +public sealed class DefinitionPickerView : PickerDialog { - private const string HintText = "Type to filter · Up/Down/PgUp/PgDn · Enter go to · Esc cancel"; - - private readonly TextField _filter; - private readonly ListView _list; - private readonly IReadOnlyList _rows; - private readonly int _placeWidth; - private readonly ICommandService _scopeCommands; - private readonly IKeybindingService _scopeKeybindings; - private IReadOnlyList _visible = []; - - public IKeybindingService Scope => _scopeKeybindings; - - public event EventHandler? Cancelled; - - public event EventHandler? Submitted; + private const int DialogWidth = 66; public DefinitionPickerView(string symbol, IReadOnlyList rows) + : base($"Definitions of {symbol}", DialogWidth, Math.Min(18, rows.Count + 4), rows, r => $"{r.Place} {r.Code}", "Go to", + r => r.Display(rows.Max(d => d.Place.Length), PickerList.Width(DialogWidth))) { - _rows = rows; - _placeWidth = rows.Max(r => r.Place.Length); - Title = $"Definitions of {symbol}"; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - Width = 66; - Height = Math.Min(20, rows.Count + 4); - CanFocus = true; - - _filter = new TextField { X = 1, Y = 0, Width = Dim.Fill(1) }; - _filter.TextChanged += (_, _) => ShowEntries(); - _list = new ListView { X = 1, Y = 1, Width = Dim.Fill(1), Height = Dim.Fill(1) }; - var hint = new VerbatimLabel { X = 1, Y = Pos.AnchorEnd(1), Width = Dim.Fill(1), Text = HintText }; - Add(_filter, _list, hint); - - _scopeCommands = new CommandService(); - _scopeKeybindings = new KeybindingService(_scopeCommands); - _scopeCommands.Register(CommandIds.DefinitionPickerCancel, OnCancel); - _scopeCommands.Register(CommandIds.DefinitionPickerConfirm, OnConfirm); - _scopeCommands.Register(CommandIds.DefinitionPickerUp, () => MoveSelection(-1)); - _scopeCommands.Register(CommandIds.DefinitionPickerDown, () => MoveSelection(1)); - _scopeCommands.Register(CommandIds.DefinitionPickerPageUp, () => MoveSelection(-PageHeight)); - _scopeCommands.Register(CommandIds.DefinitionPickerPageDown, () => MoveSelection(PageHeight)); - _scopeKeybindings.Bind("Esc", CommandIds.DefinitionPickerCancel); - _scopeKeybindings.Bind("Enter", CommandIds.DefinitionPickerConfirm); - _scopeKeybindings.Bind("CursorUp", CommandIds.DefinitionPickerUp); - _scopeKeybindings.Bind("CursorDown", CommandIds.DefinitionPickerDown); - _scopeKeybindings.Bind("PageUp", CommandIds.DefinitionPickerPageUp); - _scopeKeybindings.Bind("PageDown", CommandIds.DefinitionPickerPageDown); - ShowEntries(); - } - - internal string Filter => _filter.Text ?? ""; - - internal IReadOnlyList VisibleItems => [.. _visible.Select(r => r.Place)]; - - internal int? SelectedItem => _list.SelectedItem; - - public bool FocusFilter() => _filter.SetFocus(); - - private void OnCancel() - { - if (Filter.Length > 0) _filter.Text = ""; - else Cancelled?.Invoke(this, EventArgs.Empty); - } - - private void OnConfirm() - { - if (_list.SelectedItem is { } i && i < _visible.Count) Submitted?.Invoke(this, _visible[i].Location); - } - - private int PageHeight => Math.Max(1, _list.Viewport.Height); - - private void MoveSelection(int delta) - { - if (_visible.Count == 0) return; - _list.SelectedItem = Math.Clamp((_list.SelectedItem ?? -1) + delta, 0, _visible.Count - 1); + Commands.Register(CancelId, "Cancel", ClearOrCancel); } - private void ShowEntries() - { - var filter = Filter.Trim(); - _visible = [.. _rows.Where(r => filter.Length == 0 - || r.Place.Contains(filter, StringComparison.OrdinalIgnoreCase) - || r.Code.Contains(filter, StringComparison.OrdinalIgnoreCase))]; - _list.Source = new DefinitionListSource(_visible, _placeWidth); - _list.SelectedItem = _visible.Count == 0 ? null : 0; - } + internal IReadOnlyList VisibleItems => [.. Shown.Select(r => r.Place)]; - private sealed class DefinitionListSource(IReadOnlyList rows, int placeWidth) : IListDataSource + private void ClearOrCancel() { - public event NotifyCollectionChangedEventHandler? CollectionChanged { add { } remove { } } - - public int Count => rows.Count; - - public int MaxItemLength => 0; - - public bool SuspendCollectionChangedEvent { get; set; } - - public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) - { - listView.Move(col, row); - listView.AddStr(rows[item].Display(placeWidth, width).PadRight(width)); - } - - public bool IsMarked(int item) => false; - - public void SetMark(int item, bool value) { } - - public IList ToList() => rows.Select(r => r.Place).ToList(); - - public void Dispose() { } + if (FilterField.Text.Length > 0) FilterField.Text = ""; + else Cancel(); } } diff --git a/src/TuiCode.Workbench/Navigation/GoToLineView.cs b/src/TuiCode.Workbench/Navigation/GoToLineView.cs index 37525870..219ee780 100644 --- a/src/TuiCode.Workbench/Navigation/GoToLineView.cs +++ b/src/TuiCode.Workbench/Navigation/GoToLineView.cs @@ -1,5 +1,6 @@ +using MentalDesk.Tui; +using MentalDesk.Tui.Dialogs; using TuiCode.Abstractions; -using TuiCode.Workbench.Services; namespace TuiCode.Workbench.Navigation; @@ -7,33 +8,20 @@ namespace TuiCode.Workbench.Navigation; /// Modal "Go to line:column" prompt. Single text field — accepts either /// LINE or LINE:COL (1-based, matching VS Code / IntelliJ). /// -public sealed class GoToLineView : Window +public sealed class GoToLineView : AppDialog { + private const string GoId = "goToLine.go"; + private const int ContentRows = 4; + private readonly TextField _input; - private readonly VerbatimLabel _error; private readonly int _totalLines; - private readonly ICommandService _scopeCommands; - private readonly IKeybindingService _scopeKeybindings; - - public IKeybindingService Scope => _scopeKeybindings; - - public event EventHandler? Cancelled; - public event EventHandler<(int Row, int Column)>? Submitted; - public GoToLineView(int totalLines, int currentLine) + : base("Go to Line:Column", 50, ContentRows) { if (totalLines < 1) totalLines = 1; _totalLines = totalLines; - Title = "Go to Line:Column"; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - Width = 50; - Height = 7; - CanFocus = true; - var hint = new VerbatimLabel { X = 1, @@ -41,63 +29,42 @@ public GoToLineView(int totalLines, int currentLine) Width = Dim.Fill(1), Text = $"Line[:Col] (1-{totalLines}, current: {currentLine})", }; + _input = new TextField { X = 1, Y = 1, Width = Dim.Fill(1) }; + Add(hint, _input); - _input = new TextField - { - X = 1, - Y = 1, - Width = Dim.Fill(1), - }; - - _error = new VerbatimLabel - { - X = 1, - Y = 3, - Width = Dim.Fill(1), - Text = string.Empty, - }; - - Add(hint, _input, _error); - - _scopeCommands = new CommandService(); - _scopeKeybindings = new KeybindingService(_scopeCommands); - RegisterScopeBindings(); + Commands.Register(GoId, "Go to", OnConfirm); + Keys.Bind("Enter", GoId); + AddButtonRow(ContentRows - 1, CommandButton(ButtonKind.Primary, GoId), CommandButton(ButtonKind.Secondary, CancelId)); } - public bool FocusInput() => _input.SetFocus(); - - private void RegisterScopeBindings() - { - _scopeCommands.Register(CommandIds.GoToLineCancel, () => Cancelled?.Invoke(this, EventArgs.Empty)); - _scopeCommands.Register(CommandIds.GoToLineConfirm, OnConfirm); + /// Zero-based, for EditorTab.MoveCursor; set once . + public (int Row, int Column) Target { get; private set; } - _scopeKeybindings.Bind("Esc", CommandIds.GoToLineCancel); - _scopeKeybindings.Bind("Enter", CommandIds.GoToLineConfirm); - } + public bool FocusInput() => _input.SetFocus(); private void OnConfirm() { - var raw = _input.Text?.ToString()?.Trim() ?? string.Empty; + var raw = _input.Text?.Trim() ?? string.Empty; if (raw.Length == 0) { - Cancelled?.Invoke(this, EventArgs.Empty); + Cancel(); return; } if (!TryParse(raw, out var line, out var col)) { - _error.Text = "Expected: LINE or LINE:COL"; + ShowAlert("Expected: LINE or LINE:COL", Severity.Error); return; } if (line < 1 || line > _totalLines) { - _error.Text = $"Line out of range (1-{_totalLines})"; + ShowAlert($"Line out of range (1-{_totalLines})", Severity.Error); return; } - // Convert to zero-based for EditorTab.MoveCursor. - Submitted?.Invoke(this, (line - 1, Math.Max(0, col - 1))); + Target = (line - 1, Math.Max(0, col - 1)); + Accept(); } internal static bool TryParse(string raw, out int line, out int column) diff --git a/src/TuiCode.Workbench/Review/SubmitReviewView.cs b/src/TuiCode.Workbench/Review/SubmitReviewView.cs index a5d0833a..6dacf10f 100644 --- a/src/TuiCode.Workbench/Review/SubmitReviewView.cs +++ b/src/TuiCode.Workbench/Review/SubmitReviewView.cs @@ -1,4 +1,5 @@ using ButtonKind = MentalDesk.Tui.Dialogs.ButtonKind; +using MentalDesk.Tui.Fields; using MentalDesk.Tui.Loading; using TuiCode.Abstractions; using TuiCode.Workbench.Controls; @@ -51,10 +52,9 @@ public SubmitReviewView() X = 1, Y = 0, Orientation = Orientation.Horizontal, - TabBehavior = TabBehavior.NoStop, Labels = ["Comment", "Approve", "Request changes"], Value = GitHubReviewVerdict.Comment, - }; + }.SelectOnArrows(); _drafts = new VerbatimLabel { X = 1, Y = Pos.AnchorEnd(2), Text = string.Empty, Visible = false }; diff --git a/src/TuiCode.Workbench/Settings/EditorSettingsView.cs b/src/TuiCode.Workbench/Settings/EditorSettingsView.cs index 3fd589fc..60452937 100644 --- a/src/TuiCode.Workbench/Settings/EditorSettingsView.cs +++ b/src/TuiCode.Workbench/Settings/EditorSettingsView.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Fields; using TuiCode.Abstractions; using TuiCode.Syntax; @@ -58,10 +59,9 @@ public EditorSettingsView(EditorSettings settings, SyntaxHighlighter? syntax = n X = ControlColumn, Y = 4, Orientation = Orientation.Horizontal, - TabBehavior = TabBehavior.NoStop, Labels = ["Keep each file's", "LF", "CRLF"], Value = settings.LineEnding, - }; + }.SelectOnArrows(); _insertFinalNewline = new CheckBox { diff --git a/src/TuiCode.Workbench/Settings/GrammarAssociationsView.cs b/src/TuiCode.Workbench/Settings/GrammarAssociationsView.cs index 5022a418..e95bd41a 100644 --- a/src/TuiCode.Workbench/Settings/GrammarAssociationsView.cs +++ b/src/TuiCode.Workbench/Settings/GrammarAssociationsView.cs @@ -1,5 +1,6 @@ using TuiCode.Abstractions; using TuiCode.Syntax; +using TuiCode.Workbench.Controls; using TuiCode.Workbench.Grammars; namespace TuiCode.Workbench.Settings; @@ -117,24 +118,15 @@ private void EditSelected() ? _syntax.LanguageById(id) : _syntax.DefaultAssociations.GetValueOrDefault(row.Pattern); var picker = new GrammarPickerView(_syntax.Languages, $"Grammar for {row.Pattern}", current); - picker.Chosen += (_, grammar) => SetAssociation(row.Pattern, grammar); - picker.Closed += (_, _) => ClosePicker(picker); - - // Hosted by the settings window rather than this narrower pane, so the picker isn't clipped. - var host = SuperView ?? this; _picker = picker; - host.Add(picker); - _scopes.Push(picker.Scope); - picker.FocusSearch(); - } - - private void ClosePicker(GrammarPickerView picker) - { - _scopes.Pop(picker.Scope); - picker.SuperView?.Remove(picker); - picker.Dispose(); - _picker = null; - _list.SetFocus(); + // Hosted by the settings window rather than this narrower pane, so the picker isn't clipped. + HostedDialog.Show(SuperView ?? this, _scopes, picker, () => + { + _picker = null; + if (picker.Confirmed) SetAssociation(row.Pattern, picker.Grammar); + _list.SetFocus(); + }); + picker.FilterField.SetFocus(); } internal void SetAssociation(string pattern, SyntaxLanguage? grammar) diff --git a/src/TuiCode.Workbench/Settings/LanguageServerDialog.cs b/src/TuiCode.Workbench/Settings/LanguageServerDialog.cs index 57f32c07..33825227 100644 --- a/src/TuiCode.Workbench/Settings/LanguageServerDialog.cs +++ b/src/TuiCode.Workbench/Settings/LanguageServerDialog.cs @@ -1,12 +1,14 @@ +using MentalDesk.Tui.Dialogs; +using MentalDesk.Tui.Fields; using TuiCode.Abstractions; using TuiCode.Syntax; -using TuiCode.Workbench.Services; namespace TuiCode.Workbench.Settings; -/// Chooses one language's server (#456). The owner pushes and removes it on . -public sealed class LanguageServerDialog : Window +/// Chooses one language's server (#456). +public sealed class LanguageServerDialog : AppDialog { + private const string SaveId = "languageServer.save"; private const int FieldColumn = 12; private readonly LanguageServerForm _form; @@ -14,28 +16,17 @@ public sealed class LanguageServerDialog : Window private readonly TextField _command; private readonly TextField _arguments; private readonly VerbatimLabel _status; - private readonly ICommandService _scopeCommands = new CommandService(); private bool _filling; - public IKeybindingService Scope { get; } - - /// Raised with the server to save. - public event EventHandler? Saved; - - /// Raised when the dialog should be removed: after a save, or on Esc. - public event EventHandler? Closed; - internal LanguageServerDialog(SyntaxLanguage language, LanguageServerSetting current, Func isInstalled) + : this(new LanguageServerForm(language.Id, current, isInstalled), language.Name) { - _form = new LanguageServerForm(language.Id, current, isInstalled); + } - Title = $"{language.Name} language server"; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - Width = 64; - Height = _form.Options.Count + 10; - CanFocus = true; + private LanguageServerDialog(LanguageServerForm form, string languageName) + : base($"{languageName} language server", 64, form.Options.Count + 8) + { + _form = form; var serverLabel = new VerbatimLabel { X = 1, Y = 0, Text = "Server:" }; _server = new OptionSelector @@ -44,9 +35,8 @@ internal LanguageServerDialog(SyntaxLanguage language, LanguageServerSetting cur Y = 0, Labels = [.. _form.Options], Value = _form.Selected, - }; + }.SelectOnArrows(); _server.ValueChanged += (_, _) => OnServerChanged(); - _server.FocusedChanged += (_, e) => FollowFocus(e); var fieldsTop = _form.Options.Count + 1; var commandLabel = new VerbatimLabel { X = 1, Y = fieldsTop, Text = "Command:" }; @@ -57,19 +47,19 @@ internal LanguageServerDialog(SyntaxLanguage language, LanguageServerSetting cur _arguments.TextChanged += (_, _) => OnFieldsChanged(); _status = new VerbatimLabel { X = 1, Y = fieldsTop + 3, Width = Dim.Fill(1), Height = 2 }; - var hint = new VerbatimLabel { X = 1, Y = Pos.AnchorEnd(1), Text = "Ctrl+Enter: Save Esc: Cancel" }; - Add(serverLabel, _server, commandLabel, _command, argumentsLabel, _arguments, _status, hint); + Add(serverLabel, _server, commandLabel, _command, argumentsLabel, _arguments, _status); - Scope = new KeybindingService(_scopeCommands); - _scopeCommands.Register(CommandIds.LanguageServerSave, Save); - _scopeCommands.Register(CommandIds.LanguageServerCancel, () => Closed?.Invoke(this, EventArgs.Empty)); - Scope.Bind("Ctrl+Enter", CommandIds.LanguageServerSave); - Scope.Bind("Esc", CommandIds.LanguageServerCancel); + Commands.Register(SaveId, "Save", Accept); + Keys.Bind("Ctrl+Enter", SaveId); + AddButtonRow(fieldsTop + 6, CommandButton(ButtonKind.Primary, SaveId), CommandButton(ButtonKind.Secondary, CancelId)); ShowStatus(); } + /// The server to save, once . + public LanguageServerSetting Setting => _form.Setting; + internal LanguageServerForm Form => _form; public bool FocusServer() => _server.SetFocus(); @@ -85,17 +75,6 @@ private void OnServerChanged() ShowStatus(); } - private void FollowFocus(HasFocusEventArgs e) - { - if (e.CurrentFocused?.SuperView == _server) _server.Value = _server.FocusedItem; - // TG throws if focus moves again while it's still being set. - else App?.AddTimeout(TimeSpan.Zero, () => - { - if (_server.HasFocus) _server.FocusedItem = _form.Selected; - return false; - }); - } - private void OnFieldsChanged() { if (_filling) return; @@ -107,10 +86,4 @@ private void OnFieldsChanged() } private void ShowStatus() => _status.Text = string.Join('\n', _form.Status()); - - private void Save() - { - Saved?.Invoke(this, _form.Setting); - Closed?.Invoke(this, EventArgs.Empty); - } } diff --git a/src/TuiCode.Workbench/Settings/LanguageServersView.cs b/src/TuiCode.Workbench/Settings/LanguageServersView.cs index d99b9f68..b41c3de8 100644 --- a/src/TuiCode.Workbench/Settings/LanguageServersView.cs +++ b/src/TuiCode.Workbench/Settings/LanguageServersView.cs @@ -1,5 +1,6 @@ using TuiCode.Abstractions; using TuiCode.Syntax; +using TuiCode.Workbench.Controls; using TuiCode.Workbench.Languages; namespace TuiCode.Workbench.Settings; @@ -89,26 +90,17 @@ internal void EditSelected() { if (Dialog is not null || SelectedRow is not { } row) return; var dialog = new LanguageServerDialog(row.Language, row.Setting, _isInstalled); - dialog.Saved += (_, setting) => Choose(row.Language.Id, setting); - dialog.Closed += (_, _) => CloseDialog(dialog); - - // Hosted by the settings window rather than this narrower pane, so the dialog isn't clipped. - var host = SuperView ?? this; Dialog = dialog; - host.Add(dialog); - _scopes.Push(dialog.Scope); + // Hosted by the settings window rather than this narrower pane, so the dialog isn't clipped. + HostedDialog.Show(SuperView ?? this, _scopes, dialog, () => + { + Dialog = null; + if (dialog.Confirmed) Choose(row.Language.Id, dialog.Setting); + _list.SetFocus(); + }); dialog.FocusServer(); } - private void CloseDialog(LanguageServerDialog dialog) - { - _scopes.Pop(dialog.Scope); - dialog.SuperView?.Remove(dialog); - dialog.Dispose(); - Dialog = null; - _list.SetFocus(); - } - internal void Choose(string languageId, LanguageServerSetting setting) { if (setting == KnownLanguageServers.Default(languageId)) diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 6078b5d5..42da377a 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -1361,22 +1361,14 @@ private void OpenGrammarPicker() } var view = new GrammarPickerView(syntax.Languages, $"Grammar for {tab.File.Name}", tab.Grammar); - view.Chosen += (_, grammar) => tab.SetGrammar(grammar); - view.Closed += (_, _) => CloseGrammarPicker(view); _activeGrammarPicker = view; - _workbench.Add(view); - _scopes.Push(view.Scope); - view.FocusSearch(); - } - - private void CloseGrammarPicker(GrammarPickerView view) - { - if (!ReferenceEquals(_activeGrammarPicker, view)) return; - _scopes.Pop(view.Scope); - _workbench.Remove(view); - view.Dispose(); - _activeGrammarPicker = null; - FocusCallingRegion(); + HostedDialog.Show(_workbench, _scopes, view, () => + { + _activeGrammarPicker = null; + if (view.Confirmed) tab.SetGrammar(view.Grammar); + FocusCallingRegion(); + }); + view.FilterField.SetFocus(); } private void OpenActions() @@ -1486,10 +1478,13 @@ private void OpenGoToLine() var totalLines = CountLines(tab.Content); var view = new GoToLineView(totalLines, tab.CursorRow + 1); - view.Cancelled += (_, _) => CloseGoToLine(view); - view.Submitted += (_, target) => + _activeGoToLine = view; + HostedDialog.Show(_workbench, _scopes, view, () => { - CloseGoToLine(view); + _activeGoToLine = null; + FocusCallingRegion(); + if (!view.Confirmed) return; + var target = view.Target; // Drive the move ourselves and record it as an explicit jump, so even a short // hop lands in history (it's a deliberate navigation) without the move event // overwriting the pre-jump origin we want Back to return to. @@ -1498,11 +1493,7 @@ private void OpenGoToLine() finally { _suppressHistory = false; } tab.RevealLines(target.Row, target.Row); _history.Visit(new CursorLocation(tab.File.FullName, target.Row, target.Column), explicitJump: true); - }; - - _activeGoToLine = view; - _workbench.Add(view); - _scopes.Push(view.Scope); + }); view.FocusInput(); } @@ -1519,16 +1510,6 @@ private void GoToMatchingBracket() FocusEditorBody(); } - private void CloseGoToLine(GoToLineView view) - { - if (!ReferenceEquals(_activeGoToLine, view)) return; - _scopes.Pop(view.Scope); - _workbench.Remove(view); - view.Dispose(); - _activeGoToLine = null; - FocusCallingRegion(); - } - /// /// Go to symbol (gs): the definitions in the active file. The scan is the tab's, cached against its /// buffer, so a second gs without an edit opens on a finished list; a fresh one fills in from @@ -1698,16 +1679,14 @@ private void ShowDefinitions(EditorTab origin, string? symbol, IReadOnlyList new DefinitionRow(l, $"{DisplayPath(l.Path, root)}:{l.Line + 1}", LineAt(fileSystem, l).Trim())).ToList(); var view = new DefinitionPickerView(symbol ?? "symbol", rows); - view.Cancelled += (_, _) => CloseDefinitionPicker(view); - view.Submitted += (_, location) => - { - CloseDefinitionPicker(view); - JumpToDefinition(origin, location); - }; _activeDefinitionPicker = view; - _workbench.Add(view); - _scopes.Push(view.Scope); - view.FocusFilter(); + HostedDialog.Show(_workbench, _scopes, view, () => + { + _activeDefinitionPicker = null; + FocusCallingRegion(); + if (view.Confirmed && view.Chosen is { } chosen) JumpToDefinition(origin, chosen.Location); + }); + view.FilterField.SetFocus(); } private static string DisplayPath(string path, string? root) => @@ -1720,16 +1699,6 @@ private string LineAt(IFileSystem fileSystem, SourceLocation location) return fileSystem.File.ReadLines(location.Path).Skip(location.Line).FirstOrDefault() ?? ""; } - private void CloseDefinitionPicker(DefinitionPickerView view) - { - if (!ReferenceEquals(_activeDefinitionPicker, view)) return; - _scopes.Pop(view.Scope); - _workbench.Remove(view); - view.Dispose(); - _activeDefinitionPicker = null; - FocusCallingRegion(); - } - private void JumpToDefinition(EditorTab origin, SourceLocation location) => JumpTo(origin.File.FileSystem, origin, location); /// Opens with the caret on it, recorded as an explicit jump from . @@ -3799,17 +3768,14 @@ private void OpenRecentFolder() } var view = new RecentFolderPickerView(folders); - view.Cancelled += (_, _) => CloseRecentFolderPicker(view); - view.Submitted += (_, folder) => - { - CloseRecentFolderPicker(view); - _workbench.OpenFolder(fileSystem.DirectoryInfo.New(folder.Path)); - }; - _activeRecentFolderPicker = view; - _workbench.Add(view); - _scopes.Push(view.Scope); - view.FocusFilter(); + HostedDialog.Show(_workbench, _scopes, view, () => + { + _activeRecentFolderPicker = null; + FocusCallingRegion(); + if (view.Confirmed && view.Chosen is { } folder) _workbench.OpenFolder(fileSystem.DirectoryInfo.New(folder.Path)); + }); + view.FilterField.SetFocus(); } private void OpenFilePath() @@ -3834,16 +3800,6 @@ private void OpenFilePath() ShowPathPrompt(view); } - private void CloseRecentFolderPicker(RecentFolderPickerView view) - { - if (!ReferenceEquals(_activeRecentFolderPicker, view)) return; - _scopes.Pop(view.Scope); - _workbench.Remove(view); - view.Dispose(); - _activeRecentFolderPicker = null; - FocusCallingRegion(); - } - private void ClosePullRequestPicker(PullRequestPickerView view) { if (!ReferenceEquals(_activePullRequestPicker, view)) return; diff --git a/src/TuiCode.Workbench/Workspace/RecentFolderList.cs b/src/TuiCode.Workbench/Workspace/RecentFolderList.cs index 03858416..077de612 100644 --- a/src/TuiCode.Workbench/Workspace/RecentFolderList.cs +++ b/src/TuiCode.Workbench/Workspace/RecentFolderList.cs @@ -3,7 +3,7 @@ namespace TuiCode.Workbench.Workspace; /// A row of the or picker: the folder, its name, and its parent with the home directory as ~. public sealed record RecentFolder(string Path, string Name, string Parent); -/// The rows of the or picker: which remembered folders to offer, filtering, and how each reads. +/// The rows of the or picker: which remembered folders to offer and how each reads. internal static class RecentFolderList { private const int Gap = 2; @@ -23,16 +23,6 @@ public static RecentFolder Row(string folder, string home) return new RecentFolder(folder, path[(cut + 1)..], ShortenHome(parent, TrimEnd(home))); } - /// Case-insensitive substring of the name or the parent. - public static IReadOnlyList Filter(IReadOnlyList rows, string filter) - { - filter = filter.Trim(); - return filter.Length == 0 - ? rows - : [.. rows.Where(r => r.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) - || r.Parent.Contains(filter, StringComparison.OrdinalIgnoreCase))]; - } - /// The name in a column wide, then the parent. public static string Display(RecentFolder row, int nameWidth, int width) { diff --git a/src/TuiCode.Workbench/Workspace/RecentFolderPickerView.cs b/src/TuiCode.Workbench/Workspace/RecentFolderPickerView.cs index 80a9b0b7..b0b049ec 100644 --- a/src/TuiCode.Workbench/Workspace/RecentFolderPickerView.cs +++ b/src/TuiCode.Workbench/Workspace/RecentFolderPickerView.cs @@ -1,123 +1,16 @@ -using System.Collections; -using System.Collections.Specialized; -using TuiCode.Abstractions; -using TuiCode.Workbench.Services; +using MentalDesk.Tui.Dialogs; +using TuiCode.Workbench.Controls; namespace TuiCode.Workbench.Workspace; /// Modal picker for Open recent folder (or): a filter over the folders you've had open. -public sealed class RecentFolderPickerView : Window +public sealed class RecentFolderPickerView(IReadOnlyList folders) : PickerDialog( + "Open recent folder", DialogWidth, 14, folders, f => $"{f.Name} {f.Parent}", "Open", + f => RecentFolderList.Display(f, folders.Max(r => r.Name.Length), PickerList.Width(DialogWidth))) { - private const string Hint = "Type to filter · Up/Down/PgUp/PgDn · Enter open · Esc cancel"; + private const int DialogWidth = 78; - private readonly TextField _filter; - private readonly ListView _list; + internal IReadOnlyList VisibleItems => [.. Shown.Select(f => f.Name)]; - private readonly ICommandService _scopeCommands; - private readonly IKeybindingService _scopeKeybindings; - - private readonly IReadOnlyList _all; - private readonly int _nameWidth; - private IReadOnlyList _visible = []; - - public IKeybindingService Scope => _scopeKeybindings; - - public event EventHandler? Cancelled; - - public event EventHandler? Submitted; - - public RecentFolderPickerView(IReadOnlyList folders) - { - _all = folders; - _nameWidth = folders.Max(f => f.Name.Length); - Title = "Open recent folder"; - BorderStyle = LineStyle.Single; - X = Pos.Center(); - Y = Pos.Center(); - Width = 78; - Height = 16; - CanFocus = true; - - _filter = new TextField { X = 1, Y = 0, Width = Dim.Fill(1) }; - _filter.TextChanged += (_, _) => ShowEntries(); - - _list = new ListView { X = 1, Y = 1, Width = Dim.Fill(1), Height = Dim.Fill(1) }; - var hint = new VerbatimLabel { X = 1, Y = Pos.AnchorEnd(1), Width = Dim.Fill(1), Text = Hint }; - Add(_filter, _list, hint); - - _scopeCommands = new CommandService(); - _scopeKeybindings = new KeybindingService(_scopeCommands); - RegisterScopeBindings(); - ShowEntries(); - } - - internal string Filter => _filter.Text ?? ""; - internal IReadOnlyList VisibleItems => [.. _visible.Select(f => f.Name)]; - internal int? SelectedItem => _list.SelectedItem; - - public bool FocusFilter() => _filter.SetFocus(); - - private void RegisterScopeBindings() - { - _scopeCommands.Register(CommandIds.RecentFolderPickerCancel, OnCancel); - _scopeCommands.Register(CommandIds.RecentFolderPickerConfirm, OnConfirm); - _scopeCommands.Register(CommandIds.RecentFolderPickerUp, () => MoveSelection(-1)); - _scopeCommands.Register(CommandIds.RecentFolderPickerDown, () => MoveSelection(1)); - _scopeCommands.Register(CommandIds.RecentFolderPickerPageUp, () => MoveSelection(-PageHeight)); - _scopeCommands.Register(CommandIds.RecentFolderPickerPageDown, () => MoveSelection(PageHeight)); - - _scopeKeybindings.Bind("Esc", CommandIds.RecentFolderPickerCancel); - _scopeKeybindings.Bind("Enter", CommandIds.RecentFolderPickerConfirm); - _scopeKeybindings.Bind("CursorUp", CommandIds.RecentFolderPickerUp); - _scopeKeybindings.Bind("CursorDown", CommandIds.RecentFolderPickerDown); - _scopeKeybindings.Bind("PageUp", CommandIds.RecentFolderPickerPageUp); - _scopeKeybindings.Bind("PageDown", CommandIds.RecentFolderPickerPageDown); - } - - private void OnCancel() => Cancelled?.Invoke(this, EventArgs.Empty); - - private void OnConfirm() - { - if (_list.SelectedItem is { } i && i < _visible.Count) Submitted?.Invoke(this, _visible[i]); - } - - private int PageHeight => Math.Max(1, _list.Viewport.Height); - - private void MoveSelection(int delta) - { - if (_visible.Count == 0) return; - _list.SelectedItem = Math.Clamp((_list.SelectedItem ?? -1) + delta, 0, _visible.Count - 1); - } - - private void ShowEntries() - { - _visible = RecentFolderList.Filter(_all, Filter); - _list.Source = new RecentFolderListSource(_visible, _nameWidth); - _list.SelectedItem = _visible.Count == 0 ? null : 0; - } - - private sealed class RecentFolderListSource(IReadOnlyList rows, int nameWidth) : IListDataSource - { - public event NotifyCollectionChangedEventHandler? CollectionChanged { add { } remove { } } - - public int Count => rows.Count; - - public int MaxItemLength => 0; - - public bool SuspendCollectionChangedEvent { get; set; } - - public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) - { - listView.Move(col, row); - listView.AddStr(RecentFolderList.Display(rows[item], nameWidth, width).PadRight(width)); - } - - public bool IsMarked(int item) => false; - - public void SetMark(int item, bool value) { } - - public IList ToList() => rows.Select(r => r.Path).ToList(); - - public void Dispose() { } - } + internal int? SelectedItem => this.SelectedIndex(); } diff --git a/tests/TuiCode.Tests/EditorSettingsViewTests.cs b/tests/TuiCode.Tests/EditorSettingsViewTests.cs index 9c3acb09..a551df7b 100644 --- a/tests/TuiCode.Tests/EditorSettingsViewTests.cs +++ b/tests/TuiCode.Tests/EditorSettingsViewTests.cs @@ -26,7 +26,6 @@ await HostSteps.Run(host, OpenEditorSettings(host, workbench), () => host.App.InjectKey(Key.Space), () => host.App.InjectKey(Key.Tab), () => host.App.InjectKey(Key.CursorRight), - () => host.App.InjectKey(Key.Space), () => host.App.InjectKey(Key.Tab), () => host.App.InjectKey(Key.Space), () => host.App.InjectKey(Key.Tab), diff --git a/tests/TuiCode.Tests/OpenRecentFolderHostTests.cs b/tests/TuiCode.Tests/OpenRecentFolderHostTests.cs index 9a0b3d18..faf07c95 100644 --- a/tests/TuiCode.Tests/OpenRecentFolderHostTests.cs +++ b/tests/TuiCode.Tests/OpenRecentFolderHostTests.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Keys; using TuiCode.Abstractions; using TuiCode.Explorer; using TuiCode.Workbench; @@ -100,6 +101,32 @@ await HostSteps.Run(host, Assert.Equal(before, _fs.File.ReadAllText(StatePath)); } + [Fact] + public async Task F1_lists_the_picker_keys_and_Esc_returns_with_the_filter_and_selection_as_they_were() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + string? heading = null; + + await HostSteps.Run(host, + () => commands.TryExecute(CommandIds.OpenRecentFolder), + () => Picker(workbench) is not null, + () => Type(host, "a"), + () => Picker(workbench)!.VisibleItems.Count == 1, + () => host.App.InjectKey(Key.F1), + () => Help(workbench) is not null, + () => { heading = Help(workbench)!.Sheet.Here?.Heading; host.App.InjectKey(Key.Esc); }, + () => Help(workbench) is null, + () => Picker(workbench)!.FilterField.HasFocus, + () => Type(host, "u"), + () => Picker(workbench)!.FilterField.Text == "au", + () => host.App.InjectKey(Key.Enter), + () => Picker(workbench) is null); + + Assert.Equal("Open recent folder", heading); + Assert.Equal(Full("/code/vault"), workbench.Sidebar.Explorer.Root?.FullName); + } + [Fact] public async Task With_no_other_folders_it_says_so_rather_than_opening_an_empty_picker() { @@ -121,6 +148,9 @@ await HostSteps.Run(host, private static RecentFolderPickerView? Picker(Workbench.Workbench workbench) => workbench.SubViews.OfType().SingleOrDefault(); + private static KeysDialog? Help(Workbench.Workbench workbench) => + workbench.SubViews.OfType().SingleOrDefault(); + private static void Type(WorkbenchHost host, string text) { foreach (var c in text) host.App.InjectKey(new Key(c)); diff --git a/tests/TuiCode.Tests/PickerDialogsTests.cs b/tests/TuiCode.Tests/PickerDialogsTests.cs new file mode 100644 index 00000000..185f5a92 --- /dev/null +++ b/tests/TuiCode.Tests/PickerDialogsTests.cs @@ -0,0 +1,257 @@ +using MentalDesk.Tui.Dialogs; +using MentalDesk.Tui.Fields; +using MentalDesk.Tui.Keys; +using TuiCode.Abstractions; +using TuiCode.Syntax; +using TuiCode.Workbench.Grammars; +using TuiCode.Workbench.Languages; +using TuiCode.Workbench.Navigation; +using TuiCode.Workbench.Settings; +using TuiCode.Workbench.Workspace; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace TuiCode.Tests; + +// The pickers and prompts on the library's PickerDialog and AppDialog (#572). Builds TG views — serialised (#77). +public class PickerDialogsTests : StaticConfigurationTest +{ + private const string Home = "/Users/me"; + + private static readonly SyntaxHighlighter Syntax = new(GrammarBundle.Load()); + + public static TheoryData Pickers => new() + { + { "recent", "Enter Open" }, + { "definitions", "Enter Go to" }, + { "grammar", "Enter Choose" }, + }; + + [Theory] + [MemberData(nameof(Pickers))] + public void A_picker_says_Type_to_filter_and_shows_its_keys_on_two_buttons_with_no_hint_row(string kind, string pick) + { + using var picker = Picker(kind); + + Assert.Equal("Type to filter", Assert.IsType(picker.FilterField).Placeholder); + Assert.Equal([pick, "Esc Cancel"], picker.Dialog.SubViews.OfType