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