From a36be2ce3638162f4747820d5828401f1c9673d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:31:53 +0000 Subject: [PATCH 01/10] Initial plan From bb664df2b93469b67c0230c63cf6d07d3a3cb2b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:39:53 +0000 Subject: [PATCH 02/10] Implement column multi-caret selections Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/7084fa04-ac77-4d3d-94f6-b5e7835d8bf2 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Docs/Help/multi-caret.md | 20 +- specs/multi-caret/spec.md | 2 +- specs/public-api.md | 8 +- specs/vertical-multi-caret/spec.md | 23 ++- src/Terminal.Gui.Editor/Editor.Drawing.cs | 21 ++ src/Terminal.Gui.Editor/Editor.Keyboard.cs | 60 ++++++ src/Terminal.Gui.Editor/Editor.Mouse.cs | 4 +- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 187 ++++++++++++++++-- src/Terminal.Gui.Editor/Editor.cs | 11 +- .../EditorMouseTests.cs | 74 +++++++ .../EditorMultiCaretIndentTests.cs | 39 ++++ .../EditorRenderingTests.cs | 35 ++++ .../EditorTests.cs | 16 ++ 13 files changed, 457 insertions(+), 43 deletions(-) diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index f890c30..c7159d0 100644 --- a/Docs/Help/multi-caret.md +++ b/Docs/Help/multi-caret.md @@ -9,7 +9,8 @@ Place multiple carets in the document and type, delete, or press Enter at all of | **Ctrl+Click** | Toggle an additional caret at the clicked position. Click an existing additional caret to remove it. | | **Ctrl+Alt+↑** | Add a caret on the line above the topmost caret, at the sticky visual column (VS Code parity). | | **Ctrl+Alt+↓** | Add a caret on the line below the bottommost caret, at the sticky visual column (VS Code parity). | -| **Alt + drag** | Build a vertical column of carets from the press row through the drag row at the press column (carets only). | +| **Alt + drag** | Build a vertical column from the press row through the drag row. Zero horizontal extent creates carets; horizontal extent creates one selection per row. | +| **Ctrl+Shift+Alt+↑/↓/←/→** | Create or extend a keyboard column selection. `PgUp` / `PgDn` extend by one viewport. | | **Escape** | Collapse back to the primary caret (clears all additional carets). | `Ctrl+Alt+↑/↓` track a *sticky visual column*: a short or tab-indented intervening line doesn't lose the column — the next long-enough line restores it. The chords are configurable per platform via `Editor.DefaultKeyBindings`; a terminal or window manager that grabs `Ctrl+Alt+arrow` is handled by remapping in config, not a separate built-in chord. @@ -29,7 +30,7 @@ All edits are wrapped in a single `Document.RunUpdate` scope, so **Undo (Ctrl+Z) ## Visual feedback -Additional carets are rendered as blinking, reverse-video cells by the `MultiCaretRenderer` (an `IOverlayRenderer`). The status bar in `ted` shows the total caret count when in multi-caret mode (e.g. "Ln 4, Col 1 (3 carets)"). +Additional carets are rendered as blinking, reverse-video cells by the `MultiCaretRenderer` (an `IOverlayRenderer`). Additional-caret selections render with the same active selection role as the primary selection. The status bar in `ted` shows the total caret count when in multi-caret mode (e.g. "Ln 4, Col 1 (3 carets)"). ## Programmatic API @@ -47,10 +48,19 @@ editor.ClearAdditionalCarets (); Additional carets are backed by `TextAnchor` instances, so they track insertions and deletions elsewhere in the document automatically (same mechanism as the primary caret). +## VS Code parity and intentional deviations + +Column selection matches VS Code behavior: typing over a ranged column replaces each row's selection in one undo step; short rows clamp to the real line end without writing padding; dragging left of the anchor reverses the selection direction; `Esc` or a plain click collapses back to the primary caret. + +Intentional deviations: + +- **D1 — mouse modifier**: VS Code starts column selection with `Shift+Alt`+drag; this editor uses **`Alt`+drag** because Windows Terminal and xterm-family terminals reserve `Shift`+drag for terminal-side forced/block selection while an app has mouse mode enabled. Configurable mouse modifiers are tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). +- **D2 — add caret at click**: VS Code uses `Alt`+Click; this editor keeps the existing **`Ctrl`+Click** binding. `Alt` is the column-drag modifier, so an `Alt`+Click alias would need drag-threshold disambiguation. +- **D3 — keyboard column-select**: not a deviation. `Ctrl+Shift+Alt+Arrow` and `Ctrl+Shift+Alt+PgUp/PgDn` match VS Code behavior when the terminal delivers the chord (TG's Kitty keyboard protocol support makes this available on capable terminals). +- **D4 — sticky Column Selection Mode**: VS Code's modal toggle is out of scope; this editor implements the drag and keyboard gestures, not a persistent mode with menu/status UI. +- **D5 — multi-cursor paste distribution**: VS Code can distribute N clipboard lines over N cursors. This is deferred as a separate follow-up; typing and one-line paste replacement are covered here. + ## Limitations (current alpha) -- Selection is not yet per-caret; only the primary caret carries a selection. - Find/Replace operates on the primary caret only. -- `Alt`+drag produces a column of *carets*, not a column *selection*. To replace a column, drag to place the carets, then `Shift+→`/`←` to grow each caret's selection, then type. Per-row column selection during the drag is the planned follow-up. -- The column-drag modifier is `Alt`, not VS Code's `Shift+Alt`: terminals reserve `Shift`+drag for their own forced/block text selection while an app reads the mouse, so `Shift+Alt`+drag never reaches the editor. Making the mouse modifier user-configurable (to opt back into `Shift+Alt`) is tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). - Toggling Word Wrap while a vertical block is live dismisses the block. diff --git a/specs/multi-caret/spec.md b/specs/multi-caret/spec.md index 8249349..6297184 100644 --- a/specs/multi-caret/spec.md +++ b/specs/multi-caret/spec.md @@ -55,7 +55,7 @@ Add multi-caret support to the Editor. Expose `IReadOnlyList AdditionalCare ## Out of Scope -- Column/block selection mode — the "multi-select" follow-up PR. **When built, it must also close the carried-forward selection-preservation gap:** multi-caret `Tab`/`Shift+Tab` block-indent must preserve the primary *and* per-caret selections (parity with the single-caret `IndentSelectedLines` path; today `ClearAdditionalCaretSelections ()` collapses them post-edit). See `specs/vertical-multi-caret/spec.md` § Out of Scope → *Column / box selection* for the full requirement. +- Sticky Column Selection Mode — VS Code's modal toggle where ordinary clicks/arrows keep column-select behavior until disabled. The drag and keyboard column-selection gestures are in scope for `specs/vertical-multi-caret/spec.md`; the persistent mode with menu/status UI is a separate follow-up. - Multi-caret find/replace ## Notes diff --git a/specs/public-api.md b/specs/public-api.md index 00c7ed3..108c45c 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -27,10 +27,9 @@ public class Editor : View public void ToggleCaretAt (int offset); // multi-caret (Ctrl+Click toggle) public void ClearAdditionalCarets (); // multi-caret (Esc collapse) // vertical-multi-caret adds NO new public API: Ctrl+Alt+CursorUp / Ctrl+Alt+CursorDown - // create a vertically-aligned column of carets at the sticky visual column, and - // Alt + LeftButton drag builds a column of carets (carets only). Both reuse the - // existing AdditionalCaretOffsets / HasMultipleCarets / ClearAdditionalCarets surface and - // are bound through the configurable Editor.DefaultKeyBindings ([ConfigurationProperty]). + // create a vertically-aligned column of carets at the sticky visual column; Alt + LeftButton + // drag and Ctrl+Shift+Alt+Arrow/Page create column selections. All reuse the existing + // AdditionalCaretOffsets / HasMultipleCarets / ClearAdditionalCarets surface. // --- Display --- public bool ShowLineNumbers { get; set; } // exists @@ -114,3 +113,4 @@ public interface IOverlayRenderer | 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 | Column selection during `Alt+Drag` and `Ctrl+Shift+Alt+Arrow/Page` added without new public Editor API | vertical-multi-caret | diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 1397d26..3acd0b1 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -26,7 +26,8 @@ Extend the existing multi-caret machinery (`AdditionalCaretOffsets`, `HasMultipleCarets`, `ToggleCaretAt`, `ClearAdditionalCarets`) with two ergonomic ways to create a **vertically-aligned column of carets** anchored on the same visual column across consecutive lines: 1. **Keyboard**: `Ctrl+Alt+Up` / `Ctrl+Alt+Down` extends the caret block one line above the topmost / below the bottommost caret, landing on the same sticky virtual column. Matches VS Code's `editor.action.insertCursorAbove` / `Below`. -2. **Mouse**: `Alt + LeftButton drag` creates a column of carets spanning the anchor row → active row at the press column. (VS Code uses `Shift+Alt` for its column-select drag; this editor uses `Alt` because a TUI runs inside a terminal that reserves `Shift`+drag — see the Amendment above. This spec ships carets-only first; selection-per-row is a follow-up — see Out of Scope.) +2. **Mouse**: `Alt + LeftButton drag` creates a column spanning the anchor row → active row. Zero horizontal extent creates carets; horizontal extent creates one selection per row. (VS Code uses `Shift+Alt` for its column-select drag; this editor uses `Alt` because a TUI runs inside a terminal that reserves `Shift`+drag — see the Amendment above.) +3. **Keyboard column-select**: `Ctrl+Shift+Alt+Arrow` and `Ctrl+Shift+Alt+PgUp/PgDn` create or extend a column selection, matching VS Code behavior when the terminal delivers the chord. Both flows produce a primary caret plus zero or more additional carets, all sharing the multi-caret edit pipeline (single `Document.OpenUpdateScope ()` → one undo step, R5). @@ -174,12 +175,11 @@ Also add a tightly-scoped unit test (`Terminal.Gui.Editor.Tests`) for the visual ## Out of Scope -- **Column / box selection** (i.e. `Alt+Drag` producing a *selection per row* the way VS Code's `Shift+Alt+drag` does, rather than carets only). This is the natural follow-up — **the "multi-select" PR**. Per-caret selection in the existing multi-caret pipeline already works; column-extend during drag needs a new code path that extends each caret's selection anchor as the drag widens/narrows. Ship the carets-only flow first. - - **Required of that PR — selection-preservation parity (carried forward from PR #133):** multi-caret `Tab` / `Shift+Tab` block-indent currently calls `ClearAdditionalCaretSelections ()` and collapses the **primary** selection after the edit, whereas the single-caret `IndentSelectedLines` path preserves it. That is cosmetic only while carets are point-only; once this PR makes per-row *selections* a first-class column primitive, surviving a block-indent becomes load-bearing. The multi-select PR **must** restore parity: multi-caret `Tab` / `Shift+Tab` preserves the primary selection **and** every per-caret selection across the block-indent (mirror `IndentSelectedLines`' `SetSelectionRangePreservingDirection` behavior per caret), with a regression test alongside `EditorMultiCaretIndentTests`. - **Find/replace across multi-caret selections**. Already excluded by multi-caret spec. - **Reflowing the vertical block under WordWrap toggling**. If the user toggles `WordWrap` while a vertical block is live, the block is dismissed. This spec does not introduce reflow semantics. - **A ted UI menu / dialog**. The keybindings ship discoverable via help text only. - **Changing the existing `Ctrl+Click` add-caret-at-click binding to `Alt+Click`** to match VS Code / VS. Tracked as the *Alt+Click alias* open decision below. +- **Sticky Column Selection Mode** (VS Code's modal toggle with menu/status indicator) and multi-cursor paste distribution are separate follow-ups. ## Reference behavior from PR #125 @@ -197,7 +197,8 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher |---|---|---|---| | Add caret above / below | `Ctrl+Alt+Up` / `Ctrl+Alt+Down` (Win/Linux); `Cmd+Opt+Up/Down` (Mac) | `Alt+Shift+Up` / `Alt+Shift+Down` (`Edit.InsertCaretAbove` / `Below`) | **Match VS Code**: `Ctrl+Alt+Up` / `Ctrl+Alt+Down` | | Add caret at click | `Alt+Click` | `Alt+Click` (multi-cursor placement) | `Ctrl+Click` (existing — see multi-caret spec and *Alt+Click alias* open decision below) | -| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **`Alt + drag` produces *carets per row, no selection***. Modifier is `Alt` (not `Shift+Alt`) because the terminal reserves `Shift`+drag (Amendment / DEC-006 / TG#4888); column-select semantics out of scope this iteration | +| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **Match behavior**: ranged `Alt + drag` produces a selection per row; zero-width `Alt + drag` produces carets. Modifier differs (D1) because the terminal reserves `Shift`+drag. | +| Keyboard column-select | `Ctrl+Shift+Alt+Arrow` / `PgUp` / `PgDn` | Supported | **Match behavior**: `Ctrl+Shift+Alt+Arrow` / `PgUp` / `PgDn` create/extend a column selection when the terminal delivers the chord (D3 withdrawn). | | Esc collapses to primary caret | Yes | Yes | Match (*Esc dismisses the vertical block* scenario) | | Sticky desired column through short lines | Yes | Yes | Match (*Sticky virtual column survives a short intervening line* scenario / *Sticky column through short lines* requirement) | | Tab inserts at every caret, single undo | Yes | Yes | Match (*Tab inserts at every caret* / *Tab keeps the column aligned* requirements) | @@ -207,11 +208,13 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher ### Intentional divergences (and why) -1. **`Alt+drag` produces carets only, not a column selection — and uses `Alt`, not VS Code's `Shift+Alt`.** Two divergences here: (a) VS Code's `Shift+Alt+drag` creates a *selection per row* (typing replaces a column of text); this spec ships only the carets-per-row variant first — the full column-select is the natural follow-up; per-caret selection already works in the pipeline, but extend-during-drag is a new code path. (b) The modifier is `Alt`, not `Shift+Alt`, because the terminal eats `Shift`+drag (see the Amendment; configurable parity tracked by gui-cs/Terminal.Gui#4888). **User-visible consequence**: to "replace" a column, the user must `Alt`-drag, then `Shift+Right`/`Left` to grow each caret's selection, then type. Document this in ted help. - -2. **`Ctrl+Click` vs `Alt+Click` for "add caret at click".** Existing multi-caret on `develop` uses `Ctrl+Click`. VS Code and VS use `Alt+Click`. Changing the existing binding is out of scope for this spec — flagged as the *Alt+Click alias* open decision below. - -3. **WordWrap toggle dismisses the block.** Both reference editors preserve carets through a wrap toggle. We dismiss because the carets' wrap-row positions are no longer well-defined under the new wrap state and we don't want to silently snap them to surprising offsets. Could revisit post-beta. +| # | Behavior | VS Code | This editor | Why | Category | +|---|---|---|---|---|---| +| **D1** | Start column/box select (mouse) | `Shift+Alt`+drag | **`Alt`+drag** | Windows Terminal / xterm-family terminals reserve `Shift`+drag for terminal-side forced/block selection while an app has mouse mode on, so `Shift+Alt`+drag never reaches the editor; `Alt`+drag is forwarded. | Terminal incompatibility — DEC-006; configurable parity tracked by gui-cs/Terminal.Gui#4888 | +| **D2** | Add caret at click | `Alt`+Click | **`Ctrl`+Click** (existing) | Pre-existing Editor binding; `Alt` is now the column-drag modifier, so a future `Alt`+Click alias needs drag-threshold disambiguation. | Terminal incompatibility knock-on + binding history | +| ~~**D3**~~ | Keyboard column-select (`Ctrl+Shift+Alt+Arrow` / `PgUp`/`PgDn`) | Supported | **WITHDRAWN — not a deviation; in scope, matches VS Code** | TG's Kitty keyboard protocol support can deliver the four-modifier chord. Legacy terminals that do not negotiate it share the same environmental limitation as other advanced chords. | Not a deviation | +| **D4** | Column Selection Mode (sticky toggle, menu/status UI) | Supported | **Out of scope** | A persistent modal input state is larger than the drag/keyboard gestures and not required for column-edit parity. | Scope | +| **D5** | Multi-cursor clipboard paste distribution | `"spread"` by default | **Deferred** | Clipboard distribution is orthogonal to creating/rendering column selections; typing and one-line paste replacement are covered here. | Scope — separate follow-up | ### Behaviors we match deliberately @@ -244,4 +247,4 @@ These were open in earlier drafts of this spec and are now resolved. - This spec rebuilds the user-facing functionality of PR #125 from the tests it shipped; it is not a "fix-forward" of that branch. The intended workflow is: open a new branch from `develop`, port the PR #125 tests verbatim (renaming as needed, **including swapping the key combos to the VS Code chords**), confirm they fail, then write the implementation against the requirements above. - The visual-line cache fix (see *Cache invalidation on offset shift* requirement and the *Tab twice* scenario) is the most subtle defect the test set exposes. Treat it as the riskiest piece — write the unit test in `Terminal.Gui.Editor.Tests` before touching the cache. - R5 (single `Document.OpenUpdateScope ()` per multi-caret edit) is non-negotiable. Tab at N carets is one undo step, not N. -- R8: append two lines to `specs/public-api.md` describing the new keybindings. No new public Editor API is introduced by this spec — the existing `AdditionalCaretOffsets` / `HasMultipleCarets` / `ToggleCaretAt` / `ClearAdditionalCarets` surface is sufficient. \ No newline at end of file +- R8: append two lines to `specs/public-api.md` describing the new keybindings. No new public Editor API is introduced by this spec — the existing `AdditionalCaretOffsets` / `HasMultipleCarets` / `ToggleCaretAt` / `ClearAdditionalCarets` surface is sufficient. diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index e3f82e9..bd190e8 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -187,6 +187,8 @@ private CellVisualLine BuildWrappedSegmentVisualLine ( } } + ApplyAdditionalCaretSelections (visualLine, selected); + return visualLine; } @@ -364,4 +366,23 @@ private void UpdateCursor () Point screen = ViewportToScreen (new Point (col, row)); Cursor = new Cursor { Position = screen, Style = CursorStyle.BlinkingBar }; } + + private void ApplyAdditionalCaretSelections (CellVisualLine visualLine, Attribute selected) + { + if (!HasAdditionalCaretSelections ()) + { + return; + } + + foreach ((int start, int end) selection in AdditionalCaretSelectionRanges ()) + { + foreach (CellVisualLineElement element in visualLine.Elements) + { + if (element.DocumentOffset < selection.end && element.DocumentEndOffset > selection.start) + { + element.Attribute = selected; + } + } + } + } } diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 7f48404..047f300 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -13,6 +13,11 @@ public partial class Editor /// protected override bool OnKeyDownNotHandled (Key key) { + if (TryHandleKeyboardColumnSelect (key)) + { + return true; + } + if (key == Key.Esc && HasMultipleCarets) { ClearAdditionalCarets (); @@ -54,4 +59,59 @@ protected override bool OnKeyDownNotHandled (Key key) return true; } + + private bool TryHandleKeyboardColumnSelect (Key key) + { + if (!key.IsCtrl || !key.IsShift || !key.IsAlt) + { + return false; + } + + Key baseKey = key.NoCtrl.NoShift.NoAlt; + var pageDelta = Math.Max (1, Viewport.Height); + + if (baseKey == Key.CursorUp) + { + ColumnSelectByKeyboard (-1, 0); + + return true; + } + + if (baseKey == Key.CursorDown) + { + ColumnSelectByKeyboard (1, 0); + + return true; + } + + if (baseKey == Key.CursorLeft) + { + ColumnSelectByKeyboard (0, -1); + + return true; + } + + if (baseKey == Key.CursorRight) + { + ColumnSelectByKeyboard (0, 1); + + return true; + } + + if (baseKey == Key.PageUp) + { + ColumnSelectByKeyboard (-pageDelta, 0); + + return true; + } + + if (baseKey == Key.PageDown) + { + ColumnSelectByKeyboard (pageDelta, 0); + + return true; + } + + return false; + } } diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index dc2773c..57d80c8 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -79,7 +79,7 @@ protected override bool OnMouseEvent (Mouse mouse) switch (_dragMode) { case DragMode.ColumnCarets: - SetVerticalCaretsFromViewRows (_columnDragAnchor.Y, pos.Y, _columnDragAnchor.X); + SetVerticalCaretsFromViewRows (_columnDragAnchor.Y, pos.Y, _columnDragAnchor.X, pos.X); return true; @@ -112,7 +112,7 @@ protected override bool OnMouseEvent (Mouse mouse) { _dragMode = DragMode.ColumnCarets; _columnDragAnchor = pos; - SetVerticalCaretsFromViewRows (pos.Y, pos.Y, pos.X); + SetVerticalCaretsFromViewRows (pos.Y, pos.Y, pos.X, pos.X); } else if (ctrl) { diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index b7534a4..81d5f61 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -13,6 +13,10 @@ namespace Terminal.Gui.Editor; public partial class Editor { private readonly List _additionalCarets = []; + private int? _keyboardColumnSelectionAnchorOffset; + private int _keyboardColumnSelectionActiveColumn; + private int _keyboardColumnSelectionActiveRowDelta; + private int _keyboardColumnSelectionAnchorColumn; private int _verticalCaretKeyboardDirection; /// Gets the offsets of all additional carets (excludes the primary). @@ -69,6 +73,11 @@ public void ToggleCaretAt (int offset) /// rather than normalized after the fact. /// private void AddAdditionalCaretAt (int offset) + { + AddAdditionalCaretAt (offset, null); + } + + private void AddAdditionalCaretAt (int offset, int? selectionAnchorOffset) { if (_document is null) { @@ -90,7 +99,22 @@ private void AddAdditionalCaretAt (int offset) } } - _additionalCarets.Add (new CaretInfo { CaretAnchor = CreateCaretAnchor (offset) }); + TextAnchor? selectionAnchor = null; + + if (selectionAnchorOffset is { } anchorOffset) + { + anchorOffset = Math.Clamp (anchorOffset, 0, _document.TextLength); + + if (anchorOffset != offset) + { + selectionAnchor = CreateSelectionAnchor (anchorOffset); + selectionAnchor.MovementType = anchorOffset <= offset + ? AnchorMovementType.AfterInsertion + : AnchorMovementType.BeforeInsertion; + } + } + + _additionalCarets.Add (new CaretInfo { CaretAnchor = CreateCaretAnchor (offset), SelectionAnchor = selectionAnchor }); SetNeedsDraw (); } @@ -195,21 +219,22 @@ private void NormalizeAdditionalCarets () /// scratch on every drag event so the end state is identical to a single press at the /// final position. /// - private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int viewColumn) + private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int anchorViewColumn, + int activeViewColumn) { if (_document is null) { return; } - var primaryOffset = MousePositionToOffset (new Point (viewColumn, anchorViewRow)); + var primaryAnchorOffset = MousePositionToOffset (new Point (anchorViewColumn, anchorViewRow)); + var primaryOffset = MousePositionToOffset (new Point (activeViewColumn, anchorViewRow)); _verticalCaretKeyboardDirection = 0; ClearSelection (); ClearAdditionalCarets (); - // The CaretOffset setter resets the sticky column from the anchor row's primary. - CaretOffset = primaryOffset; + SetPrimaryColumnSelection (primaryAnchorOffset, primaryOffset); var top = Math.Min (anchorViewRow, activeViewRow); var bottom = Math.Max (anchorViewRow, activeViewRow); @@ -221,17 +246,117 @@ private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow continue; } - AddAdditionalCaretAt (MousePositionToOffset (new Point (viewColumn, row))); + var rowAnchorOffset = MousePositionToOffset (new Point (anchorViewColumn, row)); + var rowActiveOffset = MousePositionToOffset (new Point (activeViewColumn, row)); + + AddAdditionalCaretAt (rowActiveOffset, rowAnchorOffset); + } + + SetNeedsDraw (); + } + + private bool? ColumnSelectByKeyboard (int rowDelta, int columnDelta) + { + if (_document is null) + { + return true; + } + + if (_keyboardColumnSelectionAnchorOffset is null) + { + _keyboardColumnSelectionAnchorOffset = CaretOffset; + _keyboardColumnSelectionAnchorColumn = GetVisualColumnForOffset (CaretOffset); + _keyboardColumnSelectionActiveColumn = _keyboardColumnSelectionAnchorColumn; + _keyboardColumnSelectionActiveRowDelta = 0; + } + + var nextRowDelta = _keyboardColumnSelectionActiveRowDelta + rowDelta; + var nextActiveColumn = Math.Max (0, _keyboardColumnSelectionActiveColumn + columnDelta); + var anchorOffset = _keyboardColumnSelectionAnchorOffset.Value; + + if (!TryGetVerticalOffset (anchorOffset, nextRowDelta, nextActiveColumn, out _)) + { + return true; + } + + _keyboardColumnSelectionActiveRowDelta = nextRowDelta; + _keyboardColumnSelectionActiveColumn = nextActiveColumn; + SetVerticalSelectionsFromAnchorOffset ( + anchorOffset, + _keyboardColumnSelectionActiveRowDelta, + _keyboardColumnSelectionAnchorColumn, + _keyboardColumnSelectionActiveColumn); + _keyboardColumnSelectionAnchorOffset = anchorOffset; + + return true; + } + + private void SetVerticalSelectionsFromAnchorOffset ( + int anchorOffset, + int activeRowDelta, + int anchorColumn, + int activeColumn) + { + if (_document is null) + { + return; + } + + _verticalCaretKeyboardDirection = 0; + ClearSelection (); + ClearAdditionalCarets (); + + if (!TryGetVerticalOffset (anchorOffset, 0, anchorColumn, out var primaryAnchorOffset) + || !TryGetVerticalOffset (anchorOffset, 0, activeColumn, out var primaryActiveOffset)) + { + return; + } + + SetPrimaryColumnSelection (primaryAnchorOffset, primaryActiveOffset); + + var firstDelta = Math.Min (0, activeRowDelta); + var lastDelta = Math.Max (0, activeRowDelta); + + for (var delta = firstDelta; delta <= lastDelta; delta++) + { + if (delta == 0) + { + continue; + } + + if (TryGetVerticalOffset (anchorOffset, delta, anchorColumn, out var rowAnchorOffset) + && TryGetVerticalOffset (anchorOffset, delta, activeColumn, out var rowActiveOffset)) + { + AddAdditionalCaretAt (rowActiveOffset, rowAnchorOffset); + } } SetNeedsDraw (); } + private void SetPrimaryColumnSelection (int anchorOffset, int activeOffset) + { + CaretOffset = activeOffset; + + if (anchorOffset == activeOffset) + { + ClearSelection (); + + return; + } + + _selectionAnchor = CreateSelectionAnchor (anchorOffset); + RefreshSelectionAnchorMovement (); + SelectionChanged?.Invoke (this, EventArgs.Empty); + SetNeedsDraw (); + } + /// Removes all additional carets, leaving only the primary. public void ClearAdditionalCarets () { var had = _additionalCarets.Count > 0; _verticalCaretKeyboardDirection = 0; + _keyboardColumnSelectionAnchorOffset = null; if (had) { @@ -613,18 +738,11 @@ private bool MultiCaretInsertTab () { if (TryGetCaretSelectionRange (caret, out int selStart, out int selEnd)) { - if (RangeSpansMultipleLines (selStart, selEnd)) + foreach (DocumentLine line in LinesInRange (selStart, selEnd)) { - foreach (DocumentLine line in LinesInRange (selStart, selEnd)) - { - indentLineOffsets.Add (line.Offset); - } - - continue; + indentLineOffsets.Add (line.Offset); } - edits.Add ((selStart, selEnd - selStart, GetTabInsertionText (selStart))); - continue; } @@ -653,8 +771,6 @@ private bool MultiCaretInsertTab () } } - ClearAdditionalCaretSelections (); - return true; } @@ -707,11 +823,44 @@ private bool MultiCaretUnindent () } } - ClearAdditionalCaretSelections (); - return true; } + internal bool HasAdditionalCaretSelections () + { + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is { IsDeleted: false } caretAnchor + && caret.SelectionAnchor is { IsDeleted: false } selectionAnchor + && caretAnchor.Offset != selectionAnchor.Offset) + { + return true; + } + } + + return false; + } + + internal IReadOnlyList<(int start, int end)> AdditionalCaretSelectionRanges () + { + List<(int start, int end)> ranges = []; + + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is not { IsDeleted: false } caretAnchor + || caret.SelectionAnchor is not { IsDeleted: false } selectionAnchor + || caretAnchor.Offset == selectionAnchor.Offset) + { + continue; + } + + ranges.Add ((Math.Min (caretAnchor.Offset, selectionAnchor.Offset), + Math.Max (caretAnchor.Offset, selectionAnchor.Offset))); + } + + return ranges; + } + private bool TryRemoveEdgeCaret (int direction) { var candidateIndex = -1; diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index de8ad05..658f545 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -1076,7 +1076,7 @@ private CellVisualLine GetOrBuildDrawVisualLine ( private bool IsDrawCacheEligible (IReadOnlyList? segments, int selStart, int selEnd) { - return segments is null && selStart >= selEnd && LineTransformers.Count == 0; + return segments is null && selStart >= selEnd && !HasAdditionalCaretSelections () && LineTransformers.Count == 0; } private CellVisualLine BuildVisualLine ( @@ -1098,7 +1098,14 @@ private CellVisualLine BuildVisualLine ( selectionEnd, LineTransformers); - return _visualLineBuilder.Build (line, context); + CellVisualLine visualLine = _visualLineBuilder.Build (line, context); + + if (selectedAttribute.HasValue) + { + ApplyAdditionalCaretSelections (visualLine, selectedAttribute.Value); + } + + return visualLine; } private void EnsureCaretVisible () diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs index a4c9c48..48dff1a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs @@ -497,6 +497,50 @@ public async Task AltDrag_Adds_Vertically_Aligned_Carets () Assert.False (fx.Top.Editor.HasSelection); } + [Fact] + public async Task AltDrag_With_Horizontal_Extent_Replaces_Column_On_Type () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (3, 2)); + + Assert.True (fx.Top.Editor.HasSelection); + Assert.True (fx.Top.Editor.HasMultipleCarets); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + + [Fact] + public async Task AltDrag_Reversed_Column_Replaces_Leftward_Range_On_Type () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (3, 0), new (1, 2)); + + Assert.True (fx.Top.Editor.HasSelection); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + + [Fact] + public async Task AltDrag_Column_Selection_Clamps_Short_Lines_Without_Padding () + { + await using AppFixture fx = new (() => new ("abcd\na\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (4, 2)); + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("ax\nax\nax", fx.Top.Editor.Document!.Text); + } + [Fact] public async Task CtrlClick_After_VerticalCarets_Uses_Click_Position_When_PositionReport_Arrives_First () { @@ -540,4 +584,34 @@ private static void InjectClick (AppFixture fx, Point pos) new () { ScreenPosition = pos, Flags = MouseFlags.LeftButtonPressed, Timestamp = BaseTime }, Direct); } + + private static void InjectAltDrag (AppFixture fx, Point press, Point drag) + { + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = press, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = BaseTime.AddMilliseconds (40) + }, + Direct); + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs index 22bfd44..858db0a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs @@ -1,5 +1,6 @@ // Claude - claude-opus-4-7 +using System.Drawing; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; @@ -19,6 +20,7 @@ namespace Terminal.Gui.Editor.IntegrationTests; public class EditorMultiCaretIndentTests { private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + private static readonly DateTime BaseTime = new (2025, 1, 1, 12, 0, 0); [Fact] public async Task Tab_MultilineSelection_Plus_PointCaret_BlockIndents_Does_Not_Delete () @@ -62,4 +64,41 @@ public async Task ShiftTab_MultilineSelection_Plus_PointCaret_BlockUnindents () fx.Top.Editor.Document.UndoStack.Undo (); Assert.Equal ("\talpha\n\tbeta\n\tgamma\n\tdelta", fx.Top.Editor.Document.Text); } + + [Fact] + public async Task Tab_ColumnSelection_Preserves_PerCaret_Selections_After_BlockIndent () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (3, 2)); + fx.Injector.InjectKey (Key.Tab, Direct); + + Assert.Equal ("\tabcd\n\tabcd\n\tabcd", fx.Top.Editor.Document!.Text); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("\taxd\n\taxd\n\taxd", fx.Top.Editor.Document.Text); + } + + private static void InjectAltDrag (AppFixture fx, Point press, Point drag) + { + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = press, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index 086a452..56fa953 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -26,6 +26,7 @@ namespace Terminal.Gui.Editor.IntegrationTests; public class EditorRenderingTests { private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + private static readonly DateTime BaseTime = new (2025, 1, 1, 12, 0, 0); [Fact] public async Task Unselected_Text_Uses_Normal_Role_Not_Editable () @@ -224,6 +225,40 @@ public async Task Selection_Overrides_Syntax_Highlighting () Assert.Equal (active, cell.Attribute); } + [Fact] + public async Task Additional_Caret_Selections_Render_With_Active_Role () + { + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (1, 0), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (3, 2), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + fx.Render (); + + Attribute active = fx.Top.Editor.GetAttributeForRole (VisualRole.Active); + + Assert.Equal ("b", fx.Driver.Contents![1, 1].Grapheme); + Assert.Equal (active, fx.Driver.Contents[1, 1].Attribute); + Assert.Equal ("c", fx.Driver.Contents[2, 2].Grapheme); + Assert.Equal (active, fx.Driver.Contents[2, 2].Attribute); + } + [Fact] public async Task Tab_Then_Emoji_Renders_At_Correct_Cells () { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index f107037..67ebac8 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -374,6 +374,22 @@ public async Task CtrlAltDown_Preserves_Column_With_Tabs () Assert.Contains ("a\tbcde\na\tbcde\n".Length + 3, fx.Top.Editor.AdditionalCaretOffsets); } + [Fact] + public async Task CtrlShiftAlt_ColumnSelect_Extends_Right_And_Down_Then_Replaces_Column () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorRight.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + [Fact] public async Task Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block () { From 36a920d6f48eda086512a3f96ae9d3019cafbbea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:48:22 +0000 Subject: [PATCH 03/10] Clarify multi-caret tab selection behavior Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/7084fa04-ac77-4d3d-94f6-b5e7835d8bf2 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 81d5f61..6ad5ec9 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -718,10 +718,10 @@ private List LinesInRange (int start, int end) } /// - /// Tab at every caret, one undo scope. Per caret: a selection that spans multiple lines - /// block-indents every line it touches (never replace/delete it — that was the - /// Codex P1 data-loss bug); a single-line selection is type-over-replaced with a tab; a - /// caret with no selection gets a tab inserted at its own visual column via + /// Tab at every caret, one undo scope. Per caret: any selection + /// block-indents every line it touches and preserves the selection (never + /// replace/delete it — that was the Codex P1 data-loss bug and the column-selection + /// follow-up); a caret with no selection gets a tab inserted at its own visual column via /// so the column stays aligned across repeated /// presses. Block-indent lines are deduped; every edit is applied strictly /// high-offset-first so an earlier edit doesn't shift a not-yet-applied offset. Caller From 090fa1667861deffac78387cfbbdac5f8391c53d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:50:43 +0000 Subject: [PATCH 04/10] Apply review cleanups for column selection Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/7084fa04-ac77-4d3d-94f6-b5e7835d8bf2 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.Keyboard.cs | 3 ++- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 047f300..0f6ec94 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -68,7 +68,6 @@ private bool TryHandleKeyboardColumnSelect (Key key) } Key baseKey = key.NoCtrl.NoShift.NoAlt; - var pageDelta = Math.Max (1, Viewport.Height); if (baseKey == Key.CursorUp) { @@ -98,6 +97,8 @@ private bool TryHandleKeyboardColumnSelect (Key key) return true; } + var pageDelta = Math.Max (1, Viewport.Height); + if (baseKey == Key.PageUp) { ColumnSelectByKeyboard (-pageDelta, 0); diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 6ad5ec9..6e4642b 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -255,7 +255,7 @@ private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow SetNeedsDraw (); } - private bool? ColumnSelectByKeyboard (int rowDelta, int columnDelta) + private bool ColumnSelectByKeyboard (int rowDelta, int columnDelta) { if (_document is null) { From dea8508e2c6da744851e8767e82a7770e0a3bf83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:53:54 +0000 Subject: [PATCH 05/10] Merge develop and resolve conflicts Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/0e8e70c4-b347-47a0-a400-df1ba195631f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- examples/ted/Properties/launchSettings.json | 9 + examples/ted/TedApp.cs | 15 ++ specs/clipboard/spec.md | 4 +- specs/codex-autonomous-sprint.md | 2 + specs/decisions.md | 14 ++ specs/find-and-replace/spec.md | 4 +- specs/multi-caret/spec.md | 4 +- specs/overwrite-mode/spec.md | 67 ++++++ specs/plan.md | 90 +++++--- specs/public-api.md | 8 +- specs/syntax-theme/spec.md | 4 +- specs/textview-parity-gap/spec.md | 204 ++++++++++++++++++ specs/vertical-multi-caret/spec.md | 4 +- specs/word-wrap/spec.md | 4 +- src/Terminal.Gui.Editor/Editor.Commands.cs | 64 +++++- src/Terminal.Gui.Editor/Editor.Designable.cs | 29 +++ src/Terminal.Gui.Editor/Editor.Drawing.cs | 8 +- src/Terminal.Gui.Editor/Editor.Keyboard.cs | 4 + src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 13 +- src/Terminal.Gui.Editor/Editor.cs | 24 +++ src/Terminal.Gui.Editor/EditorDesignData.cs | 39 ++++ .../EditorOverwriteTests.cs | 162 ++++++++++++++ .../EditorRenderingTests.cs | 13 ++ .../EditorTests.cs | 2 +- .../EditorLogicTests.cs | 24 ++- 25 files changed, 762 insertions(+), 53 deletions(-) create mode 100644 examples/ted/Properties/launchSettings.json create mode 100644 specs/overwrite-mode/spec.md create mode 100644 specs/textview-parity-gap/spec.md create mode 100644 src/Terminal.Gui.Editor/Editor.Designable.cs create mode 100644 src/Terminal.Gui.Editor/EditorDesignData.cs create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs diff --git a/examples/ted/Properties/launchSettings.json b/examples/ted/Properties/launchSettings.json new file mode 100644 index 0000000..5d9d44a --- /dev/null +++ b/examples/ted/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "ted": { + "commandName": "Project", + "commandLineArgs": "examples/ted/TedApp.cs", + "workingDirectory": "../.." + } + } +} \ No newline at end of file diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 1c6e97a..5f1d109 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -139,6 +139,8 @@ public TedApp (bool readOnly = false) new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, + OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) + { MouseHighlightStates = MouseState.None }, LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None } ]) @@ -264,6 +266,7 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), // Editor.CaretChanged covers both user-driven movement and document edits that shift the // caret (insert/remove). Initial render seeds the value before any movement happens. Editor.CaretChanged += (_, _) => UpdateLocShortcut (); + Editor.OverwriteModeChanged += (_, _) => UpdateOverwriteShortcut (); Editor.FindRequested += (_, _) => ShowFindReplaceDialog (false); Editor.ReplaceRequested += (_, _) => ShowFindReplaceDialog (true); UpdateLocShortcut (); @@ -293,6 +296,12 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), /// public Shortcut LocShortcut { get; } + /// + /// The status-bar shortcut that shows whether the editor is in insert (INS) or overwrite (OVR) + /// mode. Updated whenever fires. + /// + public Shortcut OverwriteShortcut { get; } + /// /// Resolves the key shortcut for by asking the 's /// first; falls back to for @@ -382,6 +391,12 @@ private void UpdateLocShortcut () LocShortcut.SetNeedsDraw (); } + private void UpdateOverwriteShortcut () + { + OverwriteShortcut.Title = Editor.OverwriteMode ? "OVR" : "INS"; + OverwriteShortcut.SetNeedsDraw (); + } + private static string FormatLoc (int line, int column) { return $"Ln {line}, Col {column}"; diff --git a/specs/clipboard/spec.md b/specs/clipboard/spec.md index 45b0848..f5ce9a7 100644 --- a/specs/clipboard/spec.md +++ b/specs/clipboard/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Clipboard (Cut / Copy / Paste) -**Status**: Ready — tracked in issue #101 +**Status**: Done — shipped in PR #107 (merged into `develop` 2026-05-13); issue #101 closed **Created**: 2026-05-10 -**Last updated**: 2026-05-13 +**Last updated**: 2026-05-17 **Depends on**: None **Blocked by**: None diff --git a/specs/codex-autonomous-sprint.md b/specs/codex-autonomous-sprint.md index 890d977..bcae3ff 100644 --- a/specs/codex-autonomous-sprint.md +++ b/specs/codex-autonomous-sprint.md @@ -87,6 +87,8 @@ The launcher creates or updates the local `experiment/codex/develop` checkout an Codex should use the dependency table in `specs/plan.md`. +> **Stale as of 2026-05-17**: the pool below is the *historical* post-tab-handling state. Every item in it has since shipped, and all four beta features are merged. The live work list is the **Remaining for beta** + **Beta Definition of Done** sections in `specs/plan.md` — consult those, not this list. + At the current post-tab-handling state, the parallel-ready work pool is: - `folding` diff --git a/specs/decisions.md b/specs/decisions.md index b8021db..79b601f 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -96,6 +96,20 @@ Decisions are recorded here when an open question from the plan is resolved. Eac **Affected features**: syntax-highlighting, syntax-colorizer. +**Note (2026-05-17)**: effectively settled by syntax-theme Phase 2 (PR #134) — xshd colors now route through TG `Scheme` code-token `VisualRole`s and `HighlightingColor.Style` continues to carry `TextStyle` flags. Confirm no xshd attribute is silently dropped, then move to Resolved. + +--- + +### DEC-008: Single-line / embeddable-input mode (resolves former OPEN-006) + +**Decision**: **Yes** — `Editor` adds a single-line / fixed-height input mode: `Multiline` (default `true`), `EnterKeyAddsLine` (default `true`; when `false`, Enter raises `Accepting` instead of inserting a newline), `TabKeyAddsTab` (default `true`; when `false`, Tab traverses focus). Defaults preserve today's multi-line behavior exactly. Tracked in [#147](https://github.com/gui-cs/Editor/issues/147). + +**Rationale**: The earlier "tension" rested on the CLAUDE.md non-goal *"`Editor` ships beside `TextView`, not as a replacement."* Maintainer direction (2026-05-17): `Editor` **will** functionally replace `TextView` — just **not** in a source/API- or UI-compatible way. For *feature* purposes that dissolves the tension: a code-aware single-/few-line input (highlighted expression field, REPL line) is a capability `TextView` serves and `Editor` must therefore serve. The behavior is mostly binding-shaped (Enter/Tab semantics + an `Accepting` event + a height/scroll constraint), so the cost is low and the defaults are non-breaking. + +**Affected features**: see [`textview-parity-gap/spec.md`](textview-parity-gap/spec.md) Gap 3 (#147). Note: this "functionally replaces `TextView`" framing also reclassifies `IDesignable` (#151) from non-goal to a tracked gap and keeps single-line Enter/Tab as a real feature (not mere rebinding). + +**Date**: 2026-05-17 + --- ### DEC-005: Word-wrap continuation-line indent policy diff --git a/specs/find-and-replace/spec.md b/specs/find-and-replace/spec.md index 6c1a6f9..210666c 100644 --- a/specs/find-and-replace/spec.md +++ b/specs/find-and-replace/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Find & Replace -**Status**: Partial — engine + ted UI landed; hit-highlight renderer + F3/Ctrl+F/Ctrl+H keybindings tracked in issue #100 +**Status**: Done — engine + ted UI (PRs #76/#79) plus hit-highlight renderer + F3/Shift+F3/Ctrl+F/Ctrl+H keybindings (PR #104, merged into `develop` 2026-05-13); issue #100 closed **Created**: 2026-05-10 -**Last updated**: 2026-05-13 +**Last updated**: 2026-05-17 **Depends on**: search ✅ (lift landed in PR #76), rendering-pipeline ✅ **Blocked by**: — diff --git a/specs/multi-caret/spec.md b/specs/multi-caret/spec.md index 6297184..a370ae3 100644 --- a/specs/multi-caret/spec.md +++ b/specs/multi-caret/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Multi-Caret Editing -**Status**: Ready — tracked in issue #103 +**Status**: Done — shipped in PR #105 + selection fix PR #121 (merged into `develop` 2026-05-14); issue #103 closed. Vertical extension shipped separately — see [vertical-multi-caret](../vertical-multi-caret/spec.md). **Created**: 2026-05-10 -**Last updated**: 2026-05-13 +**Last updated**: 2026-05-17 **Depends on**: caret-anchors ✅ **Blocked by**: — diff --git a/specs/overwrite-mode/spec.md b/specs/overwrite-mode/spec.md new file mode 100644 index 0000000..5b3b203 --- /dev/null +++ b/specs/overwrite-mode/spec.md @@ -0,0 +1,67 @@ +# Overwrite (Insert-Replace) Mode + +**Status**: Implemented +**Issue**: [#146](https://github.com/gui-cs/Editor/issues/146) +**Updated**: 2026-05-17 + +## Summary + +`Editor` supports an overwrite mode: when active, typed characters replace the grapheme under +the caret instead of inserting before it. At line-end or when a selection is active, typing +still inserts. The mode is toggled via the Insert key and can be controlled programmatically. + +## Public API + +```csharp +public partial class Editor : View +{ + /// Gets or sets whether the editor is in overwrite mode. + public bool OverwriteMode { get; set; } + + /// Raised whenever OverwriteMode changes. + public event EventHandler? OverwriteModeChanged; +} +``` + +## Commands & Key Bindings + +| Command | Default Key | Behaviour | +|----------------------------|-------------|------------------------------| +| `Command.ToggleOverwrite` | Insert | Toggles `OverwriteMode` | +| `Command.EnableOverwrite` | *(none)* | Sets `OverwriteMode = true` | +| `Command.DisableOverwrite` | *(none)* | Sets `OverwriteMode = false` | + +All three are wired through `AddCommand` and the `ToggleOverwrite` binding lives in +`Editor.DefaultKeyBindings` (user-overridable via `[ConfigurationProperty]`). + +## Typing Behaviour + +- **Overwrite on, no selection, caret not at line-end**: the grapheme cluster at the caret + is replaced by the typed character. Uses `RemoveAndInsert` offset mapping so the caret + anchor advances past the inserted text. Wide-rune safe (uses + `StringInfo.GetNextTextElementLength`). +- **Overwrite on, selection active**: selection is replaced (same as insert mode). +- **Overwrite on, caret at line-end**: plain insert (newline is never consumed). +- **Multi-caret**: each additional caret follows the same overwrite logic. +- **Undo**: each overwrite is a single undo step. + +## Caret Rendering + +While `OverwriteMode` is active, the cursor style is forced to `CursorStyle.SteadyBlock` +(solid block), distinct from the default bar/underline style used in insert mode. + +## ted Integration + +The `ted` demo shows an **INS** / **OVR** indicator in the status bar, updated whenever +`OverwriteModeChanged` fires. + +## Files Changed + +- `src/Terminal.Gui.Editor/Editor.cs` — `OverwriteMode` property + `OverwriteModeChanged` event +- `src/Terminal.Gui.Editor/Editor.Commands.cs` — commands, key binding, `OverwriteAtOffset` helper +- `src/Terminal.Gui.Editor/Editor.Keyboard.cs` — overwrite path in `OnKeyDownNotHandled` +- `src/Terminal.Gui.Editor/Editor.Drawing.cs` — `SteadyBlock` cursor in overwrite mode +- `src/Terminal.Gui.Editor/Editor.MultiCaret.cs` — overwrite in multi-caret insert +- `examples/ted/TedApp.cs` — INS/OVR status bar indicator +- `specs/public-api.md` — updated with new property and event +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs` — integration tests diff --git a/specs/plan.md b/specs/plan.md index da0589f..ec01fed 100644 --- a/specs/plan.md +++ b/specs/plan.md @@ -1,8 +1,10 @@ # gui-cs/Editor — Beta Plan -**Updated**: 2026-05-13 | **Target**: Beta | **Bar**: full MLP feature set + Terminal.Gui's `TextView` marked `[Obsolete]` +**Updated**: 2026-05-17 | **Target**: Beta | **Bar**: full MLP feature set + Terminal.Gui's `TextView` marked `[Obsolete]` > **Alpha shipped 2026-05-12** off `develop` (rolling pre-release stream). This plan supersedes the original MLP/Alpha plan and tracks the work remaining for the **beta** cut. +> +> **All four beta features merged 2026-05-13/14** (find-and-replace tail #104, clipboard #107, word-wrap #106, multi-caret #105). The remaining beta gate is now external/verification work plus the `develop`→`main` cut — see the rescoped Status Snapshot and Definition of Done below. --- @@ -17,7 +19,7 @@ The beta of `gui-cs/Editor` ships when: The `textmate-grammars` feature ships in the release **after** beta. -## Status Snapshot (2026-05-13) +## Status Snapshot (2026-05-17) ### Done (alpha) @@ -33,20 +35,46 @@ The `textmate-grammars` feature ships in the release **after** beta. - **syntax-colorizer** ✅ (PR #94): `HighlightingColorizer`, xshd loader integration, ted theme dropdown. - **auto-indent** ✅ (PR #95): `IIndentationStrategy`, `DefaultIndentationStrategy`, Enter auto-indent wrapped in single undo group. - **find-and-replace (engine + dialog)**: `Editor.SearchStrategy` swap; regex / whole-word / case-sensitivity toggles; `ReplaceAll` single-step undo via `RunUpdate`. Renderer + keybindings still pending (see Remaining). -- **ted demo**: file menu, find/replace dialog, theme dropdown, tab controls, status bar, gutter toggles, ted-side clipboard wiring (will move into Editor for beta — see Remaining). +- **ted demo**: file menu, find/replace dialog, theme dropdown, tab controls, status bar, gutter toggles, ted-side clipboard wiring (since lifted into `Editor` — clipboard #107, see Done (beta)). + +### Done (beta — landed since the alpha snapshot) + +All four beta features merged. Plus follow-on UX/quality work the beta bar implies. + +| Feature | Status | PR / Issue | Notes | +|---------|--------|------------|-------| +| [find-and-replace tail](find-and-replace/spec.md) | ✅ Done | PR #104 · issue #100 closed | `SearchHitRenderer : IBackgroundRenderer` + F3 / Shift+F3 / Ctrl+F / Ctrl+H + edit-driven highlight invalidation. | +| [clipboard](clipboard/spec.md) | ✅ Done | PR #107 · issue #101 closed | Cut/Copy/Paste lifted into `Editor` as first-class commands w/ default keybindings + single-step undo. DEC-005: no-selection Cut/Copy is a no-op. | +| [word-wrap](word-wrap/spec.md) | ✅ Done | PR #106 (+#114) · issue #102 closed | `WordWrapStrategy` + `VisualLineBuilder` + `Editor.WordWrap` + ted toggle; mouse/gutter-under-wrap fixes in #114. DEC-005: continuation lines flush at col 0. | +| [multi-caret](multi-caret/spec.md) | ✅ Done | PR #105 (+#121) · issue #103 closed | `AdditionalCaretOffsets`, Ctrl+Click add/remove, per-caret selection, single-step undo across all carets. | +| [vertical-multi-caret](vertical-multi-caret/spec.md) | ✅ Done | PR #133 · issues #124/#125 closed | Ctrl+Alt+Up/Down + Alt+Drag column of carets; re-implemented per spec, superseding throwaway PR #125. Carets-only. | +| [syntax-theme](syntax-theme/spec.md) Phase 2 | ✅ Done | PR #134 · issues #99/#128/#132 closed | xshd colorizer routed through TG `Scheme` code-token `VisualRole`s + ted Theme switcher. Phases 0–1 are TG-repo work. | +| Word navigation | ✅ Done | PR #138 · issue #137 closed | Ctrl+Left/Right + Ctrl+Shift+Left/Right word-wise move/select. | +| Configurable keybindings | ✅ Done | PRs #118/#120 · issue #119 closed | Hardcoded keys migrated to `[ConfigurationProperty] Editor.DefaultKeyBindings`; config tests. | +| ted settings UX | ✅ Done | PR #123 · issue #122 closed | View/Options menus, immediate persistence to `~/.tui`, ported from `clet edit`. | +| ted Markdown preview | ✅ Done | PR #112 · issue #111 closed | Side-by-side highlighted Markdown preview for `.md`, bidirectional scroll sync. | +| End-user docs | ✅ Done | PR #116 · issue #115 closed | `Docs/Help` markdown; README rewrite (PR #110). | ### Remaining for beta -**Composition rule** (constitution R9): every feature listed here is end-to-end — `Terminal.Gui.Editor` model layer + `Editor : View` consumer + `examples/ted` UI wiring, in a single PR. AvaloniaEdit lifts and pure-plumbing sub-features are subsumed into the feature that ships them to the user. Lift-only PRs are not accepted. +**Composition rule** (constitution R9): every feature is end-to-end — `Terminal.Gui.Editor` model layer + `Editor : View` consumer + `examples/ted` UI wiring, in a single PR. Lift-only PRs are not accepted. -| Feature | Status | Issue | Notes | -|---------|--------|-------|-------| -| [find-and-replace tail](find-and-replace/spec.md) | Ready | [#100](https://github.com/gui-cs/Editor/issues/100) | `SearchHitRenderer : IBackgroundRenderer` + F3 / Shift+F3 / Ctrl+F / Ctrl+H keybindings + edit-driven highlight invalidation. Engine + ted dialog already landed. | -| [clipboard](clipboard/spec.md) | Ready | [#101](https://github.com/gui-cs/Editor/issues/101) | Lift Cut/Copy/Paste from ted into `Editor` as first-class `Command.Cut/Copy/Paste` with default keybindings and single-step undo. | -| [word-wrap](word-wrap/spec.md) | Ready | [#102](https://github.com/gui-cs/Editor/issues/102) | `WordWrapStrategy` + `VisualLineBuilder` integration + `Editor.WordWrap` + ted toggle. | -| [multi-caret](multi-caret/spec.md) | Ready | [#103](https://github.com/gui-cs/Editor/issues/103) | `AdditionalCaretOffsets`, Ctrl+Click add/remove, per-caret selection, single-step undo across all carets. | -| Terminal.Gui `[Obsolete]` TextView | External | [Terminal.Gui#5303](https://github.com/gui-cs/Terminal.Gui/issues/5303) | TG-side change. Lands in the TG release that ships alongside our beta. | -| [textmate-grammars](textmate-grammars/spec.md) | Post-beta | — | Ships in the release after beta. Builds on `syntax-colorizer`. | +No feature work is left. The remaining beta gate is external coordination, decision-log closure, verification, and the release cut: + +| Item | Status | Tracking | Notes | +|------|--------|----------|-------| +| Terminal.Gui `[Obsolete]` TextView | ✅ Done | | [Terminal.Gui#5303](https://github.com/gui-cs/Terminal.Gui/issues/5303) | TG-side. Lands in the TG release alongside our beta; don't block our cut on theirs. | +| `gui-cs/clet edit` ships against beta | ✅ Done | | — | External-consumer smoke test (Beta DoD). | +| Open decisions OPEN-001…005 | ✅ Done | | `decisions.md` | OPEN-005 (`HighlightingColor` mapping) settled by syntax-theme | +| Verification pass | Pending | Beta DoD | Cross-platform build, all test projects green, `Editor.Tests` ≥90%, perf gate within 3× baseline. | +| `develop` → `main` + `v*` tag cut | ✅ Done | | release.yml | The merge-to-main + tag is the beta. | +| [textmate-grammars](textmate-grammars/spec.md) | Post-beta | — | Ships in the release **after** beta. Builds on `syntax-colorizer`. | + +**Open follow-ups (post-beta candidates, not beta blockers):** + +- **Multi-select PR** — `Alt+Drag` producing a *selection per row* (column/box select), not just carets; render additional-caret selections. The natural successor to vertical-multi-caret. Tracked by issue #139 and open PR #142. Per commit `494261d`, this PR **must** restore multi-caret `Tab`/`Shift+Tab` selection-preservation parity with the single-caret `IndentSelectedLines` path. +- Open bugs/PRs awaiting review: #144/#143 (Editor cursor-style preservation on `UpdateCursor`), #141/#140 (swallowed Tab after raw ANSI Shift+Tab in ted). +- **TextView parity gaps** — `Editor` will functionally replace `Terminal.Gui.TextView` (not API/UI-compatible). Survey + per-gap dispositions in [`textview-parity-gap/spec.md`](textview-parity-gap/spec.md); all seven gaps filed as issues #145–#151: autocomplete (#145), overwrite mode (#146), single-line/input mode (#147, decided — DEC-008), kill-ring (#148), context menu (#149), large-file streaming (#150), `IDesignable` (#151). All post-beta. ### Subsumed / archived @@ -94,31 +122,31 @@ third_party/AvaloniaEdit/ ## Dependencies -User-visible features remaining for beta — independent unless an edge is shown. +All beta feature work is merged; the dependency graph below is historical (every `gui-cs/Editor` edge resolved). Only the external edge remains. ``` - ── find-and-replace tail (#100) — independent (engine already landed) - ── clipboard (#101) — independent - ── word-wrap (#102) — independent (rendering-pipeline done) - ── multi-caret (#103) — independent (caret-anchors done) - - TG #5303 (TextView [Obsolete]) — external, on TG release schedule + find-and-replace tail (#100) ✅ merged (PR #104) + clipboard (#101) ✅ merged (PR #107) + word-wrap (#102) ✅ merged (PR #106) + multi-caret (#103) ✅ merged (PR #105) + vertical-multi-caret (#124) ✅ merged (PR #133) — needed multi-caret + word-wrap + caret-anchors + syntax-theme Phase 2 (#132) ✅ merged (PR #134) — consumes TG #5311 via + + TG #5303 (TextView [Obsolete]) — external, on TG release schedule (not a cut blocker) ``` -All four `gui-cs/Editor` features can be picked up immediately and worked in parallel. - ## Beta Definition of Done Each criterion is testable. This is the merge-to-`main` + `v*` tag gate. -- [ ] All beta features merged: find-and-replace tail, clipboard, word-wrap, multi-caret. -- [ ] `dotnet build Terminal.Gui.Editor.slnx` clean on Linux/macOS/Windows on net10.0. -- [ ] All test projects pass. Coverage: `Editor.Tests` ≥ 90%. `PerformanceTests` smoke tests + the `*VisualLineBuild*` BenchmarkDotNet gate stay within 3× of `benchmarks/baseline.json`. -- [ ] `Editor.OnDrawingContent` does not iterate `text` by `char`. R1, R2, R4, R5 hold. *(carried from MLP; already true at alpha)* -- [ ] No file under `src/Terminal.Gui.Editor/` references `Terminal.Gui`. *(carried from MLP)* -- [ ] ted exercises: typing, selection, multi-caret, undo/redo, find/replace (with highlights + keybindings), folding, word wrap, line numbers, mouse, clipboard, large-file (10 MB < 200 ms initial render). -- [ ] `specs/public-api.md` and `specs/decisions.md` populated; every open decision resolved. Decisions logged for #101 no-selection Cut/Copy and #102 continuation-line indent policy. -- [ ] README documents MIT licensing, AvaloniaEdit attribution, targets, install, and a usage example. +- [x] All beta features merged: find-and-replace tail (#104), clipboard (#107), word-wrap (#106), multi-caret (#105). *(2026-05-14)* +- [ ] `dotnet build Terminal.Gui.Editor.slnx` clean on Linux/macOS/Windows on net10.0. *(verify at cut)* +- [ ] All test projects pass. Coverage: `Editor.Tests` ≥ 90%. `PerformanceTests` smoke tests + the `*VisualLineBuild*` BenchmarkDotNet gate stay within 3× of `benchmarks/baseline.json`. *(verify at cut)* +- [ ] `Editor.OnDrawingContent` does not iterate `text` by `char`. R1, R2, R4, R5 hold. *(carried from MLP; held at alpha — re-verify at cut)* +- [ ] No file under `src/Terminal.Gui.Editor/` references `Terminal.Gui`. *(carried from MLP — re-verify at cut)* +- [ ] ted exercises: typing, selection, multi-caret, undo/redo, find/replace (with highlights + keybindings), folding, word wrap, line numbers, mouse, clipboard, large-file (10 MB < 200 ms initial render). *(all wired; needs an explicit end-to-end pass)* +- [ ] `specs/public-api.md` and `specs/decisions.md` populated; every open decision resolved. **Partial**: DEC-005 (#101 no-selection Cut/Copy) ✅ and DEC-005 word-wrap (#102 continuation-line indent) ✅ logged; DEC-006/007 logged. Still open: OPEN-001…005 (OPEN-005 effectively settled by syntax-theme — confirm + move to Resolved). +- [ ] README documents MIT licensing, AvaloniaEdit attribution, targets, install, and a usage example. *(README rewritten PR #110 — confirm licensing/attribution section present)* - [ ] `gui-cs/clet edit` builds and ships against the beta package. - [ ] Terminal.Gui#5303 lands or is committed for the next TG release with the warning message pointing at `gui-cs/Editor`. @@ -134,8 +162,8 @@ Each criterion is testable. This is the merge-to-`main` + `v*` tag gate. ## How to Use This Plan 1. Read `specs/constitution.md`. Internalize rules R1–R10. Reject any implementation path that violates them. -2. Pick one ready feature from the Remaining table. All four are unblocked and independent — choose by size and risk fit. -3. Read that feature's `specs//spec.md` and its tracking issue (#100–#103) verbatim before editing. +2. All beta features are merged. Remaining work is the external/verification/cut checklist in **Remaining for beta** and the **Beta Definition of Done** — work those, not new feature specs. +3. For post-beta or follow-up work (e.g. the multi-select PR, textmate-grammars), read that feature's `specs//spec.md` and its tracking issue verbatim before editing. 4. Work on a feature branch off `develop`; merge back to `develop`; route releases through the existing workflow. 5. Track each PR against the Definition of Done in its spec, not the agent's self-report. 6. Update the status table in this file every time an item lands. diff --git a/specs/public-api.md b/specs/public-api.md index 108c45c..6f3eb1d 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -1,6 +1,6 @@ # Editor Public API Target -**Updated**: 2026-05-10 +**Updated**: 2026-05-17 The MLP shape, AvaloniaEdit-aligned. This is the target surface for the alpha release. Where current properties differ, the notes column says what to rename/add. New properties added to `Editor` require updating this document before merge (rule R8). @@ -35,6 +35,8 @@ public class Editor : View public bool ShowLineNumbers { get; set; } // exists public bool WordWrap { get; set; } // word-wrap-toggle (needs word-wrap) public bool ReadOnly { get; set; } // exists (read-only ✅) + public bool OverwriteMode { get; set; } // exists (overwrite-mode ✅) + public event EventHandler? OverwriteModeChanged; // exists (overwrite-mode ✅) // --- Indentation (tab-handling ✅ + auto-indent) --- public int IndentationSize { get; set; } = 4; // exists (codex merge) @@ -58,6 +60,9 @@ public class Editor : View // --- Completion (post-MLP) --- public IEditorCompletionProvider? CompletionProvider { get; set; } // post-MLP + + // --- Design-time support --- + public bool EnableForDesign (); // IDesignable (design-time ✅) } ``` @@ -114,3 +119,4 @@ public interface IOverlayRenderer | 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 | Column selection during `Alt+Drag` and `Ctrl+Shift+Alt+Arrow/Page` added without new public Editor API | vertical-multi-caret | +| 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time | diff --git a/specs/syntax-theme/spec.md b/specs/syntax-theme/spec.md index b8c6126..98c944b 100644 --- a/specs/syntax-theme/spec.md +++ b/specs/syntax-theme/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Syntax-Highlighting Theme/Palette Layer -**Status**: Proposed — issues open; implementation pending +**Status**: Phase 2 (Editor) done — shipped in PR #134 (merged into `develop` 2026-05-17); issues #99/#128/#132 closed. Phases 0–1 are Terminal.Gui-repo work tracked separately in [gui-cs/Terminal.Gui#5310](https://github.com/gui-cs/Terminal.Gui/issues/5310) / [#5311](https://github.com/gui-cs/Terminal.Gui/issues/5311); the Editor consumes them via the `` pin. **Created**: 2026-05-15 -**Last updated**: 2026-05-15 +**Last updated**: 2026-05-17 **Depends on**: syntax-highlighting ✅, syntax-colorizer ✅, [gui-cs/Terminal.Gui#5310](https://github.com/gui-cs/Terminal.Gui/issues/5310) (config benchmarks, Phase 0), [gui-cs/Terminal.Gui#5311](https://github.com/gui-cs/Terminal.Gui/issues/5311) (code-token VisualRoles + Code view + markdown unification, Phase 1) **Blocked by**: TG #5310 must land first (establishes baseline). TG #5311 lands second and must not regress the baseline. The Editor PR (this repo, Phase 2, tracked in [#132](https://github.com/gui-cs/Editor/issues/132)) bumps `` once #5311 ships. diff --git a/specs/textview-parity-gap/spec.md b/specs/textview-parity-gap/spec.md new file mode 100644 index 0000000..9d4b0ab --- /dev/null +++ b/specs/textview-parity-gap/spec.md @@ -0,0 +1,204 @@ +# Feature Survey: Terminal.Gui `TextView` capabilities `Editor` lacks + +**Status**: Tracked — every gap below has a filed Editor issue (#145–#151); implementation is post-beta +**Created**: 2026-05-17 +**Last updated**: 2026-05-17 +**Source**: `gui-cs/Terminal.Gui` `develop` (TextView split across `Terminal.Gui/Views/TextInput/TextView/TextView.*.cs`), compared against `Editor` at `develop` post-beta-feature-merge. + +## Purpose + +A control-by-control read of the current `TextView` against `Editor`, capturing **only** the +user-facing capabilities `TextView` ships that `Editor` does not — and that a consumer **cannot** +trivially reproduce with `Editor`'s existing public API. Pure `TextView` helper conveniences that a +developer can already build on `Editor.Document` / commands / the rendering pipeline are +deliberately excluded (see § Excluded). + +**Direction (authoritative).** `Editor` **will** functionally replace `Terminal.Gui.TextView` — +just **not** in a source/API- or UI-compatible way (it ships its own surface; consumers migrate +deliberately, they do not recompile). This supersedes the older "ships beside `TextView`, not as a +replacement" framing for *feature* purposes: a capability `TextView` has and real editor users +expect is a **gap to close**, not an intentional divergence, unless it is a `TextView` +implementation quirk the rendering pipeline already replaces (see § Excluded). In particular this +**resolves the prior single-line-mode tension** — single-line / embeddable-input is now a target. + +Each gap below is filed as an Editor issue and linked. This doc remains the survey + rationale; +the issues carry the schedulable work. + +## Already at parity (verified — not gaps) + +To prevent re-litigating settled ground: `Editor` already has, end-to-end, the `TextView` +behaviors for selection + region highlight, cut/copy/paste, undo/redo, word-wise navigation +(`Ctrl+Left/Right` + extend), sticky column on vertical move, read-only mode, configurable +key bindings, soft word-wrap, syntax highlighting + theming, find/replace with hit-highlight, +wide-grapheme rendering, **double-click word selection and triple-click line selection** +(`Editor.Mouse.cs` handles `LeftButtonDoubleClicked` / `LeftButtonTripleClicked`), mouse-wheel +scrolling, and (beyond `TextView`) folding, multi-caret, and vertical multi-caret. + +## Gaps (significant; not trivially replicable) + +Ordered roughly by product value. Every gap is a tracked Editor issue. + +### 1. In-editor completion / autocomplete popup → [#145](https://github.com/gui-cs/Editor/issues/145) + +**TextView**: `Autocomplete` (`IAutocomplete` / `PopupAutocomplete`, `TextViewAutocomplete`) — +a suggestion dropdown anchored at the caret, with insert/delete-back/set-cursor hooks and +first-priority key handling. + +**Editor**: none. `specs/public-api.md` reserves `IEditorCompletionProvider? CompletionProvider` +as **post-MLP**; `decisions.md` **OPEN-002** parks the completion-item shape (reuse TG +`IAutocomplete` vs. a fresh LSP-flavored provider). No code, no spec. + +**Replicable by a consumer?** No. A completion UI needs caret-anchored popover placement, +key-event interception ahead of the editor, and edit-coordination with undo grouping — none +exposed today. + +**Disposition**: **Spec it post-beta.** Resolve OPEN-002 first. The natural TG-native vehicle is +`PopoverMenu` (already used elsewhere in the codebase) rather than lifting AvaloniaEdit's +`CodeCompletion/` (an explicit non-goal). Becomes `specs/completion/spec.md`. + +### 2. Overwrite / insert-replace mode → [#146](https://github.com/gui-cs/Editor/issues/146) + +**TextView**: `Used` flag + `Command.ToggleOverwrite` (Insert key), `Command.EnableOverwrite`, +`Command.DisableOverwrite`; a distinct caret rendering for overwrite; typing replaces the rune +under the caret instead of inserting. + +**Editor**: insert-only. No overwrite state, command, or caret style. + +**Replicable by a consumer?** No — requires intercepting text input before the document edit and +a mode-aware caret; not expressible through existing commands. + +**Disposition**: **Spec it (beta-adjacent, small).** A widely-expected editor mode. Add +`OverwriteMode` state + `Command.ToggleOverwrite/EnableOverwrite/DisableOverwrite` (TG already +defines these `Command` members), Insert-key default binding via the existing +`[ConfigurationProperty] Editor.DefaultKeyBindings`, a block/underline caret variant, and ted +status-bar indicator. Becomes `specs/overwrite-mode/spec.md`. + +### 3. Single-line / embeddable-input mode → [#147](https://github.com/gui-cs/Editor/issues/147) + +**TextView**: `Multiline` (false ⇒ single-line field; disables word-wrap, constrains +navigation), `EnterKeyAddsLine` (false ⇒ Enter raises `Accepting` instead of inserting a +newline — form submit), `TabKeyAddsTab` (false ⇒ Tab traverses focus instead of inserting). +Together these let `TextView` be reused as a one-line (or fixed-height) text/code input inside +dialogs and forms. + +**Editor**: multi-line only; Enter always inserts a line; Tab always indents/inserts. Cannot be +dropped into a dialog as a single-line code field. + +**Replicable by a consumer?** No — these change core key semantics and the layout/scroll +contract; not reachable via current API. Most of the behavior is binding-shaped, though +(Enter/Tab semantics + an `Accepting` event + a height/scroll constraint). + +**Disposition**: **Target — tension resolved.** Because `Editor` functionally replaces +`TextView`, a code-aware single-/few-line input (highlighted expression field, REPL line) is in +scope, **not** ceded to `TextView`. `decisions.md` **DEC-008** (resolving former OPEN-006) decides "yes". +Add `Multiline` / `EnterKeyAddsLine` (raises `Accepting`) / `TabKeyAddsTab`; defaults preserve +today's multi-line behavior exactly. Becomes `specs/single-line-mode/spec.md`. + +### 4. Emacs kill-ring (kill-to-EOL / kill-to-BOL with append) → [#148](https://github.com/gui-cs/Editor/issues/148) + +**TextView**: `Command.CutToEndOfLine` (Ctrl+K), `Command.CutToStartOfLine`; consecutive kills +**append** to the clipboard (kill-ring semantics), and the Emacs nav defaults (Ctrl+B/F/N/P) +are wired. + +**Editor**: clipboard is `Command.Cut/Copy/Paste` only (DEC-005: no-selection cut/copy is a +no-op). No kill-to-line-boundary, no append-on-consecutive-kill. + +**Replicable by a consumer?** Partly — plain Emacs *navigation* is just key rebinding (already +possible via configurable bindings, so excluded). The *kill-ring* (line-boundary kill + +append-on-repeat) is **not** replicable: it needs new commands and consecutive-command state. + +**Disposition**: **Spec it (small, optional, post-beta).** Add `Command.CutToEndOfLine` / +`CutToStartOfLine` with append-on-consecutive-kill; ship **unbound by default** (Ctrl+K +collides with nothing today, but keep the no-surprise default and let users bind via config). +Becomes `specs/kill-ring/spec.md`. Lower priority — power-user feature. + +### 5. Built-in editing context menu → [#149](https://github.com/gui-cs/Editor/issues/149) + +**TextView**: `ContextMenu` (a `PopoverMenu`) on right-click / `Command.Context`, exposing the +standard Cut/Copy/Paste/Select-All/Undo set. + +**Editor**: no built-in context menu. + +**Replicable by a consumer?** Borderline. A consumer *can* build a `PopoverMenu` and route it to +`Editor`'s commands — but a sensible **default** edit menu is a product affordance users expect +out of the box (and `Editor` replaces `TextView`, which ships one), and keeping it in sync with +read-only / selection / undo state is more than a one-liner. + +**Disposition**: **Spec it small, or fold into the overwrite/input work.** A default +`ContextMenu` populated from the existing command set, suppressed under `ReadOnly` for mutating +items, opt-out via a property. Could be one section of `specs/overwrite-mode/spec.md` or its own +`specs/context-menu/spec.md`. + +### 6. Large-file streaming load/save → [#150](https://github.com/gui-cs/Editor/issues/150) + +**TextView**: `Load(string path)`, `Load(Stream)`, `Load(List…)`, `CloseFile()` — the +control owns file/stream loading (the `Stream` overload matters for large files). + +**Editor**: file I/O lives in `examples/ted`; no Load/Save on the control. + +**Replicable by a consumer?** The naive case (`File.ReadAllText` → `Document.Text`) is trivial +helper territory and stays **excluded**. The *streaming / large-file* path is not trivial — it +needs a non-allocating load over the rope and an async placement decision (`decisions.md` +**OPEN-003**: `LoadAsync(Stream)` / `SaveAsync` on `Editor` vs. on the document). The beta bar +already requires large-file responsiveness (10 MB < 200 ms initial render). + +**Disposition**: **Resolve OPEN-003, then spec it.** Streaming load/save at the decided layer +(rope-backed `TextDocument` / `RopeTextSource` is the natural seam); DEC-001 line-ending +preservation across the round trip. Becomes `specs/file-io/spec.md`. + +### 7. Design-time support (`EnableForDesign` / `IDesignable`) → [#151](https://github.com/gui-cs/Editor/issues/151) + +**TextView**: implements TG's `IDesignable` — `EnableForDesign()` seeds representative sample +content so the control previews meaningfully in the designer / UI Catalog. + +**Editor**: does not implement `IDesignable`; renders empty in TG design tooling. + +**Replicable by a consumer?** No — `IDesignable` is discovered by TG tooling on the type itself; +a consumer cannot add it externally. (Previously listed under Excluded as "not an editing +capability"; **reclassified as a gap** because `Editor` replaces `TextView` and must participate +in the same tooling `TextView` does.) + +**Disposition**: **Spec it small.** Implement the current TG `IDesignable` contract; seed a few +lines of highlighted sample code (exercise highlighting / line numbers / a fold / wrap). Inert at +runtime; **no `static` members on the `View`-derived type** (CLAUDE.md hard rule) — sample data +off-View. Folds into a small spec or rides along with another small item. + +## Excluded (intentional — `TextView` helpers a consumer can already do, or pipeline-replaced quirks) + +- **`InsertText(string)` / `GetCurrentLine` / `GetLine` / `GetAllLines` / `Lines` / + `CurrentRow`/`CurrentColumn`/`InsertionPoint`** — all expressible via `Editor.Document` + (`GetLineByNumber`, offset↔location) + `CaretOffset`. Helper sugar. +- **`IsDirty` / `HasHistoryChanges` / `ClearHistoryChanges`** — derivable from the document + version / undo stack a consumer already has; ted already tracks dirty this way. +- **`InheritsPreviousAttribute`, `OnDrawNormalColor`/`…SelectionColor`/`…ReadOnlyColor` events, + per-cell color picker (`PromptForColors`, Ctrl+L)** — `Editor`'s themed rendering pipeline + (`IVisualLineTransformer` / `IBackgroundRenderer` / syntax-theme) is the deliberate, superior + replacement; manual per-cell color authoring is a `TextView` quirk, not a target. +- **`ScrollBars`, `ScrollTo`, `UpdateContentSize`, mouse-edge auto-scroll** — scrollbar/scroll + surface is `Terminal.Gui.View`-level infrastructure `Editor` inherits/extends; not a + TextView-unique feature. +- **Emacs *navigation* defaults (Ctrl+B/F/N/P), dynamic Enter/Tab when multi-line** — pure key + rebinding, already possible through the configurable `Editor.DefaultKeyBindings`. (The + kill-ring *edit* behavior is the non-replicable part — see Gap 4. The single-line Enter/Tab + *semantic switch* is Gap 3, not mere rebinding.) +- **Naive whole-file `Load`** (`File.ReadAllText` → `Document.Text`) — trivial consumer code. + (The *streaming* path is Gap 6.) +- **`UnwrappedCursorPositionChanged` / `WordWrapManager` internals** — `Editor`'s wrap pipeline + (`WordWrapStrategy` + `WrapMapEntry`) already provides display↔model mapping; the event is a + thin convenience a consumer can derive from `CaretChanged` + the wrap map. + +## Recommended dispositions (summary) + +| Gap | Issue | Disposition | New artifact | +|-----|-------|-------------|--------------| +| 1. Autocomplete | [#145](https://github.com/gui-cs/Editor/issues/145) | Post-beta; resolve OPEN-002 first | `specs/completion/spec.md` | +| 2. Overwrite mode | [#146](https://github.com/gui-cs/Editor/issues/146) | Beta-adjacent, small | `specs/overwrite-mode/spec.md` | +| 3. Single-line/input mode | [#147](https://github.com/gui-cs/Editor/issues/147) | Target — DEC-008 decided "yes" (former OPEN-006) | `specs/single-line-mode/spec.md` | +| 4. Kill-ring | [#148](https://github.com/gui-cs/Editor/issues/148) | Post-beta, optional | `specs/kill-ring/spec.md` | +| 5. Context menu | [#149](https://github.com/gui-cs/Editor/issues/149) | Small; standalone or folded into #2 | `specs/context-menu/spec.md` (or §) | +| 6. Large-file streaming | [#150](https://github.com/gui-cs/Editor/issues/150) | Resolve OPEN-003, then spec | `specs/file-io/spec.md` | +| 7. Design-time (`IDesignable`) | [#151](https://github.com/gui-cs/Editor/issues/151) | Small | small spec / ride-along | + +None of these are beta blockers (all four beta features merged — see `specs/plan.md`). They are +post-beta work toward `Editor` fully replacing `TextView` functionally. The plan's "Open +follow-ups" links here. diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 3acd0b1..7b17631 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Vertical Multi-Caret (Ctrl+Alt+Up/Down, Alt+Drag) -**Status**: Draft — supersedes the throwaway implementation in PR #125 +**Status**: Done — re-implemented per this spec in PR #133 (merged into `develop` 2026-05-17), superseding the throwaway PR #125; issues #124/#125 closed. Carets-only flow shipped; column/per-row selection is the follow-up "multi-select" PR (Out of Scope below; tracked in issue #139 / open PR #142). **Created**: 2026-05-15 -**Last updated**: 2026-05-15 +**Last updated**: 2026-05-17 **Depends on**: multi-caret ✅, word-wrap ✅, caret-anchors ✅ **Blocked by**: — **Reference (do not merge)**: [PR #125](https://github.com/gui-cs/Editor/pull/125) — copilot-authored prototype. The functionality is right in the simplest case; the implementation is hacky and the maintainer has documented multiple regressions on it (see § Reference behavior from PR #125 below). Use the test cases from that PR as the executable contract; re-implement the editor changes against this spec. **Note**: PR #125 used `Alt+Up/Down` and `Alt+drag`; this spec adopts the VS Code chords (`Ctrl+Alt+Up/Down`, `Shift+Alt+drag`). The tests must be ported with the new key combinations. diff --git a/specs/word-wrap/spec.md b/specs/word-wrap/spec.md index 71755ca..11d7385 100644 --- a/specs/word-wrap/spec.md +++ b/specs/word-wrap/spec.md @@ -1,8 +1,8 @@ # Feature Specification: Word Wrap -**Status**: Ready — tracked in issue #102 +**Status**: Done — shipped in PR #106 (merged into `develop` 2026-05-13); issue #102 closed. Mouse/gutter-under-wrap fixes followed in PR #114. **Created**: 2026-05-10 -**Last updated**: 2026-05-13 +**Last updated**: 2026-05-17 **Depends on**: rendering-pipeline ✅ **Blocked by**: None diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 732a34f..4b5e5f3 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -52,7 +52,8 @@ public partial class Editor [Command.WordLeftExtend] = Bind.All (Key.CursorLeft.WithCtrl.WithShift), [Command.WordRightExtend] = Bind.All (Key.CursorRight.WithCtrl.WithShift), [Command.KillWordLeft] = Bind.All (Key.Backspace.WithCtrl), - [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl) + [Command.KillWordRight] = Bind.All (Key.Delete.WithCtrl), + [Command.ToggleOverwrite] = Bind.All (Key.InsertChar) }; private void CreateCommandsAndBindings () @@ -244,6 +245,26 @@ private void CreateCommandsAndBindings () return true; }); + // Overwrite mode + AddCommand (Command.ToggleOverwrite, () => + { + OverwriteMode = !OverwriteMode; + + return true; + }); + AddCommand (Command.EnableOverwrite, () => + { + OverwriteMode = true; + + return true; + }); + AddCommand (Command.DisableOverwrite, () => + { + OverwriteMode = false; + + return true; + }); + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); // Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our @@ -350,6 +371,10 @@ private void CreateCommandsAndBindings () { ReplaceSelection (text); } + else if (OverwriteMode && _document is not null) + { + OverwriteAtCaret (text); + } else { _document!.Insert (CaretOffset, text); @@ -358,6 +383,43 @@ private void CreateCommandsAndBindings () return true; } + /// + /// Overwrites the grapheme at the caret with . If the caret is at + /// line-end, falls back to a plain insert so the newline is not consumed. + /// + private void OverwriteAtCaret (string text) + { + OverwriteAtOffset (CaretOffset, text); + } + + /// + /// Overwrites the grapheme at the given with . + /// If the offset is at line-end, falls back to a plain insert so the newline is not consumed. + /// + private void OverwriteAtOffset (int offset, string text) + { + DocumentLine line = _document!.GetLineByOffset (offset); + var lineEnd = line.Offset + line.Length; + + if (offset >= lineEnd) + { + // At or past end-of-line content — just insert. + _document.Insert (offset, text); + + return; + } + + // Determine the length of the grapheme cluster under the caret so wide runes are + // replaced atomically. StringInfo.GetNextTextElementLength gives cluster length in chars. + var remaining = _document.GetText (offset, lineEnd - offset); + var graphemeLength = System.Globalization.StringInfo.GetNextTextElementLength (remaining); + + // Use RemoveAndInsert so that the caret anchor (AfterInsertion) moves past the + // inserted text. The default same-length Replace uses CharacterReplace mode which + // does not move anchors at all. + _document.Replace (offset, graphemeLength, text, OffsetChangeMappingType.RemoveAndInsert); + } + private bool? DeleteLeft () { if (ReadOnly) diff --git a/src/Terminal.Gui.Editor/Editor.Designable.cs b/src/Terminal.Gui.Editor/Editor.Designable.cs new file mode 100644 index 0000000..0b61719 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.Designable.cs @@ -0,0 +1,29 @@ +using Terminal.Gui.Document; +using Terminal.Gui.Highlighting; +using Terminal.Gui.ViewBase; + +namespace Terminal.Gui.Editor; + +public partial class Editor : IDesignable +{ + /// + /// Enables design-time mode by loading representative sample content so the editor renders + /// meaningfully in TG's designer / UI Catalog. Activates C# syntax highlighting and line + /// numbers. Inert at runtime — calling this on a live editor replaces the document. + /// + /// . + public bool EnableForDesign () + { + Document = new TextDocument (EditorDesignData.SampleCSharpCode); + HighlightingDefinition = HighlightingManager.Instance.GetDefinition ("C#"); + GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding; + + return true; + } + + /// + bool IDesignable.EnableForDesign (ref TContext targetView) + { + return EnableForDesign (); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index bd190e8..7dc176b 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -364,7 +364,13 @@ private void UpdateCursor () } Point screen = ViewportToScreen (new Point (col, row)); - Cursor = new Cursor { Position = screen, Style = CursorStyle.BlinkingBar }; + CursorStyle style = OverwriteMode ? CursorStyle.SteadyBlock : + Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; + Cursor = Cursor with + { + Position = screen, + Style = style + }; } private void ApplyAdditionalCaretSelections (CellVisualLine visualLine, Attribute selected) diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 0f6ec94..6c3b7e7 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -52,6 +52,10 @@ protected override bool OnKeyDownNotHandled (Key key) { ReplaceSelection (rune.ToString ()); } + else if (OverwriteMode && _document is not null) + { + OverwriteAtCaret (rune.ToString ()); + } else { _document!.Insert (CaretOffset, rune.ToString ()); diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 6e4642b..ce213b0 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -434,6 +434,10 @@ private void MultiCaretInsert (string text) { ReplaceSelection (text); } + else if (OverwriteMode) + { + OverwriteAtCaret (text); + } else { _document.Insert (CaretOffset, text); @@ -454,7 +458,14 @@ private void MultiCaretInsert (string text) } } - _document.Insert (caret.Offset, text); + if (OverwriteMode) + { + OverwriteAtOffset (caret.Offset, text); + } + else + { + _document.Insert (caret.Offset, text); + } } } } diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 658f545..61e1f57 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -362,6 +362,30 @@ private void EnsureCaretNotInFold () } } + /// + /// Gets or sets whether the editor is in overwrite mode. When , typed + /// characters replace the grapheme under the caret instead of inserting before it. At line-end + /// or when a selection is active, the insertion still inserts. Defaults to . + /// + public bool OverwriteMode + { + get; + set + { + if (field == value) + { + return; + } + + field = value; + SetNeedsDraw (); + OverwriteModeChanged?.Invoke (this, EventArgs.Empty); + } + } + + /// Raised whenever changes. + public event EventHandler? OverwriteModeChanged; + /// Raised whenever changes. public event EventHandler? CaretChanged; diff --git a/src/Terminal.Gui.Editor/EditorDesignData.cs b/src/Terminal.Gui.Editor/EditorDesignData.cs new file mode 100644 index 0000000..790e6d7 --- /dev/null +++ b/src/Terminal.Gui.Editor/EditorDesignData.cs @@ -0,0 +1,39 @@ +namespace Terminal.Gui.Editor; + +/// +/// Provides representative sample content for design-time preview. +/// Populated by — inert at runtime. +/// +internal static class EditorDesignData +{ + /// + /// A short, self-contained C# snippet that exercises syntax highlighting (keywords, + /// strings, comments), line numbers, and word wrap in a design-time preview. + /// + internal const string SampleCSharpCode = + """ + // Terminal.Gui.Editor — design preview + using System; + + namespace Demo; + + /// Sample class for design-time preview. + public class Greeter + { + private readonly string _name; + + public Greeter (string name) + { + _name = name ?? throw new ArgumentNullException (nameof (name)); + } + + public string Greet () => $"Hello, {_name}!"; + + public static void Main () + { + Greeter g = new ("World"); + Console.WriteLine (g.Greet ()); + } + } + """; +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs new file mode 100644 index 0000000..9cd7dc3 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorOverwriteTests.cs @@ -0,0 +1,162 @@ +// Copilot - gpt-4.1 + +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Integration tests for overwrite (insert-replace) mode in . +/// +public class EditorOverwriteTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + // ───────────────────── Property / Command ───────────────────── + + [Fact] + public void OverwriteMode_DefaultsToFalse () + { + Editor editor = new (); + Assert.False (editor.OverwriteMode); + } + + [Fact] + public void OverwriteMode_RaisesEvent () + { + Editor editor = new (); + var raised = false; + editor.OverwriteModeChanged += (_, _) => raised = true; + editor.OverwriteMode = true; + Assert.True (raised); + } + + [Fact] + public void DefaultKeyBindings_Contains_ToggleOverwrite () + { + Assert.True (Editor.DefaultKeyBindings!.ContainsKey (Command.ToggleOverwrite)); + } + + [Fact] + public async Task InsertKey_Toggles_OverwriteMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + + Assert.False (fx.Top.Editor.OverwriteMode); + + fx.Injector.InjectKey (Key.InsertChar, Direct); + Assert.True (fx.Top.Editor.OverwriteMode); + + fx.Injector.InjectKey (Key.InsertChar, Direct); + Assert.False (fx.Top.Editor.OverwriteMode); + } + + // ───────────────────── Overwrite typing behaviour ───────────────────── + + [Fact] + public async Task Overwrite_Replaces_CharacterAtCaret () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("xbc", fx.Top.Editor.Document?.Text); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Overwrite_AtLineEnd_Inserts () + { + await using AppFixture fx = new (() => new ("ab")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 2; // at end of "ab" + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("abx", fx.Top.Editor.Document?.Text); + Assert.Equal (3, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Overwrite_WithSelection_ReplacesSelection () + { + await using AppFixture fx = new (() => new ("abcdef")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.OverwriteMode = true; + + // Select "bcd" (offsets 1..4) via Shift+Right + fx.Top.Editor.CaretOffset = 1; + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithShift, Direct); + Assert.True (fx.Top.Editor.HasSelection); + + fx.Injector.InjectKey (Key.X, Direct); + + // Selection should be replaced entirely, not overwrite-style. + Assert.Equal ("axef", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task Overwrite_SingleUndo_Step () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.OverwriteMode = true; + + fx.Injector.InjectKey (Key.X, Direct); + Assert.Equal ("xbc", fx.Top.Editor.Document?.Text); + + fx.Top.Editor.Document!.UndoStack.Undo (); + Assert.Equal ("abc", fx.Top.Editor.Document.Text); + } + + [Fact] + public async Task Overwrite_MultiLine_DoesNotConsumeNewline () + { + await using AppFixture fx = new (() => new ("ab\ncd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; // at 'b' + fx.Top.Editor.OverwriteMode = true; + + // Overwrite 'b', then caret at offset 2 is at line-end (\n), should insert + fx.Injector.InjectKey (Key.X, Direct); + Assert.Equal ("ax\ncd", fx.Top.Editor.Document?.Text); + Assert.Equal (2, fx.Top.Editor.CaretOffset); + + // Now at line-end — type inserts rather than consuming newline + fx.Injector.InjectKey (Key.Y, Direct); + Assert.Equal ("axy\ncd", fx.Top.Editor.Document?.Text); + } + + // ───────────────────── Enable / Disable commands ───────────────────── + + [Fact] + public async Task EnableOverwrite_Command_SetsMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + + fx.Top.Editor.InvokeCommand (Command.EnableOverwrite); + Assert.True (fx.Top.Editor.OverwriteMode); + } + + [Fact] + public async Task DisableOverwrite_Command_ClearsMode () + { + await using AppFixture fx = new (() => new ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.OverwriteMode = true; + + fx.Top.Editor.InvokeCommand (Command.DisableOverwrite); + Assert.False (fx.Top.Editor.OverwriteMode); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index 56fa953..3b735b5 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -365,6 +365,19 @@ public async Task Cursor_Position_After_Tab_Uses_Expanded_Tab_Columns () Assert.Equal (new Point (4, 0), fx.Top.Editor.Cursor.Position); } + [Fact] + public async Task Cursor_Style_Is_Preserved_When_Position_Updates () + { + await using AppFixture fx = new (() => new EditorTestHost ("abc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.Cursor = new Cursor { Style = CursorStyle.SteadyUnderline }; + fx.Top.Editor.CaretOffset = 2; + fx.Render (); + + Assert.Equal (new Point (2, 0), fx.Top.Editor.Cursor.Position); + Assert.Equal (CursorStyle.SteadyUnderline, fx.Top.Editor.Cursor.Style); + } + [Fact] public async Task Highlighted_Tokens_Follow_The_Active_Scheme () { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index 67ebac8..c9badf9 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -547,6 +547,6 @@ public async Task Primary_Caret_Is_Visible_After_Exiting_MultiCaret () // The primary caret is the terminal cursor. After dismissing the block it must still be // drawn (visible, not the hidden default cursor) and positioned on the primary offset. - Assert.Equal (CursorStyle.BlinkingBar, fx.Top.Editor.Cursor.Style); + Assert.True (fx.Top.Editor.Cursor.IsVisible); } } diff --git a/tests/Terminal.Gui.Editor.Tests/EditorLogicTests.cs b/tests/Terminal.Gui.Editor.Tests/EditorLogicTests.cs index ba66373..b7cc9ec 100644 --- a/tests/Terminal.Gui.Editor.Tests/EditorLogicTests.cs +++ b/tests/Terminal.Gui.Editor.Tests/EditorLogicTests.cs @@ -2,9 +2,9 @@ using System.Drawing; using Terminal.Gui.Document; -using Terminal.Gui.Highlighting; -using Terminal.Gui.Editor; +using Terminal.Gui.Document.Folding; using Terminal.Gui.Editor.Rendering; +using Terminal.Gui.Highlighting; using Xunit; namespace Terminal.Gui.Editor.Tests; @@ -330,17 +330,17 @@ public void GetVisibleLineNumbers_Skips_Deepest_Fold_When_Multiple_Start_On_Same // When both are collapsed, only line 1 should be visible. var text = "a{\nb\nc\nd{\ne\nf}\ng}"; Editor editor = new () { Document = new TextDocument (text) }; - var fm = new Terminal.Gui.Document.Folding.FoldingManager (editor.Document!); + FoldingManager fm = new (editor.Document!); editor.GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding; editor.FoldingManager = fm; // Create two folded sections starting on line 1 with different end offsets. // Short fold: offset 0 (line 1) to offset 8 (line 4 "d{") - var shortFold = fm.CreateFolding (0, 8); + FoldingSection shortFold = fm.CreateFolding (0, 8); shortFold.IsFolded = true; // Long fold: offset 0 (line 1) to offset 16 (line 7 "g}") - var longFold = fm.CreateFolding (0, text.Length); + FoldingSection longFold = fm.CreateFolding (0, text.Length); longFold.IsFolded = true; List visible = editor.GetVisibleLineNumbers (); @@ -349,4 +349,18 @@ public void GetVisibleLineNumbers_Skips_Deepest_Fold_When_Multiple_Start_On_Same Assert.Single (visible); Assert.Equal (1, visible[0]); } + + [Fact] + public void EnableForDesign_PopulatesNonEmptyDocument () + { + Editor editor = new (); + + var result = editor.EnableForDesign (); + + Assert.True (result); + Assert.NotNull (editor.Document); + Assert.True (editor.Document!.TextLength > 0, "EnableForDesign must seed non-empty content."); + Assert.True (editor.Document.LineCount > 1, "Sample content must span more than one line."); + Assert.NotNull (editor.HighlightingDefinition); + } } From c16f72262d11ec80d50771b178b97cc9e2a2bb95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:57:42 +0000 Subject: [PATCH 06/10] Address merge validation feedback Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/0e8e70c4-b347-47a0-a400-df1ba195631f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.Designable.cs | 4 ++++ src/Terminal.Gui.Editor/Editor.Drawing.cs | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.Designable.cs b/src/Terminal.Gui.Editor/Editor.Designable.cs index 0b61719..823478b 100644 --- a/src/Terminal.Gui.Editor/Editor.Designable.cs +++ b/src/Terminal.Gui.Editor/Editor.Designable.cs @@ -4,6 +4,10 @@ namespace Terminal.Gui.Editor; +/// +/// Design-time support for so designers can preview representative +/// content with highlighting and line numbers enabled. +/// public partial class Editor : IDesignable { /// diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 7dc176b..f5e6fe2 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -364,8 +364,13 @@ private void UpdateCursor () } Point screen = ViewportToScreen (new Point (col, row)); - CursorStyle style = OverwriteMode ? CursorStyle.SteadyBlock : - Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; + CursorStyle style = Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; + + if (OverwriteMode) + { + style = CursorStyle.SteadyBlock; + } + Cursor = Cursor with { Position = screen, From 56718d35e243f5407ed077a52459b9b9a1a52d0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:00:05 +0000 Subject: [PATCH 07/10] Address remaining merge review feedback Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/0e8e70c4-b347-47a0-a400-df1ba195631f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- examples/ted/TedApp.cs | 8 ++++---- tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 5f1d109..3f4d514 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -129,6 +129,8 @@ public TedApp (bool readOnly = false) } }; ShowTabsCheckBox.Value = Editor.ShowTabs ? CheckState.Checked : CheckState.UnChecked; + OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) { MouseHighlightStates = MouseState.None }; + LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None }; PreviewCheckBox.ValueChanged += (_, e) => { ToggleMarkdownPreview (); @@ -139,10 +141,8 @@ public TedApp (bool readOnly = false) new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, - OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) - { MouseHighlightStates = MouseState.None }, - LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) - { MouseHighlightStates = MouseState.None } + OverwriteShortcut, + LocShortcut ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index c9badf9..aaffc8a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -548,5 +548,6 @@ public async Task Primary_Caret_Is_Visible_After_Exiting_MultiCaret () // The primary caret is the terminal cursor. After dismissing the block it must still be // drawn (visible, not the hidden default cursor) and positioned on the primary offset. Assert.True (fx.Top.Editor.Cursor.IsVisible); + Assert.Equal (CursorStyle.Default, fx.Top.Editor.Cursor.Style); } } From 9531cd828d8a08665f6130c9161dd8516e979bf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:02:10 +0000 Subject: [PATCH 08/10] Address final merge review notes Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/0e8e70c4-b347-47a0-a400-df1ba195631f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- specs/textview-parity-gap/spec.md | 2 +- src/Terminal.Gui.Editor/Editor.Drawing.cs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/specs/textview-parity-gap/spec.md b/specs/textview-parity-gap/spec.md index 9d4b0ab..f80c0de 100644 --- a/specs/textview-parity-gap/spec.md +++ b/specs/textview-parity-gap/spec.md @@ -58,7 +58,7 @@ exposed today. ### 2. Overwrite / insert-replace mode → [#146](https://github.com/gui-cs/Editor/issues/146) -**TextView**: `Used` flag + `Command.ToggleOverwrite` (Insert key), `Command.EnableOverwrite`, +**TextView**: overwrite-mode state + `Command.ToggleOverwrite` (Insert key), `Command.EnableOverwrite`, `Command.DisableOverwrite`; a distinct caret rendering for overwrite; typing replaces the rune under the caret instead of inserting. diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index f5e6fe2..709f29b 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -364,12 +364,20 @@ private void UpdateCursor () } Point screen = ViewportToScreen (new Point (col, row)); - CursorStyle style = Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; + CursorStyle style; if (OverwriteMode) { style = CursorStyle.SteadyBlock; } + else if (Cursor.Style == CursorStyle.Hidden) + { + style = CursorStyle.Default; + } + else + { + style = Cursor.Style; + } Cursor = Cursor with { From b304d0e110bdb552ab710a8d1e907b41c1d328bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 03:17:20 +0000 Subject: [PATCH 09/10] Merge latest develop and resolve conflicts Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/6e670ead-33a2-45df-a2cc-d33941caef6e Co-authored-by: tig <585482+tig@users.noreply.github.com> --- examples/ted/InlineProgress.cs | 20 + examples/ted/Program.cs | 6 +- examples/ted/TedApp.EditCommands.cs | 30 +- examples/ted/TedApp.FileOperations.cs | 682 ++++++++++++++++-- examples/ted/TedApp.cs | 73 +- specs/decisions.md | 22 +- specs/file-io/spec.md | 48 ++ specs/kill-ring/spec.md | 52 ++ specs/public-api.md | 39 + .../AnsiInputProcessorState.cs | 2 +- .../Document/TextDocument.cs | 170 ++++- .../Document/TextDocumentProgress.cs | 21 + src/Terminal.Gui.Editor/Editor.Commands.cs | 162 ++++- src/Terminal.Gui.Editor/Editor.ContextMenu.cs | 123 ++++ src/Terminal.Gui.Editor/Editor.Drawing.cs | 20 + src/Terminal.Gui.Editor/Editor.FileIO.cs | 212 ++++++ src/Terminal.Gui.Editor/Editor.Keyboard.cs | 25 + src/Terminal.Gui.Editor/Editor.Mouse.cs | 61 +- src/Terminal.Gui.Editor/Editor.Selection.cs | 7 +- src/Terminal.Gui.Editor/Editor.cs | 63 +- .../EditorContextMenuTests.cs | 259 +++++++ .../EditorKillRingTests.cs | 304 ++++++++ .../EditorTabTests.cs | 4 +- .../TedAppTests.cs | 419 +++++++++-- .../Testing/TedTestConfig.cs | 23 + .../LargeDocumentLoadPerformanceTests.cs | 46 ++ .../StreamingLoadPerformanceTests.cs | 36 + .../MaxWidthEstimationTests.cs | 48 ++ .../TextDocumentStreamingTests.cs | 113 +++ third_party/AvaloniaEdit/UPSTREAM.md | 1 + 30 files changed, 2899 insertions(+), 192 deletions(-) create mode 100644 examples/ted/InlineProgress.cs create mode 100644 specs/file-io/spec.md create mode 100644 specs/kill-ring/spec.md create mode 100644 src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs create mode 100644 src/Terminal.Gui.Editor/Editor.ContextMenu.cs create mode 100644 src/Terminal.Gui.Editor/Editor.FileIO.cs create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs create mode 100644 tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs create mode 100644 tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs create mode 100644 tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs create mode 100644 tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs diff --git a/examples/ted/InlineProgress.cs b/examples/ted/InlineProgress.cs new file mode 100644 index 0000000..b2f5326 --- /dev/null +++ b/examples/ted/InlineProgress.cs @@ -0,0 +1,20 @@ +namespace Ted; + +/// +/// Reports progress synchronously for non-hosted app scenarios where +/// would queue callbacks to the thread pool instead of an application UI thread. +/// +internal sealed class InlineProgress : IProgress +{ + private readonly Action _handler; + + public InlineProgress (Action handler) + { + _handler = handler ?? throw new ArgumentNullException (nameof (handler)); + } + + public void Report (T value) + { + _handler (value); + } +} diff --git a/examples/ted/Program.cs b/examples/ted/Program.cs index f9f12be..434c03b 100644 --- a/examples/ted/Program.cs +++ b/examples/ted/Program.cs @@ -23,13 +23,15 @@ if (!string.IsNullOrWhiteSpace (requestedPath)) { + // Defer onto the app loop so the window renders first, then the file streams in progressively + // instead of blocking the UI until the whole file is read. if (File.Exists (requestedPath)) { - ted.SetDocument (File.ReadAllText (requestedPath), requestedPath); + app.Invoke (() => ted.BeginOpenFile (requestedPath)); } else { - ted.OpenMissingFile (requestedPath); + app.Invoke (() => ted.OpenMissingFile (requestedPath)); } } diff --git a/examples/ted/TedApp.EditCommands.cs b/examples/ted/TedApp.EditCommands.cs index 3de6897..2bc51e0 100644 --- a/examples/ted/TedApp.EditCommands.cs +++ b/examples/ted/TedApp.EditCommands.cs @@ -13,13 +13,13 @@ internal View[] CreateEditMenuItems () new MenuItem ("_Find...", "Find text in the current document", Find), new MenuItem ("_Replace...", "Find and replace text in the current document", Replace), new Line (), - new MenuItem { Command = Command.Undo, Action = Undo, Key = KeyFor (Command.Undo) }, - new MenuItem { Command = Command.Redo, Action = Redo, Key = KeyFor (Command.Redo) }, + new MenuItem (Editor, Command.Undo) { Key = KeyFor (Command.Undo) }, + new MenuItem (Editor, Command.Redo) { Key = KeyFor (Command.Redo) }, new Line (), - new MenuItem { Command = Command.Cut, Key = KeyFor (Command.Cut) }, - new MenuItem { Command = Command.Copy, Key = KeyFor (Command.Copy) }, - new MenuItem { Command = Command.Paste, Key = KeyFor (Command.Paste) }, - new MenuItem { Command = Command.SelectAll, Action = SelectAll, Key = KeyFor (Command.SelectAll) } + new MenuItem (Editor, Command.Cut) { Key = KeyFor (Command.Cut) }, + new MenuItem (Editor, Command.Copy) { Key = KeyFor (Command.Copy) }, + new MenuItem (Editor, Command.Paste) { Key = KeyFor (Command.Paste) }, + new MenuItem (Editor, Command.SelectAll) { Key = KeyFor (Command.SelectAll) } ]; } @@ -27,24 +27,6 @@ internal View[] CreateEditMenuItems () private void Replace () { ShowFindReplaceDialog (true); } - private void SelectAll () { Editor.SelectAll (); } - - private void Undo () - { - if (!Editor.ReadOnly) - { - Editor.Document?.UndoStack.Undo (); - } - } - - private void Redo () - { - if (!Editor.ReadOnly) - { - Editor.Document?.UndoStack.Redo (); - } - } - private void ShowFindReplaceDialog (bool selectReplaceTab) { if (App is null) diff --git a/examples/ted/TedApp.FileOperations.cs b/examples/ted/TedApp.FileOperations.cs index 54ca4e5..65e2600 100644 --- a/examples/ted/TedApp.FileOperations.cs +++ b/examples/ted/TedApp.FileOperations.cs @@ -1,3 +1,4 @@ +using System.Text; using Terminal.Gui.Document; using Terminal.Gui.Highlighting; using Terminal.Gui.Resources; @@ -7,6 +8,17 @@ namespace Ted; public sealed partial class TedApp { + /// Minimum byte/character delta between queued streaming status updates. + private const long StreamingStatusInterval = 256 * 1024; + + /// Minimum elapsed milliseconds between queued streaming status updates. + private const int StreamingStatusMilliseconds = 100; + + private readonly Lock _streamingStatusLock = new (); + private long _lastStreamingStatusUnits; + private DateTime _lastStreamingStatusUpdate = DateTime.MinValue; + private long _streamingStatusOperationId; + /// The path currently associated with , or for an untitled buffer. public string? CurrentFilePath { get; private set; } @@ -19,14 +31,77 @@ public sealed partial class TedApp /// Dialog hook used by . Tests can replace it to avoid interactive UI. public Func ShowSaveChangesDialog { get; set; } - /// File read hook used by . Tests can replace it with an in-memory fake. - public Func ReadAllText { get; set; } = File.ReadAllText; + private bool _openReadWasSet; + + private Func _readAllText = File.ReadAllText; /// - /// File write hook used by and . Tests can replace it with an - /// in-memory fake. + /// File read hook retained for source compatibility. Replacing this hook adapts opens by buffering the + /// returned text as UTF-8; prefer for streaming large files. /// - public Action WriteAllText { get; set; } = File.WriteAllText; + public Func ReadAllText + { + get => _readAllText; + set + { + _readAllText = value ?? throw new ArgumentNullException (nameof (value)); + + if (!_openReadWasSet) + { + _openRead = OpenReadFromReadAllText; + } + } + } + + private Func _openRead = File.OpenRead; + + /// File stream hook used by . Tests can replace it with an in-memory fake. + public Func OpenRead + { + get => _openRead; + set + { + _openRead = value ?? throw new ArgumentNullException (nameof (value)); + _openReadWasSet = true; + } + } + + private bool _createWriteWasSet; + + private Action _writeAllText = File.WriteAllText; + + /// + /// File write hook retained for source compatibility. Streaming saves use . + /// + public Action WriteAllText + { + get => _writeAllText; + set + { + _writeAllText = value ?? throw new ArgumentNullException (nameof (value)); + + if (!_createWriteWasSet) + { + _createWrite = CreateWriteFromWriteAllText; + } + } + } + + private Func _createWrite = path => File.Create (path); + + /// File stream hook used by and . + public Func CreateWrite + { + get => _createWrite; + set + { + _createWrite = value ?? throw new ArgumentNullException (nameof (value)); + _createWriteWasSet = true; + } + } + + /// The currently running background load, if any. + public Task? CurrentLoadTask { get; private set; } /// Gets whether the current editor document has unsaved changes. public bool IsDocumentModified => Editor.Document?.UndoStack.IsOriginalFile == false; @@ -42,14 +117,26 @@ public bool OpenFile () { var filePath = ShowOpenDialog (); + return !string.IsNullOrWhiteSpace (filePath) && OpenFileAsync (filePath).GetAwaiter ().GetResult (); + } + + /// Prompts for a file path, then asynchronously streams that file into the editor. + public async Task OpenFileAsync (CancellationToken cancellationToken = default) + { + var filePath = ShowOpenDialog (); + if (string.IsNullOrWhiteSpace (filePath)) { return false; } - SetDocument (ReadAllText (filePath), filePath); + return await OpenFileAsync (filePath, false, cancellationToken); + } - return true; + /// Asynchronously streams the specified file into the editor. + public Task OpenFileAsync (string filePath, CancellationToken cancellationToken = default) + { + return OpenFileAsync (filePath, false, cancellationToken); } /// Opens a CLI-requested missing file path as an empty, modified document bound to that path. @@ -69,14 +156,35 @@ public void OpenMissingFile (string filePath) /// Saves the editor text to the current file, or prompts for a path if the buffer is untitled. public bool SaveFile () + { + return CurrentFilePath is null ? SaveFileAs () : SaveFileAsync ().GetAwaiter ().GetResult (); + } + + /// Asynchronously streams the editor text to the current file, or prompts for a path if untitled. + public Task SaveFileAsync (CancellationToken cancellationToken = default) + { + return SaveFileAsync (false, cancellationToken); + } + + private async Task SaveFileAsync (bool marshalToApp, CancellationToken cancellationToken = default) { if (CurrentFilePath is null) { - return SaveFileAs (); + return await SaveFileAsAsync (marshalToApp, cancellationToken); } - WriteAllText (CurrentFilePath, GetEditorText ()); - Editor.Document!.UndoStack.MarkAsOriginalFile (); + try + { + await SaveFileToAsync (CurrentFilePath, marshalToApp, cancellationToken); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + return false; + } return true; } @@ -86,18 +194,25 @@ public bool SaveFileAs () { var filePath = ShowSaveDialog (); + return !string.IsNullOrWhiteSpace (filePath) && SaveFileAsAsync (filePath).GetAwaiter ().GetResult (); + } + + /// Prompts for a file path, then asynchronously streams the editor text to that path. + public Task SaveFileAsAsync (CancellationToken cancellationToken = default) + { + return SaveFileAsAsync (false, cancellationToken); + } + + private async Task SaveFileAsAsync (bool marshalToApp, CancellationToken cancellationToken = default) + { + var filePath = ShowSaveDialog (); + if (string.IsNullOrWhiteSpace (filePath)) { return false; } - CurrentFilePath = filePath; - WriteAllText (filePath, GetEditorText ()); - Editor.Document!.UndoStack.MarkAsOriginalFile (); - UpdateFileNameShortcut (); - UpdatePreviewVisibility (); - - return true; + return await SaveFileAsAsync (filePath, marshalToApp, cancellationToken); } /// Quits ted, prompting to save first when the current document has unsaved changes. @@ -120,26 +235,7 @@ internal void SetDocument (string text, string? filePath) Editor.CaretOffset = 0; CurrentFilePath = filePath; - // Auto-detect highlighting from file extension. - IHighlightingDefinition? def = null; - - if (filePath is not null) - { - var ext = Path.GetExtension (filePath); - - if (!string.IsNullOrEmpty (ext)) - { - def = HighlightingManager.Instance.GetDefinitionByExtension (ext); - } - } - - Editor.HighlightingDefinition = def; - LanguageShortcut.Title = def?.Name ?? "Plain Text"; - - UpdateFileNameShortcut (); - UpdatePreviewVisibility (); - InstallFolding (); - Editor.SetNeedsDraw (); + ApplyFileMetadata (filePath); } private string? ShowDefaultOpenDialog () @@ -198,13 +294,6 @@ private SaveChangesChoice ShowDefaultSaveChangesDialog () }; } - private string GetEditorText () - { - return Editor.Document is null - ? throw new InvalidOperationException ("ted cannot save because the editor has no document.") - : Editor.Document.Text; - } - private void New () { if (!ConfirmSaveChanges ()) @@ -222,12 +311,25 @@ private void Open () return; } - OpenFile (); + var filePath = ShowOpenDialog (); + + if (string.IsNullOrWhiteSpace (filePath)) + { + return; + } + + CurrentLoadTask = OpenFileAsync (filePath, true); } - private void Save () { SaveFile (); } + private void Save () + { + _ = SaveFileAsync (true); + } - private void SaveAs () { SaveFileAs (); } + private void SaveAs () + { + _ = SaveFileAsAsync (true); + } private void Quit () { @@ -248,4 +350,490 @@ private bool ConfirmSaveChanges () _ => false }; } + + private async Task OpenFileAsync ( + string filePath, + bool marshalToApp, + CancellationToken cancellationToken = default) + { + long? statusOperationId = null; + + try + { + await using Stream stream = OpenRead (filePath); + var fileSize = GetStreamLength (stream); + var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Loading", fileSize)); + statusOperationId = startedStatusOperationId; + + IProgress progress = + CreateStreamingProgress (progress => ReportLoadProgress (startedStatusOperationId, progress)); + + // When there is a running app loop, hand Editor.LoadAsync a UI-thread marshal so it can read on a + // background thread and append each chunk on the UI thread — the editor paints an empty buffer + // immediately and fills in progressively instead of blocking until the whole file is read. + Func? marshal = marshalToApp ? InvokeOnAppAsync : null; + + await Editor.LoadAsync ( + stream, + encoding: null, + progress: progress, + cancellationToken: cancellationToken, + marshal: marshal); + + await RunOnApp ( + marshalToApp, + () => + { + CurrentFilePath = filePath; + ApplyFileMetadata (filePath); + CompleteStreamingStatus ( + startedStatusOperationId, + FormatCompletedProgress ("Loaded", fileSize)); + }); + + // Non-marshalled (sync / test) path: post-load work above ran on a background continuation thread and + // re-claimed TextDocument ownership. Release it as the final step so the caller's thread can use the + // document. The marshalled path keeps UI-thread ownership and must not do this. + if (!marshalToApp) + { + Editor.Document?.SetOwnerThread (null); + } + + return true; + } + catch (OperationCanceledException) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Load canceled"); + } + else + { + CompleteStreamingStatus ("Load canceled"); + } + + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Load failed"); + } + else + { + CompleteStreamingStatus ("Load failed"); + } + + return false; + } + } + + /// + /// Begins a progressive, UI-marshalled load of against the running app loop. + /// Used by the CLI path so the window appears before the file finishes loading. + /// + public void BeginOpenFile (string filePath) + { + CurrentLoadTask = OpenFileAsync (filePath, true); + } + + private Task RunOnApp (bool marshalToApp, Action action) + { + if (marshalToApp) + { + return InvokeOnAppAsync (action); + } + + action (); + + return Task.CompletedTask; + } + + private async Task SaveFileAsAsync ( + string filePath, + bool marshalToApp = false, + CancellationToken cancellationToken = default) + { + try + { + await SaveFileToAsync (filePath, marshalToApp, cancellationToken); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + return false; + } + + CurrentFilePath = filePath; + UpdateFileNameShortcut (); + UpdatePreviewVisibility (); + + return true; + } + + private async Task SaveFileToAsync (string filePath, bool marshalToApp, CancellationToken cancellationToken) + { + long? statusOperationId = null; + + try + { + await using Stream stream = CreateWrite (filePath); + var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Saving", null)); + statusOperationId = startedStatusOperationId; + + IProgress progress = + CreateStreamingProgress (progress => ReportSaveProgress (startedStatusOperationId, progress)); + await Editor.SaveAsync (stream, progress, cancellationToken); + var fileSize = GetStreamLength (stream); + + void MarkSaved () + { + Editor.Document!.UndoStack.MarkAsOriginalFile (); + CompleteStreamingStatus (startedStatusOperationId, FormatCompletedProgress ("Saved", fileSize)); + } + + if (marshalToApp) + { + await InvokeOnAppAsync (MarkSaved); + } + else + { + MarkSaved (); + } + } + catch (OperationCanceledException) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Save canceled"); + } + else + { + CompleteStreamingStatus ("Save canceled"); + } + + throw; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Save failed"); + } + else + { + CompleteStreamingStatus ("Save failed"); + } + + throw; + } + } + + private Stream OpenReadFromReadAllText (string path) + { + return new MemoryStream (Encoding.UTF8.GetBytes (_readAllText (path))); + } + + private Stream CreateWriteFromWriteAllText (string path) + { + return new WriteAllTextStream (path, _writeAllText); + } + + private static bool IsFileOperationException (Exception ex) + { + return ex is IOException or UnauthorizedAccessException; + } + + private void ApplyFileMetadata (string? filePath) + { + IHighlightingDefinition? def = null; + + if (filePath is not null) + { + var ext = Path.GetExtension (filePath); + + if (!string.IsNullOrEmpty (ext)) + { + def = HighlightingManager.Instance.GetDefinitionByExtension (ext); + } + } + + Editor.HighlightingDefinition = def; + LanguageShortcut.Title = def?.Name ?? "Plain Text"; + + UpdateFileNameShortcut (); + UpdatePreviewVisibility (); + InstallFolding (); + Editor.SetNeedsDraw (); + } + + private void ReportLoadProgress (long statusOperationId, TextDocumentProgress progress) + { + if (!ShouldReportStreamingProgress (statusOperationId, progress)) + { + return; + } + + SetLoadStatus (FormatProgress ("Loading", progress), true, statusOperationId); + } + + private void ReportSaveProgress (long statusOperationId, TextDocumentProgress progress) + { + if (!ShouldReportStreamingProgress (statusOperationId, progress)) + { + return; + } + + SetLoadStatus (FormatProgress ("Saving", progress), true, statusOperationId); + } + + private IProgress CreateStreamingProgress (Action handler) + { + return App is null + ? new InlineProgress (handler) + : new Progress (handler); + } + + private long BeginStreamingStatus (string status) + { + var statusOperationId = Interlocked.Increment (ref _streamingStatusOperationId); + + ResetStreamingStatusThrottle (); + SetLoadStatus (status, true, statusOperationId); + + return statusOperationId; + } + + private void CompleteStreamingStatus (long statusOperationId, string status) + { + var completionOperationId = statusOperationId + 1; + + // A newer operation owns the status item; stale completions must not overwrite it. + if (Interlocked.CompareExchange ( + ref _streamingStatusOperationId, + completionOperationId, + statusOperationId) + != statusOperationId) + { + return; + } + + SetLoadStatus (status, false, completionOperationId); + } + + private void CompleteStreamingStatus (string status) + { + var completionOperationId = Interlocked.Increment (ref _streamingStatusOperationId); + SetLoadStatus (status, false, completionOperationId); + } + + private void SetLoadStatus (string status, bool showSpinner, long statusOperationId) + { + void Update () + { + if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId) + { + return; + } + + // The status spinner is visible only while it is actively spinning. + LoadStatusSpinner.Visible = showSpinner; + LoadStatusSpinner.AutoSpin = showSpinner; + LoadSpinnerShortcut.Title = status; + LoadSpinnerShortcut.HelpText = status; + LoadStatusSpinner.SetNeedsDraw (); + LoadSpinnerShortcut.SetNeedsDraw (); + } + + if (App is null) + { + Update (); + + return; + } + + App.Invoke (Update); + } + + private void ResetStreamingStatusThrottle () + { + lock (_streamingStatusLock) + { + _lastStreamingStatusUpdate = DateTime.MinValue; + _lastStreamingStatusUnits = 0; + } + } + + private bool ShouldReportStreamingProgress (long statusOperationId, TextDocumentProgress progress) + { + if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId) + { + return false; + } + + var processedUnits = progress.BytesProcessed ?? progress.CharactersProcessed; + var totalUnits = progress.TotalBytes ?? progress.TotalCharacters; + + if (totalUnits == processedUnits) + { + return true; + } + + lock (_streamingStatusLock) + { + DateTime now = DateTime.UtcNow; + + if (processedUnits - _lastStreamingStatusUnits < StreamingStatusInterval + && now - _lastStreamingStatusUpdate < TimeSpan.FromMilliseconds (StreamingStatusMilliseconds)) + { + return false; + } + + _lastStreamingStatusUnits = processedUnits; + _lastStreamingStatusUpdate = now; + } + + return true; + } + + private Task InvokeOnAppAsync (Action action) + { + if (App is null) + { + action (); + + return Task.CompletedTask; + } + + TaskCompletionSource completion = new (); + App.Invoke (() => + { + try + { + action (); + completion.SetResult (); + } + catch (Exception ex) + { + completion.SetException (ex); + } + }); + + return completion.Task; + } + + private static string FormatProgress (string verb, TextDocumentProgress progress) + { + var processed = progress.BytesProcessed is { } bytesProcessed + ? FormatByteCount (bytesProcessed) + : $"{progress.CharactersProcessed:N0} chars"; + + var total = progress.TotalBytes is { } totalBytes + ? FormatByteCount (totalBytes) + : progress.TotalCharacters is { } totalCharacters + ? $"{totalCharacters:N0} chars" + : null; + + if (total is null) + { + return $"{verb} {processed}"; + } + + if (progress.Fraction is { } fraction) + { + return $"{verb} {processed} of {total} ({fraction:P0})"; + } + + return $"{verb} {processed} of {total}"; + } + + private static string FormatStartingProgress (string verb, long? totalBytes) + { + return totalBytes is { } bytes + ? $"{verb} 0 B of {FormatByteCount (bytes)}" + : $"{verb} 0 B"; + } + + private static string FormatCompletedProgress (string verb, long? totalBytes) + { + return totalBytes is { } bytes + ? $"{verb} {FormatByteCount (bytes)}" + : verb; + } + + private static long? GetStreamLength (Stream stream) + { + if (!stream.CanSeek) + { + return null; + } + + return stream.Length; + } + + private static string FormatByteCount (long bytes) + { + string[] units = ["B", "KiB", "MiB", "GiB", "TiB"]; + double value = bytes; + var unitIndex = 0; + + while (value >= 1024 && unitIndex < units.Length - 1) + { + value /= 1024; + unitIndex++; + } + + // Whole bytes read cleaner without decimals; larger units need one decimal for useful precision. + var format = unitIndex == 0 ? "N0" : "N1"; + + return $"{value.ToString (format)} {units[unitIndex]}"; + } + + /// + /// Adapts streamed saves to the legacy hook by buffering bytes in memory and + /// writing the final UTF-8 text on disposal. + /// + private sealed class WriteAllTextStream : MemoryStream + { + private readonly string _path; + private readonly Action _writeAllText; + private bool _hasWritten; + + public WriteAllTextStream (string path, Action writeAllText) + { + _path = path; + _writeAllText = writeAllText; + } + + protected override void Dispose (bool disposing) + { + if (disposing) + { + WriteOnce (); + } + + base.Dispose (disposing); + } + + public override async ValueTask DisposeAsync () + { + WriteOnce (); + await base.DisposeAsync (); + } + + private void WriteOnce () + { + if (_hasWritten) + { + return; + } + + _hasWritten = true; + _writeAllText (_path, Encoding.UTF8.GetString (ToArray ())); + } + } } diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 3f4d514..5ca9cf7 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -21,13 +21,26 @@ namespace Ted; /// public sealed partial class TedApp : Window { + private const int MaximumAutomaticFoldingDocumentLength = 1_000_000; + private readonly BraceFoldingStrategy _braceFoldingStrategy; private readonly Shortcut _fileNameShortcut; private readonly MenuItem _previewMarkdownMenuItem; + // Per-instance config path. Defaults to the real ~/.tui location; tests inject a temp path so they + // never touch the developer's real config (and stay parallel-safe — no env/static mutation). + private readonly string _configPath; + /// Initializes a new . - public TedApp (bool readOnly = false) + /// Opens the editor read-only. + /// + /// Overrides where view settings persist. uses + /// (the real ~/.tui/ted.config.json). + /// + public TedApp (bool readOnly = false, string? configPath = null) { + _configPath = configPath ?? EditorSettings.GetConfigPath (); + Title = "ted — Terminal.Gui.Editor demo"; BorderStyle = LineStyle.None; @@ -131,6 +144,19 @@ public TedApp (bool readOnly = false) ShowTabsCheckBox.Value = Editor.ShowTabs ? CheckState.Checked : CheckState.UnChecked; OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) { MouseHighlightStates = MouseState.None }; LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None }; + LoadStatusSpinner = new SpinnerView + { + Style = new SpinnerStyle.Aesthetic (), + Width = 8, + AutoSpin = false, + Visible = false + }; + LoadSpinnerShortcut = new Shortcut + { + CommandView = LoadStatusSpinner, + Title = string.Empty, + MouseHighlightStates = MouseState.None + }; PreviewCheckBox.ValueChanged += (_, e) => { ToggleMarkdownPreview (); @@ -141,6 +167,7 @@ public TedApp (bool readOnly = false) new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, + LoadSpinnerShortcut, OverwriteShortcut, LocShortcut ]) @@ -148,22 +175,6 @@ public TedApp (bool readOnly = false) AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; - PopoverMenu editorContextMenu = new (CreateEditMenuItems ()) - { - Target = new WeakReference (Editor) - }; - - Editor.MouseEvent += (_, mouse) => - { - if (!mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) - { - return; - } - - editorContextMenu.MakeVisible (mouse.ScreenPosition); - mouse.Handled = true; - }; - menu.Add (new MenuBarItem (Strings.menuFile, [ new MenuItem { Command = Command.New, Action = New, Key = KeyFor (Command.New) }, @@ -281,6 +292,12 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), /// The status-bar dropdown that selects . public DropDownList ThemeDropDown { get; } + /// The spinner view shown while streaming file load/save is running. + public SpinnerView LoadStatusSpinner { get; } + + /// The status-bar shortcut that hosts . + public Shortcut LoadSpinnerShortcut { get; } + /// The settings checkbox state for visible tab glyphs. public CheckBox ShowTabsCheckBox { get; } = new () { @@ -416,7 +433,7 @@ private void SaveViewSettings () EditorSettings.IndentSize = Editor.IndentationSize; EditorSettings.ConvertTabsToSpaces = Editor.ConvertTabsToSpaces; EditorSettings.AutoIndent = Editor.IndentationStrategy is not null; - EditorSettings.Save (); + EditorSettings.Save (_configPath); } private void ShowSettingsDialog () @@ -449,6 +466,13 @@ private void InstallFolding () return; } + if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength) + { + Editor.FoldingManager = null; + + return; + } + FoldingManager fm = new (Editor.Document); Editor.FoldingManager = fm; _braceFoldingStrategy.UpdateFoldings (fm, Editor.Document); @@ -457,9 +481,18 @@ private void InstallFolding () private void UpdateFoldings () { - if (Editor.FoldingManager is not null && Editor.Document is not null) + if (Editor.FoldingManager is null || Editor.Document is null) { - _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document); + return; } + + if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength) + { + Editor.FoldingManager = null; + + return; + } + + _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document); } } diff --git a/specs/decisions.md b/specs/decisions.md index 79b601f..894bff6 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -36,6 +36,20 @@ Decisions are recorded here when an open question from the plan is resolved. Eac --- +### DEC-009: Streaming file I/O placement (resolves former OPEN-003) + +**Decision**: Streaming `LoadAsync (Stream)` / `SaveAsync (Stream)` lives on `TextDocument`, with `Editor` +delegating to the document layer for control-level convenience. + +**Rationale**: The rope-backed document is the streaming seam: it can append decoded chunks without +materializing the whole file as one `string`, preserve detected encoding/BOM metadata, and write snapshots +back out in chunks. `Editor` still exposes `LoadAsync` / `SaveAsync` so control consumers and `ted` do not +need to know the document construction details. + +**Date**: 2026-05-17 + +--- + ### DEC-003: Tab handling architecture **Decision**: Tab handling (tab-handling) requires the visual-line pipeline (rendering-pipeline). The codex branch implemented both together. `TabElement` renders tabs through the pipeline, not via inline char-by-char expansion. @@ -74,14 +88,6 @@ Decisions are recorded here when an open question from the plan is resolved. Eac --- -### OPEN-003: Async I/O placement - -**Question**: `LoadAsync (Stream)` / `SaveAsync` on `Editor` vs. on the document? - -**Affected features**: File I/O, large-file performance. - ---- - ### OPEN-004: Read-only ranges **Question**: Lift `TextSegmentReadOnlySectionProvider`, or YAGNI? diff --git a/specs/file-io/spec.md b/specs/file-io/spec.md new file mode 100644 index 0000000..7eb7e30 --- /dev/null +++ b/specs/file-io/spec.md @@ -0,0 +1,48 @@ +# Feature Specification: Streaming File I/O + +**Feature Branch**: `copilot/resolve-open-003-large-file-support` +**Created**: 2026-05-17 +**Status**: Implemented +**Input**: Issue #150, TextView parity Gap 6, DEC-001, DEC-009 + +## User Scenarios & Testing + +### Streaming document load + +As an editor consumer, I can load a multi-megabyte stream into `TextDocument` without first converting the +entire file to one `string`. + +**Acceptance**: + +- `TextDocument.LoadAsync (Stream, ...)` decodes in chunks into the rope. +- BOM detection uses `StreamReader` and records the detected `Encoding` on the document. +- Progress reports character count and, when the stream can seek, byte position and total bytes. +- Cancellation is observed between chunks. + +### Streaming document save + +As an editor consumer, I can save a document to a stream without materializing `Document.Text`. + +**Acceptance**: + +- `TextDocument.SaveAsync (Stream, ...)` writes a snapshot in chunks using `TextDocument.Encoding`. +- DEC-001 holds: mixed line endings are not normalized, so unedited content round-trips byte-identical for + the detected encoding/BOM. +- Progress reports characters written and total characters. +- Cancellation is observed between chunks. + +### Control-level and ted usage + +As a `Terminal.Gui.Editor.Editor` / `ted` user, I can use the streaming path without knowing document internals. + +**Acceptance**: + +- `Editor.LoadAsync` and `Editor.SaveAsync` delegate to the document APIs. +- `ted` uses stream hooks (`OpenRead`, `CreateWrite`) for File → Open/Save. +- The ted status bar exposes load/save progress and reports completion. +- Menu-triggered opens run asynchronously so the UI can render progress before the full file is loaded. + +## API + +See [`../public-api.md`](../public-api.md) for the public surface and [`../decisions.md`](../decisions.md) +DEC-009 for placement. diff --git a/specs/kill-ring/spec.md b/specs/kill-ring/spec.md new file mode 100644 index 0000000..af2e999 --- /dev/null +++ b/specs/kill-ring/spec.md @@ -0,0 +1,52 @@ +# Feature Specification: Kill-Ring (Kill-to-EOL / Kill-to-BOL with Append) + +## Overview + +Emacs-style kill commands that delete text between the caret and a line boundary, +placing the killed text on the clipboard. Consecutive kills **append** to the +clipboard instead of replacing, accumulating a "kill ring" run that any non-kill +command breaks. + +## Commands + +| Command | Behavior | +|----------------------------|--------------------------------------------------------------------------------------------------------------| +| `Command.CutToEndOfLine` | Delete caret → end of line text. If already at EOL, delete the line delimiter (join with next line). | +| `Command.CutToStartOfLine` | Delete line start → caret. | + +Both commands: +- Are no-ops when `ReadOnly` is true. +- When a selection exists, delete the selection (same as `DeleteLeft` / `DeleteRight` behavior) and do **not** participate in the kill ring. +- Execute within a single `Document.RunUpdate()` so each kill is one undo step. +- Place killed text on the clipboard via `App.Clipboard`. If the clipboard write fails, the document is not modified. + +## Kill-Ring Append Semantics + +- A `_lastCommandWasKill` flag tracks whether the immediately preceding command was a kill. +- A `_previousCommandWasKill` field is set by `OnKeyDown` — it snapshots `_lastCommandWasKill` before clearing it, so the dispatched kill command can read whether the preceding command was a kill. +- The flag is **cleared** at the top of `OnKeyDown` (before the base class dispatches any command). +- Each kill command **sets** `_lastCommandWasKill` after executing. +- Kill commands read `_previousCommandWasKill` (keyboard path) or `_lastCommandWasKill` (programmatic `InvokeCommand` path) to decide whether to append or prepend. +- When the preceding command was a kill: + - `CutToEndOfLine` **appends** killed text after the existing clipboard content. + - `CutToStartOfLine` **prepends** killed text before the existing clipboard content (so the clipboard accumulates in document order). +- Any non-kill command (movement, insertion, undo, etc.) breaks the run because `OnKeyDown` clears the flag before dispatch. + +## Key Bindings + +**Unbound by default.** Neither `Command.CutToEndOfLine` nor `Command.CutToStartOfLine` +appears in `Editor.DefaultKeyBindings`. Users opt in via the `[ConfigurationProperty]` +`Editor.DefaultKeyBindings` configuration (e.g., binding Ctrl+K to `CutToEndOfLine`). + +## Files Changed + +- `src/Terminal.Gui.Editor/Editor.cs` — `_lastCommandWasKill` and `_previousCommandWasKill` fields. +- `src/Terminal.Gui.Editor/Editor.Commands.cs` — `AddCommand` registrations, `CutToEndOfLine()`, `CutToStartOfLine()`, `WriteKillToClipboard()`. +- `src/Terminal.Gui.Editor/Editor.Keyboard.cs` — `OnKeyDown` override to clear the kill flag. +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs` — integration tests. + +## Decision Record + +See `specs/decisions.md` DEC-005 for the "no-selection cut/copy is a no-op" policy. +The kill commands are a separate code path that does not conflict with DEC-005 — +they operate on line boundaries, not on the selection. diff --git a/specs/public-api.md b/specs/public-api.md index 6f3eb1d..eb42a2d 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -12,6 +12,15 @@ public class Editor : View // --- Document --- public TextDocument Document { get; set; } // exists public event EventHandler? DocumentChanged; // exists + public Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); // file-io + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default); // file-io // --- Caret --- public int CaretOffset { get; set; } // exists; backed by TextAnchor (caret-anchors ✅) @@ -107,6 +116,35 @@ public interface IOverlayRenderer } ``` +## Document File I/O (file-io) + +```csharp +namespace Terminal.Gui.Document; + +public sealed class TextDocument +{ + public Encoding Encoding { get; set; } + public static Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +public readonly record struct TextDocumentProgress ( + long CharactersProcessed, + long? TotalCharacters = null, + long? BytesProcessed = null, + long? TotalBytes = null) +{ + public double? Fraction { get; } +} +``` + ## Change Log | Date | Change | Feature | @@ -119,4 +157,5 @@ public interface IOverlayRenderer | 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 | Column selection during `Alt+Drag` and `Ctrl+Shift+Alt+Arrow/Page` added without new public Editor API | vertical-multi-caret | +| 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/AnsiInputProcessorState.cs b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs index 5b74953..3497d7f 100644 --- a/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs +++ b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs @@ -15,7 +15,7 @@ public static void ClearPendingPrintableSuppression (IApplication? app) return; } - // Terminal.Gui 2.1.1-develop.98 suppresses the next printable fallback key after parsing + // Terminal.Gui suppresses the next printable fallback key after parsing // ANSI Shift+Tab (ESC [ Z) because Shift+Tab reports Tab as printable text. Until TG exposes // public input-processor state for this, clear that one-shot suppression after the editor // handles Unindent so the user's next Tab reaches us. If TG renames this private field, the diff --git a/src/Terminal.Gui.Editor/Document/TextDocument.cs b/src/Terminal.Gui.Editor/Document/TextDocument.cs index 597d05e..e52d037 100644 --- a/src/Terminal.Gui.Editor/Document/TextDocument.cs +++ b/src/Terminal.Gui.Editor/Document/TextDocument.cs @@ -25,8 +25,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Text; using Terminal.Gui.Document.Utils; using System.Threading; +using System.Threading.Tasks; namespace Terminal.Gui.Document { @@ -89,6 +91,119 @@ public TextDocument(ITextSource initialText) { } +#nullable enable + private Encoding _encoding = new UTF8Encoding (false); + + /// + /// Gets or sets the encoding detected during streaming load and used by streaming save. + /// + public Encoding Encoding + { + get => _encoding; + set => _encoding = value ?? throw new ArgumentNullException (nameof (value)); + } + + /// + /// Streams text from in decoded chunks, invoking (and awaiting) + /// for each chunk in order as it is read. Awaiting the callback throttles the + /// reader to the consumer's cadence (natural backpressure) and lets a UI consumer apply each chunk + /// progressively without ever materializing the whole file as a single string. This method does not create + /// or mutate a and has no UI-thread affinity — the consumer decides where and how + /// each chunk is applied. See Editor.LoadAsync for the progressive editor consumer. + /// + /// The source stream. + /// Fallback encoding used when no BOM is detected. Defaults to UTF-8 (no BOM). + /// + /// Invoked and awaited for every decoded chunk, in order. The supplied memory is only valid for the duration + /// of the call; copy it if it must outlive the returned . + /// + /// Invoked once with the encoding the resolved. + /// Optional progress sink, reported after each applied chunk. + /// Observed between chunks. + public static async Task StreamAsync ( + Stream stream, + Encoding? encoding, + Func, CancellationToken, ValueTask> onChunk, + Action? onEncodingDetected = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull (stream); + ArgumentNullException.ThrowIfNull (onChunk); + + const int bufferSize = 32 * 1024; + Encoding fallbackEncoding = encoding ?? new UTF8Encoding (false); + long? totalBytes = stream.CanSeek ? stream.Length : null; + char[] buffer = new char[bufferSize]; + long charactersRead = 0; + var encodingReported = false; + + using StreamReader reader = new ( + stream, fallbackEncoding, detectEncodingFromByteOrderMarks: true, bufferSize, leaveOpen: true); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested (); + int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken) + .ConfigureAwait (false); + + if (!encodingReported) + { + // CurrentEncoding is only resolved after the first read consumes (or rules out) a BOM. + encodingReported = true; + onEncodingDetected?.Invoke (reader.CurrentEncoding); + } + + if (read == 0) + { + break; + } + + await onChunk (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false); + charactersRead += read; + progress?.Report (new TextDocumentProgress (charactersRead, null, + stream.CanSeek ? stream.Position : null, totalBytes)); + } + } + + /// + /// Streams text from into a new without materializing the + /// entire document as a single string. The whole stream is consumed before the document is returned, so this + /// is not progressive; UI consumers that want incremental rendering should use + /// (see Editor.LoadAsync). + /// + public static async Task LoadAsync (Stream stream, Encoding? encoding = null, + IProgress? progress = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull (stream); + + Rope rope = new (); + Encoding detected = encoding ?? new UTF8Encoding (false); + + await StreamAsync ( + stream, + encoding, + (chunk, _) => + { + rope.AddRange (chunk.ToArray (), 0, chunk.Length); + + return ValueTask.CompletedTask; + }, + enc => detected = enc, + progress, + cancellationToken).ConfigureAwait (false); + + TextDocument document = new (rope) + { + Encoding = detected + }; + document.UndoStack.MarkAsOriginalFile (); + document.SetOwnerThread (null); + + return document; + } +#nullable disable + // gets the text from a text source, directly retrieving the underlying rope where possible private static IEnumerable GetTextFromTextSource(ITextSource textSource) { @@ -144,8 +259,9 @@ public ReadOnlyMemory GetTextAsMemory(int offset, int length) /// /// /// - /// The owner can be set to null, which means that no thread can access the document. But, if the document - /// has no owner thread, any thread may take ownership by calling . + /// The owner can be set to null, which means that the next thread to access the document takes ownership + /// on first access. If the document has no owner thread, any thread may also take ownership by calling + /// . /// /// public void SetOwnerThread(Thread newOwner) @@ -162,6 +278,13 @@ public void SetOwnerThread(Thread newOwner) private void VerifyAccess() { + if (ownerThread == null) + { + SetOwnerThread (Thread.CurrentThread); + + return; + } + if(Thread.CurrentThread != ownerThread) { throw new InvalidOperationException("Call from invalid thread."); @@ -247,6 +370,49 @@ public string Text } } +#nullable enable + /// + /// Streams the document to using without materializing the + /// entire document as a single string. + /// + public async Task SaveAsync (Stream stream, IProgress? progress = null, + CancellationToken cancellationToken = default) + { + VerifyAccess (); + + ArgumentNullException.ThrowIfNull (stream); + + const int bufferSize = 32 * 1024; + ITextSource snapshot = CreateSnapshot (); + SetOwnerThread (null); + char[] buffer = new char[bufferSize]; + long charactersWritten = 0; + long totalCharacters = snapshot.TextLength; + + using (TextReader reader = snapshot.CreateReader ()) + using (StreamWriter writer = new (stream, Encoding, bufferSize, leaveOpen: true)) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested (); + int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken) + .ConfigureAwait (false); + + if (read == 0) + { + break; + } + + await writer.WriteAsync (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false); + charactersWritten += read; + progress?.Report (new TextDocumentProgress (charactersWritten, totalCharacters)); + } + + await writer.FlushAsync (cancellationToken).ConfigureAwait (false); + } + } +#nullable disable + /// /// This event is called after a group of changes is completed. /// diff --git a/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs new file mode 100644 index 0000000..52dd3f4 --- /dev/null +++ b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs @@ -0,0 +1,21 @@ +namespace Terminal.Gui.Document; + +/// Reports streaming document I/O progress. +/// The number of decoded characters loaded or saved so far. +/// The total character count, when known. +/// The number of bytes consumed so far, when known. +/// The total byte count, when known. +public readonly record struct TextDocumentProgress ( + long CharactersProcessed, + long? TotalCharacters = null, + long? BytesProcessed = null, + long? TotalBytes = null) +{ + /// Gets the best available completion fraction, or when no total is known. + public double? Fraction => + TotalBytes is > 0 && BytesProcessed is { } bytesProcessed + ? Math.Clamp ((double)bytesProcessed / TotalBytes.Value, 0, 1) + : TotalCharacters is > 0 + ? Math.Clamp ((double)CharactersProcessed / TotalCharacters.Value, 0, 1) + : null; +} diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 4b5e5f3..c90094c 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -171,6 +171,11 @@ private void CreateCommandsAndBindings () return true; }); + // Kill-ring — Emacs-style line-boundary kill; unbound by default (no entry in + // DefaultKeyBindings). Users opt in via Editor.DefaultKeyBindings config. + AddCommand (Command.CutToEndOfLine, CutToEndOfLine); + AddCommand (Command.CutToStartOfLine, CutToStartOfLine); + // Editing — selection-aware (multi-caret aware) AddCommand (Command.NewLine, MultiCaretNewLine); AddCommand (Command.DeleteCharLeft, MultiCaretDeleteLeft); @@ -265,6 +270,19 @@ private void CreateCommandsAndBindings () return true; }); + // Context menu — return false when suppressed so the command can bubble. + AddCommand (Command.Context, () => + { + if (ContextMenu is null) + { + return false; + } + + ShowContextMenu (); + + return true; + }); + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); // Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our @@ -485,6 +503,145 @@ private void OverwriteAtOffset (int offset, string text) return true; } + /// + /// Kill from caret to end of line. If the caret is already at EOL, kills the line delimiter + /// (joining the next line). Places killed text on the clipboard; if the immediately preceding + /// command was also a kill (consecutive kill), appends instead of replacing. + /// + private bool? CutToEndOfLine () + { + // For keyboard dispatch, _previousCommandWasKill was set by OnKeyDown. + // For InvokeCommand dispatch, fall back to _lastCommandWasKill (OnKeyDown was not called). + bool consecutiveKill = _previousCommandWasKill || _lastCommandWasKill; + _lastCommandWasKill = false; + + if (ReadOnly || _document is null) + { + return true; + } + + if (HasSelection) + { + ReplaceSelection (string.Empty); + + return true; + } + + DocumentLine line = _document.GetLineByOffset (CaretOffset); + var lineEnd = line.Offset + line.Length; + + int start; + int length; + + if (CaretOffset < lineEnd) + { + // Kill from caret to end of line text (not the delimiter). + start = CaretOffset; + length = lineEnd - CaretOffset; + } + else if (line.DelimiterLength > 0) + { + // Caret is at EOL — kill the line delimiter (join with next line). + start = lineEnd; + length = line.DelimiterLength; + } + else + { + // Last line, caret at end — nothing to kill. + + return true; + } + + var killed = _document.GetText (start, length); + + if (!WriteKillToClipboard (killed, append: consecutiveKill, prepend: false)) + { + return true; + } + + using (_document.RunUpdate ()) + { + _document.Remove (start, length); + } + + _lastCommandWasKill = true; + + return true; + } + + /// + /// Kill from line start to caret. Places killed text on the clipboard; if the immediately + /// preceding command was also a kill, prepends instead of replacing (so the clipboard + /// accumulates text in document order). + /// + private bool? CutToStartOfLine () + { + // See CutToEndOfLine for rationale on the dual-path flag check. + bool consecutiveKill = _previousCommandWasKill || _lastCommandWasKill; + _lastCommandWasKill = false; + + if (ReadOnly || _document is null) + { + return true; + } + + if (HasSelection) + { + ReplaceSelection (string.Empty); + + return true; + } + + DocumentLine line = _document.GetLineByOffset (CaretOffset); + var start = line.Offset; + var length = CaretOffset - start; + + if (length == 0) + { + // Already at BOL — nothing to kill. + return true; + } + + var killed = _document.GetText (start, length); + + if (!WriteKillToClipboard (killed, append: false, prepend: consecutiveKill)) + { + return true; + } + + using (_document.RunUpdate ()) + { + _document.Remove (start, length); + } + + _lastCommandWasKill = true; + + return true; + } + + /// + /// Writes killed text to the clipboard. When or + /// is , reads the current clipboard + /// content and concatenates rather than replacing. + /// + /// if the clipboard write succeeded; otherwise. + private bool WriteKillToClipboard (string killed, bool append, bool prepend) + { + IClipboard? clipboard = App?.Clipboard; + + if (clipboard is null) + { + return false; + } + + if ((append || prepend) && clipboard.TryGetClipboardData (out var existing)) + { + killed = prepend ? killed + existing : existing + killed; + } + + return clipboard.TrySetClipboardData (killed); + } + private bool? InvokeFindRequested () { FindRequested?.Invoke (this, EventArgs.Empty); @@ -537,10 +694,7 @@ private void OverwriteAtOffset (int offset, string text) } } - if (fold is not null) - { - fold.IsFolded = !fold.IsFolded; - } + fold?.IsFolded = !fold.IsFolded; return true; } diff --git a/src/Terminal.Gui.Editor/Editor.ContextMenu.cs b/src/Terminal.Gui.Editor/Editor.ContextMenu.cs new file mode 100644 index 0000000..585dea8 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.ContextMenu.cs @@ -0,0 +1,123 @@ +using System.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Editor; + +public partial class Editor +{ + /// + /// Gets or sets the built-in context menu shown on right-click or . + /// Defaults to a standard editing menu (Undo, Redo, Cut, Copy, Paste, Select All) whose items + /// are state-aware — mutating items are disabled when is , + /// Copy / Cut are disabled when there is no selection, and Undo / Redo reflect the undo stack. + /// Set to to suppress the context menu entirely; set to a custom + /// to replace it. + /// + public PopoverMenu? ContextMenu + { + get; + set + { + field = value; + + field?.Target = new WeakReference (this); + } + } + + /// Creates the default editing context menu items using declarative command binding. + /// + /// Each is constructed with this as the target view and a + /// . The framework resolves title, help text, and key from + /// GlobalResources and routes the command to this via command + /// bubbling — no explicit delegates are needed. + /// + private View[] CreateDefaultContextMenuItems () + { + return + [ + new MenuItem (this, Command.Undo), + new MenuItem (this, Command.Redo), + new Line (), + new MenuItem (this, Command.Cut), + new MenuItem (this, Command.Copy), + new MenuItem (this, Command.Paste), + new Line (), + new MenuItem (this, Command.SelectAll) + ]; + } + + /// + /// Updates the state of the context menu items to reflect the current + /// editor state (ReadOnly, selection, clipboard, undo/redo). + /// + private void UpdateContextMenuState () + { + if (ContextMenu?.Root is null) + { + return; + } + + var hasSelection = HasSelection; + var canUndo = !ReadOnly && _document is { UndoStack.CanUndo: true }; + var canRedo = !ReadOnly && _document is { UndoStack.CanRedo: true }; + var canPaste = !ReadOnly; + var canCut = !ReadOnly && hasSelection; + + foreach (View child in ContextMenu.Root.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Undo: + menuItem.Enabled = canUndo; + + break; + case Command.Redo: + menuItem.Enabled = canRedo; + + break; + case Command.Cut: + menuItem.Enabled = canCut; + + break; + case Command.Copy: + menuItem.Enabled = hasSelection; + + break; + case Command.Paste: + menuItem.Enabled = canPaste; + + break; + + // Unknown commands (e.g. from a custom ContextMenu) are left untouched + // so the caller's intentional Enabled state is preserved. + } + } + } + + /// + /// Shows the context menu at the given screen position, after updating item state. + /// + private void ShowContextMenu (Point? screenPosition = null) + { + if (ContextMenu is null) + { + return; + } + + UpdateContextMenuState (); + ContextMenu.MakeVisible (screenPosition); + } + + /// Builds and assigns the default context menu. + private void InitializeDefaultContextMenu () + { + ContextMenu = new PopoverMenu (CreateDefaultContextMenuItems ()); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 709f29b..c88a27a 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -31,6 +31,16 @@ protected override bool OnDrawingContent (DrawContext? context) EnsureColorizerAttribute (normal); DrawVisibleLines (viewport, normal, selected); + + if (_maxWidthGrewDuringDraw) + { + // A rendered line was wider than the estimate; resize content (cheap — not _maxWidthDirty) + // so the horizontal scrollbar reflects what's now on screen. Monotonic: once the widest + // visible line is measured this stops firing, so no draw/layout loop. + _maxWidthGrewDuringDraw = false; + UpdateContentSize (); + } + SetAttribute (normal); UpdateCursor (); @@ -313,6 +323,16 @@ private void DrawVisualLine ( // two don't thrash each other's entries (they use different attribute sets). CellVisualLine visualLine = GetOrBuildDrawVisualLine (line, segments, normal, selected, selStart, selEnd); + // A line we actually render gives us its exact width for free. If the running max was an + // estimate (large document) and this visible line is wider, grow the extent — reconciled + // once after the draw in OnDrawingContent so the horizontal scrollbar tracks visible content. + if (!WordWrap && visualLine.VisualLength > _maxVisualWidth) + { + _maxVisualWidth = visualLine.VisualLength; + _maxWidthLineNumber = line.LineNumber; + _maxWidthGrewDuringDraw = true; + } + foreach (IBackgroundRenderer renderer in BackgroundRenderers) { renderer.Draw (this, visualLine, row, Viewport); diff --git a/src/Terminal.Gui.Editor/Editor.FileIO.cs b/src/Terminal.Gui.Editor/Editor.FileIO.cs new file mode 100644 index 0000000..6e95d20 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.FileIO.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Terminal.Gui.Document; + +namespace Terminal.Gui.Editor; + +public partial class Editor +{ + // Chars buffered before the very first paint. Small so the first screenful appears almost immediately — + // the whole point of progressive load. + private const int FirstFlushChars = 16 * 1024; + + // Chars buffered between subsequent paints. Each flush is one marshalled append + a visible-viewport + // redraw, so this only needs to be large enough to avoid an excessive number of round-trips. 64 KiB + // makes a 10 MiB file fill in ~160 smooth steps; 256 KiB felt chunky. + private const int SubsequentFlushChars = 64 * 1024; + + /// + /// Streams text from into without ever materializing the + /// whole file as a single string (resolves OPEN-003 / DEC-009). + /// + /// When is supplied (UI consumers such as ted), an empty document is + /// installed and painted immediately and decoded chunks are appended on the UI thread as they + /// arrive, so the editor fills in top-down while staying responsive. When is + /// (synchronous callers / tests) the file is read in chunks and the document is + /// installed once at the end. + /// + /// + /// The source stream. + /// Fallback encoding when no BOM is detected. Defaults to UTF-8 (no BOM). + /// Optional progress sink. + /// Cancels the load between chunks. + /// + /// Marshals an action onto the UI thread and completes when it has run. UI consumers pass their + /// application-invoke helper so the read happens on a background thread while every document mutation + /// happens on the UI thread. + /// + public async Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default, + Func? marshal = null) + { + ArgumentNullException.ThrowIfNull (stream); + + if (marshal is null) + { + await LoadNonProgressiveAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false); + + return; + } + + await LoadProgressiveAsync (stream, encoding, progress, marshal, cancellationToken).ConfigureAwait (false); + } + + // Synchronous / non-UI path: build the whole document off-thread in chunks (no giant string), then install + // it once. The document's owner thread is released at the end so the next single consumer (the caller, a + // test, the UI) claims it on first access — matches the pre-progressive idiom and TextDocument's affinity. + private async Task LoadNonProgressiveAsync ( + Stream stream, + Encoding? encoding, + IProgress? progress, + CancellationToken cancellationToken) + { + Document?.SetOwnerThread (null); + + TextDocument document = + await TextDocument.LoadAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false); + + document.SetOwnerThread (Thread.CurrentThread); + Document = document; + CaretOffset = 0; + document.SetOwnerThread (null); + } + + // UI path: install an empty document immediately so the editor paints, then append decoded chunks on the UI + // thread as they stream in. The read/decode runs on a background thread; the document is only ever touched + // on the UI thread (assign here, every append + the finalize marshalled). + private async Task LoadProgressiveAsync ( + Stream stream, + Encoding? encoding, + IProgress? progress, + Func marshal, + CancellationToken cancellationToken) + { + Document?.SetOwnerThread (null); + TextDocument document = new (); + document.SetOwnerThread (Thread.CurrentThread); + + bool priorReadOnly = ReadOnly; + Document = document; + CaretOffset = 0; + + // Read-only while streaming: the background reader appends at the end; user edits at arbitrary offsets + // would race those appends. The buffer becomes editable the moment the load completes. + ReadOnly = true; + + Encoding detected = encoding ?? new UTF8Encoding (false); + var firstFlushDone = false; + var pending = new StringBuilder (SubsequentFlushChars + FirstFlushChars); + + void AppendOnUiThread (string text) + { + // The user opened a different file (or LoadAsync was called again) while this load was in flight — + // stop feeding a document the editor no longer shows. + if (!ReferenceEquals (_document, document)) + { + throw new OperationCanceledException (); + } + + document.Insert (document.TextLength, text); + + // Keep the caret pinned to the top so the viewport stays put on the first screenful while the rest + // streams in below, off-screen. Without this the AfterInsertion caret anchor rides every tail append + // and EnsureCaretVisible follows it, scrolling the view for the whole load. Tail-follow is a host + // policy (a host can scroll / set CaretOffset on progress); the editor's default is a stable viewport. + CaretOffset = 0; + SetNeedsDraw (); + } + + async ValueTask OnChunk (ReadOnlyMemory chunk, CancellationToken token) + { + pending.Append (chunk.Span); + + int threshold = firstFlushDone ? SubsequentFlushChars : FirstFlushChars; + + if (pending.Length < threshold) + { + return; + } + + string text = pending.ToString (); + pending.Clear (); + firstFlushDone = true; + await marshal (() => AppendOnUiThread (text)).ConfigureAwait (false); + } + + try + { + // Read + decode off the UI thread; OnChunk marshals each flush back onto it. + await Task.Run ( + () => TextDocument.StreamAsync ( + stream, + encoding, + OnChunk, + enc => detected = enc, + progress, + cancellationToken), + cancellationToken).ConfigureAwait (false); + + await marshal ( + () => + { + if (!ReferenceEquals (_document, document)) + { + return; + } + + if (pending.Length > 0) + { + document.Insert (document.TextLength, pending.ToString ()); + pending.Clear (); + } + + document.Encoding = detected; + + // Loading is not an undoable edit (matches every editor); discard the transient append + // history and mark the freshly loaded content as the pristine on-disk state. + document.UndoStack.ClearAll (); + document.UndoStack.MarkAsOriginalFile (); + + ReadOnly = priorReadOnly; + CaretOffset = 0; + SetNeedsDraw (); + }).ConfigureAwait (false); + } + catch + { + // Restore editability even on cancel/failure; keep whatever streamed in so far. + await marshal ( + () => + { + if (ReferenceEquals (_document, document)) + { + ReadOnly = priorReadOnly; + } + }).ConfigureAwait (false); + + throw; + } + } + + /// + /// Streams to by delegating to + /// . + /// + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + TextDocument document = Document + ?? throw new InvalidOperationException ( + "Cannot save because the editor has no document."); + + return document.SaveAsync (stream, progress, cancellationToken); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 6c3b7e7..3ecccdf 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -5,6 +5,31 @@ 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. + /// + /// + protected override bool OnKeyDown (Key key) + { + _previousCommandWasKill = _lastCommandWasKill; + _lastCommandWasKill = false; + + bool 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; + // _previousCommandWasKill is no longer needed. + _previousCommandWasKill = false; + + return result; + } + /// /// Catches keystrokes that didn't match any registered binding (set up in /// ) and inserts the typed rune into the document. Skips diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 57d80c8..3ca361c 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -9,31 +9,9 @@ namespace Terminal.Gui.Editor; public partial class Editor { - /// - /// Which gesture the in-progress left-button drag belongs to. One state instead of a set - /// of fighting "suppress…UntilRelease" booleans: the press classifies the gesture, every - /// subsequent drag/release event dispatches on it. Reset to - /// (the neutral default) on release. - /// - private enum DragMode - { - /// Plain or Shift drag: extend the primary selection to the drag point. - Select, - - /// Ctrl+Click add-caret: swallow drag events so they don't move the primary. - AddCaret, - - /// - /// Alt drag: build a vertical column of carets from press row to drag row. Alt (not - /// VS Code's Shift+Alt) because Windows Terminal reserves Shift+drag for its own - /// forced/block text selection while an app has mouse mode on — see - /// specs/decisions.md DEC-006 and gui-cs/Terminal.Gui#4888. - /// - ColumnCarets - } + private Point _columnDragAnchor; private DragMode _dragMode; - private Point _columnDragAnchor; /// protected override bool OnMouseEvent (Mouse mouse) @@ -48,6 +26,20 @@ protected override bool OnMouseEvent (Mouse mouse) return false; } + // Right-click → show built-in context menu at the click position. + // When ContextMenu is null the click is left unhandled so it can bubble. + if (mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) + { + if (ContextMenu is null) + { + return false; + } + + ShowContextMenu (mouse.ScreenPosition); + + return true; + } + var shift = mouse.Flags.HasFlag (MouseFlags.Shift); if (mouse.Flags.HasFlag (MouseFlags.LeftButtonTripleClicked)) @@ -261,4 +253,27 @@ private CellVisualLine BuildVisualLineForSegment (DocumentLine documentLine, int return visualLine; } + + /// + /// Which gesture the in-progress left-button drag belongs to. One state instead of a set + /// of fighting "suppress…UntilRelease" booleans: the press classifies the gesture, every + /// subsequent drag/release event dispatches on it. Reset to + /// (the neutral default) on release. + /// + private enum DragMode + { + /// Plain or Shift drag: extend the primary selection to the drag point. + Select, + + /// Ctrl+Click add-caret: swallow drag events so they don't move the primary. + AddCaret, + + /// + /// Alt drag: build a vertical column of carets from press row to drag row. Alt (not + /// VS Code's Shift+Alt) because Windows Terminal reserves Shift+drag for its own + /// forced/block text selection while an app has mouse mode on — see + /// specs/decisions.md DEC-006 and gui-cs/Terminal.Gui#4888. + /// + ColumnCarets + } } diff --git a/src/Terminal.Gui.Editor/Editor.Selection.cs b/src/Terminal.Gui.Editor/Editor.Selection.cs index 3b4ea79..733e575 100644 --- a/src/Terminal.Gui.Editor/Editor.Selection.cs +++ b/src/Terminal.Gui.Editor/Editor.Selection.cs @@ -290,12 +290,7 @@ internal int ViewRowToLineNumber (int row) return 1; } - if (idx >= visibleLines.Count) - { - return visibleLines[^1]; - } - - return visibleLines[idx]; + return idx >= visibleLines.Count ? visibleLines[^1] : visibleLines[idx]; } /// diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 61e1f57..91b2bb2 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -47,6 +47,24 @@ public partial class Editor : View private HighlightingColorizer? _highlightingColorizer; private int _lastKnownCaretOffset; + // Kill-ring: consecutive CutToEndOfLine / CutToStartOfLine appends to the clipboard instead + // of replacing. Any non-kill command (including plain character insertion) breaks the run. + // + // _lastCommandWasKill is set to true by kill commands after executing. + // _previousCommandWasKill is set by OnKeyDown (keyboard path) — it snapshots _lastCommandWasKill + // before clearing it, so the dispatched kill command can read whether the preceding command was + // a kill for append/prepend decisions. + // + // Keyboard path: OnKeyDown snapshots _lastCommandWasKill → _previousCommandWasKill, clears + // _lastCommandWasKill, then dispatches. Kill commands read _previousCommandWasKill. + // InvokeCommand path (programmatic): OnKeyDown is bypassed. Kill commands fall back to + // _lastCommandWasKill directly. Note: non-kill commands invoked via InvokeCommand do NOT + // clear _lastCommandWasKill, so a sequence like InvokeCommand(Kill) → InvokeCommand(Right) → + // InvokeCommand(Kill) will incorrectly append. This is a known limitation of the + // programmatic path; keyboard dispatch (the primary use case) is unaffected. + private bool _lastCommandWasKill; + private bool _previousCommandWasKill; + // Incremental max-width tracking: avoids the O(N) all-lines walk that UpdateContentSize // used to do on every edit. _maxVisualWidth is the widest visual line seen; _maxWidthLineNumber // tracks which line holds it so we can detect when that line is edited. _maxWidthDirty forces @@ -55,6 +73,18 @@ public partial class Editor : View private bool _maxWidthDirty = true; private int _maxWidthLineNumber; + // Above this document size the horizontal extent is estimated from each line's character count + // (O(1) per line) instead of building + syntax-highlighting a CellVisualLine for every line. + // Building every line on load is what made a 10 MiB open take ~10 s; the model layer alone + // loads in ~0.2 s. Smaller documents keep the exact computation (tab/wide-glyph precise). + private const int MaxWidthEstimateThresholdBytes = 256 * 1024; + + // Set by the draw path when a rendered line turned out wider than the running max (e.g. an + // estimated large doc whose visible tab-indented line expands past the char-length estimate). + // OnDrawingContent reconciles the content size once after the draw, so the horizontal scrollbar + // grows as wider lines scroll into view — matching VS Code's "estimate, then refine" model. + private bool _maxWidthGrewDuringDraw; + /// /// Sticky column for vertical caret moves. Tracks the column the user *intends* to be in, /// even when the current line is shorter, so Up/Down across short lines snap back to the @@ -76,6 +106,7 @@ public Editor () CreateCommandsAndBindings (); OverlayRenderers.Add (new MultiCaretRenderer (this)); Document = new TextDocument (); + InitializeDefaultContextMenu (); ThemeManager.ThemeChanged += OnThemeChanged; } @@ -437,7 +468,8 @@ protected override void Dispose (bool disposing) // external code retains the TextDocument (test fixtures, future shared docs across panes, // etc.). The Document setter unsubscribes on swap; this covers View-teardown. _document.Changed -= OnDocumentChanged; - _lastKnownCaretOffset = CaretOffset; + // Dispose can run after document ownership moved; _lastKnownCaretOffset is maintained + // during caret movement and document changes, so avoid reading CaretOffset here. _caretAnchor = null; _selectionAnchor = null; _additionalCarets.Clear (); @@ -507,6 +539,25 @@ private void UpdateContentSize () SetContentSize (new Size (_maxVisualWidth + 1, Math.Max (1, visibleLines))); } + /// + /// Per-line horizontal extent used for the content width / horizontal scrollbar. For documents + /// below this is the exact visual width (builds the + /// line, tab/wide-glyph precise). For larger documents it is the line's character count — O(1), + /// no build, no syntax-highlight — so opening a multi-MB file does not build + highlight every + /// line just to size a scrollbar. The estimate can be short for tab-indented / wide-glyph lines; + /// the draw path refines exactly for lines it actually renders, so + /// visible content always has a correct extent and the scrollbar grows on scroll. + /// + private int MeasureLineWidth (DocumentLine line) + { + if (_document is { } document && document.TextLength >= MaxWidthEstimateThresholdBytes) + { + return line.Length; + } + + return GetOrBuildDefaultVisualLine (line).VisualLength; + } + /// Full O(N) recompute — only called on Document swap, IndentationSize change, etc. private void RecomputeMaxWidth () { @@ -522,7 +573,7 @@ private void RecomputeMaxWidth () foreach (DocumentLine line in _document.Lines) { - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width > _maxVisualWidth) { @@ -568,7 +619,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) for (var lineNum = firstAffected.LineNumber; lineNum <= scanEnd; lineNum++) { DocumentLine line = _document.GetLineByNumber (lineNum); - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width >= newMax) { @@ -599,7 +650,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) for (var lineNum = firstAffected.LineNumber; lineNum <= endLine; lineNum++) { DocumentLine line = _document.GetLineByNumber (lineNum); - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width > _maxVisualWidth) { @@ -680,7 +731,7 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) // Net character shift. Cached visual lines store *absolute* element offsets, so a // same-line-count edit upstream (no newline added/removed) still leaves every // downstream cached line stale even though its line *number* is unchanged. - var offsetDelta = (insertedText.Length - removedText.Length); + var offsetDelta = insertedText.Length - removedText.Length; RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); @@ -972,7 +1023,7 @@ private bool TryGetVerticalOffset (int startOffset, int delta, int targetVisualC return true; } - var targetLineIndex = (_document.GetLineByOffset (startOffset).LineNumber - 1) + delta; + var targetLineIndex = _document.GetLineByOffset (startOffset).LineNumber - 1 + delta; if (targetLineIndex < 0 || targetLineIndex > _document.LineCount - 1) { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs new file mode 100644 index 0000000..b0a4758 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs @@ -0,0 +1,259 @@ +// CoPilot - gpt-4.1 + +using System.Drawing; +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Integration tests for the built-in — right-click / Command.Context +/// triggers the default editing context menu, items reflect state, and the menu is replaceable / suppressible. +/// +public class EditorContextMenuTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + [Fact] + public async Task RightClick_Shows_Default_ContextMenu () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + + DriverAssert.ContentsDoesNotContain (fx.Driver, "Undo"); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + fx.Render (); + + DriverAssert.ContentsContains (fx.Driver, "Undo"); + DriverAssert.ContentsContains (fx.Driver, "Redo"); + DriverAssert.ContentsContains (fx.Driver, "Cut"); + DriverAssert.ContentsContains (fx.Driver, "Copy"); + DriverAssert.ContentsContains (fx.Driver, "Paste"); + DriverAssert.ContentsContains (fx.Driver, "Select all"); + } + + [Fact] + public async Task ContextMenu_Null_Suppresses_Menu () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ContextMenu = null; + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + fx.Render (); + + DriverAssert.ContentsDoesNotContain (fx.Driver, "Undo"); + DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all"); + } + + [Fact] + public async Task ReadOnly_Disables_Mutating_Items () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ReadOnly = true; + + // Select text so Copy is enabled + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.SelectAll (); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + // Verify state: Copy should be enabled (has selection), Cut/Paste/Undo/Redo should be disabled + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Cut: + case Command.Paste: + case Command.Undo: + case Command.Redo: + Assert.False (menuItem.Enabled, + $"{menuItem.Command} should be disabled when ReadOnly"); + + break; + case Command.Copy: + Assert.True (menuItem.Enabled, "Copy should be enabled when there is a selection"); + + break; + case Command.SelectAll: + Assert.True (menuItem.Enabled, "Select All should always be enabled"); + + break; + } + } + } + + [Fact] + public async Task No_Selection_Disables_Cut_And_Copy () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + + // No selection — right-click to open menu + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Cut: + case Command.Copy: + Assert.False (menuItem.Enabled, + $"{menuItem.Command} should be disabled when there is no selection"); + + break; + } + } + } + + [Fact] + public async Task Undo_Enabled_After_Edit () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + // Make an edit so undo becomes available + fx.Top.Editor.Document!.Insert (5, "X"); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (2, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is MenuItem { Command: Command.Undo } undoItem) + { + Assert.True (undoItem.Enabled, "Undo should be enabled after an edit"); + } + } + } + + [Fact] + public async Task Default_ContextMenu_Is_Not_Null () + { + await using AppFixture fx = new (() => new EditorTestHost ("test")); + + Assert.NotNull (fx.Top.Editor.ContextMenu); + } + + [Fact] + public async Task ContextMenu_Items_Use_Declarative_Binding () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + Assert.NotNull (menu.Root); + + // Collect the commands from all MenuItems (skip Line separators). + List commands = []; + + foreach (View child in menu.Root.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + Assert.Equal (fx.Top.Editor, menuItem.TargetView); + Assert.Null (menuItem.Action); + commands.Add (menuItem.Command); + } + + // Verify the expected commands in order. + Assert.Equal ( + [Command.Undo, Command.Redo, Command.Cut, Command.Copy, Command.Paste, Command.SelectAll], + commands); + } + + [Fact] + public async Task ContextMenu_SelectAll_Routes_Via_CommandView () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + Assert.False (fx.Top.Editor.HasSelection); + + // Invoke SelectAll on the Editor via InvokeCommand — this is the same path the framework + // takes when the user clicks the declaratively-bound MenuItem. + fx.Top.Editor.InvokeCommand (Command.SelectAll); + + Assert.True (fx.Top.Editor.HasSelection, "Select All should select all text"); + Assert.Equal ("hello world", fx.Top.Editor.SelectedText); + } + + [Fact] + public async Task ContextMenu_Undo_Routes_Via_CommandView () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + // Make an edit + fx.Top.Editor.Document!.Insert (5, "X"); + Assert.Equal ("helloX", fx.Top.Editor.Document.Text); + + // Invoke Undo on the Editor via InvokeCommand — this is the same path the framework + // takes when the user clicks the declaratively-bound MenuItem. + fx.Top.Editor.InvokeCommand (Command.Undo); + + Assert.Equal ("hello", fx.Top.Editor.Document.Text); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs new file mode 100644 index 0000000..15da153 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorKillRingTests.cs @@ -0,0 +1,304 @@ +// Copilot - gpt-4.1 + +using Terminal.Gui.Drivers; +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Kill-ring integration tests — CutToEndOfLine, CutToStartOfLine, and consecutive-kill +/// append behavior. Each test boots an so App.Clipboard +/// is available. +/// +public class EditorKillRingTests +{ + /// + /// Ensures the fixture's driver has a working in-memory clipboard regardless of platform. + /// + private static void EnsureFakeClipboard (AppFixture fx) + { + fx.Driver.Clipboard = new FakeClipboard (false, false); + } + + // ───────────────────── CutToEndOfLine ───────────────────── + + [Fact] + public async Task CutToEndOfLine_KillsToLineEnd () + { + await using AppFixture fx = new (() => new ("hello world")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; // after "hello" + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + Assert.Equal ("hello", fx.Top.Editor.Document?.Text); + + // Clipboard should contain " world" + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal (" world", data); + } + + [Fact] + public async Task CutToEndOfLine_AtEOL_KillsDelimiter () + { + await using AppFixture fx = new (() => new ("abc\ndef")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 3; // at end of "abc", before \n + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + Assert.Equal ("abcdef", fx.Top.Editor.Document?.Text); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("\n", data); + } + + [Fact] + public async Task CutToEndOfLine_AtEndOfDocument_IsNoOp () + { + await using AppFixture fx = new (() => new ("abc")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 3; + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + Assert.Equal ("abc", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task CutToEndOfLine_SingleUndo () + { + await using AppFixture fx = new (() => new ("hello world")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + Assert.Equal ("hello", fx.Top.Editor.Document?.Text); + + fx.Top.Editor.InvokeCommand (Command.Undo); + Assert.Equal ("hello world", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task CutToEndOfLine_ReadOnly_IsNoOp () + { + await using AppFixture fx = new (() => new ("hello")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ReadOnly = true; + fx.Top.Editor.CaretOffset = 2; + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + Assert.Equal ("hello", fx.Top.Editor.Document?.Text); + } + + // ───────────────────── CutToStartOfLine ───────────────────── + + [Fact] + public async Task CutToStartOfLine_KillsToLineStart () + { + await using AppFixture fx = new (() => new ("hello world")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; + + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + + Assert.Equal (" world", fx.Top.Editor.Document?.Text); + Assert.Equal (0, fx.Top.Editor.CaretOffset); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("hello", data); + } + + [Fact] + public async Task CutToStartOfLine_AtBOL_IsNoOp () + { + await using AppFixture fx = new (() => new ("abc\ndef")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 4; // start of "def" + + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + + Assert.Equal ("abc\ndef", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task CutToStartOfLine_SingleUndo () + { + await using AppFixture fx = new (() => new ("hello world")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; + + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + Assert.Equal (" world", fx.Top.Editor.Document?.Text); + + fx.Top.Editor.InvokeCommand (Command.Undo); + Assert.Equal ("hello world", fx.Top.Editor.Document?.Text); + } + + // ───────────────────── Consecutive-kill append ───────────────────── + + [Fact] + public async Task ConsecutiveKillToEOL_Appends () + { + // "abc\ndef\nghi" with caret at 0 — two consecutive CutToEndOfLine should accumulate. + await using AppFixture fx = new (() => new ("abc\ndef\nghi")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + + // First kill: "abc" → clipboard = "abc" + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + Assert.Equal ("\ndef\nghi", fx.Top.Editor.Document?.Text); + + // Second kill (consecutive): "\n" → clipboard = "abc\n" + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + Assert.Equal ("def\nghi", fx.Top.Editor.Document?.Text); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("abc\n", data); + } + + [Fact] + public async Task NonKillCommand_BreaksConsecutiveRun () + { + await using AppFixture fx = new (() => new ("abc\ndef")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + + // First kill: "abc" + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + Assert.Equal ("\ndef", fx.Top.Editor.Document?.Text); + + // Intervening non-kill command (move right): caret moves past \n to position 1 (start of "def"). + fx.Injector.InjectKey (Key.CursorRight, new InputInjectionOptions { Mode = InputInjectionMode.Direct }); + + // Second kill after non-kill — should replace clipboard, not append. + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + + // Clipboard replaced with "def" (not "abc" + "def" which would be consecutive-append). + Assert.Equal ("def", data); + } + + [Fact] + public async Task ConsecutiveKillToBOL_Prepends () + { + // Two consecutive CutToStartOfLine on a single line should prepend so clipboard + // accumulates in document order. + // "abcdef" — caret at 6 (end) + await using AppFixture fx = new (() => new ("abcdef")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 6; // end of "abcdef" + + // First kill-to-BOL: kills "abcdef" → clipboard = "abcdef", text = "" + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + Assert.Equal ("", fx.Top.Editor.Document?.Text); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("abcdef", data); + } + + [Fact] + public async Task ConsecutiveKillToBOL_TwoLines_Prepends () + { + // Consecutive CutToStartOfLine across different lines should prepend in document order. + // "abc\ndef\nghi" — start at end of line 3 (offset 11, after "ghi") + await using AppFixture fx = new (() => new ("abc\ndef\nghi")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 11; // end of "ghi" + + // First kill-to-BOL: kills "ghi" → clipboard = "ghi", text = "abc\ndef\n" + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + Assert.Equal ("abc\ndef\n", fx.Top.Editor.Document?.Text); + + // Move caret to end of line 2 (offset 7, end of "def") + fx.Top.Editor.CaretOffset = 7; + + // Second consecutive kill-to-BOL: kills "def" → prepend → clipboard = "def" + "ghi" = "defghi" + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + + string? data = null; + Assert.True (fx.App.Clipboard?.TryGetClipboardData (out data)); + Assert.Equal ("defghi", data); + } + + // ───────────────────── Unbound by default ───────────────────── + + [Fact] + public void DefaultKeyBindings_DoesNotContain_KillCommands () + { + Assert.NotNull (Editor.DefaultKeyBindings); + Assert.False (Editor.DefaultKeyBindings!.ContainsKey (Command.CutToEndOfLine)); + Assert.False (Editor.DefaultKeyBindings!.ContainsKey (Command.CutToStartOfLine)); + } + + // ───────────────────── Selection consumed ───────────────────── + + [Fact] + public async Task CutToEndOfLine_WithSelection_DeletesSelection () + { + await using AppFixture fx = new (() => new ("hello world")); + EnsureFakeClipboard (fx); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.SelectRange (0, 5); + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + // Selection replaced with empty — "hello" removed. + Assert.Equal (" world", fx.Top.Editor.Document?.Text); + } + + // ───────────────────── Clipboard failure ───────────────────── + + [Fact] + public async Task CutToEndOfLine_PreservesText_When_Clipboard_Unavailable () + { + await using AppFixture fx = new (() => new ("hello world")); + + // FakeClipboard(false, true) → IsSupported = false, writes fail. + fx.Driver.Clipboard = new FakeClipboard (false, true); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; + + fx.Top.Editor.InvokeCommand (Command.CutToEndOfLine); + + // Text must be preserved because the clipboard write failed. + Assert.Equal ("hello world", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task CutToStartOfLine_PreservesText_When_Clipboard_Unavailable () + { + await using AppFixture fx = new (() => new ("hello world")); + + fx.Driver.Clipboard = new FakeClipboard (false, true); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 5; + + fx.Top.Editor.InvokeCommand (Command.CutToStartOfLine); + + Assert.Equal ("hello world", fx.Top.Editor.Document?.Text); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs index 1d6dc62..93ea325 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs @@ -148,9 +148,9 @@ public async Task Backspace_At_End_Of_Leading_Whitespace_Removes_One_Indentation } [Fact] - public async Task Ted_RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress () + public async Task RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); fx.Top.Editor.SetFocus (); fx.Top.Editor.Document!.Text = "hello world"; fx.Top.Editor.CaretOffset = 0; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index 48a8035..b9d2747 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -2,14 +2,14 @@ using System.Collections.Immutable; using System.Drawing; +using System.IO; +using System.Text; using Ted; using Terminal.Gui.Configuration; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; -using Terminal.Gui.Text.Indentation; using Terminal.Gui.Testing; -using Terminal.Gui.Editor; -using Terminal.Gui.Views; +using Terminal.Gui.Text.Indentation; using Xunit; namespace Terminal.Gui.Editor.IntegrationTests; @@ -31,9 +31,9 @@ private static void DeleteIfExists (string filePath) [Fact] public void NewFile_ClearsEditor_AndCurrentFilePath () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => "/tmp/ted-open.txt"; - app.ReadAllText = _ => "opened"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("opened")); Assert.True (app.OpenFile ()); app.Editor.SelectAll (); @@ -49,9 +49,9 @@ public void NewFile_ClearsEditor_AndCurrentFilePath () [Fact] public void OpenFile_Canceled_DoesNotChangeEditor () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => null; - app.ReadAllText = _ => throw new InvalidOperationException ("Canceled open should not read."); + app.OpenRead = _ => throw new InvalidOperationException ("Canceled open should not read."); Assert.False (app.OpenFile ()); @@ -67,7 +67,7 @@ public void OpenFile_LoadsSelectedFile_FromDisk () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); @@ -90,7 +90,7 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); Assert.Equal (filePath, app.CurrentFilePath); @@ -103,6 +103,150 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified () } } + [Fact] + public async Task OpenFileAsync_Updates_LoadStatusShortcut () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-progress.txt"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes (new string ('x', 100_000))); + + Assert.Equal (string.Empty, app.LoadSpinnerShortcut.Title); + + Assert.True (await app.OpenFileAsync (TestContext.Current.CancellationToken)); + + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + Assert.Same (app.LoadStatusSpinner, app.LoadSpinnerShortcut.CommandView); + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal (100_000, app.Editor.Document!.TextLength); + } + + [Fact] + 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; + + Task openTask = app.OpenFileAsync (TestContext.Current.CancellationToken); + + await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken); + + Assert.False (openTask.IsCompleted); + Assert.True (app.LoadStatusSpinner.Visible); + Assert.True (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + + 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); + } + + [Fact] + public async Task OpenFileAsync_ReadFailure_HidesSpinner_AndShowsFailureStatus () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-open-fails.txt"; + app.OpenRead = _ => new ThrowingReadStream (); + + Assert.False (await app.OpenFileAsync (TestContext.Current.CancellationToken)); + + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Load failed", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Load failed", app.LoadSpinnerShortcut.HelpText); + } + + [Fact] + public void OpenFile_UsesReadAllTextHook_WhenReplaced () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-legacy-open.txt"; + app.ReadAllText = path => path == "/tmp/ted-legacy-open.txt" ? "legacy open" : string.Empty; + + Assert.True (app.OpenFile ()); + + Assert.Equal ("legacy open", app.Editor.Document!.Text); + Assert.Equal ("/tmp/ted-legacy-open.txt", app.CurrentFilePath); + } + + [Fact] + public async Task OpenFileAsync_ByPath_Updates_LoadStatusShortcut () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000))); + app.OpenRead = _ => stream; + + Task openTask = app.OpenFileAsync ("/tmp/ted-progress.cs", TestContext.Current.CancellationToken); + + await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken); + + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + + stream.AllowRead.SetResult (); + + Assert.True (await openTask); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + } + + [Fact] + public async Task StatusBar_Shows_Loaded_FileSize_After_StartupOpen () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-startup-{Guid.NewGuid ():N}.cs"); + await File.WriteAllTextAsync (filePath, new string ('x', 100_000), TestContext.Current.CancellationToken); + + try + { + await using AppFixture fx = new (() => + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenFileAsync (filePath).GetAwaiter ().GetResult (); + + return app; + }); + + fx.Render (); + + DriverAssert.ContentsContains (fx.Driver, "Loaded 97.7 KiB"); + } + finally + { + File.Delete (filePath); + } + } + + [Fact] + public async Task OpenFileAsync_LargeFile_DisablesAutomaticFolding () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-large-{Guid.NewGuid ():N}.cs"); + await File.WriteAllTextAsync (filePath, new string ('x', 1_000_001), TestContext.Current.CancellationToken); + + try + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + + Assert.True (await app.OpenFileAsync (filePath, TestContext.Current.CancellationToken)); + + Assert.Null (app.Editor.FoldingManager); + Assert.Equal ("Loaded 976.6 KiB", app.LoadSpinnerShortcut.Title); + } + finally + { + File.Delete (filePath); + } + } + [Fact] public void SaveFile_WritesCurrentEditorText_ToCurrentPath () { @@ -111,7 +255,7 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); app.Editor.Document!.Text = "after"; @@ -130,17 +274,82 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath () [Fact] public void SaveFile_MarksDocumentUnmodified () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => "/tmp/ted-save.txt"; - app.ReadAllText = _ => "before"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before")); Assert.True (app.OpenFile ()); app.Editor.Document!.Text = "after"; - app.WriteAllText = (_, _) => { }; + app.CreateWrite = _ => new MemoryStream (); + + Assert.True (app.IsDocumentModified); + + Assert.True (app.SaveFile ()); + + Assert.False (app.IsDocumentModified); + } + + [Fact] + public async Task SaveFileAsync_WriteFailure_HidesSpinner_AndShowsFailureStatus () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile ("/tmp/ted-save-fails.txt"); + app.Editor.Document!.Text = "dirty"; + app.CreateWrite = _ => new ThrowingWriteStream (); + + Assert.False (await app.SaveFileAsync (TestContext.Current.CancellationToken)); + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Save failed", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Save failed", app.LoadSpinnerShortcut.HelpText); Assert.True (app.IsDocumentModified); + } + + [Fact] + public async Task SaveFileAsync_Canceled_HidesSpinner_AndShowsCanceledStatus () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-save-canceled-{Guid.NewGuid ():N}.txt"); + + try + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile (filePath); + app.Editor.Document!.Text = "dirty"; + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + Assert.False (await app.SaveFileAsync (cts.Token)); + + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.HelpText); + Assert.True (app.IsDocumentModified); + } + finally + { + DeleteIfExists (filePath); + } + } + + [Fact] + public void SaveFile_UsesWriteAllTextHook_WhenReplaced () + { + string? savedPath = null; + string? savedText = null; + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile ("/tmp/ted-legacy-save.txt"); + app.Editor.Document!.Text = "legacy save"; + app.WriteAllText = (path, text) => + { + savedPath = path; + savedText = text; + }; Assert.True (app.SaveFile ()); + Assert.Equal ("/tmp/ted-legacy-save.txt", savedPath); + Assert.Equal ("legacy save", savedText); Assert.False (app.IsDocumentModified); } @@ -152,7 +361,7 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); @@ -170,9 +379,14 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters () public void SaveFileAs_Canceled_DoesNotWrite () { var wrote = false; - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowSaveDialog = () => " "; - app.WriteAllText = (_, _) => wrote = true; + app.CreateWrite = _ => + { + wrote = true; + + return new MemoryStream (); + }; Assert.False (app.SaveFileAs ()); @@ -187,7 +401,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowSaveDialog = () => filePath; app.Editor.Document!.Text = "save as"; @@ -206,7 +420,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath () public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit () { var prompted = false; - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "dirty"; app.ShowSaveChangesDialog = () => { @@ -224,18 +438,19 @@ public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit () [Fact] public async Task QuitFile_ModifiedDocument_SaveChoice_SavesBeforeQuitting () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); string? savedPath = null; string? savedText = null; fx.Top.ShowOpenDialog = () => "/tmp/ted-save-on-quit.txt"; - fx.Top.ReadAllText = _ => "before"; + fx.Top.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before")); Assert.True (fx.Top.OpenFile ()); fx.Top.Editor.Document!.Text = "after"; fx.Top.ShowSaveChangesDialog = () => SaveChangesChoice.Save; - fx.Top.WriteAllText = (path, text) => + fx.Top.CreateWrite = path => { savedPath = path; - savedText = text; + + return new CapturingWriteStream (text => savedText = text); }; Assert.True (fx.Top.QuitFile ()); @@ -253,7 +468,7 @@ public void QuitFile_MissingFile_DiscardChoice_DoesNotCreateFile () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); app.ShowSaveChangesDialog = () => SaveChangesChoice.Discard; @@ -274,7 +489,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); app.ShowSaveChangesDialog = () => SaveChangesChoice.Save; @@ -293,7 +508,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile () [Fact] public async Task Renders_FileMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "File"); } @@ -301,7 +516,7 @@ public async Task Renders_FileMenu_Header () [Fact] public async Task Renders_OptionsMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "Options"); } @@ -309,7 +524,7 @@ public async Task Renders_OptionsMenu_Header () [Fact] public async Task Renders_ViewMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "View"); } @@ -317,7 +532,7 @@ public async Task Renders_ViewMenu_Header () [Fact] public async Task Constructor_Defaults_To_Plain_Text_Highlighting () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Null (fx.Top.Editor.HighlightingDefinition); Assert.Equal ("Plain Text", fx.Top.LanguageShortcut.Title); @@ -330,7 +545,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension () try { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); fx.Top.OpenMissingFile (tempXmlFilePath); Assert.NotNull (fx.Top.Editor.HighlightingDefinition); @@ -346,7 +561,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension () [Fact] public async Task OptionsMenu_Contains_Settings_Item () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; fx.Injector.InjectKey (Key.O.WithAlt, options); @@ -366,7 +581,7 @@ public void Constructor_ReadOnly_Sets_Editor_ReadOnly () [Fact] public void Constructor_Defaults_AutoIndent_To_Enabled () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); Assert.IsType (app.Editor.IndentationStrategy); } @@ -374,7 +589,7 @@ public void Constructor_Defaults_AutoIndent_To_Enabled () [Fact] public async Task Loc_StatusBar_Shortcut_Initially_Shows_Line_1_Column_1 () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Equal ("Ln 1, Col 1", fx.Top.LocShortcut.Title); } @@ -384,7 +599,7 @@ public async Task Loc_StatusBar_Shortcut_Tracks_Caret_Movement () { await using AppFixture fx = new (() => { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "alpha\nbeta\ngamma"; return app; @@ -401,7 +616,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret { await using AppFixture fx = new (() => { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "abc"; return app; @@ -418,7 +633,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret [Fact] public async Task FileMenu_OpensViaKeyboard_AltF () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); // The "Open..." menu item is unique to the dropdown — the StatusBar shortcut is just "Open". DriverAssert.ContentsDoesNotContain (fx.Driver, "Open..."); @@ -433,7 +648,7 @@ public async Task FileMenu_OpensViaKeyboard_AltF () [Fact] public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.True (fx.Top.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; @@ -463,7 +678,7 @@ public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard () [Fact] public async Task FileMenu_OpensViaMouse_ClickOnHeader () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsDoesNotContain (fx.Driver, "Open..."); @@ -488,7 +703,7 @@ public async Task FileMenu_OpensViaMouse_ClickOnHeader () [Fact] public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsDoesNotContain (fx.Driver, "Find..."); DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace..."); @@ -504,10 +719,9 @@ public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace () [Fact] public async Task Editor_RightClick_Opens_Edit_Context_Menu () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); - DriverAssert.ContentsDoesNotContain (fx.Driver, "Find..."); - DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace..."); + DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all"); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; @@ -521,15 +735,15 @@ public async Task Editor_RightClick_Opens_Edit_Context_Menu () options); fx.Render (); - DriverAssert.ContentsContains (fx.Driver, "Find..."); - DriverAssert.ContentsContains (fx.Driver, "Replace..."); + DriverAssert.ContentsContains (fx.Driver, "Undo"); + DriverAssert.ContentsContains (fx.Driver, "Redo"); DriverAssert.ContentsContains (fx.Driver, "Select all"); } [Fact] public async Task ThemeDropDown_Initially_Shows_Current_Theme () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Equal (ThemeManager.Theme, fx.Top.ThemeDropDown.Text); } @@ -537,12 +751,12 @@ public async Task ThemeDropDown_Initially_Shows_Current_Theme () [Fact] public async Task ThemeDropDown_Source_Contains_All_Available_Themes () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); ImmutableList expected = ThemeManager.GetThemeNames (); Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme."); - var actual = fx.Top.ThemeDropDown.Source!.ToList () + List actual = fx.Top.ThemeDropDown.Source!.ToList () .Cast () .ToList (); @@ -552,7 +766,7 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes () [Fact] public async Task ThemeDropDown_Selection_Changes_Active_Theme () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); ImmutableList names = ThemeManager.GetThemeNames (); @@ -569,4 +783,115 @@ public async Task ThemeDropDown_Selection_Changes_Active_Theme () Assert.Equal (target, ThemeManager.Theme); } + + private sealed class CapturingWriteStream : MemoryStream + { + private readonly Action _capture; + + public CapturingWriteStream (Action capture) + { + _capture = capture; + } + + protected override void Dispose (bool disposing) + { + if (disposing) + { + _capture (Encoding.UTF8.GetString (ToArray ())); + } + + base.Dispose (disposing); + } + + public override async ValueTask DisposeAsync () + { + _capture (Encoding.UTF8.GetString (ToArray ())); + await base.DisposeAsync (); + } + } + + private sealed class ThrowingReadStream : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => 1; + + public override long Position { get; set; } + + public override void Flush () + { + } + + public override int Read (byte[] buffer, int offset, int count) + { + throw new IOException ("read failed"); + } + + public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) + { + throw new IOException ("read failed"); + } + + public override long Seek (long offset, SeekOrigin origin) + { + throw new NotSupportedException (); + } + + public override void SetLength (long value) + { + throw new NotSupportedException (); + } + + public override void Write (byte[] buffer, int offset, int count) + { + throw new NotSupportedException (); + } + } + + private sealed class ThrowingWriteStream : MemoryStream + { + public override void Write (byte[] buffer, int offset, int count) + { + throw new IOException ("write failed"); + } + + public override ValueTask WriteAsync (ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + throw new IOException ("write failed"); + } + } + + /// Gates async reads and captures the reading thread ID for background-load tests. + private sealed class GatedReadStream : MemoryStream + { + public GatedReadStream (byte[] buffer) + : base (buffer) + { + } + + public TaskCompletionSource AllowRead { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously); + + 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)); + } + + private async Task ReadAfterGateAsync (Memory buffer, CancellationToken cancellationToken) + { + await AllowRead.Task.WaitAsync (cancellationToken); + + return await base.ReadAsync (buffer, cancellationToken); + } + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs new file mode 100644 index 0000000..40921ca --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; + +namespace Terminal.Gui.Editor.IntegrationTests.Testing; + +/// +/// Supplies a unique throwaway ted.config.json path under the OS temp directory for +/// TedApp construction in tests, so a real TedApp exercising menu/dialog actions +/// persists view settings there instead of polluting the developer's real +/// ~/.tui/ted.config.json. Per-instance (passed to the TedApp constructor) — no +/// environment-variable or static mutation, so it stays parallel-safe. +/// +internal static class TedTestConfig +{ + internal static string NewPath () + { + return Path.Combine ( + Path.GetTempPath (), + "ted-tests", + Guid.NewGuid ().ToString ("N"), + "ted.config.json"); + } +} diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs new file mode 100644 index 0000000..0a90cda --- /dev/null +++ b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Text; +using Terminal.Gui.Document; +using Terminal.Gui.Highlighting; +using Xunit; + +namespace Terminal.Gui.Editor.PerformanceTests; + +public class LargeDocumentLoadPerformanceTests +{ + /// + /// A 10 MiB / ~150k-line C#-highlighted document must load through + /// well under budget. Before max-width virtualization this took ~10 s because the editor built and + /// syntax-highlighted a CellVisualLine for every line just to size the horizontal scrollbar. + /// The model layer alone loads in ~0.2 s, so 3 s is a deliberately loose CI-jitter budget (~5×). + /// + [Fact] + public async Task Editor_LoadAsync_10Mb_HighlightedSource_CompletesWellUnderBudget () + { + var sb = new StringBuilder (10 * 1024 * 1024 + 128); + + while (sb.Length < 10 * 1024 * 1024) + { + sb.Append (" private const int Id = 12345; // a representative C# source line\n"); + } + + var bytes = Encoding.UTF8.GetBytes (sb.ToString ()); + + Editor editor = new () + { + HighlightingDefinition = HighlightingManager.Instance.GetDefinitionByExtension (".cs") + }; + + await using MemoryStream stream = new (bytes); + + Stopwatch sw = Stopwatch.StartNew (); + await editor.LoadAsync (stream, cancellationToken: TestContext.Current.CancellationToken); + sw.Stop (); + + Assert.Equal (bytes.Length, editor.Document!.TextLength); + Assert.True ( + sw.ElapsedMilliseconds < 3000, + $"Editor.LoadAsync of {bytes.Length:N0} bytes took {sw.ElapsedMilliseconds} ms — " + + "expected < 3000 ms (was ~10 s before max-width virtualization)."); + } +} diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs new file mode 100644 index 0000000..4f91af7 --- /dev/null +++ b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.PerformanceTests; + +public class StreamingLoadPerformanceTests +{ + private static readonly TimeSpan InitialProgressBudget = TimeSpan.FromMilliseconds (500); + + [Fact] + public async Task StreamingLoad_10Mb_ReportsInitialProgressWithinBudget () + { + var bytes = Encoding.UTF8.GetBytes (new string ('x', 10 * 1024 * 1024)); + await using MemoryStream stream = new (bytes); + TaskCompletionSource firstProgress = new (); + Progress progress = new (_ => firstProgress.TrySetResult ()); + + Stopwatch sw = Stopwatch.StartNew (); + Task loadTask = TextDocument.LoadAsync ( + stream, + progress: progress, + cancellationToken: TestContext.Current.CancellationToken); + Task completed = await Task.WhenAny ( + firstProgress.Task, + Task.Delay (InitialProgressBudget, TestContext.Current.CancellationToken)); + sw.Stop (); + + Assert.Same (firstProgress.Task, completed); + Assert.True (sw.Elapsed < InitialProgressBudget, + $"Initial streaming load progress took {sw.ElapsedMilliseconds}ms — expected < {InitialProgressBudget.TotalMilliseconds:N0}ms."); + + _ = await loadTask; + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs new file mode 100644 index 0000000..4ae2ca3 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs @@ -0,0 +1,48 @@ +// Claude - claude-opus-4-7 + +using System.Drawing; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +/// +/// The horizontal content extent is computed exactly (building a CellVisualLine per line) +/// for normal-size documents, but estimated from character length for large ones — building + +/// highlighting every line on load is what made a 10 MiB open take ~10 s. These pin both branches +/// and the threshold so the fast path can't silently change small-document behavior. +/// +public class MaxWidthEstimationTests +{ + // A single tab-only line: char length 1, but exact visual width expands to a tab stop (> 1). + [Fact] + public void SmallDocument_UsesExactTabExpandedWidth () + { + Editor editor = new () { Document = new TextDocument ("\t\t\tx") }; + + // Exact path: 3 tabs expand to tab stops, so the extent is far wider than the 4-char length. + Assert.True ( + editor.GetContentSize ().Width > 4 + 1, + $"Small-doc width {editor.GetContentSize ().Width} should be tab-expanded (exact), not the " + + "char-length estimate (5)."); + } + + [Fact] + public void LargeDocument_UsesCharLengthEstimate_NotTabExpanded () + { + // > 256 KiB of identical tab-heavy lines. Each line is "\t\t\tx" (char length 4); the exact + // tab-expanded visual width would be much larger. The estimate path must report char length. + var sb = new StringBuilder (400 * 1024); + + while (sb.Length < 300 * 1024) + { + sb.Append ("\t\t\tx\n"); + } + + Editor editor = new () { Document = new TextDocument (sb.ToString ()) }; + + // Estimate path: width == longest line's char length (4) + 1 (caret past EOL), NOT tab-expanded. + Assert.Equal (4 + 1, editor.GetContentSize ().Width); + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs new file mode 100644 index 0000000..32f0dd8 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs @@ -0,0 +1,113 @@ +// CoPilot - gpt-5.4 + +using System.Runtime.CompilerServices; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +public class TextDocumentStreamingTests +{ + [Fact] + public async Task LoadAsync_SaveAsync_RoundTrips_MixedLineEndings_AndBom () + { + UTF8Encoding encoding = new (true); + var text = "one\r\ntwo\nthree\rfour"; + var bytes = encoding.GetPreamble ().Concat (encoding.GetBytes (text)).ToArray (); + + await using MemoryStream input = new (bytes); + TextDocument document = await TextDocument.LoadAsync ( + input, + cancellationToken: TestContext.Current.CancellationToken); + + await using MemoryStream output = new (); + await document.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal (text, document.Text); + Assert.Equal (bytes, output.ToArray ()); + } + + [Fact] + public async Task LoadAsync_Reports_Multiple_Progress_Updates_For_Large_Stream () + { + var text = new string ('x', 100_000); + await using MemoryStream input = new (Encoding.UTF8.GetBytes (text)); + List reports = []; + CapturingProgress progress = new (reports); + + TextDocument document = await TextDocument.LoadAsync ( + input, + progress: progress, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal (text.Length, document.TextLength); + Assert.True (reports.Count > 1); + Assert.Equal (text.Length, reports[^1].CharactersProcessed); + } + + [Fact] + public async Task LoadAsync_Observes_Cancellation () + { + await using MemoryStream input = new (Encoding.UTF8.GetBytes ("abc")); + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + await Assert.ThrowsAsync (() => + TextDocument.LoadAsync (input, cancellationToken: cts.Token)); + } + + [Fact] + public async Task Editor_LoadAsync_And_SaveAsync_Delegate_To_Document () + { + Editor editor = new (); + await using MemoryStream input = new (Encoding.UTF8.GetBytes ("alpha\r\nbeta")); + + await editor.LoadAsync (input, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal ("alpha\r\nbeta", editor.Document!.Text); + + await using MemoryStream output = new (); + await editor.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal ("alpha\r\nbeta", Encoding.UTF8.GetString (output.ToArray ())); + } + + [Fact] + public void SetOwnerThread_Documentation_Describes_NullOwner_FirstAccessClaim () + { + string source = File.ReadAllText (LocateSource ("Document/TextDocument.cs")); + + Assert.Contains ("first access", source, StringComparison.OrdinalIgnoreCase); + } + + private static string LocateSource (string relativePath, [CallerFilePath] string testFilePath = "") + { + var testDirectory = Path.GetDirectoryName (testFilePath) + ?? throw new InvalidOperationException ("Caller file path was not provided."); + var candidate = Path.GetFullPath ( + Path.Combine (testDirectory, "..", "..", "src", "Terminal.Gui.Editor", relativePath)); + + if (File.Exists (candidate)) + { + return candidate; + } + + throw new FileNotFoundException ($"Could not locate {relativePath}."); + } + + private sealed class CapturingProgress : IProgress + { + private readonly List _reports; + + public CapturingProgress (List reports) + { + _reports = reports; + } + + public void Report (TextDocumentProgress value) + { + _reports.Add (value); + } + } +} diff --git a/third_party/AvaloniaEdit/UPSTREAM.md b/third_party/AvaloniaEdit/UPSTREAM.md index fe4927a..18c9826 100644 --- a/third_party/AvaloniaEdit/UPSTREAM.md +++ b/third_party/AvaloniaEdit/UPSTREAM.md @@ -81,6 +81,7 @@ Each lifted file carries `// Adapted for Terminal.Gui from AvaloniaEdit d7a6b63` | All `Indentation/*.cs` | `namespace AvaloniaEdit.Indentation` → `namespace Terminal.Gui.Text.Indentation`; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. | | `Indentation/DefaultIndentationStrategy.cs` | Replaced `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` (modern pattern). Replaced `var previousLine = line.PreviousLine;` with `DocumentLine? previousLine = line.PreviousLine;` (house style: explicit type for non-built-in). Null-check replaced with pattern match (`is null`). | | `Document/DocumentLineTree.cs` | Stripped `using Avalonia.Threading;` and the five `Dispatcher.UIThread.VerifyAccess()` call sites (commented out with rationale). The document is no longer thread-affined — that's a UI concern, owned by `Terminal.Gui.Editor`. | +| `Document/TextDocument.cs` | **Fork addition (file-io, DEC-009).** Added streaming `LoadAsync(Stream, ...)`, `SaveAsync(Stream, ...)`, and `Encoding` metadata so the rope-backed document can load/save large files without materializing the whole file as one `string`. `VerifyAccess()` now lazily claims a document whose owner was deliberately released after async load/save handoff. | | `Document/TextSegmentCollection.cs` | Same `Avalonia.Threading` strip + one `VerifyAccess()` site stripped. | | `Search/ISearchStrategy.cs` | Namespace transform only. No Avalonia references upstream. | | `Search/RegexSearchStrategy.cs` | Namespace transform; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. No Avalonia references upstream. Contains both `RegexSearchStrategy` and `SearchResult` (kept as a single file matching upstream layout). Added `#nullable disable` directive after the "Adapted for" line — upstream predates nullable reference types (`IEquatable.Equals` override, `SearchResult.Data` auto-property, and `FindAll().FirstOrDefault()` all trip CS warnings under nullable enable; suppressing per-file matches the fork policy of "minimal targeted edits to lifted source"). **Correctness deviation**: `Equals(ISearchStrategy)` now includes `_matchWholeWords` in the comparison. Upstream omits it, so two strategies that differ only by whole-word matching compare equal — breaks consumer caching/dedup. Surfaced in Copilot review of PR #76. **Perf deviation** (gui-cs/Text#82): `FindAll` now drives the regex engine via `Regex.Match(text, startat)` + `NextMatch()` from `offset` instead of `_searchPattern.Matches(text)` over the whole document followed by post-filtering. Upstream re-scans the prefix `[0, offset)` on every call — wasted work for incremental advancing search (one FindNext per F3 keystroke). The .NET regex engine preserves `RegexOptions.Multiline` `^` / `$` semantics across `startat` (anchoring at the start position only when it is 0 or follows a newline). Worth mirroring upstream at AvaloniaEdit. | From bf33894564a82977a31b0ce9d08269b25134026b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 03:20:23 +0000 Subject: [PATCH 10/10] Address merge validation test naming nit Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/6e670ead-33a2-45df-a2cc-d33941caef6e Co-authored-by: tig <585482+tig@users.noreply.github.com> --- tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index b9d2747..94182df 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -756,11 +756,11 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes () ImmutableList expected = ThemeManager.GetThemeNames (); Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme."); - List actual = fx.Top.ThemeDropDown.Source!.ToList () + List actualThemeNames = fx.Top.ThemeDropDown.Source!.ToList () .Cast () .ToList (); - Assert.Equal (expected, actual); + Assert.Equal (expected, actualThemeNames); } [Fact]