diff --git a/Docs/Help/keyboard-reference.md b/Docs/Help/keyboard-reference.md index 0219da5..4878fde 100644 --- a/Docs/Help/keyboard-reference.md +++ b/Docs/Help/keyboard-reference.md @@ -44,6 +44,24 @@ This page lists all default keyboard shortcuts. All shortcuts are `Command`-boun | `Tab` | Indent line / indent selected lines | | `Shift+Tab` | Un-indent line / un-indent selected lines | +## Multi-Caret + +| Key | Action | +|---|---| +| `Ctrl+Click` | Toggle an additional caret at the clicked position | +| `Ctrl+Alt+↑` | Add a caret on the line above (at the sticky column) — VS Code parity | +| `Ctrl+Alt+↓` | Add a caret on the line below (at the sticky column) — VS Code parity | +| `Alt`+drag | Build a vertical column of carets from the press row to the drag row (carets only) | +| `Tab` / `Shift+Tab` | Indent / un-indent at every caret (one undo step) | +| `Esc` | Collapse back to the primary caret | + +`Ctrl+Alt+↑/↓` is configurable per platform via `Terminal.Gui.Editor.Editor.DefaultKeyBindings` +(see *Remapping shortcuts* below) — there is no separate built-in fallback chord for terminals/WMs +that grab `Ctrl+Alt+arrow`. The column-drag uses `Alt`+drag (not VS Code's `Shift+Alt`): inside +a terminal, `Shift`+drag is the terminal's own forced text-selection and `Alt` makes it a +rectangular block, so `Shift+Alt`+drag would never reach the editor. The mouse modifier is not +yet user-configurable — tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). + ## Clipboard | Key | Action | diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index 3ecd70d..f890c30 100644 --- a/Docs/Help/multi-caret.md +++ b/Docs/Help/multi-caret.md @@ -7,8 +7,13 @@ Place multiple carets in the document and type, delete, or press Enter at all of | Action | Effect | |---|---| | **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). | | **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. + The primary caret (the one controlled by normal navigation keys) is never removed by Ctrl+Click. ## Editing with multiple carets @@ -24,7 +29,7 @@ All edits are wrapped in a single `Document.RunUpdate` scope, so **Undo (Ctrl+Z) ## Visual feedback -Additional carets are rendered as inverted-attribute cells by the `MultiCaretRenderer` (an `IBackgroundRenderer`). 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`). 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 @@ -46,4 +51,6 @@ Additional carets are backed by `TextAnchor` instances, so they track insertions - Selection is not yet per-caret; only the primary caret carries a selection. - Find/Replace operates on the primary caret only. -- Ctrl+Click is the only gesture for adding carets; column-select (Alt+Shift+Arrow) is planned. +- `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/Docs/Help/selection.md b/Docs/Help/selection.md index 03e15f1..e2b73d3 100644 --- a/Docs/Help/selection.md +++ b/Docs/Help/selection.md @@ -75,7 +75,7 @@ All edits are wrapped in a single `Document.RunUpdate` scope, so **Undo (Ctrl+Z) ## Visual feedback -Additional carets are rendered as inverted-attribute cells by the `MultiCaretRenderer` (an `IBackgroundRenderer`). 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`). 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 diff --git a/specs/decisions.md b/specs/decisions.md index b3682cf..b8021db 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -105,3 +105,25 @@ Decisions are recorded here when an open question from the plan is resolved. Eac **Rationale**: Matches VS Code's default behavior with `editor.wrappingIndent: "none"`. Simplifies implementation — no need to compute or track the original line's indentation level for each wrap segment. Revisit in a future version if users need indented continuation lines. **Date**: 2026-05-13 + +--- + +### DEC-006: Vertical multi-caret keybindings (VS Code keyboard parity; `Alt`+drag mouse modifier) + +**Decision**: Add-caret-above/below use the VS Code keyboard chords `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown`, shipped as a `[ConfigurationProperty]` `PlatformKeyBinding` entry in `Editor.DefaultKeyBindings` with **no** editor-specific fallback chord (a terminal/WM that grabs the chord is handled by user override via `View.ViewKeyBindings`). The two commands are registered via `AddCommand` against the real `Command.InsertCaretAbove` / `Command.InsertCaretBelow` enum members and bound through the same configurable path — not an inline `if` in `OnKeyDownNotHandled`. (Those members were added upstream by [gui-cs/Terminal.Gui#5318](https://github.com/gui-cs/Terminal.Gui/issues/5318) / PR [#5319](https://github.com/gui-cs/Terminal.Gui/pull/5319) and are consumed by pinning `$(TerminalGuiVersion)` to `2.1.1-develop.98`.) + +The column-of-carets mouse gesture uses **`Alt` + LeftButton drag**, **not** VS Code's `Shift+Alt`. Windows Terminal — and the xterm family it emulates — reserves `Shift`+drag as the user's *forced* text-selection override while an application has mouse mode enabled, and `Alt` turns that into a *block/rectangular* selection ([MS docs](https://learn.microsoft.com/en-us/windows/terminal/customize-settings/interaction); cf. microsoft/terminal#9608). So `Shift+Alt`+drag is swallowed by the terminal's own rectangular-select and never reaches the editor; `Alt`+drag is forwarded. The mouse modifier is currently **not** user-configurable (unlike the keybindings) — that gap, and restoring optional `Shift+Alt` parity, is tracked upstream by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888) (*"Extend the configurable `KeyBindings` to `MouseBindings` (and combos)"*), to be prioritized. + +**Rationale**: Keyboard parity preserves muscle memory and is fully user-overridable via the TG-standard `[ConfigurationProperty]` + `PlatformKeyBinding` mechanism. For the *mouse* modifier, terminal reality wins over GUI-editor parity: a TUI lives inside a terminal emulator, so a gesture the terminal eats is simply unusable — and unlike a key, the mouse modifier has no config override yet. `Alt`+drag is terminal-safe today; full configurable parity follows once TG#4888 lands. **Command-enum debt — RESOLVED 2026-05-17:** the two commands were *temporarily* registered as `(Command) 1001/1002` casts (a sanctioned short-term workaround per Constitution "This Is TG", filed as the great TG issue [gui-cs/Terminal.Gui#5318](https://github.com/gui-cs/Terminal.Gui/issues/5318)). That issue shipped the real `Command.InsertCaretAbove` / `Command.InsertCaretBelow` members (TG PR [#5319](https://github.com/gui-cs/Terminal.Gui/pull/5319), in `Terminal.Gui 2.1.1-develop.98`); `$(TerminalGuiVersion)` is now pinned to `2.1.1-develop.98`, the magic-int casts and the workaround block in `Editor.Commands.cs` are deleted, and the bindings use the real members. The broader "should *any* view be able to contribute commands without casting ints" design question is parked (deliberately, as a possibly-YAGNI hypothetical) in [gui-cs/Terminal.Gui#5320](https://github.com/gui-cs/Terminal.Gui/issues/5320). + +**Date**: 2026-05-16 (mouse-modifier amendment same day, after Windows Terminal validation; Command-enum debt resolved 2026-05-17 — TG#5318/#5319 shipped, pinned `2.1.1-develop.98`) + +--- + +### DEC-007: `ClearAdditionalCarets` stays `public` + +**Decision**: `Editor.ClearAdditionalCarets ()` remains `public` (resolves spec Open Decision "ClearAdditionalCarets visibility"). + +**Rationale**: It is already shipped multi-caret API documented in `specs/public-api.md`, and `Editor` itself is a `src/` consumer (Esc handler, plain-click handler, the `Alt` column-drag reset). R9 requires a `src/`/`examples/` consumer (tests don't count) — that bar is met, so demoting to `internal` would be a gratuitous breaking change to documented surface. + +**Date**: 2026-05-16 diff --git a/specs/public-api.md b/specs/public-api.md index cc58068..00c7ed3 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -26,6 +26,11 @@ public class Editor : View public bool HasMultipleCarets { get; } // multi-caret 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]). // --- Display --- public bool ShowLineNumbers { get; set; } // exists @@ -108,3 +113,4 @@ public interface IOverlayRenderer | 2026-05-11 | Caret and selection storage migrated to TextAnchor-backed tracking | caret-anchors | | 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 | diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md new file mode 100644 index 0000000..14a33c7 --- /dev/null +++ b/specs/vertical-multi-caret/spec.md @@ -0,0 +1,246 @@ +# Feature Specification: Vertical Multi-Caret (Ctrl+Alt+Up/Down, Alt+Drag) + +**Status**: Draft — supersedes the throwaway implementation in PR #125 +**Created**: 2026-05-15 +**Last updated**: 2026-05-15 +**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. + +> **Amendment 2026-05-16 (authoritative — supersedes the body below where they differ).** The +> column-drag mouse modifier shipped as **`Alt` + LeftButton drag**, *not* VS Code's +> `Shift+Alt`. Reason: Windows Terminal (and xterm-family terminals it emulates) reserves +> `Shift`+drag as the user's *forced* text-selection override while an application has mouse +> mode enabled, and `Alt` makes that a *block/rectangular* selection — so `Shift+Alt`+drag is +> consumed by the terminal for its own rectangular select and never reaches the editor. `Alt` +> alone is forwarded to the app. Everywhere this spec says `Shift+Alt`/`Shift+Alt+drag` for +> *this editor's* gesture, read `Alt`/`Alt+drag`; references describing *VS Code's own* +> behavior are left as-is and remain factually correct. Restoring user-configurable parity +> (so a user could opt back into `Shift+Alt`, or any modifier) is tracked upstream by +> [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888) — *"Extend +> the configurable `KeyBindings` to `MouseBindings` (and combos)"*. See `specs/decisions.md` +> DEC-006. + +## Overview + +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.) + +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). + +The feature also makes Tab work uniformly across all carets in the multi-caret system (an existing gap that vertical-caret usage exposes), and fixes interaction bugs that block the vertical-caret flow from being usable. + +**Terminal compatibility & platform defaults**: `Ctrl+Alt+Up/Down` and `Alt+mouse` rely on the terminal forwarding the modifier flags. Some Linux desktops grab `Ctrl+Alt+arrow` for workspace switching, and a few terminals don't distinguish `Ctrl+Alt+arrow` from `Ctrl+arrow` without the Kitty keyboard protocol. The fix is **not** an editor-specific fallback chord — it is to ship per-platform defaults the TG way: a `[ConfigurationProperty]` `DefaultKeyBindings` populated from a `PlatformKeyBinding` record (`All` / `Windows` / `Linux` / `Macos`), wholly overridable by the user through TG configuration (`View.ViewKeyBindings`). See TG's `docfx/docs/keyboard.md` for the binding-layer model (`Application.DefaultKeyBindings` → `View.DefaultKeyBindings` → per-view defaults; `Bind.AllPlus()` helper). See **Platform default keybindings** in Requirements. + +## User Scenarios + +All scenarios are stated as black-box behavior the user observes. Each has at least one executable test in `tests/Terminal.Gui.Editor.IntegrationTests/` (filenames listed in § Tests below). + +### Scenario — Ctrl+Alt+Down adds a caret on the line below + +**Given** the caret is at offset 8 in `"longer line\nshrt\nanother line"` (column 8 on line 1), +**When** the user presses `Ctrl+Alt+Down` twice, +**Then** the primary caret stays at offset 8; two additional carets land at offsets 16 and 25 (column 8 on lines 2 and 3 measured by visual column, falling back to end-of-line on the short middle line). + +### Scenario — Ctrl+Alt+Up adds a caret on the line above + +Symmetric to the previous scenario. **Given** carets at lines `n`, `n-1`, …, `n-k`, **When** the user presses `Ctrl+Alt+Up`, **Then** a new additional caret appears at line `n-k-1` at the sticky visual column. Pressing `Ctrl+Alt+Up` past line 1 is a no-op. + +### Scenario — Sticky virtual column survives a short intervening line + +**Given** `"abcde\nx\nabcde"` and the primary caret at column 4 on line 1, **When** the user presses `Ctrl+Alt+Down` twice, **Then** the second additional caret lands at column 4 on line 3 (offset `"abcde\nx\nabcd".Length`), not at the column the short line 2 collapsed to. + +### Scenario — Sticky virtual column with tabs + +**Given** `"a\tbcde\na\tbcde\na\tbcde"` with `IndentationSize` defaulting to 4 and the caret at offset 3 (visual column 5, after `a` + tab), **When** the user presses `Ctrl+Alt+Down` twice, **Then** additional carets land at offset 3 within each subsequent line (`"a\tbcde\n".Length + 3` and `"a\tbcde\na\tbcde\n".Length + 3`) — i.e. the visual column is preserved, accounting for tab expansion. + +### Scenario — Alt+Drag creates a vertical column of carets + +**Given** the document `"abcd\nabcd\nabcd"`, **When** the user presses `Alt + LeftButton` at view position (1, 0) and drags to (1, 2), then releases, +**Then** the primary caret is at offset 1; two additional carets exist at offsets 6 and 11; no selection is active. (Note: VS Code's `Shift+Alt+drag` *selects* a column; we emit carets only and use `Alt` not `Shift+Alt` — see the Amendment, Out of Scope, and Comparison.) + +### Scenario — Esc dismisses the vertical block + +**Given** vertical carets on lines 1–3 in `"abcd\nabcd\nabcd\nabcd"` produced from `Ctrl+Alt+Down × 2` starting at offset 1, **When** the user presses `Esc`, **Then** `HasMultipleCarets` is false and `CaretOffset == 1`. Pressing `CursorDown` three times moves the primary caret to offset `"abcd\nabcd\nabcd\n".Length + 1` (line 4, column 1). The previous caret block does **not** trap the primary. + +### Scenario — Esc after moving inside the block restores normal Down behavior + +**Given** vertical carets on lines 1–3 from `Ctrl+Alt+Down × 2`, **When** the user presses `CursorDown` (moves the block), then `CursorDown` again, then `Esc`, then `CursorDown`, **Then** `CaretOffset == "abcd\nabcd\nabcd\n".Length + 1`. Subsequent down-arrow moves are not limited by where the additional carets used to be. + +### Scenario — Down through additional caret does not duplicate + +**Given** `"aa\naa\naa"` with the primary caret at offset 1 and two additional carets at offsets 4 and 7 (from `Ctrl+Alt+Down × 2`), **When** the user presses `CursorDown` (primary moves onto the offset of the first additional caret) and types `X`, **Then** the document becomes `"aa\naxa\naxa"` — exactly two `X` insertions, one per *distinct* caret, never three. The additional caret that coincided with the primary is normalized away before the edit, not after. + +### Scenario — Tab inserts at every caret + +**Given** `"ab\nab\nab"` with three vertical carets at column 1 of each line, **When** the user presses `Tab`, **Then** the document becomes `"a\tb\na\tb\na\tb"` (one tab inserted at every caret, in a single undo step). + +### Scenario — Tab twice with spaces produces consistent indentation + +**Given** `ConvertTabsToSpaces = true`, the document `"using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;"`, and three vertical carets after `"using"` on each line (offsets 5, 16, 39 in the original), **When** the user presses `Tab` once, **Then** every caret moves to the next 4-cell stop, padding with spaces so all three carets remain at the same visual column. **When** the user presses `Tab` again, **Then** every caret moves to the *next* 4-cell stop with the same number of spaces inserted on each line. Concretely: + +| Step | Document | +|------|----------| +| start | `"using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;"` | +| after Tab 1 | `"using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;"` | +| after Tab 2 | `"using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;"` | + +(This is the bug from the PR #125 thread — the second Tab desynchronized because a downstream visual-line cache was holding stale absolute offsets.) + +### Scenario — Tab with a multi-line selection plus an extra caret block-indents (never deletes) + +**Given** `"alpha\nbeta\ngamma\ndelta"`, the primary selection covering lines 1–3 (`alpha` / `beta` / `gamma`), and one additional point caret (no selection) at column 0 of line 4 (`delta`), **When** the user presses `Tab`, **Then** lines 1–3 are each indented one level **and** a tab is inserted at the line-4 caret — the selected block is **not** replaced by a single tab — all in one undo step. **When** the user then presses `Shift+Tab`, **Then** lines 1–3 are block-unindented and line 4's leading indentation is reduced one level, one undo step. (This locks in the fix for the Codex review **P1** on `Editor.Indentation.cs`: `InsertTab()` short-circuiting to `MultiCaretInsertTab()` *before* the multi-line-selection branch caused `ReplaceSelection` to silently delete the selected block.) + +### Scenario — Ctrl+Click after a vertical block puts the new caret where the user clicked + +**Given** vertical carets created via `Ctrl+Alt+Down × 2` and the primary at offset 1 in `"abcd\nabcd\nabcd\nabcd"`, **When** a terminal emits the mouse events for a `Ctrl+LeftButton` press at view (3, 3) in the order `PositionReport+Ctrl` then `LeftButtonPressed+Ctrl` (some terminals reorder this way), **Then** the primary caret does not move and an additional caret appears at offset `"abcd\nabcd\nabcd\nabc".Length`. The pre-press `PositionReport` must not hijack the primary while the user is mid-Ctrl-click. (Ctrl+Click for add-caret-at-click is the existing multi-caret binding; this scenario locks in that the new vertical flow doesn't break it.) + +### Scenario — Primary caret is visible after exiting multi-caret mode + +**Given** a vertical caret block, **When** the user dismisses it (Esc, plain click, plain arrow key without Ctrl+Alt, or any other multi-caret exit), **Then** the primary caret is still drawn and `UpdateCursor ()` positions the terminal cursor on it. The cursor must not "disappear" or render in a hidden style. *(See § Open Decisions — the maintainer reported this in the PR #125 thread; reproduction steps must be captured as a failing test before implementation.)* + +## Requirements + +Named requirements — every label is also a search anchor used by tests and code review. + +- **Add caret above** — `Ctrl+Alt+CursorUp` (default; see *Platform default keybindings*) adds an additional caret one line above the topmost caret in the current caret block at the sticky visual column. +- **Add caret below** — `Ctrl+Alt+CursorDown` does the same below the bottommost caret. +- **Top/bottom bounds are no-ops** — `Ctrl+Alt+CursorUp` past line 1 and `Ctrl+Alt+CursorDown` past the last line do nothing (no throw, no change to existing carets). +- **Column-drag press** — `Alt + LeftButtonPressed` followed by `PositionReport+Alt` drag events build a column of carets at the press column from anchor row to active row, replacing any prior selection/multi-caret state. The first row (anchor) is the primary; the other rows are additional. `LeftButtonReleased` ends the drag without altering the state already built. Plain `Shift+LeftButton` (no Alt) continues to extend selection per the existing mouse handler; the `Alt` modifier is what switches the drag into column-of-carets mode. (`Alt`, not `Shift+Alt` — see the Amendment.) +- **Column-drag tracks live** — extending the `Alt`-drag downward adds carets; dragging back up removes the ones below the new active row. The end state must be identical to having pressed once at the final position, modulo event timestamps. +- **Visual column, not char offset** — vertical-column placement uses **cell width**, not raw character offset. Tabs, double-width graphemes, and wrap segments are measured via the same primitives the rendering pipeline uses (`CellVisualLine.GetVisualColumn` / `.GetRelativeOffset`). +- **Sticky column through short lines** — when a line is too short to host the sticky visual column, the caret on that line lands at end-of-line; the sticky column is preserved so that later vertical moves through longer lines restore it (matches the existing single-caret virtual-column behavior). +- **Wrap-aware vertical** — when `WordWrap == true`, "above" and "below" mean the previous/next wrap row, not the previous/next document line. Sticky visual column is preserved across wrap segments using the same `WrapMapEntry` machinery the single caret uses. +- **Tab at every caret** — `Tab` and `Shift+Tab` honor `HasMultipleCarets`. Per caret, all in one `RunUpdate` scope (one undo step reverses every caret's effect): a caret **with no selection** gets a tab inserted (`Tab`) / its line unindented one level (`Shift+Tab`); a caret with a **single-line** selection has that selection replaced by a tab (`Tab`, type-over) / its line unindented (`Shift+Tab`); a caret whose selection **spans multiple lines** block-indents every line the selection touches (`Tab`) / block-unindents them (`Shift+Tab`) — exactly as the single-caret path already does. A multi-line selection is **never** silently replaced/deleted by a single tab, whether it is the primary's selection or an additional caret's. +- **Tab keeps the column aligned** — tabs inserted at multiple carets in one operation must leave every caret at the same visual column afterward (see *Tab twice with spaces* scenario). The post-edit visual column is recomputed from the rebuilt visual lines; a downstream cache that hasn't been invalidated must not stale-feed the recompute. +- **Caret normalization** — additional carets are normalized whenever caret offsets or document structure change: + - any additional caret whose anchor is `IsDeleted` is dropped; + - any additional caret coinciding with the primary's offset is dropped (no duplicate edits — see *Down through additional caret does not duplicate* scenario); + - duplicate additional carets at the same offset collapse to one. + Normalization runs after every primary-caret move and after every document change, **before** the next edit applies. +- **Esc clears multi-caret and refreshes sticky column** — `Esc` while `HasMultipleCarets` clears additional carets and selection, leaves the primary in place, and refreshes the sticky virtual column to the primary's current visual column so subsequent `CursorUp` / `CursorDown` navigate freely past where the block used to be. +- **Plain click clears multi-caret** — a non-modifier `LeftButtonPressed` while `HasMultipleCarets` clears additional carets and selection and places the primary at the click position (existing behavior — re-stated; the vertical flow must not break it). +- **Ctrl+Click reorder defense** — a `Ctrl+LeftButton` press that follows a vertical-caret block toggles a caret at the click position. `PositionReport+Ctrl` events that arrive *before* the matching `LeftButtonPressed+Ctrl` must not move the primary caret. Reuse the same drag-suppression discipline already in place for `Ctrl+Click`, extended to cover the "report-before-press" reorder. +- **Primary caret stays visible** — the primary caret is always drawn after the additional carets are cleared. `UpdateCursor ()` reports its position; no code path leaves the terminal cursor hidden or pointed at a stale offset after dismissing multi-caret. +- **Cache invalidation on offset shift** — internal `DocumentLine` / `CellVisualLine` caches keyed by line number are invalidated for *all* lines whose element offsets shift, not only lines whose line *number* changes. A multi-caret edit that inserts on three lines but adds no newlines must still invalidate any downstream cached line whose absolute offsets moved. +- **ted demonstrates the feature** — `examples/ted` works end-to-end: `Ctrl+Alt+Up`, `Ctrl+Alt+Down`, `Alt+Drag`, Tab in vertical mode, Esc to exit. No new ted UI affordance is required (the keybindings are discoverable; existing status bar / help text gets a one-line update — see § Files in Scope). +- **Platform default keybindings** — the feature declares an `AddCommand` entry and binds it through a `[ConfigurationProperty]` `DefaultKeyBindings` populated from a `PlatformKeyBinding` record, *not* a hard-coded `if (key == ...)` in `OnKeyDownNotHandled`. Per-platform defaults: + - `Windows` / `Linux`: `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown` (VS Code parity). + - `Macos`: the chord that the common macOS terminals (Terminal.app, iTerm2) actually deliver to a TUI process. macOS GUI VS Code uses `Cmd+Opt+Up/Down`, but terminal emulators typically intercept `Cmd` and may not forward `Ctrl+Alt+arrow` cleanly — the exact macOS string is settled at wiring time against a real terminal (see Open Decisions). Use `Bind.AllPlus()` to layer the shared and per-OS keys. + Because `DefaultKeyBindings` is a `[ConfigurationProperty]`, a user whose terminal or WM grabs the default chord overrides it via `View.ViewKeyBindings` in TG config — no editor-specific knob, no alternate built-in chord. The binding must round-trip through TG config (appears in a keybinding inspector, overridable in user settings). + +## Files in Scope + +- `src/Terminal.Gui.Editor/Editor.MultiCaret.cs` — new private helpers (`AddCaretVertically`, `AddAdditionalCaretAt`, `NormalizeAdditionalCarets`, `TryGetVerticalOffset`, `GetVisualColumnForOffset`, `SetVerticalCaretsFromViewRows`, `MultiCaretInsertTab`). Replace the corresponding helpers in PR #125 with versions that share infrastructure with the single-caret Up/Down logic rather than re-deriving wrap maps and visual columns. +- `src/Terminal.Gui.Editor/Editor.Keyboard.cs` — declare the add-caret-above/below command via `AddCommand` and bind it through a `[ConfigurationProperty]` `DefaultKeyBindings` built from a `PlatformKeyBinding` record (`Windows`/`Linux` → `Ctrl+Alt+CursorUp/Down`; `Macos` → TBD-against-real-terminal). No inline if-chain in `OnKeyDownNotHandled`. Esc handler routes through `ClearAdditionalCarets ()` which is responsible for sticky-column refresh. Follow the binding-layer model documented in TG `docfx/docs/keyboard.md`. +- `src/Terminal.Gui.Editor/Editor.Mouse.cs` — `Alt`-drag press/drag/release state machine. Factor the existing Ctrl+Click drag-suppression into a small state enum so Ctrl, Alt, and plain drags don't fight via three orthogonal booleans. Plain `Shift+LeftButton` extend-selection path is preserved; the `Alt` bit on the mouse event is what dispatches to the column-of-carets branch. +- `src/Terminal.Gui.Editor/Editor.Indentation.cs` — Tab/Shift-Tab fall through to `MultiCaretInsertTab` / `MultiCaretUnindent` when `HasMultipleCarets`. +- `src/Terminal.Gui.Editor/Editor.cs` — `OnDocumentChanged` runs `NormalizeAdditionalCarets`; `SetCaretOffset` runs `NormalizeAdditionalCarets` *after* the offset is committed; visual-line cache invalidation key set includes lines whose absolute offsets shifted, gated only by `lineDelta == 0 && offsetDelta != 0`. +- `examples/ted/MainWindow.cs` (or wherever help text lives) — one-line update mentioning `Ctrl+Alt+Up/Down` and `Alt+Drag` in the existing key-help string. Mention the VS Code parity. No new menu items, no new dialogs. +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs` — keyboard scenario tests. +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs` — mouse scenario tests. +- `specs/public-api.md` — append note that `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown` create vertical carets and `Alt+Drag` creates a vertical column (R8). +- `specs/decisions.md` — record the VS Code keybinding choice ("match VS Code chords for vertical multi-caret") as a resolved decision. + +## Tests + +The PR #125 test set is the executable contract for behavior — but the key combinations in those tests must be rewritten from `Alt` to `Ctrl+Alt` (keys); the mouse drag stays on `Alt` (the modifier is unchanged from PR #125 for the mouse — see the Amendment; only the keyboard chords move to `Ctrl+Alt`). Re-create these in the new branch, with new names, and require them to be failing-first before the implementation lands: + +- `CtrlAltDown_Adds_Vertically_Aligned_Carets` (*Ctrl+Alt+Down adds a caret on the line below*) +- `CtrlAltDown_Preserves_Exact_Column_On_Next_Long_Line_After_Short_Line` (*Sticky virtual column survives a short intervening line*) +- `CtrlAltDown_Preserves_Column_With_Tabs` (*Sticky virtual column with tabs*) +- `AltDrag_Adds_Vertically_Aligned_Carets` (*Alt+Drag creates a vertical column of carets*) +- `Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block` (*Esc dismisses the vertical block*) +- `Esc_After_Moving_Within_MultiCaret_Allows_Moving_Below_Last_Former_Multi` (*Esc after moving inside the block*) +- `Vertical_MultiCaret_Does_Not_Duplicate_When_Primary_Moves_Onto_Additional` (*Down through additional caret does not duplicate*) +- `Tab_Inserts_At_All_Carets` (*Tab inserts at every caret*) +- `Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spaces` (*Tab twice with spaces*) +- `CtrlClick_After_VerticalCarets_Uses_Click_Position_When_PositionReport_Arrives_First` (*Ctrl+Click after a vertical block*) +- *(new)* `Primary_Caret_Is_Visible_After_Exiting_MultiCaret` (*Primary caret is visible after exiting*) — needs a reliable repro; if `UpdateCursor` isn't visible to the integration host, assert on `CaretOffset` + a render-snapshot showing the caret cell is styled as the primary caret. + +Also add a tightly-scoped unit test (`Terminal.Gui.Editor.Tests`) for the visual-line cache rekey: an offset-shift-without-newline-change must invalidate downstream cache entries. This protects the *Tab twice with spaces* scenario from regression at the unit boundary. + +## Definition of Done + +- [ ] All tests above land **failing first** on a tip-of-`develop` baseline, then pass after the implementation. +- [ ] No new boolean flag in `Editor.Mouse.cs` — the Ctrl/Alt/plain-drag interaction is expressed as a single state, not three booleans that have to be cleared on every branch. +- [ ] `AddAdditionalCaretAt` and `NormalizeAdditionalCarets` are the only paths that mutate `_additionalCarets`. `ToggleCaretAt` is rewritten in terms of them. +- [ ] `Editor.cs` change to visual-line cache invalidation is exercised by a unit test (not just observed through the *Tab twice* scenario). +- [ ] `examples/ted` demonstrates the feature; help text mentions the keybindings. +- [ ] `dotnet format` and `dotnet jb cleanupcode` are clean. +- [ ] Performance: a 1000-line document with 100 vertical carets typing a character must complete the edit in one `RunUpdate` scope and not regress the existing multi-caret BenchmarkDotNet metric (perf-gate: <1.3× of the current `*VisualLineBuild*` baseline run). + +## 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. 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. +- **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. + +## Reference behavior from PR #125 + +PR #125 (copilot, draft) shipped the same user-visible features (under the older `Alt+Up/Down` / `Alt+Drag` chords) but was rejected for being hacky: + +- Maintainer feedback in the PR thread, in order: (1) "basically non-functional. The first time I use it, it basically works. But then all cursor/caret management is messed up." (2) After fix attempt: "still very broken. Eg after dismissing multi-caret, main caret won't move below where last multi was. Tabs don't insert at all carets. Similar issues as before with carets being placed -1 column from row above when alt-down is pressed in some cases." (3) After another fix attempt: "when vert carets are active, trying to add another caret with ctrl-click puts it in the wrong location." (4) After another fix attempt: "tabs are not working right. I vertically select 3 rows and press tab. adds 4 spaces as expected. hit tab again, get this:" (followed by misaligned screenshot). (5) Final, unresolved: "now the main cursor/caret disappears after exiting multi mode." +- Each fix in that PR addressed the symptom of the latest bug, leaving an accreted patch on the multi-caret machinery rather than a designed surface. The mouse handler in particular now carries three `_suppress…UntilRelease` booleans whose interactions are not obvious. +- The user-visible feature set (vertical carets via keyboard, vertical carets via drag, Tab-at-all-carets, normalization, Ctrl+Click after vertical) is the right set. The tests in that PR are the executable spec, modulo the keybinding rename to the VS Code chords. The implementation is throwaway. + +## Comparison with VS Code and Visual Studio 2026 + +Cross-walk of every user-facing behavior against the two reference editors. Where this spec diverges, the divergence is intentional and called out below. + +| Behavior | VS Code | Visual Studio 2022/2026 | This spec | +|---|---|---|---| +| 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 | +| 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) | +| Caret-on-caret normalization (no duplicate edits) | Yes (carets at same offset collapse silently) | Yes | Match (*Caret normalization* requirement) | +| Wrapped-line vertical navigation | "Above" / "below" follows wrap rows | Same | Match (*Wrap-aware vertical* requirement) | +| WordWrap toggle while multi-caret is live | VS Code preserves carets at nearest valid offset | VS preserves carets | **Diverge**: block is dismissed (Out of Scope) | + +### 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. + +### Behaviors we match deliberately + +- **Keyboard chords** (`Ctrl+Alt+Up/Down`) — match VS Code so users coming from VS Code or `code-insiders` keep their muscle memory. (The mouse column-drag modifier is `Alt`, not VS Code's `Shift+Alt` — a deliberate terminal-compat divergence; see the Amendment.) +- **Sticky desired column through short lines and tab-expanded columns** — both reference editors track visual column, not character offset. +- **Tab at every caret, one undo step** — both editors. +- **Esc dismisses the block, leaves the primary caret in place, allows continued navigation past where the block was** — both editors. +- **No duplicate edits when the primary lands on an additional caret's offset** — both editors silently dedupe. + +## Open Decisions + +- **Primary caret disappears after exiting** — the maintainer reported this in the PR #125 thread; the implementer could not reproduce. Before this spec moves to **Ready**, the reproduction must be captured as a failing integration test (or the bug confirmed not-reproducible on the latest `develop` and the corresponding requirement marked done). Candidate causes worth probing: `_virtualCaretColumn` refresh inside `ClearAdditionalCarets` racing with `UpdateCursor`; a stale `_caretAnchor` after multi-caret edits; a styled-cell that overdraws the caret cell because a transformer's stale element range survives the multi-caret edit. + +- **`ClearAdditionalCarets` visibility** — today it is `public`. With this spec, the only sane caller is `Editor` itself (Esc handler, plain-click handler, `WordWrap` toggle). R9 says public surface needs a consumer; if ted doesn't call it, this should drop to `internal`. Resolve before merging. + +- **Alt+Click alias for add-caret-at-click** — existing multi-caret uses `Ctrl+Click`. Both VS Code and VS use `Alt+Click`. This spec does *not* change the binding, but adding `Alt+Click` as an *alias* (both work, no breakage) is a small win for newcomers. Out of scope here; worth filing as a follow-up if the maintainer agrees. + +- **macOS default chord** — the `Macos` entry in the `PlatformKeyBinding` must be settled against a real terminal. macOS GUI VS Code uses `Cmd+Opt+Up/Down`, but Terminal.app and iTerm2 typically swallow `Cmd` and may not deliver `Ctrl+Alt+arrow` distinctly to a TUI process. The implementer validates against Terminal.app + iTerm2 (and ideally Ghostty/kitty) and picks the chord those actually deliver, recording it in `specs/decisions.md`. This is a wiring-time empirical question, not a design fork. + +## Resolved Decisions + +These were open in earlier drafts of this spec and are now resolved. + +- **Keybinding choice for the new chords.** Resolved 2026-05-15: match VS Code for the keyboard — `Ctrl+Alt+Up`/`Down` for add-caret-above/below. **Amended 2026-05-16**: the column-drag mouse modifier is `Alt` (not VS Code's `Shift+Alt`) because Windows Terminal consumes `Shift`+drag for its own forced/block selection while an app has mouse mode on (see the Amendment near the top). Configurable modifier parity is tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). Recorded in `specs/decisions.md` DEC-006. +- **Tab / Shift+Tab when a multi-line selection coexists with extra carets.** Resolved 2026-05-17 (after Codex review P1 on `Editor.Indentation.cs`, PR #133): a selection spanning multiple lines is **block-indented / block-unindented** (every line it touches), never replaced by a single tab — matching the single-caret path and every mainstream editor. Single-line selections are type-over-replaced; point carets get a tab / line-unindent. Every caret's effect applies in one `RunUpdate` scope = one undo step. The earlier requirement wording ("replacement, if a per-caret selection is active") was under-specified and, as implemented, silently destroyed a selected multi-line block — the data-loss regression this resolves. See the *Tab with a multi-line selection plus an extra caret* scenario. +- **No editor-specific fallback chord.** Resolved 2026-05-15: do **not** ship a second built-in chord for environments that grab `Ctrl+Alt+arrow`. Keys are fully adjustable through TG keybindings; instead pre-ship per-platform defaults via a `[ConfigurationProperty]` `DefaultKeyBindings` + `PlatformKeyBinding` (the TG-standard mechanism, per `docfx/docs/keyboard.md`). Users in hostile terminal/WM environments override through `View.ViewKeyBindings` config like any other binding. + +## Notes + +- 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 diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 019b1d8..732a34f 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -40,6 +40,13 @@ public partial class Editor [Command.FindPrevious] = Bind.All (Key.F3.WithShift), [Command.Find] = Bind.All (Key.F.WithCtrl), [Command.Replace] = Bind.All (Key.H.WithCtrl), + + // Vertical multi-caret — VS Code parity (Ctrl+Alt+Up/Down). A PlatformKeyBinding, so a + // user whose terminal/WM grabs the chord overrides it via View.ViewKeyBindings config; + // no editor-specific fallback chord. macOS uses the same chord pending real-terminal + // validation (specs/decisions.md DEC-006). + [Command.InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), + [Command.InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt), [Command.WordLeft] = Bind.All (Key.CursorLeft.WithCtrl), [Command.WordRight] = Bind.All (Key.CursorRight.WithCtrl), [Command.WordLeftExtend] = Bind.All (Key.CursorLeft.WithCtrl.WithShift), @@ -208,6 +215,9 @@ private void CreateCommandsAndBindings () AddCommand (Command.FindNext, FindNextCommand); AddCommand (Command.FindPrevious, FindPreviousCommand); + // Vertical multi-caret: add a caret one line above / below the block at the sticky column. + AddCommand (Command.InsertCaretAbove, () => AddCaretVertically (-1)); + AddCommand (Command.InsertCaretBelow, () => AddCaretVertically (1)); // Word navigation and kill AddCommand (Command.WordLeft, () => { diff --git a/src/Terminal.Gui.Editor/Editor.Indentation.cs b/src/Terminal.Gui.Editor/Editor.Indentation.cs index 87b8263..926be3a 100644 --- a/src/Terminal.Gui.Editor/Editor.Indentation.cs +++ b/src/Terminal.Gui.Editor/Editor.Indentation.cs @@ -17,6 +17,11 @@ private bool InsertTab () return true; } + if (HasMultipleCarets) + { + return MultiCaretInsertTab (); + } + if (HasSelection && SelectionSpansMultipleLines ()) { IndentSelectedLines (); @@ -50,6 +55,11 @@ private bool Unindent () return true; } + if (HasMultipleCarets) + { + return MultiCaretUnindent (); + } + List lines = HasSelection && SelectionSpansMultipleLines () ? GetSelectedLines () : [_document.GetLineByOffset (CaretOffset)]; @@ -135,6 +145,20 @@ private bool TryDeleteIndentationLeft () /// private bool TryDeleteIndentationLeftAt (int offset) { + if (!TryGetIndentationRemovalAt (offset, out (int offset, int length) removal)) + { + return false; + } + + _document!.Remove (removal.offset, removal.length); + + return true; + } + + private bool TryGetIndentationRemovalAt (int offset, out (int offset, int length) removal) + { + removal = default; + if (_document is null || offset == 0) { return false; @@ -148,7 +172,6 @@ private bool TryDeleteIndentationLeftAt (int offset) return false; } - // Walk forward through indent units; delete the last complete one ending at the caret. var scanOffset = line.Offset; (int offset, int length) lastSegment = (0, 0); @@ -170,7 +193,7 @@ private bool TryDeleteIndentationLeftAt (int offset) return false; } - _document.Remove (lastSegment.offset, lastSegment.length); + removal = lastSegment; return true; } diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 38e23e3..dc2773c 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -10,11 +10,30 @@ namespace Terminal.Gui.Editor; public partial class Editor { /// - /// Set to when a Ctrl+Click press is handled so subsequent drag - /// (PositionReport) events don't hijack the primary caret via . - /// Cleared on mouse release. + /// 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 bool _suppressDragUntilRelease; + 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 DragMode _dragMode; + private Point _columnDragAnchor; /// protected override bool OnMouseEvent (Mouse mouse) @@ -45,26 +64,37 @@ protected override bool OnMouseEvent (Mouse mouse) return true; } - // Drag: left button held while position changes — extend selection from the press point. - // Tested first because PositionReport+LeftButtonPressed also satisfies the plain-press check. - // Suppress when the press was a Ctrl+Click (multi-caret add) so the drag handler doesn't - // move the primary caret via ExtendCaretTo. + // Drag: left button held while position changes. Tested before the plain-press check + // because PositionReport+LeftButtonPressed also satisfies it. if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) { - if (_suppressDragUntilRelease) + // A Ctrl-modified drag is never a primary move — it's part of a Ctrl+Click gesture. + // Some terminals emit PositionReport+Ctrl before the matching LeftButtonPressed+Ctrl; + // swallowing it here keeps the pre-press report from hijacking the primary caret. + if (mouse.Flags.HasFlag (MouseFlags.Ctrl)) { return true; } - var offset = MousePositionToOffset (pos); + switch (_dragMode) + { + case DragMode.ColumnCarets: + SetVerticalCaretsFromViewRows (_columnDragAnchor.Y, pos.Y, _columnDragAnchor.X); - // Route through the selection helper so SelectionChanged fires only on real changes. - ExtendCaretTo (offset); + return true; - return true; + case DragMode.AddCaret: + return true; + + default: + // Route through the selection helper so SelectionChanged fires only on real changes. + ExtendCaretTo (MousePositionToOffset (pos)); + + return true; + } } - // Press: focus, place caret, optionally start a selection (shift) or clear it (plain). + // Press: focus, then classify the gesture by modifier. if (mouse.Flags.HasFlag (MouseFlags.LeftButtonPressed)) { if (CanFocus && !HasFocus) @@ -72,30 +102,39 @@ protected override bool OnMouseEvent (Mouse mouse) SetFocus (); } - var offset = MousePositionToOffset (pos); var ctrl = mouse.Flags.HasFlag (MouseFlags.Ctrl); + var alt = mouse.Flags.HasFlag (MouseFlags.Alt); - if (ctrl) + // Alt (not VS Code's Shift+Alt): Windows Terminal eats Shift+drag for its own + // forced/block selection while the app has mouse mode on, so Shift+Alt+drag never + // reaches the editor there. Alt+drag is forwarded. See DEC-006 / TG#4888. + if (alt) + { + _dragMode = DragMode.ColumnCarets; + _columnDragAnchor = pos; + SetVerticalCaretsFromViewRows (pos.Y, pos.Y, pos.X); + } + else if (ctrl) { - ToggleCaretAt (offset); - _suppressDragUntilRelease = true; + _dragMode = DragMode.AddCaret; + ToggleCaretAt (MousePositionToOffset (pos)); } else if (shift) { - _suppressDragUntilRelease = false; - ExtendCaretTo (offset); + _dragMode = DragMode.Select; + ExtendCaretTo (MousePositionToOffset (pos)); } else { - _suppressDragUntilRelease = false; + _dragMode = DragMode.Select; ClearAdditionalCarets (); ClearSelection (); - CaretOffset = offset; + CaretOffset = MousePositionToOffset (pos); } // Grab the mouse so subsequent drag/release events route here even if the cursor leaves // this view's bounds — TG's default routing only delivers events to the view under the - // pointer, which would break the drag-to-select gesture mid-stroke. + // pointer, which would break the drag gesture mid-stroke. App?.Mouse.GrabMouse (this); return true; @@ -107,7 +146,7 @@ protected override bool OnMouseEvent (Mouse mouse) return false; } - _suppressDragUntilRelease = false; + _dragMode = DragMode.Select; App?.Mouse.UngrabMouse (); return true; diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 20b504e..b7534a4 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -1,3 +1,4 @@ +using System.Drawing; using Terminal.Gui.Document; namespace Terminal.Gui.Editor; @@ -12,6 +13,7 @@ namespace Terminal.Gui.Editor; public partial class Editor { private readonly List _additionalCarets = []; + private int _verticalCaretKeyboardDirection; /// Gets the offsets of all additional carets (excludes the primary). public IReadOnlyList AdditionalCaretOffsets => _additionalCarets @@ -23,8 +25,10 @@ public partial class Editor public bool HasMultipleCarets => _additionalCarets.Count > 0; /// - /// Adds an additional caret at the given . If a caret already exists - /// within tolerance (same offset), it is removed instead (toggle behavior for Ctrl+Click). + /// Adds an additional caret at the given , or removes the one + /// already there (toggle behavior for Ctrl+Click). The add path goes through + /// ; the toggle-off is an explicit, user-driven single + /// removal. The primary caret is never removed. /// public void ToggleCaretAt (int offset) { @@ -33,44 +37,222 @@ public void ToggleCaretAt (int offset) return; } + _verticalCaretKeyboardDirection = 0; + offset = Math.Clamp (offset, 0, _document.TextLength); - // If clicking on the primary caret, ignore — we never remove the primary. if (offset == CaretOffset) { return; } - // Check if there's already an additional caret at this offset — remove it if so. for (var i = _additionalCarets.Count - 1; i >= 0; i--) { - if (_additionalCarets[i].CaretAnchor is { IsDeleted: false } anchor && anchor.Offset == offset) + if (_additionalCarets[i].CaretAnchor is not { IsDeleted: false } anchor || anchor.Offset != offset) { - _additionalCarets.RemoveAt (i); - SetNeedsDraw (); + continue; + } + + _additionalCarets.RemoveAt (i); + SetNeedsDraw (); + + return; + } + + AddAdditionalCaretAt (offset); + } + + /// + /// Adds one additional caret at . The single add path that + /// mutates : it never duplicates the primary and never + /// stacks two additional carets on one offset, so the caret set is deduped by construction + /// rather than normalized after the fact. + /// + private void AddAdditionalCaretAt (int offset) + { + if (_document is null) + { + return; + } + + offset = Math.Clamp (offset, 0, _document.TextLength); + + if (offset == CaretOffset) + { + return; + } + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is { IsDeleted: false } anchor && anchor.Offset == offset) + { return; } } - // Add a new additional caret. - TextAnchor caretAnchor = CreateCaretAnchor (offset); - _additionalCarets.Add (new CaretInfo { CaretAnchor = caretAnchor }); + _additionalCarets.Add (new CaretInfo { CaretAnchor = CreateCaretAnchor (offset) }); SetNeedsDraw (); } - /// Removes all additional carets, leaving only the primary. - public void ClearAdditionalCarets () + /// + /// Re-establishes the multi-caret invariant: drops any additional caret whose anchor was + /// deleted, any that coincides with the primary, and collapses duplicates at the same + /// offset. Called after every primary-caret move and every document change (before the + /// next edit applies) — the single invariant-trim path that mutates + /// . + /// + private void NormalizeAdditionalCarets () { if (_additionalCarets.Count == 0) { + _verticalCaretKeyboardDirection = 0; + return; } - _additionalCarets.Clear (); + HashSet seen = [CaretOffset]; + var removed = false; + + for (var i = _additionalCarets.Count - 1; i >= 0; i--) + { + if (_additionalCarets[i].CaretAnchor is { IsDeleted: false } anchor && seen.Add (anchor.Offset)) + { + continue; + } + + _additionalCarets.RemoveAt (i); + removed = true; + } + + if (removed) + { + if (_additionalCarets.Count == 0) + { + _verticalCaretKeyboardDirection = 0; + } + + SetNeedsDraw (); + } + } + + /// + /// Extends the vertical caret block one line above ( < 0) the + /// topmost caret or below (> 0) the bottommost, landing on the sticky visual column. + /// Crossing the top/bottom document bound is a no-op. Bound to + /// Ctrl+Alt+CursorUp / Ctrl+Alt+CursorDown via the configurable + /// . + /// + private bool? AddCaretVertically (int delta) + { + if (_document is null) + { + return true; + } + + if (HasMultipleCarets + && _verticalCaretKeyboardDirection != 0 + && delta != _verticalCaretKeyboardDirection + && TryRemoveEdgeCaret (_verticalCaretKeyboardDirection)) + { + if (!HasMultipleCarets) + { + _verticalCaretKeyboardDirection = 0; + } + + return true; + } + + // The sticky column is captured once, when the block is first created, from the primary + // caret's column — then preserved across extensions (single-caret virtual-column + // behavior, reused rather than re-derived). + if (!HasMultipleCarets) + { + _virtualCaretColumn = GetVisualColumnForOffset (CaretOffset); + } + + // Reference = the extreme caret in the requested direction: topmost for up, bottommost + // for down. The block grows past that edge. + var reference = CaretOffset; + + foreach (var offset in AdditionalCaretOffsets) + { + reference = delta < 0 ? Math.Min (reference, offset) : Math.Max (reference, offset); + } + + if (TryGetVerticalOffset (reference, delta, _virtualCaretColumn, out var target)) + { + AddAdditionalCaretAt (target); + _verticalCaretKeyboardDirection = delta; + } + + return true; + } + + /// + /// Builds a column of carets from the (which hosts the + /// primary) through the , all at + /// . Used by the Shift+Alt column-drag. Rebuilt from + /// 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) + { + if (_document is null) + { + return; + } + + var primaryOffset = MousePositionToOffset (new Point (viewColumn, anchorViewRow)); + _verticalCaretKeyboardDirection = 0; + + ClearSelection (); + ClearAdditionalCarets (); + + // The CaretOffset setter resets the sticky column from the anchor row's primary. + CaretOffset = primaryOffset; + + var top = Math.Min (anchorViewRow, activeViewRow); + var bottom = Math.Max (anchorViewRow, activeViewRow); + + for (var row = top; row <= bottom; row++) + { + if (row == anchorViewRow) + { + continue; + } + + AddAdditionalCaretAt (MousePositionToOffset (new Point (viewColumn, row))); + } + SetNeedsDraw (); } + /// Removes all additional carets, leaving only the primary. + public void ClearAdditionalCarets () + { + var had = _additionalCarets.Count > 0; + _verticalCaretKeyboardDirection = 0; + + if (had) + { + _additionalCarets.Clear (); + } + + // Refresh the sticky virtual column to the primary's current column so vertical + // navigation resumes freely from where the primary actually is — not wherever the + // dismissed block left it. (specs/vertical-multi-caret: "Esc clears multi-caret and + // refreshes sticky column".) + if (_document is not null) + { + _virtualCaretColumn = GetCaretColumn (); + } + + if (had) + { + SetNeedsDraw (); + } + } + /// /// Returns all caret offsets (primary + additional) sorted in descending order. /// Used by editing commands to process from high to low offset so earlier edits don't @@ -357,6 +539,212 @@ private void MultiCaretInsert (string text) return true; } + /// + /// Resolves the selection range for a caret. The primary caret uses the editor's + /// selection; an additional caret uses its own anchor + offset. Returns + /// (and a zero-width range at the caret) when there is no + /// selection. + /// + private bool TryGetCaretSelectionRange (CaretEditInfo caret, out int start, out int end) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + start = SelectionStart; + end = SelectionEnd; + + return end > start; + } + } + else if (caret.SelectionAnchor is { IsDeleted: false } anchor) + { + start = Math.Min (anchor.Offset, caret.Offset); + end = Math.Max (anchor.Offset, caret.Offset); + + return end > start; + } + + start = end = caret.Offset; + + return false; + } + + private bool RangeSpansMultipleLines (int start, int end) + { + DocumentLine first = _document!.GetLineByOffset (start); + DocumentLine last = _document.GetLineByOffset (Math.Max (start, end - 1)); + + return first.LineNumber != last.LineNumber; + } + + private List LinesInRange (int start, int end) + { + DocumentLine first = _document!.GetLineByOffset (start); + DocumentLine last = _document.GetLineByOffset (Math.Max (start, end - 1)); + List lines = []; + + for (var lineNumber = first.LineNumber; lineNumber <= last.LineNumber; lineNumber++) + { + lines.Add (_document.GetLineByNumber (lineNumber)); + } + + return lines; + } + + /// + /// 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 + /// 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 + /// () guarantees a non-null, writable document and + /// . See specs/vertical-multi-caret/spec.md + /// (Tab with a multi-line selection plus an extra caret). + /// + private bool MultiCaretInsertTab () + { + HashSet indentLineOffsets = []; + List<(int offset, int length, string text)> edits = []; + + foreach (CaretEditInfo caret in GetAllCaretsDescending ()) + { + if (TryGetCaretSelectionRange (caret, out int selStart, out int selEnd)) + { + if (RangeSpansMultipleLines (selStart, selEnd)) + { + foreach (DocumentLine line in LinesInRange (selStart, selEnd)) + { + indentLineOffsets.Add (line.Offset); + } + + continue; + } + + edits.Add ((selStart, selEnd - selStart, GetTabInsertionText (selStart))); + + continue; + } + + edits.Add ((caret.Offset, 0, GetTabInsertionText (caret.Offset))); + } + + var indentText = GetIndentText (); + + foreach (var lineOffset in indentLineOffsets) + { + edits.Add ((lineOffset, 0, indentText)); + } + + using (_document!.RunUpdate ()) + { + foreach ((int offset, int length, string text) edit in edits.OrderByDescending (static e => e.offset)) + { + if (edit.length > 0) + { + _document.Replace (edit.offset, edit.length, edit.text); + } + else + { + _document.Insert (edit.offset, edit.text); + } + } + } + + ClearAdditionalCaretSelections (); + + return true; + } + + /// + /// Shift+Tab at every caret, one undo scope. Per caret: a selection that spans multiple + /// lines block-unindents every line it touches (not just the caret's line — that + /// gap was the related Codex P2); any other caret unindents its own line. Lines are + /// deduped (two carets / overlapping selections unindent a line once) and removals run + /// high-offset-first so earlier removals don't shift later ones. Carets are anchors, so + /// they follow the removals automatically. Caller () guarantees a + /// non-null, writable document and . + /// + private bool MultiCaretUnindent () + { + HashSet seenLineOffsets = []; + List<(int offset, int length)> removals = []; + + foreach (CaretEditInfo caret in GetAllCaretsDescending ()) + { + List lines = TryGetCaretSelectionRange (caret, out int selStart, out int selEnd) && RangeSpansMultipleLines (selStart, selEnd) + ? LinesInRange (selStart, selEnd) + : [_document!.GetLineByOffset (caret.Offset)]; + + foreach (DocumentLine line in lines) + { + if (!seenLineOffsets.Add (line.Offset)) + { + continue; + } + + ISegment segment = TextUtilities.GetSingleIndentationSegment (_document!, line.Offset, IndentationSize); + + if (segment.Length > 0) + { + removals.Add ((segment.Offset, segment.Length)); + } + } + } + + if (removals.Count == 0) + { + return true; + } + + using (_document!.RunUpdate ()) + { + foreach ((int offset, int length) removal in removals.OrderByDescending (static r => r.offset)) + { + _document.Remove (removal.offset, removal.length); + } + } + + ClearAdditionalCaretSelections (); + + return true; + } + + private bool TryRemoveEdgeCaret (int direction) + { + var candidateIndex = -1; + var candidateOffset = direction < 0 ? int.MaxValue : int.MinValue; + Func isBetter = direction < 0 + ? static (offset, current) => offset < current + : static (offset, current) => offset > current; + + for (var i = 0; i < _additionalCarets.Count; i++) + { + if (_additionalCarets[i].CaretAnchor is not { IsDeleted: false } anchor) + { + continue; + } + + if (isBetter (anchor.Offset, candidateOffset)) + { + candidateOffset = anchor.Offset; + candidateIndex = i; + } + } + + if (candidateIndex < 0) + { + return false; + } + + _additionalCarets.RemoveAt (candidateIndex); + SetNeedsDraw (); + + return true; + } + private void ClearAdditionalCaretSelections () { foreach (CaretInfo caret in _additionalCarets) diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 608e9d8..de8ad05 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -379,6 +379,11 @@ private void SetCaretOffset (int value, bool resetVirtualColumn) _caretAnchor = _document is null ? null : CreateCaretAnchor (clamped); _lastKnownCaretOffset = clamped; + // The primary just moved. Re-establish the multi-caret invariant *before* the next edit + // applies: drop any additional caret that now coincides with the primary so a primary + // landing on an additional caret doesn't produce a duplicate insert. + NormalizeAdditionalCarets (); + if (resetVirtualColumn) { _virtualCaretColumn = GetCaretColumn (); @@ -442,6 +447,11 @@ private void OnDocumentChanged (object? sender, DocumentChangeEventArgs e) } RefreshSelectionAnchorMovement (); + + // Document structure changed — re-establish the multi-caret invariant before the next + // edit (drop deleted / duplicate / primary-coinciding additional carets). + NormalizeAdditionalCarets (); + EnsureCaretVisible (); SetNeedsDraw (); } @@ -643,11 +653,16 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) var removedNewlines = removedText.Count (c => c == '\n'); var lineDelta = insertedNewlines - removedNewlines; - RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines); - RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines); + // 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); + + RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); + RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); static void RekeyCache (Dictionary cache, int threshold, int lineDelta, - int removedNewlines) + int removedNewlines, int offsetDelta) { if (cache.Count == 0) { @@ -671,16 +686,22 @@ static void RekeyCache (Dictionary cache, int threshold, in } else if (kvp.Key > invalidateEnd) { - if (lineDelta == 0) - { - // No newline change — downstream entries are still valid as-is. - } - else + if (lineDelta != 0) { // Line numbers shifted — remove old key, re-add with shifted key. (toRemove ??= []).Add (kvp.Key); (toRekey ??= []).Add (kvp); } + else if (offsetDelta != 0) + { + // No newline change, but the edit shifted absolute offsets. The cached + // visual line for this downstream line carries stale absolute element + // offsets — drop it (correctness > a few cache hits). This is the defect + // the "Tab twice with spaces" multi-caret scenario exposes. + (toRemove ??= []).Add (kvp.Key); + } + + // else: offsetDelta == 0 — pure in-place rewrite, downstream entries valid. } } @@ -775,7 +796,16 @@ private int GetGutterWidth () private int GetCaretColumn () { - var caretOffset = CaretOffset; + return GetVisualColumnForOffset (CaretOffset); + } + + /// + /// Returns the visual (cell) column of an arbitrary document offset, accounting for tabs, + /// double-width graphemes, and word-wrap segments. Single-caret and multi-caret vertical + /// placement share this so the multi-caret path never re-derives column geometry. + /// + private int GetVisualColumnForOffset (int caretOffset) + { DocumentLine? line = _document?.GetLineByOffset (caretOffset); if (line is null) @@ -879,15 +909,72 @@ private void MoveCaretVerticallyWrapped (int delta) SetCaretOffset (line.Offset + seg.StartOffset + localOffset, false); } + /// + /// Resolves the document offset visual rows from + /// at the sticky , + /// using the same wrap-map / visual-line primitives as single-caret vertical movement. + /// Returns (a no-op for the caller) when the move would cross the + /// top or bottom document bound. + /// + private bool TryGetVerticalOffset (int startOffset, int delta, int targetVisualColumn, out int targetOffset) + { + targetOffset = startOffset; + + if (_document is null) + { + return false; + } + + if (WordWrap) + { + List map = GetWrapMap (); + var targetRow = GetWrapRowForOffset (startOffset) + delta; + + if (targetRow < 0 || targetRow >= map.Count) + { + return false; + } + + WrapMapEntry entry = map[targetRow]; + DocumentLine wrapLine = _document.GetLineByNumber (entry.LineNumber); + var wrapText = _document.GetText (wrapLine); + IReadOnlyList wrapSegments = + WordWrapStrategy.ComputeSegments (wrapText, GetWrapColumn (), IndentationSize); + WrapSegment seg = wrapSegments[entry.SegmentIndex]; + var segText = wrapText.Substring (seg.StartOffset, seg.Length); + var localOffset = ComputeRelativeOffsetDirect (segText, targetVisualColumn); + targetOffset = wrapLine.Offset + seg.StartOffset + localOffset; + + return true; + } + + var targetLineIndex = (_document.GetLineByOffset (startOffset).LineNumber - 1) + delta; + + if (targetLineIndex < 0 || targetLineIndex > _document.LineCount - 1) + { + return false; + } + + DocumentLine line = _document.GetLineByNumber (targetLineIndex + 1); + targetOffset = line.Offset + GetOrBuildDefaultVisualLine (line).GetRelativeOffset (targetVisualColumn); + + return true; + } + /// Returns the visual row in the wrap map for the current caret position. private int GetCaretWrapRow () + { + return GetWrapRowForOffset (CaretOffset); + } + + /// Returns the visual row in the wrap map for an arbitrary document offset. + private int GetWrapRowForOffset (int caretOffset) { if (_document is null) { return 0; } - var caretOffset = CaretOffset; DocumentLine line = _document.GetLineByOffset (caretOffset); var offsetInLine = caretOffset - line.Offset; var text = _document.GetText (line); diff --git a/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs b/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs index b6ed94d..bae6346 100644 --- a/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs +++ b/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs @@ -8,7 +8,7 @@ namespace Terminal.Gui.Editor.Rendering; /// -/// Renders additional (non-primary) caret positions as underlined + blinking cells. +/// Renders additional (non-primary) caret positions as blinking, reverse-video cells. /// Installed automatically by when multi-caret mode is active. /// public sealed class MultiCaretRenderer : IOverlayRenderer @@ -37,9 +37,10 @@ public void Draw (View host, CellVisualLine line, int row, Rectangle viewport) Attribute normal = host.GetAttributeForRole (VisualRole.Normal); - // Use underline + blink to distinguish additional carets — more visible than a simple - // foreground/background invert, which can blend with selection or theme highlights. - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + // Use reverse-video + blink to distinguish additional carets. Underline rendered poorly + // and inconsistently across terminals; the reverse (foreground/background swap) is far + // more legible and reliably supported. + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); foreach (var offset in _editor.AdditionalCaretOffsets) { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs index 98d9e62..a4c9c48 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs @@ -389,7 +389,7 @@ public async Task CtrlClick_First_Slash_Only_Highlights_One_Cell () Assert.Equal (5, fx.Top.Editor.CaretOffset); // primary didn't move Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); // Cell 0 (first '/') should have the caret attribute. Cell cell0 = fx.Driver.Contents![0, 0]; @@ -434,7 +434,7 @@ public async Task CtrlClick_First_Slash_Of_TripleSlash_Only_Highlights_One_Cell Assert.Contains (0, fx.Top.Editor.AdditionalCaretOffsets); Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); Cell cell0 = fx.Driver.Contents![0, 0]; Assert.Equal ("/", cell0.Grapheme); @@ -449,6 +449,91 @@ public async Task CtrlClick_First_Slash_Of_TripleSlash_Only_Highlights_One_Cell Assert.NotEqual (caretAttr, cell2.Attribute); } + // --------------------------------------------------------------------------------------------- + // Vertical multi-caret mouse gestures (specs/vertical-multi-caret/spec.md). Ported from PR #125. + // Alt + LeftButton drag builds a column of carets — 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 (DEC-006; configurable Shift+Alt parity tracked by gui-cs/Terminal.Gui#4888). + // --------------------------------------------------------------------------------------------- + + [Fact] + public async Task AltDrag_Adds_Vertically_Aligned_Carets () + { + await using AppFixture fx = new (() => new ("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 (1, 2), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (1, 2), + Flags = MouseFlags.LeftButtonReleased, + Timestamp = BaseTime.AddMilliseconds (40) + }, + Direct); + + Assert.Equal (1, fx.Top.Editor.CaretOffset); + Assert.True (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (2, fx.Top.Editor.AdditionalCaretOffsets.Count); + Assert.Contains (6, fx.Top.Editor.AdditionalCaretOffsets); + Assert.Contains (11, fx.Top.Editor.AdditionalCaretOffsets); + Assert.False (fx.Top.Editor.HasSelection); + } + + [Fact] + public async Task CtrlClick_After_VerticalCarets_Uses_Click_Position_When_PositionReport_Arrives_First () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + Assert.True (fx.Top.Editor.HasMultipleCarets); + + var primaryBefore = fx.Top.Editor.CaretOffset; + + // Some terminals emit PositionReport before the plain LeftButtonPressed during a Ctrl+Click. + // The pre-press report must not hijack the primary caret. + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (3, 3), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Ctrl, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (3, 3), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Ctrl, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + Assert.Equal (primaryBefore, fx.Top.Editor.CaretOffset); + Assert.Contains ("abcd\nabcd\nabcd\nabc".Length, fx.Top.Editor.AdditionalCaretOffsets); + } + private static void InjectClick (AppFixture fx, Point pos) { fx.Injector.InjectMouse ( diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs new file mode 100644 index 0000000..22bfd44 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs @@ -0,0 +1,65 @@ +// Claude - claude-opus-4-7 + +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Regression cover for the Codex review P1 on Editor.Indentation.cs: +/// InsertTab() / Unindent() short-circuited to the multi-caret path the +/// moment was true — before the +/// multi-line-selection branch — so a multi-line selection coexisting with an extra +/// caret was silently replaced by a single tab (data loss) instead of block-indented. +/// Encodes the Tab with a multi-line selection plus an extra caret scenario from +/// specs/vertical-multi-caret/spec.md. +/// +public class EditorMultiCaretIndentTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + [Fact] + public async Task Tab_MultilineSelection_Plus_PointCaret_BlockIndents_Does_Not_Delete () + { + await using AppFixture fx = new (() => new ("alpha\nbeta\ngamma\ndelta")); + fx.Top.Editor.SetFocus (); + + // Primary selection covers lines 1-3 (alpha/beta/gamma); an additional point + // caret (no selection) sits on line 4 (delta). + fx.Top.Editor.SelectRange (0, "alpha\nbeta\ngamma".Length); + fx.Top.Editor.ToggleCaretAt ("alpha\nbeta\ngamma\n".Length); + Assert.True (fx.Top.Editor.HasMultipleCarets); + + fx.Injector.InjectKey (Key.Tab, Direct); + + // Lines 1-3 are block-indented AND the line-4 caret gets a tab. The selected + // block must NOT be replaced by a single tab. + Assert.Equal ("\talpha\n\tbeta\n\tgamma\n\tdelta", fx.Top.Editor.Document!.Text); + + // One undo step reverses every caret's effect. + fx.Top.Editor.Document.UndoStack.Undo (); + Assert.Equal ("alpha\nbeta\ngamma\ndelta", fx.Top.Editor.Document.Text); + } + + [Fact] + public async Task ShiftTab_MultilineSelection_Plus_PointCaret_BlockUnindents () + { + await using AppFixture fx = new (() => new ("\talpha\n\tbeta\n\tgamma\n\tdelta")); + fx.Top.Editor.SetFocus (); + + // Primary selection covers indented lines 1-3; additional point caret on line 4. + fx.Top.Editor.SelectRange (0, "\talpha\n\tbeta\n\tgamma\n".Length); + fx.Top.Editor.ToggleCaretAt ("\talpha\n\tbeta\n\tgamma\n\t".Length); + Assert.True (fx.Top.Editor.HasMultipleCarets); + + fx.Injector.InjectKey (Key.Tab.WithShift, Direct); + + // Lines 1-3 block-unindent AND line 4 loses its leading indent — one undo step. + Assert.Equal ("alpha\nbeta\ngamma\ndelta", fx.Top.Editor.Document!.Text); + + fx.Top.Editor.Document.UndoStack.Undo (); + Assert.Equal ("\talpha\n\tbeta\n\tgamma\n\tdelta", fx.Top.Editor.Document.Text); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index c0a45d3..086a452 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -384,7 +384,7 @@ public async Task Editor_Background_Follows_Scheme_Not_Highlighter () } [Fact] - public async Task MultiCaret_Renders_Underline_Blink_Attribute_On_Text () + public async Task MultiCaret_Renders_Reverse_Blink_Attribute_On_Text () { // P1: MultiCaretRenderer must draw AFTER text elements so that the caret // cell is not overwritten by the subsequent element.Draw call. @@ -396,9 +396,9 @@ public async Task MultiCaret_Renders_Underline_Blink_Attribute_On_Text () fx.Render (); Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); - // The cell at column 3 ('d') should have the underline+blink attribute, not the normal one. + // The cell at column 3 ('d') should have the reverse+blink attribute, not the normal one. Cell cell = fx.Driver.Contents![0, 3]; Assert.Equal ("d", cell.Grapheme); Assert.Equal (caretAttr, cell.Attribute); @@ -436,9 +436,9 @@ public async Task MultiCaret_Does_Not_Leak_Attribute_To_Adjacent_Cell () fx.Render (); Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); - // Column 0 (first '/') should have the underline+blink caret attribute. + // Column 0 (first '/') should have the reverse+blink caret attribute. Cell cell0 = fx.Driver.Contents![0, 0]; Assert.Equal ("/", cell0.Grapheme); Assert.Equal (caretAttr, cell0.Attribute); @@ -477,18 +477,18 @@ public async Task MultiCaret_First_Slash_With_SyntaxHighlighting_Only_Highlights Assert.Equal ("/", cell1.Grapheme); Assert.Equal ("/", cell2.Grapheme); - // cell0 must have Underline|Blink (the caret style). + // cell0 must have Blink|Reverse (the caret style). Assert.True ( - cell0.Attribute!.Value.Style.HasFlag (TextStyle.Underline | TextStyle.Blink), - $"Cell 0 should have Underline|Blink but has Style={cell0.Attribute!.Value.Style}"); + cell0.Attribute!.Value.Style.HasFlag (TextStyle.Blink | TextStyle.Reverse), + $"Cell 0 should have Blink|Reverse but has Style={cell0.Attribute!.Value.Style}"); - // cell1 and cell2 must NOT have Underline or Blink. + // cell1 and cell2 must NOT have Reverse or Blink. Assert.False ( - cell1.Attribute!.Value.Style.HasFlag (TextStyle.Underline), - $"Cell 1 should NOT have Underline but has Style={cell1.Attribute!.Value.Style}"); + cell1.Attribute!.Value.Style.HasFlag (TextStyle.Reverse), + $"Cell 1 should NOT have Reverse but has Style={cell1.Attribute!.Value.Style}"); Assert.False ( - cell2.Attribute!.Value.Style.HasFlag (TextStyle.Underline), - $"Cell 2 should NOT have Underline but has Style={cell2.Attribute!.Value.Style}"); + cell2.Attribute!.Value.Style.HasFlag (TextStyle.Reverse), + $"Cell 2 should NOT have Reverse but has Style={cell2.Attribute!.Value.Style}"); } [Fact] @@ -512,7 +512,7 @@ public async Task MultiCaret_WordWrap_No_Duplicate_At_Boundary () fx.Render (); Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); - Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Underline | TextStyle.Blink); + Attribute caretAttr = new (normal.Foreground, normal.Background, TextStyle.Blink | TextStyle.Reverse); // Row 1, col 0 should show the caret attribute on 'f'. Cell row1FirstCol = fx.Driver.Contents![1, 0]; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index b370c32..f107037 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -1,5 +1,6 @@ // Claude - claude-opus-4-7 +using Terminal.Gui.Drivers; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; @@ -302,4 +303,234 @@ public async Task MouseWheel_Scrolls_LongDocument () Assert.Equal (0, fx.Top.Editor.Viewport.Y); DriverAssert.ContentsContains (fx.Driver, "line-00"); } + + // --------------------------------------------------------------------------------------------- + // Vertical multi-caret (specs/vertical-multi-caret/spec.md). Ported from the PR #125 test set, + // re-keyed to the VS Code chords: Ctrl+Alt+Up/Down for add-caret-above/below. These are the + // executable contract; they fail on a tip-of-develop baseline and pass after the implementation. + // --------------------------------------------------------------------------------------------- + + [Fact] + public async Task CtrlAltDown_Adds_Vertically_Aligned_Carets () + { + await using AppFixture fx = new (() => new ("longer line\nshrt\nanother line")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 8; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + + Assert.Equal (8, fx.Top.Editor.CaretOffset); + Assert.True (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (2, fx.Top.Editor.AdditionalCaretOffsets.Count); + Assert.Contains (16, fx.Top.Editor.AdditionalCaretOffsets); + Assert.Contains (25, fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task CtrlAltUp_Adds_Caret_On_Line_Above () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 11; + + fx.Injector.InjectKey (Key.CursorUp.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorUp.WithCtrl.WithAlt, Direct); + + // A third Ctrl+Alt+Up is past line 1 — a no-op, not a throw and not an extra caret. + fx.Injector.InjectKey (Key.CursorUp.WithCtrl.WithAlt, Direct); + + Assert.Equal (11, fx.Top.Editor.CaretOffset); + Assert.True (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (2, fx.Top.Editor.AdditionalCaretOffsets.Count); + Assert.Contains (6, fx.Top.Editor.AdditionalCaretOffsets); + Assert.Contains (1, fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task CtrlAltDown_Preserves_Exact_Column_On_Next_Long_Line_After_Short_Line () + { + await using AppFixture fx = new (() => new ("abcde\nx\nabcde")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 4; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + + Assert.Contains ("abcde\nx\nabcd".Length, fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task CtrlAltDown_Preserves_Column_With_Tabs () + { + await using AppFixture fx = new (() => new ("a\tbcde\na\tbcde\na\tbcde")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 3; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + + Assert.Contains ("a\tbcde\n".Length + 3, fx.Top.Editor.AdditionalCaretOffsets); + Assert.Contains ("a\tbcde\na\tbcde\n".Length + 3, fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Esc, Direct); + + Assert.False (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + + fx.Injector.InjectKey (Key.CursorDown, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + + Assert.Equal ("abcd\nabcd\nabcd\n".Length + 1, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Esc_After_Moving_Within_MultiCaret_Allows_Moving_Below_Last_Former_Multi () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + fx.Injector.InjectKey (Key.Esc, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + + Assert.Equal ("abcd\nabcd\nabcd\n".Length + 1, fx.Top.Editor.CaretOffset); + } + + [Fact] + public async Task Vertical_MultiCaret_Does_Not_Duplicate_When_Primary_Moves_Onto_Additional () + { + await using AppFixture fx = new (() => new ("aa\naa\naa")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown, Direct); + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("aa\naxa\naxa", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task Tab_Inserts_At_All_Carets () + { + await using AppFixture fx = new (() => new ("ab\nab\nab")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Tab, Direct); + + Assert.Equal ("a\tb\na\tb\na\tb", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spaces () + { + await using AppFixture fx = + new (() => new ("using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ConvertTabsToSpaces = true; + fx.Top.Editor.CaretOffset = "using".Length; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Tab, Direct); + + Assert.Equal ( + "using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;", + fx.Top.Editor.Document?.Text); + + fx.Injector.InjectKey (Key.Tab, Direct); + + Assert.Equal ( + "using Ted;\nusing Terminal.Gui.App;\nusing Terminal.Gui.Configuration;", + fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step () + { + await using AppFixture fx = new (() => new ("\tab\n\tab\n\tab")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Tab.WithShift, Direct); + + Assert.Equal ("ab\nab\nab", fx.Top.Editor.Document?.Text); + + // One RunUpdate scope ⇒ a single Ctrl+Z restores the indentation on every line. + fx.Injector.InjectKey (Key.Z.WithCtrl, Direct); + + Assert.Equal ("\tab\n\tab\n\tab", fx.Top.Editor.Document?.Text); + } + + [Fact] + public async Task CtrlAltDown_Then_CtrlAltUp_Collapses_Last_Down_Selection () + { + await using AppFixture fx = new (() => new ("a\nb\nc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorUp.WithCtrl.WithAlt, Direct); + + Assert.False (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (0, fx.Top.Editor.CaretOffset); + Assert.Empty (fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task CtrlAltUp_Then_CtrlAltDown_Collapses_Last_Up_Selection () + { + await using AppFixture fx = new (() => new ("a\nb\nc")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 2; + + fx.Injector.InjectKey (Key.CursorUp.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + + Assert.False (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (2, fx.Top.Editor.CaretOffset); + Assert.Empty (fx.Top.Editor.AdditionalCaretOffsets); + } + + [Fact] + public async Task Primary_Caret_Is_Visible_After_Exiting_MultiCaret () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Esc, Direct); + fx.Render (); + + Assert.False (fx.Top.Editor.HasMultipleCarets); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + + // 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); + } } diff --git a/tests/Terminal.Gui.Editor.Tests/EditorVisualLineCacheTests.cs b/tests/Terminal.Gui.Editor.Tests/EditorVisualLineCacheTests.cs index 76aa701..ee4a99e 100644 --- a/tests/Terminal.Gui.Editor.Tests/EditorVisualLineCacheTests.cs +++ b/tests/Terminal.Gui.Editor.Tests/EditorVisualLineCacheTests.cs @@ -68,6 +68,25 @@ public void Document_Edit_Drops_Downstream_Line_Entries () "Cache entries downstream of a newline-bearing edit must be dropped (line numbers shifted)."); } + [Fact] + public void Same_Line_Count_Edit_Drops_Downstream_Entries_Whose_Offsets_Shifted () + { + Editor editor = new () { Document = new TextDocument ("alpha\nbeta\ngamma") }; + ForceCachePopulation (editor); + CellVisualLine line3Before = ReadCache (editor, 3)!; + + // Insert on line 1 with NO newline: line numbers are unchanged, but every line after the + // edit shifts by 3 absolute offsets. A cached visual line for line 3 still carries the + // pre-edit absolute element offsets, so it must be dropped even though lineDelta == 0. + // This is the visual-line cache defect the "Tab twice with spaces" scenario exposes. + editor.Document!.Insert (2, "XYZ"); + + CellVisualLine? line3After = ReadCache (editor, 3); + Assert.False (ReferenceEquals (line3Before, line3After), + "Downstream cache entries must be dropped when an edit shifts their absolute offsets, " + + "even with no net newline change (specs/vertical-multi-caret 'Cache invalidation on offset shift')."); + } + [Fact] public void IndentationSize_Change_Drops_Stale_Cached_Lines () {