From 447de158db9a78939535bc3224e636b2b2efc61a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 18:41:40 +0000 Subject: [PATCH 01/14] =?UTF-8?q?spec:=20vertical=20multi-caret=20(Alt+Up/?= =?UTF-8?q?Down,=20Alt+Drag)=20=E2=80=94=20re-spec=20of=20PR=20#125?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #125 ships the right user-facing feature set (Alt+Up/Down for vertically-aligned carets, Alt+Drag for column-of-carets, Tab at all carets, normalization on primary-caret moves, Ctrl+Click after vertical block) but the implementation accreted around maintainer-reported regressions and is rejected as throwaway. This spec captures the behavior from the PR's tests, lifts the failing-first regressions as acceptance criteria, and flags the open caret-disappears-after-exit bug as OD-1 to be reproduced before the spec moves to Ready. --- specs/vertical-multi-caret/spec.md | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 specs/vertical-multi-caret/spec.md diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md new file mode 100644 index 0000000..1913aef --- /dev/null +++ b/specs/vertical-multi-caret/spec.md @@ -0,0 +1,176 @@ +# Feature Specification: Vertical Multi-Caret (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. + +## 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**: `Alt+Up` / `Alt+Down` extends the caret block one line above the topmost / below the bottommost caret, landing on the same sticky virtual column. +2. **Mouse**: `Alt + LeftButton drag` creates a column of carets spanning the anchor row → active row at the press column. + +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. + +## 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 1 — 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 `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 2 — Alt+Up adds a caret on the line above + +Symmetric to Scenario 1. **Given** carets at lines `n`, `n-1`, …, `n-k`, **When** the user presses `Alt+Up`, **Then** a new additional caret appears at line `n-k-1` at the sticky visual column. Pressing `Alt+Up` past line 1 is a no-op. + +### Scenario 3 — 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 `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 4 — 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 `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 5 — 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. + +### Scenario 6 — Esc dismisses the vertical block; subsequent navigation starts from the (former) primary + +**Given** vertical carets on lines 1–3 in `"abcd\nabcd\nabcd\nabcd"` produced from `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 7 — Esc after moving inside the block restores normal Down behavior past the block + +**Given** vertical carets on lines 1–3 from `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 8 — 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 `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 9 — 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 10 — 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 11 — Ctrl+Click after a vertical block puts the new caret where the user clicked + +**Given** vertical carets created via `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. + +### Scenario 12 — 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 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 OD-1 — the maintainer reported this in the PR #125 thread; reproduction steps must be captured as a failing test before implementation.)* + +## Requirements + +- **FR-001** — `Alt+CursorUp` adds an additional caret one line above the topmost caret in the current caret block at the sticky visual column. Keybinding lives in `Editor.Keyboard.cs` and is overridable via the standard Terminal.Gui keybinding mechanism. +- **FR-002** — `Alt+CursorDown` does the same below the bottommost caret. +- **FR-003** — `Alt+CursorUp` past line 1 and `Alt+CursorDown` past the last line are no-ops (do not throw, do not move existing carets). +- **FR-004** — `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. +- **FR-005** — During an Alt-drag, the column tracks the drag cursor live: extending the drag downward adds carets; dragging back up removes the ones below the new active row. (The view must end up identical to having pressed at the final position from the start, modulo timestamps.) +- **FR-006** — Vertical-column placement uses the **visual column** (cell width), not the raw character offset. Tabs, double-width graphemes, and wrap segments are all measured via the same primitives the rendering pipeline uses (`CellVisualLine.GetVisualColumn` / `.GetRelativeOffset`). +- **FR-007** — 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). +- **FR-008** — 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. +- **FR-009** — `Tab` and `Shift+Tab` honor `HasMultipleCarets`: every caret gets its own insertion (or replacement, if a per-caret selection is active), the whole operation is one `RunUpdate` scope, and one undo step reverses all of them. +- **FR-010** — Tabs inserted at multiple carets in one operation must leave every caret at the same visual column afterward (Scenario 10). 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. +- **FR-011** — 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 — Scenario 8); + - 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. +- **FR-012** — `Esc` while `HasMultipleCarets` clears additional carets and selection, leaving the primary caret in place; the sticky virtual column is refreshed to the primary's current visual column so subsequent `CursorUp` / `CursorDown` navigate freely past where the block used to be (Scenarios 6–7). +- **FR-013** — A plain (non-modifier) `LeftButtonPressed` while `HasMultipleCarets` clears additional carets and selection and places the primary at the click position (existing behavior — re-state for completeness; the vertical flow must not break it). +- **FR-014** — 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 (Scenario 11). Reuse the same `_suppressDragUntilRelease` discipline already in place for `Ctrl+Click`, extended to cover the "report-before-press" reorder. +- **FR-015** — 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 (Scenario 12). +- **FR-016** — 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. +- **FR-017** — `examples/ted` works end-to-end with this feature: Alt+Up, 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). + +## 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` — `Alt+CursorUp` / `Alt+CursorDown` keybindings (single dispatch each, no inline if-chain). Esc handler routes through `ClearAdditionalCarets ()` which is responsible for sticky-column refresh. +- `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. +- `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 Alt+Up/Down and Alt+Drag in the existing key-help string. No new menu items, no new dialogs. +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs` — keyboard scenarios (Scenarios 1–4, 6–10, 12). +- `tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs` — mouse scenarios (Scenarios 5, 11). +- `specs/public-api.md` — append note that `Alt+CursorUp` / `Alt+CursorDown` create vertical carets (R8). + +## Tests + +The PR #125 test set is the executable contract. Re-create these in the new branch and require them to be failing-first before the implementation lands: + +- `AltDown_Adds_Vertically_Aligned_Carets` (Scenario 1) +- `AltDown_Preserves_Exact_Column_On_Next_Long_Line_After_Short_Line` (Scenario 3) +- `AltDown_Preserves_Column_With_Tabs` (Scenario 4) +- `AltDrag_Adds_Vertically_Aligned_Carets` (Scenario 5) +- `Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block` (Scenario 6) +- `Esc_After_Moving_Within_MultiCaret_Allows_Moving_Below_Last_Former_Multi` (Scenario 7) +- `Vertical_MultiCaret_Does_Not_Duplicate_When_Primary_Moves_Onto_Additional` (Scenario 8) +- `Tab_Inserts_At_All_Carets` (Scenario 9) +- `Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spaces` (Scenario 10) +- `CtrlClick_After_VerticalCarets_Uses_Click_Position_When_PositionReport_Arrives_First` (Scenario 11) +- *(new)* `Primary_Caret_Is_Visible_After_Exiting_MultiCaret` (Scenario 12) — 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 Scenario 10 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 Scenario 10). +- [ ] `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 / block selection** (i.e. Alt+Shift+Down creating a *selection* per line rather than a caret per line). That is a follow-up; this spec covers carets-only, no selection at creation time. +- **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 (R-future-decision; this spec does not introduce reflow semantics). +- **A ted UI menu / dialog**. The keybindings ship discoverable via help text only. + +## Reference behavior from PR #125 + +PR #125 (copilot, draft) shipped the same user-visible features 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 (Alt+Up/Down, Alt+Drag, Tab-at-all-carets, normalization, Ctrl+Click after vertical) is the right set. The tests in that PR are the executable spec. The implementation is throwaway. + +## Open Decisions + +- **OD-1 — "Primary caret disappears after exiting multi mode."** The maintainer reported this in the PR #125 thread; the implementer could not reproduce. Before this spec is moved to **Ready**, the reproduction must be captured as a failing integration test (or the bug must be confirmed not-reproducible on the latest `develop` and the 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. + +- **OD-2 — Alt+Shift+Up/Down semantics.** Reserved. This spec does *not* claim Alt+Shift+arrow; the column-selection variant ships separately. + +- **OD-3 — Whether `ClearAdditionalCarets` is a public API.** 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. + +## 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), confirm they fail, then write the implementation against the requirements above. +- The visual-line cache fix (Scenario 10 / FR-016) 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. From 41c4a4a89054ad234df83e63d65e284f274c4380 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 20:22:24 +0000 Subject: [PATCH 02/14] spec: add VS Code / Visual Studio 2026 comparison and OD-4/OD-5 Cross-walk every user-facing behavior in the spec against VS Code and VS 2022/2026. Call out the three intentional divergences (Alt vs Ctrl+Alt vs Alt+Shift for add-caret-above/below; Alt+drag carets-only vs Shift+Alt+drag column-select; Ctrl+Click vs Alt+Click for caret-at-click) with rationale. Add OD-4 (keybinding choice) and OD-5 (Alt+Click alias). --- specs/vertical-multi-caret/spec.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 1913aef..8a556ec 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -160,6 +160,39 @@ PR #125 (copilot, draft) shipped the same user-visible features but was rejected - 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 (Alt+Up/Down, Alt+Drag, Tab-at-all-carets, normalization, Ctrl+Click after vertical) is the right set. The tests in that PR are the executable spec. The implementation is throwaway. +## Comparison with VS Code and Visual Studio 2026 + +For each user-facing behavior in this spec, here's how it lines up with the two editors most likely to set the expectation for our users. Where this spec diverges, the divergence is intentional and called out in Open Decisions for a deliberate choice rather than a drift. + +| 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`) | `Alt+Up` / `Alt+Down` | +| Add caret at click | `Alt+Click` | `Alt+Click` (multi-cursor placement) | `Ctrl+Click` (existing — see multi-caret spec) | +| 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* | +| Esc collapses to primary caret | Yes | Yes | Yes (Scenario 6) | +| Sticky desired column through short lines | Yes | Yes | Yes (Scenario 3, FR-007) | +| Tab inserts at every caret, single undo | Yes | Yes | Yes (Scenarios 9–10, FR-009, FR-010) | +| Caret-on-caret normalization (no duplicate edits) | Yes (carets at same offset collapse silently) | Yes | Yes (Scenario 8, FR-011) | +| Wrapped-line vertical navigation | "Above" / "below" follows wrap rows | Same | Same (FR-008) | +| WordWrap toggle while multi-caret is live | VS Code preserves carets at nearest valid offset | VS preserves carets | Out of scope — block is dismissed | + +### Intentional divergences (and why) + +1. **Keybinding for add-caret-above/below.** Neither `Ctrl+Alt+Up/Down` (VS Code) nor `Alt+Shift+Up/Down` (VS) is currently free in Terminal.Gui — and `Alt+Up/Down` is. This spec adopts `Alt+Up/Down` for ergonomic muscle-memory ("Alt = multi-caret modifier" mirrors `Alt+Click` in the desktop editors). The cost is that users coming from VS Code or VS will reach for a chord we don't bind. Mitigation: the keybinding is overridable, and ted's help text spells out the binding. This is a Decisions-worthy choice — see OD-4. + +2. **Alt+drag produces carets, not a column selection.** VS Code's `Shift+Alt+drag` and VS's `Shift+Alt+drag` create a *selection per row* — typing replaces a column of text, not just inserts at a column of carets. This spec ships only the carets-per-row variant in this iteration. **Reason**: per-caret selection in the existing multi-caret pipeline already works (`CaretInfo.SelectionAnchor`), but column-extend during drag needs a new code path (extending each caret's anchor as the drag widens/narrows the column). That work belongs in a follow-up so the carets-only flow can ship first. **User-visible consequence**: to "replace" a column, the user must Alt-drag, then Shift+Right/Left to grow the selection, then type. Document this in ted help. + +3. **Drag modifier is `Alt` alone, not `Shift+Alt`.** Both reference editors use `Shift+Alt+drag`. We use `Alt+drag` because `Shift+LeftButton` already means "extend selection to click" in the existing mouse handler (`Editor.Mouse.cs`), so `Shift+Alt+drag` would have to disambiguate the two — and users would reasonably expect `Shift+Alt+drag` to do *both* (extend selection *and* column-select), which is incoherent. `Alt+drag` is unambiguous, and the cost is only that we're a chord lighter than the reference editors. See OD-4. + +4. **Ctrl+Click vs Alt+Click for "add caret at click".** Existing multi-caret behavior on `develop` uses `Ctrl+Click` (see `multi-caret/spec.md` FR-003 and `Editor.Mouse.cs`). VS Code and VS use `Alt+Click`. Changing the existing binding is out of scope for this spec — flagged in OD-5. + +### Behaviors we match deliberately + +- **Sticky desired column through short lines and tab-expanded columns.** Both reference editors track visual column, not character offset; FR-006 / FR-007 / Scenario 4 match. +- **Tab at every caret, one undo step.** Both editors. FR-009 / FR-010 / Scenarios 9–10. +- **Esc dismisses the block, leaves the primary caret in place, allows continued navigation past where the block was.** Both editors. Scenarios 6–7 / FR-012. +- **No duplicate edits when the primary lands on an additional caret's offset.** Both editors silently dedupe. FR-011 / Scenario 8. + ## Open Decisions - **OD-1 — "Primary caret disappears after exiting multi mode."** The maintainer reported this in the PR #125 thread; the implementer could not reproduce. Before this spec is moved to **Ready**, the reproduction must be captured as a failing integration test (or the bug must be confirmed not-reproducible on the latest `develop` and the 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. @@ -168,6 +201,10 @@ PR #125 (copilot, draft) shipped the same user-visible features but was rejected - **OD-3 — Whether `ClearAdditionalCarets` is a public API.** 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. +- **OD-4 — Keybinding choice for add-caret-above/below and the drag modifier.** This spec proposes `Alt+Up` / `Alt+Down` and `Alt+drag`, which differ from both VS Code (`Ctrl+Alt+Up/Down`, `Shift+Alt+drag`) and Visual Studio (`Alt+Shift+Up/Down`, `Shift+Alt+drag`). Reasoning is in the Comparison section. Two alternatives are open: (a) match VS Code (`Ctrl+Alt+Up/Down`, `Shift+Alt+drag`) for broadest familiarity, accepting that we'd have to redefine `Shift+Click` to keep `Shift+Alt+drag` coherent; (b) match Visual Studio (`Alt+Shift+Up/Down`) for the key only, and keep `Alt+drag` for the mouse. Pick one and record in `specs/decisions.md` before implementation. + +- **OD-5 — Add `Alt+Click` as a second binding 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 it's a small win for newcomers to add `Alt+Click` as an alias. Out of scope for this spec, but worth filing as a follow-up if the maintainer agrees. + ## 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), confirm they fail, then write the implementation against the requirements above. From f2d11f79d2f909d73c462df454f6ecef031613c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 20:30:20 +0000 Subject: [PATCH 03/14] spec: match VS Code keybindings, drop FR-### identifiers Resolve OD-4 in favor of matching VS Code chords (modulo terminal incompat): Ctrl+Alt+Up/Down for add-caret-above/below, Shift+Alt+drag modifier for the column-drag. Comparison table rows that previously diverged on chords now match. The Shift+Alt+drag-produces-carets-only divergence stays (column-select-per-row is the follow-up); Ctrl+Click vs Alt+Click stays as a separate open question. Rename requirements from cryptic FR-### identifiers to human-readable labels (e.g. "Add caret above", "Caret normalization", "Cache invalidation on offset shift"). Open Decisions and scenarios are referenced by name throughout. Tests renamed from AltDown_* / AltDrag_* to CtrlAltDown_* / ShiftAltDrag_*. Add Resolved Decisions section capturing the keybinding choice and a new open question for whether ted should ship an alternate-chord fallback for terminals/WMs that grab Ctrl+Alt+arrow. --- specs/vertical-multi-caret/spec.md | 203 +++++++++++++++-------------- 1 file changed, 107 insertions(+), 96 deletions(-) diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 8a556ec..525907b 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -1,67 +1,69 @@ -# Feature Specification: Vertical Multi-Caret (Alt+Up/Down, Alt+Drag) + \ No newline at end of file From 075f5ad555a64fc439272b3bc682944368c69d6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 00:40:59 +0000 Subject: [PATCH 04/14] spec: per-platform default keybindings, no fallback chord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the alternate-chord open question: no editor-specific fallback. Keys are fully adjustable via TG keybindings, so pre-ship per-platform defaults the TG-standard way — a [ConfigurationProperty] DefaultKeyBindings populated from a PlatformKeyBinding record (All/Windows/Linux/Macos), user-overridable through View.ViewKeyBindings config. Windows/Linux default to the VS Code chord (Ctrl+Alt+Up/Down); the macOS default is a wiring-time empirical question (Terminal.app / iTerm2 swallow Cmd and may not deliver Ctrl+Alt+arrow) tracked as a remaining open decision. References TG docfx/docs/keyboard.md for the binding-layer model and Bind.AllPlus() helper. --- specs/vertical-multi-caret/spec.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 525907b..8d02778 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -18,7 +18,7 @@ Both flows produce a primary caret plus zero or more additional carets, all shar 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**: `Ctrl+Alt+Up/Down` and `Shift+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 `modifyOtherKeys` / the Kitty keyboard protocol. The TG keybinding system makes the chords overridable; users in those environments rebind. See **Keybinding overridable** in Requirements. +**Terminal compatibility & platform defaults**: `Ctrl+Alt+Up/Down` and `Shift+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 @@ -87,7 +87,7 @@ Symmetric to the previous scenario. **Given** carets at lines `n`, `n-1`, …, ` Named requirements — every label is also a search anchor used by tests and code review. -- **Add caret above** — `Ctrl+Alt+CursorUp` adds an additional caret one line above the topmost caret in the current caret block at the sticky visual column. The binding is registered via the standard `KeyBindings.Add` mechanism, not an inline if-chain in `OnKeyDownNotHandled`. +- **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** — `Shift+Alt + LeftButtonPressed` followed by `PositionReport+Shift+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. @@ -108,12 +108,15 @@ Named requirements — every label is also a search anchor used by tests and cod - **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`, `Shift+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). -- **Keybindings overridable** — both chords are registered via `KeyBindings.Add (...)` so a user whose terminal or window manager intercepts `Ctrl+Alt+arrow` (e.g. GNOME workspace switching) or doesn't forward `Shift+Alt` mouse modifiers can rebind. The default binding is the VS Code chord; ted ships no alternate default. The keybindings must round-trip through TG's config (i.e. they show up in any keybinding inspector and can be overridden in user settings). +- **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` — `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown` bindings registered via `KeyBindings.Add` (R8-friendly: no inline if-chain in `OnKeyDownNotHandled`). Esc handler routes through `ClearAdditionalCarets ()` which is responsible for sticky-column refresh. +- `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` — `Shift+Alt`-drag press/drag/release state machine. Factor the existing Ctrl+Click drag-suppression into a small state enum so Ctrl, Shift+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`. @@ -207,13 +210,14 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher - **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. -- **Terminal/WM workspace-switch collisions** — `Ctrl+Alt+arrow` is grabbed by some Linux desktops for workspace switching. *Keybindings overridable* says rebind, but should `examples/ted` ship a fallback chord (e.g. `Alt+Shift+Up/Down` matching VS) for environments where the primary chord is unreachable? Default: no second default binding, but if user testing shows the primary is unreachable for a noticeable fraction of users, revisit. +- **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. `Ctrl+Alt+Up`/`Down` for add-caret-above/below; `Shift+Alt + drag` modifier for the column-drag. Cross-link to `specs/decisions.md` when that entry is written. +- **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 From dc835c6d51697ec72bf8c31160574476ee7d9eec Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 16:25:34 -0700 Subject: [PATCH 05/14] feat: vertical multi-caret (Ctrl+Alt+Up/Down, Shift+Alt+Drag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements specs/vertical-multi-caret/spec.md — the re-spec of the rejected prototype PR #125, closing issue #124. Behaviour (VS Code chords, per DEC-006): - Ctrl+Alt+CursorUp / Ctrl+Alt+CursorDown add a caret above the topmost / below the bottommost caret at the sticky visual column (tabs, short lines, and word-wrap aware). Crossing a document bound is a no-op. - Shift+Alt + LeftButton drag builds a vertical column of carets (carets only; per-row selection is the documented follow-up). - Tab / Shift+Tab insert at every caret in one undo scope, each caret computing its own tab stop so the column stays aligned across presses. Design (addresses the maintainer's "hacky" rejection of #125): - Keybindings registered as Editor-local Command ids via AddCommand and bound through the configurable [ConfigurationProperty] Editor.DefaultKeyBindings (PlatformKeyBinding) — no inline if-chain in OnKeyDownNotHandled, no fallback chord. - AddAdditionalCaretAt (the only add path) dedupes by construction; NormalizeAdditionalCarets (the only invariant-trim path) runs on every primary move and document change; ToggleCaretAt rewritten on top. - Editor.Mouse.cs replaces the three suppress-until-release booleans with a single DragMode state; Ctrl-modified drags can no longer hijack the primary caret (Ctrl+Click reorder defence). - Visual-line cache: a same-line-count edit that shifts absolute offsets now invalidates downstream cached lines (lineDelta == 0 && offsetDelta != 0) — fixes the "Tab twice desyncs" defect; pinned by a unit test in EditorVisualLineCacheTests. - Vertical placement reuses the single-caret visual-column / wrap-map primitives (GetVisualColumnForOffset, TryGetVerticalOffset). Tests: 12 integration tests ported from the PR #125 set, re-keyed to the VS Code chords (failing-first on develop, green now) + 1 cache unit test. Full suites green (Tests 437/437, IntegrationTests 187/187); dotnet format + jb cleanup clean. OD-1 (primary caret disappears after exit) confirmed not reproducible on develop; kept Primary_Caret_Is_Visible_After_Exiting_MultiCaret as a regression guard. OD-3 resolved: ClearAdditionalCarets stays public (DEC-007). Docs (decisions, public-api, ted help) updated; stray CDATA wrapper stripped from the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/Help/keyboard-reference.md | 15 ++ Docs/Help/multi-caret.md | 8 +- specs/decisions.md | 20 ++ specs/public-api.md | 6 + specs/vertical-multi-caret/spec.md | 5 +- src/Terminal.Gui.Editor/Editor.Commands.cs | 21 +- src/Terminal.Gui.Editor/Editor.Indentation.cs | 5 + src/Terminal.Gui.Editor/Editor.Mouse.cs | 79 ++++-- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 233 +++++++++++++++++- src/Terminal.Gui.Editor/Editor.cs | 107 +++++++- .../EditorMouseTests.cs | 83 +++++++ .../EditorTests.cs | 182 ++++++++++++++ .../EditorVisualLineCacheTests.cs | 19 ++ 13 files changed, 731 insertions(+), 52 deletions(-) diff --git a/Docs/Help/keyboard-reference.md b/Docs/Help/keyboard-reference.md index 0219da5..982d7eb 100644 --- a/Docs/Help/keyboard-reference.md +++ b/Docs/Help/keyboard-reference.md @@ -44,6 +44,21 @@ 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 | +| `Shift+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+↑/↓` and the `Shift+Alt`+drag modifier are 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`. + ## Clipboard | Key | Action | diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index 3ecd70d..b8d4fe5 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). | +| **Shift+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 @@ -46,4 +51,5 @@ 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. +- `Shift+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. +- Toggling Word Wrap while a vertical block is live dismisses the block. diff --git a/specs/decisions.md b/specs/decisions.md index b3682cf..9415fd8 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -105,3 +105,23 @@ 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 parity, no fallback chord) + +**Decision**: Vertical multi-caret uses the VS Code chords — `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown` for add-caret-above/below and `Shift+Alt + LeftButton` drag for the column-of-carets gesture (carets only; per-row selection is a follow-up). The keys ship as a `[ConfigurationProperty]` `PlatformKeyBinding` entry in `Editor.DefaultKeyBindings`; there is **no** editor-specific fallback chord. Users whose terminal/WM grabs the chord override it through `View.ViewKeyBindings` config like any other binding. Because TG's `Command` enum (consumed via the pinned `Terminal.Gui` package) has no vertical-multi-caret slot, the two commands are registered as Editor-local `Command` ids (`(Command) 1001` / `1002`) via `AddCommand` and bound through the same configurable path as every other Editor binding — not an inline `if` in `OnKeyDownNotHandled`. + +**Rationale**: Matches `specs/vertical-multi-caret/spec.md` Resolved Decisions (2026-05-15). VS Code parity preserves muscle memory; the TG-standard `[ConfigurationProperty]` + `PlatformKeyBinding` mechanism makes the chord fully user-overridable without bespoke editor knobs. Upstream follow-up: TG should reserve a documented view-local `Command` range so consumers don't pick magic ints — filed as a TG issue per Constitution tenet "This Is TG" (workarounds require a great TG issue). + +**Date**: 2026-05-16 + +--- + +### 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 `Shift+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..5158872 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 + // Shift+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`, `Shift+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 index 8d02778..1c17755 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -1,4 +1,4 @@ - \ No newline at end of file +- R8: append two lines to `specs/public-api.md` describing the new keybindings. No new public Editor API is introduced by this spec — the existing `AdditionalCaretOffsets` / `HasMultipleCarets` / `ToggleCaretAt` / `ClearAdditionalCarets` surface is sufficient. \ 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 0e20e1b..c00c193 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -20,6 +20,14 @@ public partial class Editor /// Process-wide static. Do not mutate from parallel tests — see Terminal.Gui's same convention /// on . /// + // TG's Command enum (consumed via the pinned Terminal.Gui package) has no + // vertical-multi-caret slot. We register Editor-local Command ids and bind them through the + // same configurable DefaultKeyBindings path as every other Editor binding — there is no + // inline if-chain in OnKeyDownNotHandled. Upstream follow-up (TG should reserve a view-local + // Command range) is recorded in specs/decisions.md. + private const Command InsertCaretAbove = (Command)1001; + private const Command InsertCaretBelow = (Command)1002; + [ConfigurationProperty (Scope = typeof (SettingsScope))] public new static Dictionary? DefaultKeyBindings { get; set; } = new () { @@ -39,7 +47,14 @@ public partial class Editor [Command.FindNext] = Bind.All (Key.F3), [Command.FindPrevious] = Bind.All (Key.F3.WithShift), [Command.Find] = Bind.All (Key.F.WithCtrl), - [Command.Replace] = Bind.All (Key.H.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). + [InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), + [InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt) }; private void CreateCommandsAndBindings () @@ -202,6 +217,10 @@ 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 (InsertCaretAbove, () => AddCaretVertically (-1)); + AddCommand (InsertCaretBelow, () => AddCaretVertically (1)); + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); // Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our diff --git a/src/Terminal.Gui.Editor/Editor.Indentation.cs b/src/Terminal.Gui.Editor/Editor.Indentation.cs index 87b8263..e46b604 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 (); diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 5bc5802..7891930 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -10,11 +10,25 @@ 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, + + /// Shift+Alt drag: build a vertical column of carets from press row to drag row. + ColumnCarets + } + + private DragMode _dragMode; + private Point _columnDragAnchor; /// protected override bool OnMouseEvent (Mouse mouse) @@ -45,26 +59,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); + + return true; - // Route through the selection helper so SelectionChanged fires only on real changes. - ExtendCaretTo (offset); + case DragMode.AddCaret: + return true; - 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 +97,36 @@ 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) + if (shift && 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 +138,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 87fd694..33456f2 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; @@ -23,8 +24,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) { @@ -35,42 +38,195 @@ public void ToggleCaretAt (int offset) 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) { 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) + { + 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; + } + + // 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); + } + + 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)); + + 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; + + 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 @@ -358,6 +514,57 @@ private List GetAllCaretsDescending () return true; } + /// + /// Inserts a tab (or its space expansion) at every caret in one undo scope. Each caret's + /// insertion text is computed from that caret's own visual column via + /// , so every caret advances to the same next tab stop + /// and the column stays aligned across repeated presses. Carets are processed in + /// descending offset order so an earlier (higher) edit doesn't shift a not-yet-processed + /// offset. Caller () guarantees a non-null, writable document and + /// . + /// + private bool MultiCaretInsertTab () + { + using (_document!.RunUpdate ()) + { + foreach (CaretEditInfo caret in GetAllCaretsDescending ()) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + ReplaceSelection (GetTabInsertionText (SelectionStart)); + } + else + { + _document.Insert (CaretOffset, GetTabInsertionText (CaretOffset)); + } + + continue; + } + + if (caret.SelectionAnchor is { IsDeleted: false } selAnchor) + { + var selStart = Math.Min (selAnchor.Offset, caret.Offset); + var selEnd = Math.Max (selAnchor.Offset, caret.Offset); + + if (selEnd > selStart) + { + _document.Replace (selStart, selEnd - selStart, GetTabInsertionText (selStart)); + + continue; + } + } + + _document.Insert (caret.Offset, GetTabInsertionText (caret.Offset)); + } + } + + ClearAdditionalCaretSelections (); + + 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 4b5470a..fbfe69b 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -389,6 +389,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 (); @@ -447,6 +452,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/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs index 98d9e62..1341246 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs @@ -449,6 +449,89 @@ 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, + // re-keyed to the VS Code modifier: Shift+Alt + LeftButton drag builds a column of carets. + // --------------------------------------------------------------------------------------------- + + [Fact] + public async Task ShiftAltDrag_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.Shift | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (1, 2), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Shift | 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/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index b370c32..511ced0 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,185 @@ 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 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 () { From c90ea243d222ca4c558287839892ddefc525406f Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 17:39:49 -0700 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20column-drag=20uses=20Alt=20(not=20?= =?UTF-8?q?Shift+Alt)=20=E2=80=94=20Windows=20Terminal=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows Terminal (and the xterm family it emulates) reserves Shift+drag as the user's forced text-selection override while an app has mouse mode on, and Alt turns that into a rectangular/block selection. So the spec's Shift+Alt+drag column gesture was being consumed by the terminal's own block-select and never reaching the editor. Alt+drag is forwarded. - Editor.Mouse.cs: column-drag press now keys on `alt` alone (was `shift && alt`); DragMode.ColumnCarets doc updated. - EditorMouseTests: ShiftAltDrag_Adds_Vertically_Aligned_Carets renamed to AltDrag_Adds_Vertically_Aligned_Carets; press/drag flags drop Shift. - Spec: authoritative Amendment block + normative refs updated to Alt; DEC-006 amended; public-api / Docs/Help (keyboard-reference, multi-caret) updated. VS-Code-describing text left accurate. Keyboard chords (Ctrl+Alt+Up/Down) unchanged. Making the mouse modifier user-configurable (to opt back into Shift+Alt parity) is tracked upstream by gui-cs/Terminal.Gui#4888 — to be prioritized. Full suites green: Tests 437/437, IntegrationTests 187/187; dotnet format + jb cleanup clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/Help/keyboard-reference.md | 11 ++-- Docs/Help/multi-caret.md | 5 +- specs/decisions.md | 12 +++-- specs/public-api.md | 4 +- specs/vertical-multi-caret/spec.md | 54 ++++++++++++------- src/Terminal.Gui.Editor/Editor.Mouse.cs | 12 ++++- .../EditorMouseTests.cs | 12 +++-- 7 files changed, 70 insertions(+), 40 deletions(-) diff --git a/Docs/Help/keyboard-reference.md b/Docs/Help/keyboard-reference.md index 982d7eb..4878fde 100644 --- a/Docs/Help/keyboard-reference.md +++ b/Docs/Help/keyboard-reference.md @@ -51,13 +51,16 @@ This page lists all default keyboard shortcuts. All shortcuts are `Command`-boun | `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 | -| `Shift+Alt`+drag | Build a vertical column of carets from the press row to the drag row (carets only) | +| `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+↑/↓` and the `Shift+Alt`+drag modifier are 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`. +`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 diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index b8d4fe5..69a9973 100644 --- a/Docs/Help/multi-caret.md +++ b/Docs/Help/multi-caret.md @@ -9,7 +9,7 @@ Place multiple carets in the document and type, delete, or press Enter at all of | **Ctrl+Click** | Toggle an additional caret at the clicked position. Click an existing additional caret to remove it. | | **Ctrl+Alt+↑** | Add a caret on the line above the topmost caret, at the sticky visual column (VS Code parity). | | **Ctrl+Alt+↓** | Add a caret on the line below the bottommost caret, at the sticky visual column (VS Code parity). | -| **Shift+Alt + drag** | Build a vertical column of carets from the press row through the drag row at the press column (carets only). | +| **Alt + drag** | Build a vertical column 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. @@ -51,5 +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. -- `Shift+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. +- `Alt`+drag produces a column of *carets*, not a column *selection*. To replace a column, drag to place the carets, then `Shift+→`/`←` to grow each caret's selection, then type. Per-row column selection during the drag is the planned follow-up. +- The column-drag modifier is `Alt`, not VS Code's `Shift+Alt`: terminals reserve `Shift`+drag for their own forced/block text selection while an app reads the mouse, so `Shift+Alt`+drag never reaches the editor. Making the mouse modifier user-configurable (to opt back into `Shift+Alt`) is tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). - Toggling Word Wrap while a vertical block is live dismisses the block. diff --git a/specs/decisions.md b/specs/decisions.md index 9415fd8..6d09f06 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -108,13 +108,15 @@ Decisions are recorded here when an open question from the plan is resolved. Eac --- -### DEC-006: Vertical multi-caret keybindings (VS Code parity, no fallback chord) +### DEC-006: Vertical multi-caret keybindings (VS Code keyboard parity; `Alt`+drag mouse modifier) -**Decision**: Vertical multi-caret uses the VS Code chords — `Ctrl+Alt+CursorUp` / `Ctrl+Alt+CursorDown` for add-caret-above/below and `Shift+Alt + LeftButton` drag for the column-of-carets gesture (carets only; per-row selection is a follow-up). The keys ship as a `[ConfigurationProperty]` `PlatformKeyBinding` entry in `Editor.DefaultKeyBindings`; there is **no** editor-specific fallback chord. Users whose terminal/WM grabs the chord override it through `View.ViewKeyBindings` config like any other binding. Because TG's `Command` enum (consumed via the pinned `Terminal.Gui` package) has no vertical-multi-caret slot, the two commands are registered as Editor-local `Command` ids (`(Command) 1001` / `1002`) via `AddCommand` and bound through the same configurable path as every other Editor binding — not an inline `if` in `OnKeyDownNotHandled`. +**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`). Because TG's `Command` enum (consumed via the pinned `Terminal.Gui` package) has no vertical-multi-caret slot, the two commands are registered as Editor-local `Command` ids (`(Command) 1001` / `1002`) via `AddCommand` and bound through the same configurable path — not an inline `if` in `OnKeyDownNotHandled`. -**Rationale**: Matches `specs/vertical-multi-caret/spec.md` Resolved Decisions (2026-05-15). VS Code parity preserves muscle memory; the TG-standard `[ConfigurationProperty]` + `PlatformKeyBinding` mechanism makes the chord fully user-overridable without bespoke editor knobs. Upstream follow-up: TG should reserve a documented view-local `Command` range so consumers don't pick magic ints — filed as a TG issue per Constitution tenet "This Is TG" (workarounds require a great TG issue). +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. -**Date**: 2026-05-16 +**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. Upstream follow-up also noted: TG should reserve a documented view-local `Command` range so consumers don't pick magic ints (Constitution "This Is TG": workarounds require a great TG issue). + +**Date**: 2026-05-16 (mouse-modifier amendment same day, after Windows Terminal validation) --- @@ -122,6 +124,6 @@ Decisions are recorded here when an open question from the plan is resolved. Eac **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 `Shift+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. +**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 5158872..00c7ed3 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -28,7 +28,7 @@ public class Editor : View 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 - // Shift+Alt + LeftButton drag builds a column of carets (carets only). Both reuse the + // 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]). @@ -113,4 +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`, `Shift+Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret | +| 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 index 1c17755..5d1f2fa 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -1,4 +1,4 @@ -# Feature Specification: Vertical Multi-Caret (Ctrl+Alt+Up/Down, Shift+Alt+Drag) +# Feature Specification: Vertical Multi-Caret (Ctrl+Alt+Up/Down, Alt+Drag) **Status**: Draft — supersedes the throwaway implementation in PR #125 **Created**: 2026-05-15 @@ -7,18 +7,32 @@ **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**: `Shift+Alt + LeftButton drag` creates a column of carets spanning the anchor row → active row at the press column. Matches VS Code's column-select drag (this spec ships carets-only first; selection-per-row is a follow-up — see Out of Scope). +2. **Mouse**: `Alt + LeftButton drag` creates a column 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 `Shift+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. +**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 @@ -42,10 +56,10 @@ Symmetric to the previous scenario. **Given** carets at lines `n`, `n-1`, …, ` **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 — Shift+Alt+Drag creates a vertical column of carets +### Scenario — Alt+Drag creates a vertical column of carets -**Given** the document `"abcd\nabcd\nabcd"`, **When** the user presses `Shift+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 — see Out of Scope and Comparison.) +**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 @@ -90,8 +104,8 @@ Named requirements — every label is also a search anchor used by tests and cod - **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** — `Shift+Alt + LeftButtonPressed` followed by `PositionReport+Shift+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. -- **Column-drag tracks live** — extending the `Shift+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. +- **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. @@ -107,7 +121,7 @@ Named requirements — every label is also a search anchor used by tests and cod - **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`, `Shift+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). +- **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. @@ -117,23 +131,23 @@ Named requirements — every label is also a search anchor used by tests and cod - `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` — `Shift+Alt`-drag press/drag/release state machine. Factor the existing Ctrl+Click drag-suppression into a small state enum so Ctrl, Shift+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.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 `Shift+Alt+Drag` in the existing key-help string. Mention the VS Code parity. No new menu items, no new dialogs. +- `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 `Shift+Alt+Drag` creates a vertical column (R8). +- `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) and `Alt` to `Shift+Alt` (mouse). Re-create these in the new branch, with new names, and require them to be failing-first before the implementation lands: +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*) -- `ShiftAltDrag_Adds_Vertically_Aligned_Carets` (*Shift+Alt+Drag creates a vertical column of carets*) +- `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*) @@ -147,7 +161,7 @@ Also add a tightly-scoped unit test (`Terminal.Gui.Editor.Tests`) for the visual ## 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/Shift+Alt/plain-drag interaction is expressed as a single state, not three booleans that have to be cleared on every branch. +- [ ] 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. @@ -156,7 +170,7 @@ Also add a tightly-scoped unit test (`Terminal.Gui.Editor.Tests`) for the visual ## Out of Scope -- **Column / box selection** (i.e. `Shift+Alt+Drag` producing a *selection per row* the way VS Code 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. +- **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. @@ -178,7 +192,7 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher |---|---|---|---| | Add caret above / below | `Ctrl+Alt+Up` / `Ctrl+Alt+Down` (Win/Linux); `Cmd+Opt+Up/Down` (Mac) | `Alt+Shift+Up` / `Alt+Shift+Down` (`Edit.InsertCaretAbove` / `Below`) | **Match VS Code**: `Ctrl+Alt+Up` / `Ctrl+Alt+Down` | | Add caret at click | `Alt+Click` | `Alt+Click` (multi-cursor placement) | `Ctrl+Click` (existing — see multi-caret spec and *Alt+Click alias* open decision below) | -| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **Match modifier, not semantics**: `Shift+Alt + drag` produces *carets per row, no selection* (column-select variant is out of scope for this iteration) | +| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **`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) | @@ -188,7 +202,7 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher ### Intentional divergences (and why) -1. **`Shift+Alt+drag` produces carets only, not a column selection.** 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. **User-visible consequence**: to "replace" a column, the user must `Shift+Alt`-drag, then `Shift+Right`/`Left` to grow each caret's selection, then type. Document this in ted help. +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. @@ -196,7 +210,7 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher ### Behaviors we match deliberately -- **Keybinding chords** (`Ctrl+Alt+Up/Down`, `Shift+Alt+drag` modifier) — match VS Code so users coming from VS Code or `code-insiders` keep their muscle memory. +- **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. @@ -216,7 +230,7 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher 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. `Ctrl+Alt+Up`/`Down` for add-caret-above/below; `Shift+Alt + drag` modifier for the column-drag. Cross-link to `specs/decisions.md` when that entry is written. +- **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. - **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 diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 7891930..e4bdb73 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -23,7 +23,12 @@ private enum DragMode /// Ctrl+Click add-caret: swallow drag events so they don't move the primary. AddCaret, - /// Shift+Alt drag: build a vertical column of carets from press row to drag row. + /// + /// 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 } @@ -100,7 +105,10 @@ protected override bool OnMouseEvent (Mouse mouse) var ctrl = mouse.Flags.HasFlag (MouseFlags.Ctrl); var alt = mouse.Flags.HasFlag (MouseFlags.Alt); - if (shift && alt) + // 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; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs index 1341246..a152778 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs @@ -450,12 +450,14 @@ public async Task CtrlClick_First_Slash_Of_TripleSlash_Only_Highlights_One_Cell } // --------------------------------------------------------------------------------------------- - // Vertical multi-caret mouse gestures (specs/vertical-multi-caret/spec.md). Ported from PR #125, - // re-keyed to the VS Code modifier: Shift+Alt + LeftButton drag builds a column of carets. + // 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 ShiftAltDrag_Adds_Vertically_Aligned_Carets () + public async Task AltDrag_Adds_Vertically_Aligned_Carets () { await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); fx.Top.Editor.SetFocus (); @@ -464,7 +466,7 @@ public async Task ShiftAltDrag_Adds_Vertically_Aligned_Carets () new () { ScreenPosition = new (1, 0), - Flags = MouseFlags.LeftButtonPressed | MouseFlags.Shift | MouseFlags.Alt, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, Timestamp = BaseTime }, Direct); @@ -473,7 +475,7 @@ public async Task ShiftAltDrag_Adds_Vertically_Aligned_Carets () new () { ScreenPosition = new (1, 2), - Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Shift | MouseFlags.Alt, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, Timestamp = BaseTime.AddMilliseconds (20) }, Direct); From 93e4dc0b16cb00d45be2c80b1059ff7c313019a6 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 19:23:00 -0700 Subject: [PATCH 07/14] fix: Shift+Tab unindents at every caret (Codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review (P1) caught that the multi-caret path only covered Tab: Unindent() still computed removals from the primary caret's line only, so Shift+Tab in a vertical multi-caret session unindented one line and left the other carets untouched — breaking the indent/unindent symmetry the spec requires ("Tab and Shift+Tab honor HasMultipleCarets"; Files in Scope names MultiCaretUnindent). - Editor.MultiCaret.cs: add MultiCaretUnindent() — removes one indentation unit from every distinct caret line (deduped), high-offset -first, in a single RunUpdate scope; reuses TextUtilities.GetSingleIndentationSegment like single-caret Unindent. - Editor.Indentation.cs: Unindent() routes to MultiCaretUnindent() when HasMultipleCarets (symmetric with InsertTab). - Test: ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step — confirmed failing-first ("ab\n\tab\n\tab"), green after the fix; also asserts a single Ctrl+Z restores all lines (one undo step). Full suites green: Tests 437/437, IntegrationTests 188/188; dotnet format + jb cleanup clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Terminal.Gui.Editor/Editor.Indentation.cs | 5 ++ src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 47 +++++++++++++++++++ .../EditorTests.cs | 19 ++++++++ 3 files changed, 71 insertions(+) diff --git a/src/Terminal.Gui.Editor/Editor.Indentation.cs b/src/Terminal.Gui.Editor/Editor.Indentation.cs index e46b604..3f19a1a 100644 --- a/src/Terminal.Gui.Editor/Editor.Indentation.cs +++ b/src/Terminal.Gui.Editor/Editor.Indentation.cs @@ -55,6 +55,11 @@ private bool Unindent () return true; } + if (HasMultipleCarets) + { + return MultiCaretUnindent (); + } + List lines = HasSelection && SelectionSpansMultipleLines () ? GetSelectedLines () : [_document.GetLineByOffset (CaretOffset)]; diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 33456f2..c8a6746 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -565,6 +565,53 @@ private bool MultiCaretInsertTab () return true; } + /// + /// Removes one indentation unit from every distinct line that hosts a caret, in a single + /// undo scope. Lines are deduped (two carets on one line unindent it 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 ()) + { + DocumentLine line = _document!.GetLineByOffset (caret.Offset); + + 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 void ClearAdditionalCaretSelections () { foreach (CaretInfo caret in _additionalCarets) diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index 511ced0..be50d5d 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -465,6 +465,25 @@ public async Task Tab_Twice_Inserts_Consistently_At_All_Vertical_Carets_With_Spa 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 Primary_Caret_Is_Visible_After_Exiting_MultiCaret () { From 0845d40b67c9f1d1f5729387d7b64c9823bf0d63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 04:51:38 +0000 Subject: [PATCH 08/14] fix: correct multi-caret shift-tab and reverse vertical expansion Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/ea1f9ebe-4830-4aee-b1f7-be12719a852c Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.Indentation.cs | 17 ++- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 128 ++++++++++++++++-- .../EditorTests.cs | 45 ++++++ 3 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.Indentation.cs b/src/Terminal.Gui.Editor/Editor.Indentation.cs index 3f19a1a..926be3a 100644 --- a/src/Terminal.Gui.Editor/Editor.Indentation.cs +++ b/src/Terminal.Gui.Editor/Editor.Indentation.cs @@ -145,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; @@ -158,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); @@ -180,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.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index c8a6746..b1f8dd9 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -1,5 +1,6 @@ using System.Drawing; using Terminal.Gui.Document; +using Terminal.Gui.Editor.Rendering; namespace Terminal.Gui.Editor; @@ -13,6 +14,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 @@ -36,6 +38,8 @@ public void ToggleCaretAt (int offset) return; } + _verticalCaretKeyboardDirection = 0; + offset = Math.Clamp (offset, 0, _document.TextLength); if (offset == CaretOffset) @@ -102,6 +106,8 @@ private void NormalizeAdditionalCarets () { if (_additionalCarets.Count == 0) { + _verticalCaretKeyboardDirection = 0; + return; } @@ -121,6 +127,11 @@ private void NormalizeAdditionalCarets () if (removed) { + if (_additionalCarets.Count == 0) + { + _verticalCaretKeyboardDirection = 0; + } + SetNeedsDraw (); } } @@ -139,6 +150,19 @@ private void NormalizeAdditionalCarets () 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). @@ -159,6 +183,7 @@ private void NormalizeAdditionalCarets () if (TryGetVerticalOffset (reference, delta, _virtualCaretColumn, out var target)) { AddAdditionalCaretAt (target); + _verticalCaretKeyboardDirection = delta; } return true; @@ -179,6 +204,7 @@ private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow } var primaryOffset = MousePositionToOffset (new Point (viewColumn, anchorViewRow)); + _verticalCaretKeyboardDirection = 0; ClearSelection (); ClearAdditionalCarets (); @@ -206,6 +232,7 @@ private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow public void ClearAdditionalCarets () { var had = _additionalCarets.Count > 0; + _verticalCaretKeyboardDirection = 0; if (had) { @@ -234,7 +261,7 @@ public void ClearAdditionalCarets () /// private List GetAllCaretsDescending () { - List result = [new CaretEditInfo { Offset = CaretOffset, IsPrimary = true }]; + List result = [new () { Offset = CaretOffset, IsPrimary = true }]; foreach (CaretInfo caret in _additionalCarets) { @@ -574,23 +601,19 @@ private bool MultiCaretInsertTab () /// private bool MultiCaretUnindent () { - HashSet seenLineOffsets = []; + HashSet seenRemovalOffsets = []; List<(int offset, int length)> removals = []; foreach (CaretEditInfo caret in GetAllCaretsDescending ()) { - DocumentLine line = _document!.GetLineByOffset (caret.Offset); - - if (!seenLineOffsets.Add (line.Offset)) + if (!TryGetUnindentRemovalAt (caret.Offset, out (int offset, int length) removal)) { continue; } - ISegment segment = TextUtilities.GetSingleIndentationSegment (_document, line.Offset, IndentationSize); - - if (segment.Length > 0) + if (seenRemovalOffsets.Add (removal.offset)) { - removals.Add ((segment.Offset, segment.Length)); + removals.Add (removal); } } @@ -612,6 +635,93 @@ private bool MultiCaretUnindent () return true; } + private bool TryGetUnindentRemovalAt (int caretOffset, out (int offset, int length) removal) + { + removal = default; + + if (_document is null || caretOffset == 0) + { + return false; + } + + DocumentLine line = _document.GetLineByOffset (caretOffset); + CellVisualLine visualLine = GetOrBuildDefaultVisualLine (line); + var logicalColumn = caretOffset - line.Offset; + var visualColumn = visualLine.GetVisualColumn (logicalColumn); + + if (visualColumn <= 0) + { + return false; + } + + var previousTabStop = visualColumn - 1; + previousTabStop -= previousTabStop % IndentationSize; + + var removalStartOffset = line.Offset + visualLine.GetRelativeOffset (previousTabStop); + + if (removalStartOffset >= caretOffset) + { + return false; + } + + var removalLength = caretOffset - removalStartOffset; + var segmentText = _document.GetText (removalStartOffset, removalLength); + + if (segmentText.Any (static c => c != ' ' && c != '\t')) + { + return false; + } + + removal = (removalStartOffset, removalLength); + + return true; + } + + private bool TryRemoveEdgeCaret (int direction) + { + var candidateIndex = -1; + var candidateOffset = direction < 0 ? int.MaxValue : int.MinValue; + + for (var i = 0; i < _additionalCarets.Count; i++) + { + if (_additionalCarets[i].CaretAnchor is not { IsDeleted: false } anchor) + { + continue; + } + + if (direction < 0) + { + if (anchor.Offset >= candidateOffset) + { + continue; + } + + candidateOffset = anchor.Offset; + candidateIndex = i; + + continue; + } + + if (anchor.Offset <= candidateOffset) + { + continue; + } + + 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/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index be50d5d..56c1281 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -484,6 +484,51 @@ public async Task ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step () Assert.Equal ("\tab\n\tab\n\tab", fx.Top.Editor.Document?.Text); } + [Fact] + public async Task ShiftTab_After_Tab_Removes_Previous_Tab_Stop_At_Each_Caret () + { + await using AppFixture fx = new (() => new ("ab Editor\nab Editor")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ConvertTabsToSpaces = true; + fx.Top.Editor.CaretOffset = 4; + + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); + fx.Injector.InjectKey (Key.Tab, Direct); + fx.Injector.InjectKey (Key.Tab.WithShift, Direct); + + Assert.Equal ("ab Editor\nab Editor", 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 () { From ef5a4fa0f603af3be2f6d0bc6cf0964082a91a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 04:54:26 +0000 Subject: [PATCH 09/14] fix: resolve review follow-up and command binding syntax Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/ea1f9ebe-4830-4aee-b1f7-be12719a852c Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.Commands.cs | 2 +- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 548f99c..453c05e 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -54,7 +54,7 @@ public partial class Editor // no editor-specific fallback chord. macOS uses the same chord pending real-terminal // validation (specs/decisions.md DEC-006). [InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), - [InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt) + [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), diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index b1f8dd9..45c7f50 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -261,7 +261,7 @@ public void ClearAdditionalCarets () /// private List GetAllCaretsDescending () { - List result = [new () { Offset = CaretOffset, IsPrimary = true }]; + List result = [new CaretEditInfo { Offset = CaretOffset, IsPrimary = true }]; foreach (CaretInfo caret in _additionalCarets) { From ca12b79c81ba19baf7fd887956c3e33ef08915c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 04:57:10 +0000 Subject: [PATCH 10/14] fix: keep shift-tab line unindent behavior and retain reverse collapse Agent-Logs-Url: https://github.com/gui-cs/Editor/sessions/ea1f9ebe-4830-4aee-b1f7-be12719a852c Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 75 +++---------------- .../EditorTests.cs | 15 ---- 2 files changed, 12 insertions(+), 78 deletions(-) diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 45c7f50..53eb27e 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -1,6 +1,5 @@ using System.Drawing; using Terminal.Gui.Document; -using Terminal.Gui.Editor.Rendering; namespace Terminal.Gui.Editor; @@ -601,19 +600,23 @@ private bool MultiCaretInsertTab () /// private bool MultiCaretUnindent () { - HashSet seenRemovalOffsets = []; + HashSet seenLineOffsets = []; List<(int offset, int length)> removals = []; foreach (CaretEditInfo caret in GetAllCaretsDescending ()) { - if (!TryGetUnindentRemovalAt (caret.Offset, out (int offset, int length) removal)) + DocumentLine line = _document!.GetLineByOffset (caret.Offset); + + if (!seenLineOffsets.Add (line.Offset)) { continue; } - if (seenRemovalOffsets.Add (removal.offset)) + ISegment segment = TextUtilities.GetSingleIndentationSegment (_document, line.Offset, IndentationSize); + + if (segment.Length > 0) { - removals.Add (removal); + removals.Add ((segment.Offset, segment.Length)); } } @@ -635,52 +638,13 @@ private bool MultiCaretUnindent () return true; } - private bool TryGetUnindentRemovalAt (int caretOffset, out (int offset, int length) removal) - { - removal = default; - - if (_document is null || caretOffset == 0) - { - return false; - } - - DocumentLine line = _document.GetLineByOffset (caretOffset); - CellVisualLine visualLine = GetOrBuildDefaultVisualLine (line); - var logicalColumn = caretOffset - line.Offset; - var visualColumn = visualLine.GetVisualColumn (logicalColumn); - - if (visualColumn <= 0) - { - return false; - } - - var previousTabStop = visualColumn - 1; - previousTabStop -= previousTabStop % IndentationSize; - - var removalStartOffset = line.Offset + visualLine.GetRelativeOffset (previousTabStop); - - if (removalStartOffset >= caretOffset) - { - return false; - } - - var removalLength = caretOffset - removalStartOffset; - var segmentText = _document.GetText (removalStartOffset, removalLength); - - if (segmentText.Any (static c => c != ' ' && c != '\t')) - { - return false; - } - - removal = (removalStartOffset, removalLength); - - 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++) { @@ -689,26 +653,11 @@ private bool TryRemoveEdgeCaret (int direction) continue; } - if (direction < 0) + if (isBetter (anchor.Offset, candidateOffset)) { - if (anchor.Offset >= candidateOffset) - { - continue; - } - candidateOffset = anchor.Offset; candidateIndex = i; - - continue; } - - if (anchor.Offset <= candidateOffset) - { - continue; - } - - candidateOffset = anchor.Offset; - candidateIndex = i; } if (candidateIndex < 0) diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index 56c1281..f107037 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -484,21 +484,6 @@ public async Task ShiftTab_Unindents_At_All_Carets_In_One_Undo_Step () Assert.Equal ("\tab\n\tab\n\tab", fx.Top.Editor.Document?.Text); } - [Fact] - public async Task ShiftTab_After_Tab_Removes_Previous_Tab_Stop_At_Each_Caret () - { - await using AppFixture fx = new (() => new ("ab Editor\nab Editor")); - fx.Top.Editor.SetFocus (); - fx.Top.Editor.ConvertTabsToSpaces = true; - fx.Top.Editor.CaretOffset = 4; - - fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithAlt, Direct); - fx.Injector.InjectKey (Key.Tab, Direct); - fx.Injector.InjectKey (Key.Tab.WithShift, Direct); - - Assert.Equal ("ab Editor\nab Editor", fx.Top.Editor.Document?.Text); - } - [Fact] public async Task CtrlAltDown_Then_CtrlAltUp_Collapses_Last_Down_Selection () { From 9df67c0e0172a723b2aa9736aec2689ab123d85a Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 22:05:12 -0700 Subject: [PATCH 11/14] docs: wire TG#5318 into the bespoke-Command-id workaround tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer review (bespoke (Command)1001/1002 casts are a no-no; the correct fix is upstream in TG's Command enum), filed gui-cs/Terminal.Gui#5318 requesting proper Command.InsertCaretAbove / .InsertCaretBelow members. - Editor.Commands.cs: comment above the const ids now marks them an explicitly TEMPORARY workaround tracked by TG#5318, with the removal plan (replace with real enum members + drop the block once TG#5318 ships and $(TerminalGuiVersion) is bumped). "Bespoke command values are not an acceptable end state." - specs/decisions.md DEC-006: same, cross-linked to TG#5318. Comment/doc only — no code-behavior change. Pure Tests suite green (454/454) on the merged canonical state. Co-Authored-By: Claude Opus 4.7 (1M context) --- specs/decisions.md | 2 +- src/Terminal.Gui.Editor/Editor.Commands.cs | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/specs/decisions.md b/specs/decisions.md index 6d09f06..b96ce66 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -114,7 +114,7 @@ Decisions are recorded here when an open question from the plan is resolved. Eac 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. Upstream follow-up also noted: TG should reserve a documented view-local `Command` range so consumers don't pick magic ints (Constitution "This Is TG": workarounds require a great TG issue). +**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 (tracked):** the two commands are *temporarily* registered as `(Command) 1001/1002` casts because TG's `Command` enum (pinned `Terminal.Gui` package) has no multi-caret member and the configurable keybinding surface is keyed by `Command`. This is a sanctioned short-term workaround **only** because it is filed as a great TG issue per Constitution "This Is TG": [gui-cs/Terminal.Gui#5318](https://github.com/gui-cs/Terminal.Gui/issues/5318) requests proper `Command.InsertCaretAbove` / `.InsertCaretBelow` members. When TG#5318 ships and `$(TerminalGuiVersion)` is bumped, the casts must be replaced with the real enum members and the workaround block in `Editor.Commands.cs` removed. Bespoke command values are **not** an acceptable end state. **Date**: 2026-05-16 (mouse-modifier amendment same day, after Windows Terminal validation) diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 453c05e..6f53ff6 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -20,11 +20,15 @@ public partial class Editor /// Process-wide static. Do not mutate from parallel tests — see Terminal.Gui's same convention /// on . /// - // TG's Command enum (consumed via the pinned Terminal.Gui package) has no - // vertical-multi-caret slot. We register Editor-local Command ids and bind them through the - // same configurable DefaultKeyBindings path as every other Editor binding — there is no - // inline if-chain in OnKeyDownNotHandled. Upstream follow-up (TG should reserve a view-local - // Command range) is recorded in specs/decisions.md. + // TEMPORARY WORKAROUND — tracked by gui-cs/Terminal.Gui#5318. + // TG's Command enum (consumed via the pinned Terminal.Gui package) has no multi-caret + // member, and the configurable keybinding surface is keyed by Command, so there is no + // sanctioned way to register these without a Command value. Casting magic ints is a hack + // (config serializes the key as a bare number; collision risk if TG renumbers). The correct + // fix is upstream: add Command.InsertCaretAbove / .InsertCaretBelow to TG. Once TG#5318 + // ships and Directory.Build.props bumps $(TerminalGuiVersion), replace these casts with the + // real enum members and delete this block. Bespoke command values are not an acceptable end + // state. See specs/decisions.md DEC-006. private const Command InsertCaretAbove = (Command)1001; private const Command InsertCaretBelow = (Command)1002; From 29200ed1bcec573a61499962c12f2b930806c965 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 23:07:31 -0700 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20drop=20(Command)1001/1002=20workar?= =?UTF-8?q?ound=20=E2=80=94=20use=20real=20TG=20enum=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TG#5318 shipped Command.InsertCaretAbove / .InsertCaretBelow (TG PR #5319, in Terminal.Gui 2.1.1-develop.98). Remove the sanctioned short-term magic-int workaround now that the upstream fix is available: - Directory.Build.props: pin $(TerminalGuiVersion) 2.1.1-develop.59 → 2.1.1-develop.98 (the develop build containing #5319). - Editor.Commands.cs: delete the TEMPORARY WORKAROUND comment block and the (Command)1001/1002 const casts; bind/AddCommand against the real Command.InsertCaretAbove / Command.InsertCaretBelow members. - specs/decisions.md DEC-006: mark the Command-enum debt RESOLVED (2026-05-17), cross-link TG#5319 and the parked design issue TG#5320. Keybindings now round-trip through config by readable name ("InsertCaretAbove") instead of the bare number "1001", satisfying the gui-cs/Editor side of TG#5318's acceptance criterion. Verified on 2.1.1-develop.98: restore + build clean (0 warnings), Tests 454/454, IntegrationTests 207/207. `dotnet format --verify-no-changes` clean. (jb cleanupcode hit the known local profile-discovery flakiness the Stop hook documents; no style drift — the edit is mechanical and dotnet format is the deterministic gate.) Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- specs/decisions.md | 6 +++--- src/Terminal.Gui.Editor/Editor.Commands.cs | 20 ++++---------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 2101c31..5253cb4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,7 +27,7 @@ LICENSE - 2.1.1-develop.59 + 2.1.1-develop.98 diff --git a/specs/decisions.md b/specs/decisions.md index b96ce66..b8021db 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -110,13 +110,13 @@ Decisions are recorded here when an open question from the plan is resolved. Eac ### 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`). Because TG's `Command` enum (consumed via the pinned `Terminal.Gui` package) has no vertical-multi-caret slot, the two commands are registered as Editor-local `Command` ids (`(Command) 1001` / `1002`) via `AddCommand` and bound through the same configurable path — not an inline `if` in `OnKeyDownNotHandled`. +**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 (tracked):** the two commands are *temporarily* registered as `(Command) 1001/1002` casts because TG's `Command` enum (pinned `Terminal.Gui` package) has no multi-caret member and the configurable keybinding surface is keyed by `Command`. This is a sanctioned short-term workaround **only** because it is filed as a great TG issue per Constitution "This Is TG": [gui-cs/Terminal.Gui#5318](https://github.com/gui-cs/Terminal.Gui/issues/5318) requests proper `Command.InsertCaretAbove` / `.InsertCaretBelow` members. When TG#5318 ships and `$(TerminalGuiVersion)` is bumped, the casts must be replaced with the real enum members and the workaround block in `Editor.Commands.cs` removed. Bespoke command values are **not** an acceptable end state. +**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) +**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`) --- diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 6f53ff6..732a34f 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -20,18 +20,6 @@ public partial class Editor /// Process-wide static. Do not mutate from parallel tests — see Terminal.Gui's same convention /// on . /// - // TEMPORARY WORKAROUND — tracked by gui-cs/Terminal.Gui#5318. - // TG's Command enum (consumed via the pinned Terminal.Gui package) has no multi-caret - // member, and the configurable keybinding surface is keyed by Command, so there is no - // sanctioned way to register these without a Command value. Casting magic ints is a hack - // (config serializes the key as a bare number; collision risk if TG renumbers). The correct - // fix is upstream: add Command.InsertCaretAbove / .InsertCaretBelow to TG. Once TG#5318 - // ships and Directory.Build.props bumps $(TerminalGuiVersion), replace these casts with the - // real enum members and delete this block. Bespoke command values are not an acceptable end - // state. See specs/decisions.md DEC-006. - private const Command InsertCaretAbove = (Command)1001; - private const Command InsertCaretBelow = (Command)1002; - [ConfigurationProperty (Scope = typeof (SettingsScope))] public new static Dictionary? DefaultKeyBindings { get; set; } = new () { @@ -57,8 +45,8 @@ public partial class Editor // 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). - [InsertCaretAbove] = Bind.All (Key.CursorUp.WithCtrl.WithAlt), - [InsertCaretBelow] = Bind.All (Key.CursorDown.WithCtrl.WithAlt), + [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), @@ -228,8 +216,8 @@ private void CreateCommandsAndBindings () AddCommand (Command.FindPrevious, FindPreviousCommand); // Vertical multi-caret: add a caret one line above / below the block at the sticky column. - AddCommand (InsertCaretAbove, () => AddCaretVertically (-1)); - AddCommand (InsertCaretBelow, () => AddCaretVertically (1)); + AddCommand (Command.InsertCaretAbove, () => AddCaretVertically (-1)); + AddCommand (Command.InsertCaretBelow, () => AddCaretVertically (1)); // Word navigation and kill AddCommand (Command.WordLeft, () => { From a6fb52cee9c73b6d41a7b3d6f151dc177835449c Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 23:25:15 -0700 Subject: [PATCH 13/14] fix: render additional carets reverse-video + blink (not underline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Underline rendered poorly and inconsistently across terminals for the multi-caret indicator. Switch the MultiCaretRenderer caret attribute to TextStyle.Blink | TextStyle.Reverse — far more legible and reliably supported — and bring everything that described the old style into line: - MultiCaretRenderer.cs: rewrite the now-contradictory rationale comment and the XML-doc summary (was "underlined + blinking"). - EditorRenderingTests / EditorMouseTests: update the expected caret Attribute, the positive HasFlag assertion to Blink|Reverse, the negative assertions to "NOT Reverse", and rename MultiCaret_Renders_Underline_Blink_Attribute_On_Text → ..._Reverse_Blink_... . - Docs/Help/multi-caret.md, selection.md: "inverted-attribute cells" → "blinking, reverse-video cells"; also corrected the renderer interface name (IBackgroundRenderer → the actual IOverlayRenderer). Tests 454/454, IntegrationTests 207/207, dotnet format clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/Help/multi-caret.md | 2 +- Docs/Help/selection.md | 2 +- .../Rendering/MultiCaretRenderer.cs | 9 +++--- .../EditorMouseTests.cs | 4 +-- .../EditorRenderingTests.cs | 28 +++++++++---------- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index 69a9973..f890c30 100644 --- a/Docs/Help/multi-caret.md +++ b/Docs/Help/multi-caret.md @@ -29,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 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/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 a152778..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); diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index 4f6a19a..c220791 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -376,7 +376,7 @@ public async Task UseThemeBackground_Defaults_To_True () } [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. @@ -388,9 +388,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); @@ -428,9 +428,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); @@ -469,18 +469,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] @@ -504,7 +504,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]; From 64acb0132339127ab5229499f2519f7a79519748 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 16 May 2026 23:47:11 -0700 Subject: [PATCH 14/14] fix: multi-caret Tab/Shift+Tab block-indents multi-line selections (Codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InsertTab()/Unindent() short-circuited to the multi-caret path the moment HasMultipleCarets 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) on Tab, and on Shift+Tab only the caret-hosting lines unindented (the block was ignored). Fix in Editor.MultiCaret.cs: classify each caret (primary uses the editor selection; additional uses its own anchor) via the new TryGetCaretSelectionRange / RangeSpansMultipleLines / LinesInRange helpers. A multi-line selection block-indents / block-unindents every line it touches; a single-line selection is type-over-replaced; a point caret gets a tab / line-unindent. All edits dedupe block lines and apply strictly high-offset-first in one RunUpdate scope (one undo step). A multi-line selection is never replaced/deleted by a tab. - specs/vertical-multi-caret/spec.md: amended the "Tab at every caret" requirement (was under-specified — "replacement, if a per-caret selection is active"), added the "Tab with a multi-line selection plus an extra caret" scenario, recorded the Resolved Decision. - New EditorMultiCaretIndentTests: Tab and Shift+Tab with a multi-line primary selection + a point caret; assert block-indent (not delete) and single-step undo. These failed before the fix. Tests 460/460; IntegrationTests 207/207; dotnet format clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- specs/vertical-multi-caret/spec.md | 7 +- src/Terminal.Gui.Editor/Editor.MultiCaret.cs | 155 +++++++++++++----- .../EditorMultiCaretIndentTests.cs | 65 ++++++++ 3 files changed, 186 insertions(+), 41 deletions(-) create mode 100644 tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 5d1f2fa..14a33c7 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -89,6 +89,10 @@ Symmetric to the previous scenario. **Given** carets at lines `n`, `n-1`, …, ` (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.) @@ -109,7 +113,7 @@ Named requirements — every label is also a search anchor used by tests and cod - **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`: every caret gets its own insertion (or replacement, if a per-caret selection is active), the whole operation is one `RunUpdate` scope, and one undo step reverses all of them. +- **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; @@ -231,6 +235,7 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher 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 diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index 6f181b0..b7534a4 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -540,48 +540,116 @@ private void MultiCaretInsert (string text) } /// - /// Inserts a tab (or its space expansion) at every caret in one undo scope. Each caret's - /// insertion text is computed from that caret's own visual column via - /// , so every caret advances to the same next tab stop - /// and the column stays aligned across repeated presses. Carets are processed in - /// descending offset order so an earlier (higher) edit doesn't shift a not-yet-processed - /// offset. Caller () guarantees a non-null, writable document and - /// . + /// 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 () { - using (_document!.RunUpdate ()) + HashSet indentLineOffsets = []; + List<(int offset, int length, string text)> edits = []; + + foreach (CaretEditInfo caret in GetAllCaretsDescending ()) { - foreach (CaretEditInfo caret in GetAllCaretsDescending ()) + if (TryGetCaretSelectionRange (caret, out int selStart, out int selEnd)) { - if (caret.IsPrimary) + if (RangeSpansMultipleLines (selStart, selEnd)) { - if (HasSelection) + foreach (DocumentLine line in LinesInRange (selStart, selEnd)) { - ReplaceSelection (GetTabInsertionText (SelectionStart)); - } - else - { - _document.Insert (CaretOffset, GetTabInsertionText (CaretOffset)); + indentLineOffsets.Add (line.Offset); } continue; } - if (caret.SelectionAnchor is { IsDeleted: false } selAnchor) - { - var selStart = Math.Min (selAnchor.Offset, caret.Offset); - var selEnd = Math.Max (selAnchor.Offset, caret.Offset); + edits.Add ((selStart, selEnd - selStart, GetTabInsertionText (selStart))); - if (selEnd > selStart) - { - _document.Replace (selStart, selEnd - selStart, GetTabInsertionText (selStart)); + continue; + } - continue; - } - } + edits.Add ((caret.Offset, 0, GetTabInsertionText (caret.Offset))); + } - _document.Insert (caret.Offset, 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); + } } } @@ -591,11 +659,13 @@ private bool MultiCaretInsertTab () } /// - /// Removes one indentation unit from every distinct line that hosts a caret, in a single - /// undo scope. Lines are deduped (two carets on one line unindent it 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 . + /// 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 () { @@ -604,18 +674,23 @@ private bool MultiCaretUnindent () foreach (CaretEditInfo caret in GetAllCaretsDescending ()) { - DocumentLine line = _document!.GetLineByOffset (caret.Offset); + List lines = TryGetCaretSelectionRange (caret, out int selStart, out int selEnd) && RangeSpansMultipleLines (selStart, selEnd) + ? LinesInRange (selStart, selEnd) + : [_document!.GetLineByOffset (caret.Offset)]; - if (!seenLineOffsets.Add (line.Offset)) + foreach (DocumentLine line in lines) { - continue; - } + if (!seenLineOffsets.Add (line.Offset)) + { + continue; + } - ISegment segment = TextUtilities.GetSingleIndentationSegment (_document, line.Offset, IndentationSize); + ISegment segment = TextUtilities.GetSingleIndentationSegment (_document!, line.Offset, IndentationSize); - if (segment.Length > 0) - { - removals.Add ((segment.Offset, segment.Length)); + if (segment.Length > 0) + { + removals.Add ((segment.Offset, segment.Length)); + } } } 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); + } +}