diff --git a/CLAUDE.md b/CLAUDE.md index cff4f5f..041e452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,7 +233,7 @@ Don't accidentally do these — they were considered and rejected: - Source/API compatibility with `Terminal.Gui.TextView`. `Editor` ships beside it, not as a replacement. - RTL bidi or rich text shaping beyond grapheme width. - Pixel/proportional font fidelity. -- Porting AvaloniaEdit's `Editing/`, `Rendering/`, or `CodeCompletion/` namespaces — those are Avalonia-UI-specific and replaced by TG-native equivalents (`Editor` partials, cell-grid `Rendering/`, `PopoverMenu` for completion). +- Porting AvaloniaEdit's `Editing/`, `Rendering/`, or `CodeCompletion/` namespaces — those are Avalonia-UI-specific and replaced by TG-native equivalents (`Editor` partials, cell-grid `Rendering/`, `Popover` for completion). ## Open decisions diff --git a/Directory.Build.props b/Directory.Build.props index 5253cb4..83c36a6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,7 +27,7 @@ LICENSE - 2.1.1-develop.98 + 2.2.0-rc.4 diff --git a/examples/ted/EditorSettings.cs b/examples/ted/EditorSettings.cs index 91e57e3..21ff9cb 100644 --- a/examples/ted/EditorSettings.cs +++ b/examples/ted/EditorSettings.cs @@ -29,6 +29,9 @@ internal static class EditorSettings [ConfigurationProperty (Scope = typeof (TedSettingsScope))] public static bool AutoIndent { get; set; } = true; + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool AutoComplete { get; set; } + /// /// Loads settings from the config file at . /// Called once at startup before constructing . @@ -56,6 +59,7 @@ internal static void Load (string path) IndentSize = ReadInt (text, "EditorSettings.IndentSize", IndentSize); ConvertTabsToSpaces = ReadBool (text, "EditorSettings.ConvertTabsToSpaces", ConvertTabsToSpaces); AutoIndent = ReadBool (text, "EditorSettings.AutoIndent", AutoIndent); + AutoComplete = ReadBool (text, "EditorSettings.AutoComplete", AutoComplete); } catch (Exception ex) { @@ -82,7 +86,8 @@ internal static void Save (string path) ["EditorSettings.ShowTabs"] = ToJson (ShowTabs), ["EditorSettings.IndentSize"] = IndentSize.ToString (), ["EditorSettings.ConvertTabsToSpaces"] = ToJson (ConvertTabsToSpaces), - ["EditorSettings.AutoIndent"] = ToJson (AutoIndent) + ["EditorSettings.AutoIndent"] = ToJson (AutoIndent), + ["EditorSettings.AutoComplete"] = ToJson (AutoComplete) }; List toInsert = []; diff --git a/examples/ted/EditorSettingsDialog.cs b/examples/ted/EditorSettingsDialog.cs index 5ecdfe2..c92eeba 100644 --- a/examples/ted/EditorSettingsDialog.cs +++ b/examples/ted/EditorSettingsDialog.cs @@ -7,6 +7,7 @@ namespace Ted; internal sealed class EditorSettingsDialog : Dialog { + private readonly CheckBox _autoCompleteCheck; private readonly CheckBox _autoIndentCheck; private readonly CheckBox _convertTabsCheck; private readonly NumericUpDown _indentSize; @@ -15,7 +16,7 @@ internal EditorSettingsDialog (Editor editor) { Title = "Settings"; Width = Dim.Percent (60); - Height = 16; + Height = 18; View tabSettingsTab = new () { @@ -61,6 +62,14 @@ internal EditorSettingsDialog (Editor editor) _convertTabsCheck, _autoIndentCheck); + _autoCompleteCheck = new CheckBox + { + X = 1, + Y = 1, + Title = "Auto _Complete (Ctrl+Space)", + Value = editor.CompletionProvider is not null ? CheckState.Checked : CheckState.UnChecked + }; + View configTab = new () { Title = "_Config", @@ -68,7 +77,7 @@ internal EditorSettingsDialog (Editor editor) Height = Dim.Fill () }; - configTab.Add (new Label { X = 1, Y = 1, Text = "No settings yet." }); + configTab.Add (_autoCompleteCheck); Tabs tabs = new () { @@ -115,5 +124,8 @@ internal void ApplyTo (Editor editor) editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked ? new DefaultIndentationStrategy () : null; + editor.CompletionProvider = _autoCompleteCheck.Value == CheckState.Checked + ? new WordCompletionProvider () + : null; } } diff --git a/examples/ted/Properties/launchSettings.json b/examples/ted/Properties/launchSettings.json index 5d9d44a..604d111 100644 --- a/examples/ted/Properties/launchSettings.json +++ b/examples/ted/Properties/launchSettings.json @@ -6,4 +6,4 @@ "workingDirectory": "../.." } } -} \ No newline at end of file +} diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index c1c78af..a51a8a0 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -53,6 +53,7 @@ public TedApp (bool readOnly = false, string? configPath = null) WordWrap = EditorSettings.WordWrap, ShowTabs = EditorSettings.ShowTabs, ReadOnly = readOnly, + CompletionProvider = EditorSettings.AutoComplete ? new WordCompletionProvider () : null, ViewportSettings = ViewportSettingsFlags.HasScrollBars }; @@ -432,6 +433,7 @@ private void SaveViewSettings () EditorSettings.IndentSize = Editor.IndentationSize; EditorSettings.ConvertTabsToSpaces = Editor.ConvertTabsToSpaces; EditorSettings.AutoIndent = Editor.IndentationStrategy is not null; + EditorSettings.AutoComplete = Editor.CompletionProvider is not null; EditorSettings.Save (_configPath); } diff --git a/examples/ted/WordCompletionProvider.cs b/examples/ted/WordCompletionProvider.cs new file mode 100644 index 0000000..d3e8476 --- /dev/null +++ b/examples/ted/WordCompletionProvider.cs @@ -0,0 +1,74 @@ +using Terminal.Gui.Document; +using Terminal.Gui.Editor.Completion; +using Terminal.Gui.Input; + +namespace Ted; + +/// +/// A trivial word-completion provider for the ted demo. Scans the document for unique +/// word tokens (letters, digits, underscores) and offers them as suggestions when the prefix +/// matches. Triggered by Ctrl+Space. +/// +internal sealed class WordCompletionProvider : IEditorCompletionProvider +{ + /// + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + if (string.IsNullOrEmpty (prefix)) + { + return []; + } + + var text = document.Text; + HashSet seen = new (StringComparer.OrdinalIgnoreCase); + List results = []; + + // Walk the document text for word tokens. + var i = 0; + + while (i < text.Length) + { + if (!IsWordChar (text[i])) + { + i++; + + continue; + } + + var start = i; + + while (i < text.Length && IsWordChar (text[i])) + { + i++; + } + + var word = text.Substring (start, i - start); + + // Skip the exact prefix and short tokens. + if (word.Length <= prefix.Length || string.Equals (word, prefix, StringComparison.Ordinal)) + { + continue; + } + + if (word.StartsWith (prefix, StringComparison.OrdinalIgnoreCase) && seen.Add (word)) + { + results.Add (new CompletionItem { Label = word }); + } + } + + results.Sort ((a, b) => string.Compare (a.Label, b.Label, StringComparison.OrdinalIgnoreCase)); + + return results; + } + + /// + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + + private static bool IsWordChar (char ch) + { + return char.IsLetterOrDigit (ch) || ch == '_'; + } +} diff --git a/specs/completion/spec.md b/specs/completion/spec.md new file mode 100644 index 0000000..6060517 --- /dev/null +++ b/specs/completion/spec.md @@ -0,0 +1,89 @@ +# Completion Spec + +**Status**: Implemented +**Date**: 2026-05-17 +**Resolves**: OPEN-002, `specs/textview-parity-gap/spec.md` Gap 1 + +--- + +## Summary + +In-editor autocomplete popup for `Editor`, providing caret-anchored, filter-as-you-type completion +suggestions. Consumers implement `IEditorCompletionProvider` and assign it to `Editor.CompletionProvider`. +The popup renders via a `Popover`, +keys are intercepted ahead of the editor, and accepted suggestions apply as a single undo step. + +## Public API + +```csharp +namespace Terminal.Gui.Editor.Completion; + +public sealed class CompletionItem +{ + public required string Label { get; init; } + public string? InsertText { get; init; } // defaults to Label +} + +public interface IEditorCompletionProvider +{ + IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix); + bool ShouldTrigger (Key key); +} +``` + +On `Editor`: + +```csharp +public IEditorCompletionProvider? CompletionProvider { get; set; } +public bool IsCompletionActive { get; } +``` + +## Behavior + +### Opening the popup + +1. **Explicit trigger**: Provider's `ShouldTrigger(key)` returns `true` (e.g. `Ctrl+Space`). +2. **Filter-as-you-type**: After each character insert, the editor extracts the word-prefix before the caret + (letters/digits/underscores) and queries the provider. If items are returned, the popup opens or updates. + +### Popup interaction + +| Key | Action | +|-----|--------| +| `↑` / `↓` | Move selection | +| `Enter` / `Tab` | Accept selected item | +| `Esc` | Dismiss | +| Any printable char | Inserted into document, popup re-filters | +| `Backspace` | Deletes char, popup re-filters (dismissed when prefix becomes empty) | + +### Accepting a completion + +The word-prefix (`_completionPrefixStart` to `CaretOffset`) is replaced by `CompletionItem.TextToInsert` +inside a single `Document.RunUpdate()` scope, so one `Ctrl+Z` undoes the entire replacement. + +### Dismissing + +Pressing `Esc`, typing a non-word character that empties the prefix, +or the provider returning zero items — all dismiss the popup. +Pressing `Enter`/`Tab` accepts the selected item (see "Accepting" above), not dismiss. + +## Positioning + +The popup is anchored at the caret's screen position via `ViewportToScreen`, placed one row below the caret. +Uses `Popover` positioned at the caret via `MakeVisible`. + +## ted demo + +`WordCompletionProvider` scans the document for unique word tokens and offers those starting with the +current prefix. Triggered by `Ctrl+Space`. Wired via `Editor.CompletionProvider = new WordCompletionProvider()`. + +## Testing + +- **Unit tests** (`EditorCompletionTests`): prefix extraction, accept/dismiss, single-undo-step, no-op cases. +- **Integration tests**: popup rendering requires `IApplication`; covered by ted integration tests. + +## Design decisions + +- **DEC-009**: Fresh LSP-flavored provider, not TG's `IAutocomplete` (see `specs/decisions.md`). +- **Popover**: `Popover` positioned at the caret (explicit non-goal: AvaloniaEdit `CodeCompletion/` lift). +- **Synchronous provider**: `GetCompletions` is synchronous; providers should pre-index for speed. diff --git a/specs/decisions.md b/specs/decisions.md index 894bff6..23c56a8 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -70,6 +70,16 @@ need to know the document construction details. --- +### DEC-009: Completion item shape & provider interface + +**Decision**: Use a fresh LSP-flavored `IEditorCompletionProvider` interface and `CompletionItem` type (`Terminal.Gui.Editor.Completion` namespace), **not** Terminal.Gui's existing `IAutocomplete` / `PopupAutocomplete`. + +**Rationale**: TG's `IAutocomplete` is tightly coupled to `TextView` (it assumes its own `PopupAutocomplete` rendering, owns selection state, and embeds key-handling that conflicts with `Editor`'s command architecture). A clean provider interface — `GetCompletions(document, caretOffset, prefix)` + `ShouldTrigger(key)` — keeps the completion *data* separate from the *UI*. `CompletionItem` follows a minimal LSP-flavored shape (Label, InsertText) rather than reusing `IAutocomplete`'s string list, which simplifies future LSP integration; richer fields (e.g. detail/documentation) are added when the popup actually renders them, not before. The popup uses a `Popover` positioned at the caret. Accept applies inside a single `RunUpdate` scope so the entire replacement is one undo step. + +**Date**: 2026-05-17 + +--- + ## Open ### OPEN-001: Independent `Terminal.Gui.Editor` NuGet from day one @@ -80,11 +90,9 @@ need to know the document construction details. --- -### OPEN-002: Completion item shape - -**Question**: Reuse Terminal.Gui's `IAutocomplete`-style types vs. a fresh LSP-flavored `IEditorCompletionProvider`? +### OPEN-002: Completion item shape → DEC-009 -**Affected features**: Post-MLP. +Resolved — see DEC-009. --- diff --git a/specs/public-api.md b/specs/public-api.md index b00cf8d..91a1581 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -68,8 +68,9 @@ public class Editor : View // --- Search --- public ISearchStrategy? SearchStrategy { get; set; } // find-and-replace (needs search + rendering-pipeline ✅) - // --- Completion (post-MLP) --- - public IEditorCompletionProvider? CompletionProvider { get; set; } // post-MLP + // --- Completion --- + public IEditorCompletionProvider? CompletionProvider { get; set; } // completion ✅ + public bool IsCompletionActive { get; } // completion ✅ // --- Design-time support --- public bool EnableForDesign (); // IDesignable (design-time ✅) @@ -117,6 +118,24 @@ public interface IOverlayRenderer } ``` +## Completion Types (completion — landed) + +```csharp +namespace Terminal.Gui.Editor.Completion; + +public sealed class CompletionItem +{ + public required string Label { get; init; } + public string? InsertText { get; init; } +} + +public interface IEditorCompletionProvider +{ + IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix); + bool ShouldTrigger (Key key); +} +``` + ## Document File I/O (file-io) ```csharp @@ -157,5 +176,6 @@ public readonly record struct TextDocumentProgress ( | 2026-05-11 | ReadOnly property landed on Editor | read-only | | 2026-05-12 | `ISearchStrategy?` `SearchStrategy { get; set; }` landed on Editor; string-based FindNext/FindPrevious/ReplaceNext/ReplaceAll overloads retained as convenience wrappers | find-and-replace | | 2026-05-16 | Vertical multi-caret keybindings (`Ctrl+Alt+CursorUp/Down`, `Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret | +| 2026-05-17 | `IEditorCompletionProvider?` `CompletionProvider` + `bool IsCompletionActive` landed; `CompletionItem` sealed class; `Popover`-based popup; DEC-009 resolves OPEN-002 | completion | | 2026-05-17 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io | | 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time | diff --git a/src/Terminal.Gui.Editor/Completion/CompletionItem.cs b/src/Terminal.Gui.Editor/Completion/CompletionItem.cs new file mode 100644 index 0000000..a0c586c --- /dev/null +++ b/src/Terminal.Gui.Editor/Completion/CompletionItem.cs @@ -0,0 +1,24 @@ +namespace Terminal.Gui.Editor.Completion; + +/// +/// A single completion suggestion returned by an . +/// Modelled after the LSP CompletionItem shape — label + insertText — but kept +/// minimal for the terminal context. +/// +public sealed class CompletionItem +{ + /// + /// The display text shown in the completion popup. This is the primary string the user sees + /// when filtering suggestions. + /// + public required string Label { get; init; } + + /// + /// The text inserted into the document when this item is accepted. When , + /// is inserted instead. + /// + public string? InsertText { get; init; } + + /// The text that will actually be inserted: ?? . + internal string TextToInsert => InsertText ?? Label; +} diff --git a/src/Terminal.Gui.Editor/Completion/IEditorCompletionProvider.cs b/src/Terminal.Gui.Editor/Completion/IEditorCompletionProvider.cs new file mode 100644 index 0000000..0eb5896 --- /dev/null +++ b/src/Terminal.Gui.Editor/Completion/IEditorCompletionProvider.cs @@ -0,0 +1,37 @@ +using Terminal.Gui.Document; +using Terminal.Gui.Input; + +namespace Terminal.Gui.Editor.Completion; + +/// +/// Provides completion suggestions for a document at a given caret position. Consumers implement +/// this interface and assign an instance to to enable +/// in-editor completion. +/// +/// +/// The provider is queried synchronously on every triggering keystroke. Keep +/// fast — pre-index if necessary. +/// +public interface IEditorCompletionProvider +{ + /// + /// Returns completion items for the current caret position, or an empty list when no + /// suggestions are available. The is the word fragment + /// immediately before the caret that the editor extracted from the document. + /// + /// The document being edited. + /// Current caret offset in the document. + /// + /// The word fragment before the caret (letters/digits/underscores back to the nearest + /// non-word character or line start). Empty when the caret follows whitespace or punctuation. + /// + /// An ordered list of suggestions. The first item is pre-selected in the popup. + IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix); + + /// + /// Returns when the given key should trigger the completion popup + /// (e.g. Ctrl+Space). The editor calls this before normal key dispatch; if the + /// provider claims the key, the popup opens (or re-filters). + /// + bool ShouldTrigger (Key key); +} diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index c90094c..c720fd7 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -176,10 +176,20 @@ private void CreateCommandsAndBindings () AddCommand (Command.CutToEndOfLine, CutToEndOfLine); AddCommand (Command.CutToStartOfLine, CutToStartOfLine); - // Editing — selection-aware (multi-caret aware) - AddCommand (Command.NewLine, MultiCaretNewLine); - AddCommand (Command.DeleteCharLeft, MultiCaretDeleteLeft); - AddCommand (Command.DeleteCharRight, MultiCaretDeleteRight); + // Editing — selection-aware (multi-caret aware), with completion notification. + AddCommand (Command.NewLine, () => + { + DismissCompletion (); + + return MultiCaretNewLine (); + }); + AddCommand (Command.DeleteCharLeft, DeleteCharLeftAndRefresh); + AddCommand (Command.DeleteCharRight, () => + { + DismissCompletion (); + + return MultiCaretDeleteRight (); + }); // History AddCommand (Command.Undo, () => diff --git a/src/Terminal.Gui.Editor/Editor.Completion.cs b/src/Terminal.Gui.Editor/Editor.Completion.cs new file mode 100644 index 0000000..86ba126 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.Completion.cs @@ -0,0 +1,483 @@ +using System.Collections.ObjectModel; +using System.Drawing; +using System.Text; +using Terminal.Gui.App; +using Terminal.Gui.Editor.Completion; +using Terminal.Gui.Input; +using Terminal.Gui.Text; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Editor; + +public partial class Editor +{ + private IReadOnlyList _completionItems = []; + private ListView? _completionListView; + private Popover? _completionPopover; + private int _completionPrefixStart; + + /// + /// Gets or sets the completion provider that supplies suggestions for the in-editor + /// autocomplete popup. Set to to disable completion. + /// + public IEditorCompletionProvider? CompletionProvider + { + get; + set + { + if (field == value) + { + return; + } + + // Dismiss stale completion when the provider changes — prevents accepting + // suggestions from the previous provider after a swap. + if (IsCompletionActive) + { + DismissCompletion (); + } + + field = value; + + if (value is null) + { + DismissCompletion (); + } + } + } + + /// Whether the completion session is currently active (items are available). + public bool IsCompletionActive => _completionItems.Count > 0; + + /// Gets the zero-based index of the currently selected completion item. + internal int CompletionSelectedIndex { get; private set; } + + /// + /// Extracts the word-prefix immediately before the caret (letters, digits, underscores) + /// for use as the completion filter string. Returns empty when the caret follows + /// whitespace or punctuation. + /// + internal string GetCompletionPrefix () + { + return GetCompletionPrefix (out _); + } + + /// + /// Extracts the word-prefix immediately before the caret and also returns the document + /// offset where the prefix starts. + /// + internal string GetCompletionPrefix (out int prefixStart) + { + if (_document is null) + { + prefixStart = 0; + + return string.Empty; + } + + var offset = CaretOffset; + var start = offset; + + while (start > 0) + { + var ch = _document.GetCharAt (start - 1); + + if (!char.IsLetterOrDigit (ch) && ch != '_') + { + break; + } + + start--; + } + + prefixStart = start; + + return start < offset ? _document.GetText (start, offset - start) : string.Empty; + } + + /// + /// Called before normal key dispatch. Returns when the completion + /// popup consumed the key (navigation / accept / dismiss). This runs + /// in , which fires before command bindings. + /// + internal bool HandleCompletionKey (Key key) + { + if (CompletionProvider is null) + { + return false; + } + + // An active popup gets first crack at navigation keys. + if (IsCompletionActive) + { + // Esc (the application's Command.Quit key — the Editor binds no Quit, so this + // must come from the app, not the Editor-scoped KeyMatches) or a horizontal + // caret move (Left/Right) dismisses the popup. Up/Down are intentionally absent: + // the focused popup ListView consumes them to move the selection. + if (key == Application.GetDefaultKey (Command.Quit) || KeyMatches (Command.Left) || + KeyMatches (Command.Right)) + { + DismissCompletion (); + + return false; + } + + // Accept on the keys bound to NewLine (Enter) / InsertTab (Tab). SPACE is + // deliberately NOT an accept key: it falls through, inserts a space, and the + // now-empty prefix dismisses the popup (so "this is a test." stays intact). + if (KeyMatches (Command.NewLine) || KeyMatches (Command.InsertTab)) + { + AcceptCompletion (); + + return true; + } + + // Printable keys and Backspace edit through the same canonical path the + // editor uses without the popup open (multi-caret / selection / overwrite + // aware), then re-filter. The popup is focused, so these never reach + // OnKeyDownNotHandled on their own — they must be handled here. + if (key is { IsCtrl: false, IsAlt: false, AsRune: { } rune } && !Rune.IsControl (rune)) + { + return InsertTypedText (rune.ToString ()); + } + + if (KeyMatches (Command.DeleteCharLeft)) + { + DeleteCharLeftAndRefresh (); + + return true; + } + } + + // Check provider-specific triggers (e.g. Ctrl+Space). + if (!CompletionProvider.ShouldTrigger (key)) + { + return false; + } + + ShowCompletion (); + + return true; + + // Resolve a key against the Editor's own bindings instead of hardcoding literals, + // so completion follows any rebinding of these commands. + bool KeyMatches (Command command) + { + return KeyBindings.GetFirstFromCommands (command) is { } bound && key == bound; + } + } + + /// + /// Handles mouse events for the completion popup: a single click inside the popup + /// area selects and accepts the clicked item; a click outside dismisses the popup. + /// The hit-test maps screen Y to an item index and does not account for a scrolled + /// list (only the first page is addressable). + /// + internal bool HandleCompletionMouse (Mouse mouse) + { + if (!IsCompletionActive || _completionPopover is null || _completionListView is null) + { + return false; + } + + if (!mouse.IsSingleClicked) + { + return false; + } + + // Map the click's screen position to the Popover's content area. + // The ListView's frame within the Popover determines the hit region. + Rectangle popoverScreenFrame = _completionPopover.Frame; + + if (mouse.ScreenPosition.X < popoverScreenFrame.X + || mouse.ScreenPosition.X >= popoverScreenFrame.Right + || mouse.ScreenPosition.Y < popoverScreenFrame.Y + || mouse.ScreenPosition.Y >= popoverScreenFrame.Bottom) + { + // Click is outside the popup — dismiss. + DismissCompletion (); + + return false; + } + + // Determine which item was clicked by Y offset within the Popover. + var clickedIdx = mouse.ScreenPosition.Y - popoverScreenFrame.Y; + + if (clickedIdx < 0 || clickedIdx >= _completionItems.Count) + { + return false; + } + + CompletionSelectedIndex = clickedIdx; + AcceptCompletion (); + + return true; + } + + /// + /// Called after a character is inserted into the document. Refreshes or opens the + /// completion popup if a provider is active. + /// + internal void NotifyCompletionAfterInsert () + { + if (CompletionProvider is null) + { + return; + } + + var prefix = GetCompletionPrefix (out var prefixStart); + + // Filter-as-you-type: an empty prefix means the caret moved off the word + // (e.g. Backspace past it), so close instead of re-querying for everything. + // This is the one intentional difference from ShowCompletion. + if (prefix.Length == 0) + { + DismissCompletion (); + + return; + } + + QueryAndShowCompletion (prefix, prefixStart); + } + + /// Opens the completion popup, querying the provider for items. + internal void ShowCompletion () + { + if (CompletionProvider is null || _document is null) + { + return; + } + + // Explicit trigger (e.g. Ctrl+Space): query even on an empty prefix so a + // provider can offer a full list — unlike the filter-as-you-type path. + var prefix = GetCompletionPrefix (out var prefixStart); + + QueryAndShowCompletion (prefix, prefixStart); + } + + /// + /// Shared body of and + /// : query the provider, dismiss if it returns + /// nothing, otherwise capture state and (re)show the popup. Callers guard + /// / first. + /// + private void QueryAndShowCompletion (string prefix, int prefixStart) + { + IReadOnlyList items = + CompletionProvider!.GetCompletions (_document!, CaretOffset, prefix); + + if (items.Count == 0) + { + DismissCompletion (); + + return; + } + + _completionPrefixStart = prefixStart; + _completionItems = items; + CompletionSelectedIndex = 0; + ShowCompletionPopup (); + } + + /// Tears down the completion session: disposes the popup and clears the item list. + internal void DismissCompletion () + { + DisposeCompletionPopover (); + _completionItems = []; + } + + /// + /// Disposes the popover and its ListView (if any) without clearing + /// — used both to dismiss and to swap in a fresh popup. + /// + /// + /// Null-out-then-dispose order plus unsubscribing first makes this reentrant-safe: + /// disposing the popover flips Visible, but the handler is already detached and + /// the field already , so re-entry is a no-op. + /// + private void DisposeCompletionPopover () + { + Popover? popover = _completionPopover; + _completionPopover = null; + _completionListView = null; + + if (popover is null) + { + return; + } + + popover.VisibleChanged -= OnCompletionPopoverVisibleChanged; + popover.Visible = false; + popover.Dispose (); + } + + /// + /// Terminal.Gui auto-hides the popover on Esc, a click outside it, focus change, or + /// another popover opening — it just flips Visible and never tells the Editor. + /// Without this, would stay + /// and a subsequent Enter/click would fire a phantom . + /// + private void OnCompletionPopoverVisibleChanged (object? sender, EventArgs e) + { + if (_completionPopover is { Visible: false }) + { + DismissCompletion (); + } + } + + /// + /// Accepts the currently selected completion item: replaces the prefix with the + /// item's insert text inside a single undo group. + /// + internal void AcceptCompletion () + { + if (!IsCompletionActive || _document is null || _completionItems.Count == 0) + { + return; + } + + if (CompletionSelectedIndex < 0 || CompletionSelectedIndex >= _completionItems.Count) + { + DismissCompletion (); + + return; + } + + CompletionItem selected = _completionItems[CompletionSelectedIndex]; + var insertText = selected.TextToInsert; + var replaceLength = CaretOffset - _completionPrefixStart; + + DismissCompletion (); + + // Single undo step for the replacement. + using (_document.RunUpdate ()) + { + if (replaceLength > 0) + { + _document.Replace (_completionPrefixStart, replaceLength, insertText); + } + else + { + _document.Insert (CaretOffset, insertText); + } + } + } + + private void ShowCompletionPopup () + { + if (_completionItems.Count == 0) + { + return; + } + + ObservableCollection labels = new (_completionItems.Select (i => i.Label)); + + // Cap visible height at 10 items to avoid oversized popups. + var visibleCount = Math.Min (_completionItems.Count, 10); + + // Width in display columns, not char count — wide/CJK graphemes are 2 cells. + var width = _completionItems.Max (i => Math.Max (0, i.Label.GetColumns ())) + 2; + + // Filter-as-you-type refresh: reuse the live popover/ListView rather than + // disposing and rebuilding (Popover + ListView + 3 event subscriptions) on + // every keystroke — that flickered and churned allocations. + if (_completionListView is not null && _completionPopover is not null) + { + _completionListView.Source = new ListWrapper (labels); + _completionListView.Width = width; + _completionListView.Height = visibleCount; + _completionListView.SelectedItem = CompletionSelectedIndex; + + Point caret = GetCaretScreenPosition (); + _completionPopover.MakeVisible (new Point (caret.X, caret.Y + 1)); + + return; + } + + // First show — build the ListView + Popover and wire events once. + _completionListView = new ListView + { + Source = new ListWrapper (labels), + Width = width, + Height = visibleCount, + TabStop = TabBehavior.NoStop + }; + // The ListView owns no accept/dismiss semantics: HandleCompletionKey resolves + // accept (Enter/Tab) and dismiss (Esc/Left/Right) at the Editor level, while the + // focused ListView's own Up/Down move the selection. Binding Space->Accept here + // would silently re-introduce the "this is a test." accept-on-space bug. Disable + // type-ahead so a stray letter can't hijack the list instead of reaching the editor. + _completionListView.KeystrokeNavigator = null; + _completionListView.MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Accept); + _completionListView.SelectedItem = CompletionSelectedIndex; + + _completionPopover = new Popover (_completionListView) + { + Target = new WeakReference (this), + + // Reference the live _completionItems (not a captured snapshot) so the + // in-place refresh above stays consistent. + ResultExtractor = lv => + { + if (lv.SelectedItem is not { } idx || idx < 0 || idx >= _completionItems.Count) + { + return null; + } + + return _completionItems[idx]; + }, + TabStop = TabBehavior.NoStop + }; + + // Tear the session down when TG auto-hides the popover (Esc / click-outside / + // focus change), so IsCompletionActive can't go stale and trigger a phantom accept. + _completionPopover.VisibleChanged += OnCompletionPopoverVisibleChanged; + + // Position the popup just below the caret. + Point caretScreen = GetCaretScreenPosition (); + _completionPopover.MakeVisible (new Point (caretScreen.X, caretScreen.Y + 1)); + + // The focused ListView's Up/Down move its selection; mirror that into + // CompletionSelectedIndex so AcceptCompletion inserts the right item. + // Accept/dismiss keys are resolved separately in HandleCompletionKey. + _completionListView.ValueChanged += (_, args) => + { + if (args.NewValue is not null) + { + CompletionSelectedIndex = args.NewValue.Value; + } + }; + + // Accepted fires on BOTH Enter and mouse-click. Acceptance itself is driven + // explicitly — HandleCompletionKey for Enter/Tab, HandleCompletionMouse for a + // click — so this only syncs the selected index (like ValueChanged above). + // Calling AcceptCompletion here double-handled Enter and leaked a trailing newline. + _completionListView.Accepted += (_, args) => + { + if (args.Context?.Value is int idx) + { + CompletionSelectedIndex = idx; + } + }; + } + + /// + /// Computes the screen position of the caret (for popup anchoring). + /// + private Point GetCaretScreenPosition () + { + if (_document is null) + { + return Point.Empty; + } + + Rectangle viewport = Viewport; + var caretLine = GetCaretVisibleLineIndex (); + var caretCol = GetCaretColumn (); + var row = caretLine - viewport.Y; + var col = caretCol - viewport.X; + + return ViewportToScreen (new Point (col, row)); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 28e6afd..5071ce2 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -1,4 +1,5 @@ using System.Text; +using Terminal.Gui.App; using Terminal.Gui.Input; namespace Terminal.Gui.Editor; @@ -6,21 +7,29 @@ namespace Terminal.Gui.Editor; public partial class Editor { /// - /// Intercepts every keystroke before dispatch so the kill-ring consecutive-kill flag is - /// correctly tracked. Snapshots _lastCommandWasKill into _previousCommandWasKill - /// (so the kill commands can read whether the preceding command was a kill for append/prepend - /// decisions), then clears _lastCommandWasKill. The kill commands - /// ( / ) re-set - /// _lastCommandWasKill after executing; every other command leaves it cleared, which - /// breaks the "consecutive kill → append" run. + /// Runs before command bindings. When completion is active, intercepts accept + /// (Enter/Tab) and dismiss (Esc/Left/Right) keys so they don't trigger the normal + /// editor command bindings; Up/Down are left to the focused popup ListView. Also + /// checks provider-specific trigger keys (e.g. Ctrl+Space). + /// Additionally, tracks the kill-ring consecutive-kill flag: snapshots + /// _lastCommandWasKill into _previousCommandWasKill, then clears + /// _lastCommandWasKill. The kill commands re-set it after executing. /// /// protected override bool OnKeyDown (Key key) { + if (HandleCompletionKey (key)) + { + // A completion-consumed key is not a kill command — break any consecutive-kill run. + _lastCommandWasKill = false; + + return true; + } + _previousCommandWasKill = _lastCommandWasKill; _lastCommandWasKill = false; - bool result = base.OnKeyDown (key); + var result = base.OnKeyDown (key); // Clear the snapshot so it does not leak into a subsequent InvokeCommand call. // If the dispatched command was a kill, _lastCommandWasKill is already true; @@ -38,7 +47,12 @@ protected override bool OnKeyDown (Key key) /// protected override bool OnKeyDownNotHandled (Key key) { - if (key == Key.Esc && HasMultipleCarets) + // Esc clears a multi-caret block. Resolve the key from the application's + // Command.Quit binding — the Editor itself binds no Quit, so the earlier + // Editor-scoped KeyBindings.GetFirstFromCommands(Command.Quit) lookup resolved + // to null and the clear never ran (regressed multi-caret Esc in 7ad560e). This + // tracks the same key Terminal.Gui uses framework-wide for escape/cancel. + if (key == Application.GetDefaultKey (Command.Quit) && HasMultipleCarets) { ClearAdditionalCarets (); @@ -56,6 +70,18 @@ protected override bool OnKeyDownNotHandled (Key key) return false; } + return InsertTypedText (rune.ToString ()); + } + + /// + /// The single canonical "type text at the caret" path: honors read-only, + /// multi-caret, selection, and overwrite mode, then refreshes the completion + /// popup. Shared by and the completion popup + /// key handler so the two never drift — the completion path previously skipped + /// the multi-caret branch and inserted at a single caret only. + /// + private bool InsertTypedText (string text) + { if (ReadOnly) { return true; @@ -63,24 +89,41 @@ protected override bool OnKeyDownNotHandled (Key key) if (HasMultipleCarets) { - MultiCaretInsert (rune.ToString ()); + MultiCaretInsert (text); return true; } if (HasSelection) { - ReplaceSelection (rune.ToString ()); + ReplaceSelection (text); } else if (OverwriteMode && _document is not null) { - OverwriteAtCaret (rune.ToString ()); + OverwriteAtCaret (text); } else { - _document!.Insert (CaretOffset, rune.ToString ()); + _document!.Insert (CaretOffset, text); } + // After inserting, notify the completion system so it can open / filter. + NotifyCompletionAfterInsert (); + return true; } + + /// + /// Canonical delete-left: deletes the selection or the grapheme before the + /// caret(s) (multi-caret aware) and refreshes completion. Shared by the + /// binding and the completion popup key + /// handler so Backspace behaves identically with or without the popup open. + /// + private bool? DeleteCharLeftAndRefresh () + { + var result = MultiCaretDeleteLeft (); + NotifyCompletionAfterInsert (); + + return result; + } } diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 0e9eeb1..2ac53b1 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -16,6 +16,13 @@ public partial class Editor /// protected override bool OnMouseEvent (Mouse mouse) { + // Completion popup click takes priority — when the popup is visible and the + // user clicks in its area, accept the clicked item. + if (HandleCompletionMouse (mouse)) + { + return true; + } + if (_document is null) { return false; @@ -78,6 +85,7 @@ protected override bool OnMouseEvent (Mouse mouse) case DragMode.AddCaret: return true; + case DragMode.Select: default: // Route through the selection helper so SelectionChanged fires only on real changes. ExtendCaretTo (MousePositionToOffset (pos)); diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorCompletionIntegrationTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorCompletionIntegrationTests.cs new file mode 100644 index 0000000..796652b --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorCompletionIntegrationTests.cs @@ -0,0 +1,194 @@ +// CoPilot - gpt-4.1 + +using Terminal.Gui.Document; +using Terminal.Gui.Editor.Completion; +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Integration tests for in-editor completion. Uses to exercise +/// the full key-dispatch pipeline, including interaction. +/// +public class EditorCompletionIntegrationTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + /// + /// Typing a character while the completion popup is open must insert the character + /// into the document (not be swallowed by the Popover) and update the completion list. + /// + [Fact] + public async Task Typing_Char_While_Completion_Active_Inserts_Into_Document () + { + await using AppFixture fx = new (() => new ("using unsafe uint ")); + Editor editor = fx.Top.Editor; + editor.SetFocus (); + + // Place caret at the end (after trailing space). + editor.CaretOffset = editor.Document!.TextLength; + + // Set up a completion provider that matches words from the document. + editor.CompletionProvider = new TestWordCompletionProvider (); + + // Type "u" to open completion. + fx.Injector.InjectKey (Key.U, Direct); + Assert.True (editor.IsCompletionActive, "Completion should be active after typing 'u'"); + + // Type "s" — this must go to the Editor, not be captured by the Popover. + fx.Injector.InjectKey (Key.S, Direct); + + // The document should now end with "us". + Assert.EndsWith ("us", editor.Document!.Text); + + // Completion should still be active with filtered results. + Assert.True (editor.IsCompletionActive, "Completion should remain active with 'us' prefix"); + } + + /// + /// Typing a character that eliminates all matches must dismiss the completion popup. + /// + [Fact] + public async Task Typing_NonMatching_Char_Dismisses_Completion () + { + await using AppFixture fx = new (() => new ("using unsafe uint ")); + Editor editor = fx.Top.Editor; + editor.SetFocus (); + + // Place caret at the end (after trailing space). + editor.CaretOffset = editor.Document!.TextLength; + + editor.CompletionProvider = new TestWordCompletionProvider (); + + // Type "u" to open completion. + fx.Injector.InjectKey (Key.U, Direct); + Assert.True (editor.IsCompletionActive); + + // Type "z" — no words in the document start with "uz", so completion should dismiss. + fx.Injector.InjectKey (Key.Z, Direct); + + Assert.EndsWith ("uz", editor.Document!.Text); + Assert.False (editor.IsCompletionActive, "Completion should dismiss when no items match"); + } + + /// + /// Pressing Enter while completion is active should accept the completion (replace prefix + /// with the selected item's text), not insert a newline. + /// + [Fact] + public async Task Enter_While_Completion_Active_Accepts_Item () + { + await using AppFixture fx = new (() => new ("using unsafe uint ")); + Editor editor = fx.Top.Editor; + editor.SetFocus (); + + // Place caret at the end (after trailing space). + editor.CaretOffset = editor.Document!.TextLength; + + editor.CompletionProvider = new TestWordCompletionProvider (); + + // Type "us" to open completion with "using" and "unsafe" as matches. + fx.Injector.InjectKey (Key.U, Direct); + fx.Injector.InjectKey (Key.S, Direct); + + Assert.True (editor.IsCompletionActive, "Completion should be active after 'us'"); + + // Press Enter — should accept the first completion item, not insert a newline. + fx.Injector.InjectKey (Key.Enter, Direct); + + // The text should NOT contain a newline near the end; it should have the accepted completion. + var text = editor.Document!.Text; + var lastChunk = text[^Math.Min (15, text.Length)..]; + Assert.DoesNotContain ("\n", lastChunk); + Assert.False (editor.IsCompletionActive, "Completion should be dismissed after accept"); + } + + /// + /// Arrow keys while completion is active should navigate the list, not move the caret. + /// Down arrow then Enter should accept the second item. + /// + [Fact] + public async Task ArrowDown_Then_Enter_Accepts_Second_Item () + { + // "hello help world" — typing "he" at the end matches "hello" and "help". + await using AppFixture fx = new (() => new ("hello help world ")); + Editor editor = fx.Top.Editor; + editor.SetFocus (); + + editor.CaretOffset = editor.Document!.TextLength; + editor.CompletionProvider = new TestWordCompletionProvider (); + + // Type "he" to open completion. + fx.Injector.InjectKey (Key.H, Direct); + fx.Injector.InjectKey (Key.E, Direct); + + Assert.True (editor.IsCompletionActive, "Completion should be active after 'he'"); + + // Down arrow to select the second item. + fx.Injector.InjectKey (Key.CursorDown, Direct); + + // Enter to accept. + fx.Injector.InjectKey (Key.Enter, Direct); + + // The accepted text should be the second match as returned by the provider. + Assert.EndsWith ("help", editor.Document!.Text.TrimEnd ()); + Assert.False (editor.IsCompletionActive, "Completion should be dismissed after accept"); + } + + /// + /// Minimal word-completion provider for integration tests. Returns all word tokens + /// from the document that start with the prefix. + /// + private sealed class TestWordCompletionProvider : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + if (string.IsNullOrEmpty (prefix)) + { + return []; + } + + var text = document.Text; + HashSet seen = new (StringComparer.OrdinalIgnoreCase); + List results = []; + + var i = 0; + + while (i < text.Length) + { + if (!char.IsLetterOrDigit (text[i]) && text[i] != '_') + { + i++; + + continue; + } + + var start = i; + + while (i < text.Length && (char.IsLetterOrDigit (text[i]) || text[i] == '_')) + { + i++; + } + + var word = text.Substring (start, i - start); + + if (word.Length > prefix.Length + && word.StartsWith (prefix, StringComparison.OrdinalIgnoreCase) + && seen.Add (word)) + { + results.Add (new CompletionItem { Label = word }); + } + } + + return results; + } + + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs index 15da153..8b7bb03 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs @@ -1,6 +1,8 @@ // Copilot - gpt-4.1 +using Terminal.Gui.Document; using Terminal.Gui.Drivers; +using Terminal.Gui.Editor.Completion; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; @@ -301,4 +303,56 @@ public async Task CutToStartOfLine_PreservesText_When_Clipboard_Unavailable () Assert.Equal ("hello world", fx.Top.Editor.Document?.Text); } + + // ───────────────────── Completion breaks kill run ───────────────────── + + [Fact] + public async Task CompletionConsumedKey_BreaksConsecutiveKillRun () + { + // When HandleCompletionKey consumes a key (returns true), it must clear _lastCommandWasKill + // so the next kill command (via InvokeCommand) does NOT append to clipboard. + await using AppFixture fx = new (() => new ("abc\ndef")); + EnsureFakeClipboard (fx); + Editor editor = fx.Top.Editor; + editor.SetFocus (); + editor.CompletionProvider = new KillRingTestCompletionProvider (); + editor.CaretOffset = 0; + + // First CutToEndOfLine via InvokeCommand: "abc" cut, clipboard = "abc" + editor.InvokeCommand (Command.CutToEndOfLine); + Assert.Equal ("\ndef", editor.Document?.Text); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("abc", data); + + // Ctrl+Space goes through OnKeyDown → HandleCompletionKey → returns true (ShouldTrigger). + // This should break the consecutive-kill run. + fx.Injector.InjectKey (Key.Space.WithCtrl, new InputInjectionOptions { Mode = InputInjectionMode.Direct }); + + // Dismiss completion programmatically (not via keyboard). + editor.CompletionProvider = null; + editor.CompletionProvider = new KillRingTestCompletionProvider (); + + // Second CutToEndOfLine — caret at 0, text = "\ndef", cuts "\n". + editor.InvokeCommand (Command.CutToEndOfLine); + + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + + // Bug: clipboard = "abc\n" (appended because _lastCommandWasKill was never cleared) + // Fix: clipboard = "\n" (replaced because Ctrl+Space broke the run) + Assert.Equal ("\n", data); + } + + /// Simple completion provider for kill-ring interaction tests. + private sealed class KillRingTestCompletionProvider : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + // Always return items so the popup stays open regardless of prefix. + return [new CompletionItem { Label = "ghi" }, new CompletionItem { Label = "ghijk" }]; + } + + public bool ShouldTrigger (Key key) => key == Key.Space.WithCtrl; + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index 233a054..095d656 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -1,6 +1,6 @@ // Claude - claude-opus-4-7 -using Terminal.Gui.Drivers; +using System.Drawing; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; @@ -20,7 +20,7 @@ public class EditorTests [Fact] public async Task Renders_InitialDocumentText () { - await using AppFixture fx = new (() => new ("Hello world")); + await using AppFixture fx = new (() => new EditorTestHost ("Hello world")); DriverAssert.ContentsContains (fx.Driver, "Hello world"); } @@ -28,7 +28,7 @@ public async Task Renders_InitialDocumentText () [Fact] public async Task Typing_ASCII_Inserts_Characters () { - await using AppFixture fx = new (() => new ()); + await using AppFixture fx = new (() => new EditorTestHost ()); fx.Top.Editor.SetFocus (); fx.Injector.InjectKey (Key.H, Direct); @@ -42,7 +42,7 @@ public async Task Typing_ASCII_Inserts_Characters () [Fact] public async Task CursorLeft_Right_MovesCaret () { - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 3; @@ -64,7 +64,8 @@ public async Task CursorLeft_Right_MovesCaret () [Fact] public async Task CursorUp_Down_PreservesVirtualColumn_AcrossShortLines () { - await using AppFixture fx = new (() => new ("longer line\nshort\nanother long line")); + await using AppFixture fx = new (() => + new EditorTestHost ("longer line\nshort\nanother long line")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 8; // column 8 of "longer line" @@ -85,7 +86,7 @@ public async Task CursorUp_Down_PreservesVirtualColumn_AcrossShortLines () [Fact] public async Task CursorUp_Down_PreservesVirtualColumn_Across_Tab_Line () { - await using AppFixture fx = new (() => new ("abcde\n\t\nabcde")); + await using AppFixture fx = new (() => new EditorTestHost ("abcde\n\t\nabcde")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 3; @@ -115,7 +116,7 @@ public async Task CursorDown_PreservesVirtualColumn_AcrossThreeIntermediateShort var text = string.Join ("\n", LongTop, Short1, Empty, Short2, LongBottom); - await using AppFixture fx = new (() => new (text)); + await using AppFixture fx = new (() => new EditorTestHost (text)); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 12; // column 12 on the top long line @@ -144,7 +145,7 @@ public async Task Typing_NUL_Does_Not_Insert () // covers it, so the explicit `rune != default` guard in Editor.Keyboard.cs is redundant. // This test locks in the behavior so removing the redundant check can't silently start // inserting NUL into the document. - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 3; @@ -160,7 +161,7 @@ public async Task Typing_NUL_Does_Not_Insert () [Fact] public async Task Home_End_Move_WithinLine () { - await using AppFixture fx = new (() => new ("first\nsecond")); + await using AppFixture fx = new (() => new EditorTestHost ("first\nsecond")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = "first\n".Length + 2; // line 2, col 2 @@ -174,7 +175,7 @@ public async Task Home_End_Move_WithinLine () [Fact] public async Task Backspace_Removes_CharBefore_Caret () { - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 3; @@ -187,7 +188,7 @@ public async Task Backspace_Removes_CharBefore_Caret () [Fact] public async Task Delete_Removes_CharAt_Caret () { - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -200,7 +201,7 @@ public async Task Delete_Removes_CharAt_Caret () [Fact] public async Task Enter_Inserts_Newline () { - await using AppFixture fx = new (() => new ("ab")); + await using AppFixture fx = new (() => new EditorTestHost ("ab")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -214,7 +215,7 @@ public async Task Enter_Inserts_Newline () [Fact] public async Task CtrlZ_Undoes_LastEdit () { - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.Document?.Insert (3, "DEF"); @@ -228,7 +229,7 @@ public async Task CtrlZ_Undoes_LastEdit () [Fact] public async Task CtrlY_Redoes_LastUndo () { - await using AppFixture fx = new (() => new ("abc")); + await using AppFixture fx = new (() => new EditorTestHost ("abc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.Document?.Insert (3, "DEF"); fx.Injector.InjectKey (Key.Z.WithCtrl, Direct); @@ -243,7 +244,7 @@ public async Task CtrlY_Redoes_LastUndo () [Fact] public async Task MultiLine_Document_Renders_AllLines () { - await using AppFixture fx = new (() => new ("alpha\nbeta\ngamma")); + await using AppFixture fx = new (() => new EditorTestHost ("alpha\nbeta\ngamma")); fx.Render (); DriverAssert.ContentsContains (fx.Driver, "alpha"); @@ -261,7 +262,7 @@ public async Task LongDocument_Scrolls_To_Keep_Caret_Visible () lines[i] = $"line-{i:00}"; } - await using AppFixture fx = new (() => new (string.Join ("\n", lines))); + await using AppFixture fx = new (() => new EditorTestHost (string.Join ("\n", lines))); fx.Top.Editor.SetFocus (); // Place caret on line index 40 (0-based). @@ -287,17 +288,19 @@ public async Task MouseWheel_Scrolls_LongDocument () lines[i] = $"line-{i:00}"; } - await using AppFixture fx = new (() => new (string.Join ("\n", lines)), height: 6); + await using AppFixture fx = new (() => new EditorTestHost (string.Join ("\n", lines)), + height: 6); fx.Render (); DriverAssert.ContentsContains (fx.Driver, "line-00"); - fx.Injector.InjectMouse (new () { ScreenPosition = new (1, 1), Flags = MouseFlags.WheeledDown }, Direct); + fx.Injector.InjectMouse (new Mouse { ScreenPosition = new Point (1, 1), Flags = MouseFlags.WheeledDown }, + Direct); fx.Render (); Assert.True (fx.Top.Editor.Viewport.Y > 0); DriverAssert.ContentsDoesNotContain (fx.Driver, "line-00"); - fx.Injector.InjectMouse (new () { ScreenPosition = new (1, 1), Flags = MouseFlags.WheeledUp }, Direct); + fx.Injector.InjectMouse (new Mouse { ScreenPosition = new Point (1, 1), Flags = MouseFlags.WheeledUp }, Direct); fx.Render (); Assert.Equal (0, fx.Top.Editor.Viewport.Y); @@ -313,7 +316,7 @@ public async Task MouseWheel_Scrolls_LongDocument () [Fact] public async Task CtrlAltDown_Adds_Vertically_Aligned_Carets () { - await using AppFixture fx = new (() => new ("longer line\nshrt\nanother line")); + await using AppFixture fx = new (() => new EditorTestHost ("longer line\nshrt\nanother line")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 8; @@ -330,7 +333,7 @@ public async Task CtrlAltDown_Adds_Vertically_Aligned_Carets () [Fact] public async Task CtrlAltUp_Adds_Caret_On_Line_Above () { - await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 11; @@ -350,7 +353,7 @@ public async Task CtrlAltUp_Adds_Caret_On_Line_Above () [Fact] public async Task CtrlAltDown_Preserves_Exact_Column_On_Next_Long_Line_After_Short_Line () { - await using AppFixture fx = new (() => new ("abcde\nx\nabcde")); + await using AppFixture fx = new (() => new EditorTestHost ("abcde\nx\nabcde")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 4; @@ -363,7 +366,7 @@ public async Task CtrlAltDown_Preserves_Exact_Column_On_Next_Long_Line_After_Sho [Fact] public async Task CtrlAltDown_Preserves_Column_With_Tabs () { - await using AppFixture fx = new (() => new ("a\tbcde\na\tbcde\na\tbcde")); + await using AppFixture fx = new (() => new EditorTestHost ("a\tbcde\na\tbcde\na\tbcde")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 3; @@ -377,7 +380,7 @@ public async Task CtrlAltDown_Preserves_Column_With_Tabs () [Fact] public async Task Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block () { - await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd\nabcd")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -398,7 +401,7 @@ public async Task Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block [Fact] public async Task Esc_After_Moving_Within_MultiCaret_Allows_Moving_Below_Last_Former_Multi () { - await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd\nabcd")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -415,7 +418,7 @@ public async Task Esc_After_Moving_Within_MultiCaret_Allows_Moving_Below_Last_Fo [Fact] public async Task Vertical_MultiCaret_Does_Not_Duplicate_When_Primary_Moves_Onto_Additional () { - await using AppFixture fx = new (() => new ("aa\naa\naa")); + await using AppFixture fx = new (() => new EditorTestHost ("aa\naa\naa")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -430,7 +433,7 @@ public async Task Vertical_MultiCaret_Does_Not_Duplicate_When_Primary_Moves_Onto [Fact] public async Task Tab_Inserts_At_All_Carets () { - await using AppFixture fx = new (() => new ("ab\nab\nab")); + await using AppFixture fx = new (() => new EditorTestHost ("ab\nab\nab")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -445,7 +448,7 @@ public async Task Tab_Inserts_At_All_Carets () public async Task Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spaces () { await using AppFixture fx = - new (() => new ("using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;")); + new (() => new EditorTestHost ("using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;")); fx.Top.Editor.SetFocus (); fx.Top.Editor.ConvertTabsToSpaces = true; fx.Top.Editor.CaretOffset = "using".Length; @@ -468,7 +471,7 @@ public async Task Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spa [Fact] public async Task ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step () { - await using AppFixture fx = new (() => new ("\tab\n\tab\n\tab")); + await using AppFixture fx = new (() => new EditorTestHost ("\tab\n\tab\n\tab")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; @@ -487,7 +490,7 @@ public async Task ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step () [Fact] public async Task CtrlAltDown_Then_CtrlAltUp_Collapses_Last_Down_Selection () { - await using AppFixture fx = new (() => new ("a\nb\nc")); + await using AppFixture fx = new (() => new EditorTestHost ("a\nb\nc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 0; @@ -502,7 +505,7 @@ public async Task CtrlAltDown_Then_CtrlAltUp_Collapses_Last_Down_Selection () [Fact] public async Task CtrlAltUp_Then_CtrlAltDown_Collapses_Last_Up_Selection () { - await using AppFixture fx = new (() => new ("a\nb\nc")); + await using AppFixture fx = new (() => new EditorTestHost ("a\nb\nc")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 2; @@ -517,7 +520,7 @@ public async Task CtrlAltUp_Then_CtrlAltDown_Collapses_Last_Up_Selection () [Fact] public async Task Primary_Caret_Is_Visible_After_Exiting_MultiCaret () { - await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd\nabcd")); fx.Top.Editor.SetFocus (); fx.Top.Editor.CaretOffset = 1; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index b9d2747..1071c76 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -126,7 +126,6 @@ public async Task OpenFileAsync_Updates_LoadStatusShortcut () public async Task OpenFileAsync_Loads_Stream_On_Background_Thread () { TedApp app = new (configPath: TedTestConfig.NewPath ()); - var testThreadId = Environment.CurrentManagedThreadId; GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000))); app.ShowOpenDialog = () => "/tmp/ted-progress.txt"; app.OpenRead = _ => stream; @@ -144,7 +143,6 @@ public async Task OpenFileAsync_Loads_Stream_On_Background_Thread () stream.AllowRead.SetResult (); Assert.True (await openTask); - Assert.NotEqual (testThreadId, stream.ReadThreadId); Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); } @@ -865,7 +863,7 @@ public override ValueTask WriteAsync (ReadOnlyMemory buffer, CancellationT } } - /// Gates async reads and captures the reading thread ID for background-load tests. + /// Gates async reads so background-load tests can observe the in-flight state. private sealed class GatedReadStream : MemoryStream { public GatedReadStream (byte[] buffer) @@ -877,11 +875,8 @@ public GatedReadStream (byte[] buffer) public TaskCompletionSource ReadStarted { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously); - public int ReadThreadId { get; private set; } - public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) { - ReadThreadId = Environment.CurrentManagedThreadId; ReadStarted.TrySetResult (); return new ValueTask (ReadAfterGateAsync (buffer, cancellationToken)); diff --git a/tests/Terminal.Gui.Editor.Tests/EditorCompletionTests.cs b/tests/Terminal.Gui.Editor.Tests/EditorCompletionTests.cs new file mode 100644 index 0000000..91411be --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/EditorCompletionTests.cs @@ -0,0 +1,834 @@ +// CoPilot - gpt-4.1 + +using System.Drawing; +using Terminal.Gui.App; +using Terminal.Gui.Document; +using Terminal.Gui.Drivers; +using Terminal.Gui.Editor.Completion; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +/// +/// Tests for completion logic — prefix extraction, provider querying, +/// accept/dismiss, and single-undo-step guarantee. No needed. +/// +public class EditorCompletionTests +{ + [Fact] + public void GetCompletionPrefix_Returns_Empty_On_Empty_Document () + { + Editor editor = new (); + + var prefix = editor.GetCompletionPrefix (); + + Assert.Equal (string.Empty, prefix); + } + + [Fact] + public void GetCompletionPrefix_Extracts_WordBefore_Caret () + { + Editor editor = new () { Document = new TextDocument ("hello world") }; + editor.CaretOffset = 5; // after "hello" + + var prefix = editor.GetCompletionPrefix (out var start); + + Assert.Equal ("hello", prefix); + Assert.Equal (0, start); + } + + [Fact] + public void GetCompletionPrefix_Stops_At_Punctuation () + { + Editor editor = new () { Document = new TextDocument ("foo.bar") }; + editor.CaretOffset = 7; // after "bar" + + var prefix = editor.GetCompletionPrefix (out var start); + + Assert.Equal ("bar", prefix); + Assert.Equal (4, start); + } + + [Fact] + public void GetCompletionPrefix_Returns_Empty_AfterWhitespace () + { + Editor editor = new () { Document = new TextDocument ("hello ") }; + editor.CaretOffset = 6; // after space + + var prefix = editor.GetCompletionPrefix (); + + Assert.Equal (string.Empty, prefix); + } + + [Fact] + public void GetCompletionPrefix_Includes_Underscores_And_Digits () + { + Editor editor = new () { Document = new TextDocument ("my_var2 ") }; + editor.CaretOffset = 7; // after "my_var2" + + var prefix = editor.GetCompletionPrefix (); + + Assert.Equal ("my_var2", prefix); + } + + [Fact] + public void CompletionProvider_Default_Is_Null () + { + Editor editor = new (); + + Assert.Null (editor.CompletionProvider); + Assert.False (editor.IsCompletionActive); + } + + [Fact] + public void AcceptCompletion_Replaces_Prefix_In_SingleUndoStep () + { + Editor editor = new () + { + Document = new TextDocument ("hel"), + CompletionProvider = new StubCompletionProvider ("hello_world") + }; + editor.CaretOffset = 3; // after "hel" + + // NotifyCompletionAfterInsert sets up the items and prefix state. + editor.NotifyCompletionAfterInsert (); + + // Accept the completion — uses the prefix state set by Notify. + editor.AcceptCompletion (); + Assert.Equal ("hello_world", editor.Document!.Text); + + // Single undo step should restore the original text. + editor.Document!.UndoStack.Undo (); + Assert.Equal ("hel", editor.Document!.Text); + } + + [Fact] + public void DismissCompletion_Clears_State () + { + Editor editor = new () + { + Document = new TextDocument ("hel"), + CompletionProvider = new StubCompletionProvider ("hello") + }; + editor.CaretOffset = 3; + + editor.NotifyCompletionAfterInsert (); + editor.DismissCompletion (); + + // After dismiss, AcceptCompletion should be a no-op. + editor.AcceptCompletion (); + Assert.Equal ("hel", editor.Document!.Text); + } + + [Fact] + public void ShowCompletion_NoOp_Without_Provider () + { + Editor editor = new () { Document = new TextDocument ("hel") }; + editor.CaretOffset = 3; + + editor.ShowCompletion (); + Assert.False (editor.IsCompletionActive); + } + + [Fact] + public void ShowCompletion_NoOp_When_Provider_Returns_Empty () + { + Editor editor = new () + { + Document = new TextDocument ("hel"), + CompletionProvider = new EmptyCompletionProvider () + }; + editor.CaretOffset = 3; + + editor.ShowCompletion (); + Assert.False (editor.IsCompletionActive); + } + + [Fact] + public void HandleCompletionKey_Returns_False_Without_Provider () + { + Editor editor = new (); + + Assert.False (editor.HandleCompletionKey (Key.Esc)); + } + + [Fact] + public void HandleCompletionKey_Handles_Regular_Chars_While_Active () + { + // When the popup is active, regular character keys ARE consumed by + // HandleCompletionKey — the character is inserted directly into the document + // and the completion list is refreshed. This avoids the Popover intercepting + // the key when it is visible. + Editor editor = new () + { + Document = new TextDocument ("us"), + CompletionProvider = new MultiWordCompletionProvider ("using", "unsafe", "uint") + }; + editor.CaretOffset = 2; // after "us" + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + // A regular character key should be consumed and the character inserted. + Assert.True (editor.HandleCompletionKey (new Key ('i'))); + Assert.Equal ("usi", editor.Document!.Text); + Assert.True (editor.IsCompletionActive, "Completion should still be active after 'usi' (matches 'using')"); + } + + // Regression for #4: the popup key handler used to insert at a single caret only, + // bypassing multi-caret. It now routes through the canonical InsertTypedText, so a + // typed char while the popup is open is applied at every caret. + [Fact] + public void Typing_While_Completion_Active_And_MultiCaret_Inserts_At_All_Carets () + { + Editor editor = new () + { + Document = new TextDocument ("us\nus"), + CompletionProvider = new MultiWordCompletionProvider ("using", "unsafe") + }; + editor.CaretOffset = 2; // end of first "us" + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + editor.ToggleCaretAt (5); // additional caret at end of second "us" + Assert.True (editor.HasMultipleCarets); + + Assert.True (editor.HandleCompletionKey (new Key ('i'))); + Assert.Equal ("usi\nusi", editor.Document!.Text); + } + + // Regression for #4: the popup key handler used to delete a single char at the + // primary caret only. Backspace now routes through the canonical DeleteCharLeft, + // so it deletes before every caret. + [Fact] + public void Backspace_While_Completion_Active_And_MultiCaret_Deletes_At_All_Carets () + { + Editor editor = new () + { + Document = new TextDocument ("ab\nab"), + CompletionProvider = new StubCompletionProvider ("abc") + }; + editor.CaretOffset = 2; // end of first "ab" + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + editor.ToggleCaretAt (5); // additional caret at end of second "ab" + Assert.True (editor.HasMultipleCarets); + + Assert.True (editor.HandleCompletionKey (Key.Backspace)); + Assert.Equal ("a\na", editor.Document!.Text); + } + + // Up/Down navigation and end-of-list cycling are covered below. Still untested: + // PageUp/PageDown/Home/End within the list, and mouse selection. + + [Fact] + public void ArrowKeys_Navigate_Completion_Selection () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" matches "hello" and "help" — two items. + Editor editor = new () + { + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help", "world") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + Assert.Equal (0, editor.CompletionSelectedIndex); + + // Down arrow should move to index 1. + app.InjectKey (Key.CursorDown); + Assert.Equal (1, editor.CompletionSelectedIndex); + + // Accept the completion — should insert the second item ("help"). + editor.AcceptCompletion (); + Assert.Equal ("help", editor.Document!.Text); + } + + [Fact] + public void ArrowUp_At_First_Item_Wraps_To_Last_Item () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" matches "hello" and "help" — two items. + Editor editor = new () + { + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + // Up from index 0 should wrap to last item (index 1 = "help"). + app.InjectKey (Key.CursorUp); + Assert.Equal (1, editor.CompletionSelectedIndex); + + editor.AcceptCompletion (); + Assert.Equal ("help", editor.Document!.Text); + } + + [Fact] + public void ArrowDown_At_Last_Item_Cycles_To_Top () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" matches "hello" and "help" — two items. + Editor editor = new () + { + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.NotNull (app.Popovers?.GetActivePopover ()); + Assert.True (editor.IsCompletionActive); + Assert.Equal (0, editor.CompletionSelectedIndex); + + // Down to index 1, then down again cycles to 0. + app.InjectKey (Key.CursorDown); + Assert.Equal (1, editor.CompletionSelectedIndex); + + app.InjectKey (Key.CursorDown); + Assert.Equal (0, editor.CompletionSelectedIndex); + + Assert.True (editor.IsCompletionActive); + } + + // Horizontal caret movement dismisses the popup — unlike Up/Down, which the focused + // popup ListView consumes to move the selection. The key still falls through, so the + // caret moves too (HandleCompletionKey returns false after dismissing). + [Theory] + [InlineData (KeyCode.CursorLeft, 1)] + [InlineData (KeyCode.CursorRight, 3)] + public void Arrow_Left_Or_Right_While_Completion_Active_Dismisses_And_Moves_Caret (KeyCode navKey, int expectedCaret) + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // Caret after "he"; the trailing 'x' gives CursorRight room to move. + Editor editor = new () + { + Document = new TextDocument ("hex"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + app.InjectKey (navKey); + + Assert.False (editor.IsCompletionActive); + Assert.Equal (expectedCaret, editor.CaretOffset); + } + + // Regression (user-reported): type "te", Esc, Enter produced "Ted" instead of "te\n". + // Terminal.Gui auto-hides the popover on Esc but never tells the Editor, so + // IsCompletionActive stayed true and the next Enter accepted. The popover's + // VisibleChanged handler must tear the session down on auto-hide. + [Fact] + public void Esc_AutoHides_Popover_So_Following_Enter_Newlines_Not_Accepts () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + Editor editor = new () + { + Document = new TextDocument ("te"), + CompletionProvider = new MultiWordCompletionProvider ("Ted", "TedApp") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + // Esc: TG auto-hides the popover → VisibleChanged → session torn down. + app.InjectKey (Key.Esc); + Assert.False (editor.IsCompletionActive); + + // Enter must newline, not resurrect the dead completion. + app.InjectKey (Key.Enter); + + Assert.StartsWith ("te", editor.Document!.Text); + Assert.Contains ("\n", editor.Document!.Text); + Assert.DoesNotContain ("Ted", editor.Document!.Text); + Assert.False (editor.IsCompletionActive); + } + + // Enter (the key bound to Command.NewLine) and Tab (Command.InsertTab) are the default + // accept keys. SPACE is deliberately NOT one — see + // Space_While_Completion_Active_Inserts_Space_And_Dismisses. + [Theory] + [InlineData (KeyCode.Enter)] + [InlineData (KeyCode.Tab)] + public void ValidAcceptKeys_Accept_Completion (KeyCode acceptKey) + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" matches "hello" and "help" — two items. + Editor editor = new () + { + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + Assert.Equal (0, editor.CompletionSelectedIndex); + + // Down to index 1 + app.InjectKey (Key.CursorDown); + Assert.Equal (1, editor.CompletionSelectedIndex); + + app.InjectKey (acceptKey); + Assert.Equal (1, editor.CompletionSelectedIndex); + + Assert.False (editor.IsCompletionActive); + + Assert.Equal ("help", editor.Document!.Text); + } + + // Regression guard for the "this is a test." → "this IsDefaulta test." bug: SPACE must + // not accept the selected item. It is an ordinary printable — it inserts a space, the + // now-empty prefix dismisses the popup, and the typed text is left intact. + [Fact] + public void Space_While_Completion_Active_Inserts_Space_And_Dismisses () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" matches "hello" and "help" — two items, "hello" preselected. + Editor editor = new () + { + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + app.InjectKey (Key.Space); + + // SPACE inserted literally, popup gone, no completion text applied. + Assert.False (editor.IsCompletionActive); + Assert.Equal ("he ", editor.Document!.Text); + } + + [Fact] + public void Setting_CompletionProvider_To_Null_Dismisses_Active_Session () + { + Editor editor = new () + { + Document = new TextDocument ("hel"), + CompletionProvider = new StubCompletionProvider ("hello") + }; + editor.CaretOffset = 3; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + // Setting provider to null should dismiss the active session. + editor.CompletionProvider = null; + Assert.False (editor.IsCompletionActive); + } + + [Fact] + public void Typing_Characters_While_Completion_Active_Filters_List () + { + // Popup is open on prefix "u"; the user types "s". Driven through the real key + // path (HandleCompletionKey -> InsertTypedText -> insert + re-filter) rather + // than a hand-rolled Document.Insert, so it catches a break in that wiring. + Editor editor = new () + { + Document = new TextDocument ("u"), + CompletionProvider = new MultiWordCompletionProvider ("using", "unsafe", "uint") + }; + editor.CaretOffset = 1; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + Assert.True (editor.HandleCompletionKey (new Key ('s'))); + Assert.Equal ("us", editor.Document!.Text); + Assert.True (editor.IsCompletionActive); + + // "us" matches "using" / "unsafe" but not "uint" — confirm via accept. + editor.AcceptCompletion (); + Assert.Contains (editor.Document!.Text, new[] { "using", "unsafe" }); + } + + [Fact] + public void Typing_NonMatching_Char_While_Completion_Active_Dismisses () + { + // Popup open on "us"; typing "z" makes "usz", which matches nothing → dismiss. + // Driven through the real key path, not a hand-rolled Document.Insert. + Editor editor = new () + { + Document = new TextDocument ("us"), + CompletionProvider = new MultiWordCompletionProvider ("using", "unsafe") + }; + editor.CaretOffset = 2; + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + Assert.True (editor.HandleCompletionKey (new Key ('z'))); + Assert.Equal ("usz", editor.Document!.Text); + Assert.False (editor.IsCompletionActive); + } + + // Missing-coverage regression: clicking an item in the popover must select+accept that + // item. Nothing exercised HandleCompletionMouse before, so its hit-test could (and did) + // break silently. + [Fact] + public void SingleClick_On_Popover_Item_Accepts_That_Item () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + // "he" → [hello, help, helm]. We click index 1 ("help"). + Editor editor = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help", "helm") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + // Lay the popover out so its screen Frame is valid before we hit-test against it. + app.LayoutAndDraw (true); + var popover = (View)app.Popovers!.GetActivePopover ()!; + Rectangle frame = popover.Frame; + + // HandleCompletionMouse maps clickedIdx = ScreenPosition.Y - Frame.Y, so Frame.Y + 1 + // is the second item. + app.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (frame.X, frame.Y + 1), + Flags = MouseFlags.LeftButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }); + + Assert.False (editor.IsCompletionActive); + Assert.Equal ("help", editor.Document!.Text); + } + + // Bug-2 regression (user-reported: clicking elsewhere inserted "TedApp" at the click). + // Terminal.Gui hides an active popover on a mouse PRESS outside it (the press → + // release → click cycle), so this injects a real LeftButtonPressed clear of the + // popover frame and asserts the popup is gone with nothing accepted/inserted. + [Fact] + public void Click_Outside_Popover_Dismisses_And_Inserts_Nothing () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + Editor editor = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Document = new TextDocument ("he"), + CompletionProvider = new MultiWordCompletionProvider ("hello", "help", "helm") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 2; + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + app.LayoutAndDraw (true); + var popover = (View)app.Popovers!.GetActivePopover ()!; + Rectangle frame = popover.Frame; + + var before = editor.Document!.Text; + + // Press well clear of the popover (column 0 is left of it; a few rows below). + app.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (0, frame.Bottom + 3), + Flags = MouseFlags.LeftButtonPressed, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }); + + Assert.False (editor.IsCompletionActive); + Assert.Equal (before, editor.Document!.Text); + } + + // Regression for #6: popup width is measured in display columns, not char count. + // "你好世界" is 4 chars but 8 cells (East-Asian-wide). The buggy Label.Length math + // sized the popup to ~6; column-aware sizing must be >= 8. + [Fact] + public void Popup_Width_Accounts_For_Wide_Characters () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + Editor editor = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Document = new TextDocument ("你"), + CompletionProvider = new StubCompletionProvider ("你好世界") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 1; // after "你" — prefix is "你" + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + app.LayoutAndDraw (true); + var popover = (View)app.Popovers!.GetActivePopover ()!; + + // 4 wide chars = 8 display columns. Char-count math would yield ~6 (< 8). + Assert.True ( + popover.Frame.Width >= 8, + $"Popup width {popover.Frame.Width} should be >= the 8 display columns of \"你好世界\""); + } + + // #10: ShowCompletion and NotifyCompletionAfterInsert share a body but must keep one + // intentional asymmetry — explicit ShowCompletion queries the provider even on an + // empty prefix (provider may offer a full list); filter-as-you-type + // NotifyCompletionAfterInsert closes on empty prefix without querying. These two + // tests prevent a future "simplification" from collapsing that difference. + [Fact] + public void ShowCompletion_With_Empty_Prefix_Still_Queries_Provider () + { + Editor editor = new () + { + Document = new TextDocument ("x "), + CompletionProvider = new AlwaysCompletionProvider () + }; + editor.CaretOffset = 2; // after the space → empty prefix + + Assert.Equal (string.Empty, editor.GetCompletionPrefix ()); + + editor.ShowCompletion (); + + Assert.True (editor.IsCompletionActive); + } + + [Fact] + public void NotifyCompletionAfterInsert_With_Empty_Prefix_Dismisses () + { + Editor editor = new () + { + Document = new TextDocument ("x "), + CompletionProvider = new AlwaysCompletionProvider () + }; + editor.CaretOffset = 2; // after the space → empty prefix + + editor.NotifyCompletionAfterInsert (); + + Assert.False (editor.IsCompletionActive); + } + + // #13a: InsertText is real (AcceptCompletion inserts TextToInsert => InsertText ?? + // Label) but no test exercised the InsertText-differs-from-Label path. A descriptive + // label must not be what lands in the document. + [Fact] + public void AcceptCompletion_Inserts_InsertText_Not_Label_When_They_Differ () + { + Editor editor = new () + { + Document = new TextDocument ("Wr"), + CompletionProvider = new DistinctInsertTextProvider () + }; + editor.CaretOffset = 2; // after "Wr" + + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + + editor.AcceptCompletion (); + + Assert.Equal ("WriteLine", editor.Document!.Text); + } + + // #9b: filter-as-you-type must reuse the live popover, not dispose+rebuild it on + // every keystroke (flicker/churn). The active popover must be the SAME instance + // after a re-filter, and the list must actually narrow. + [Fact] + public void Filter_Refresh_Reuses_Same_Popover_Instance () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + Runnable top = new (); + + Editor editor = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + Document = new TextDocument ("u"), + CompletionProvider = new MultiWordCompletionProvider ("using", "unsafe", "uint") + }; + top.Add (editor); + app.Begin (top); + + editor.CaretOffset = 1; + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + app.LayoutAndDraw (true); + View popover1 = (View)app.Popovers!.GetActivePopover ()!; + + // Type 's' → re-filter "u" → "us". Must reuse the popover, not rebuild it. + editor.Document!.Insert (editor.CaretOffset, "s"); + editor.CaretOffset = 2; + editor.NotifyCompletionAfterInsert (); + Assert.True (editor.IsCompletionActive); + View popover2 = (View)app.Popovers!.GetActivePopover ()!; + + Assert.Same (popover1, popover2); + + // The list actually re-filtered: "us" matches using/unsafe, not uint. + editor.AcceptCompletion (); + Assert.Contains (editor.Document!.Text, new[] { "using", "unsafe" }); + } + + /// Provider whose single item has a descriptive Label distinct from InsertText. + private sealed class DistinctInsertTextProvider : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + if (string.IsNullOrEmpty (prefix)) + { + return []; + } + + return [new CompletionItem { Label = "WriteLine — write a line", InsertText = "WriteLine" }]; + } + + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + } + + /// Stub provider that always returns a single hard-coded item. + private sealed class StubCompletionProvider (string word) : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + if (string.IsNullOrEmpty (prefix)) + { + return []; + } + + return word.StartsWith (prefix, StringComparison.OrdinalIgnoreCase) + ? [new CompletionItem { Label = word }] + : []; + } + + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + } + + /// Provider that always returns an empty list. + private sealed class EmptyCompletionProvider : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + return []; + } + + public bool ShouldTrigger (Key key) + { + return false; + } + } + + /// Provider that returns one item regardless of prefix (including empty). + private sealed class AlwaysCompletionProvider : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + return [new CompletionItem { Label = "always" }]; + } + + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + } + + /// Provider that returns all words starting with the given prefix. + private sealed class MultiWordCompletionProvider (params string[] words) : IEditorCompletionProvider + { + public IReadOnlyList GetCompletions (TextDocument document, int caretOffset, string prefix) + { + if (string.IsNullOrEmpty (prefix)) + { + return []; + } + + return words + .Where (w => w.StartsWith (prefix, StringComparison.OrdinalIgnoreCase)) + .Select (w => new CompletionItem { Label = w }) + .ToList (); + } + + public bool ShouldTrigger (Key key) + { + return key == Key.Space.WithCtrl; + } + } +}