Skip to content

vertical-multi-caret follow-up: column selection during drag + render additional-caret selections #139

@tig

Description

@tig

Summary

Follow-up to #124 / PR #133 (vertical-multi-caret). PR #133 ships carets-only: Alt+drag and Ctrl+Alt+Up/Down create a vertical column of bare carets. This issue tracks the deferred column / box selection (the natural next step) and a pre-existing rendering gap it depends on.

Parity intent: the end-user behavior of column/box selection must match VS Code. Only input gestures may differ, and only where a terminal makes VS Code's chord physically impossible. Every behavioral deviation is enumerated, categorized, and justified in the Behavioral parity section below — nothing deviates silently.

The spec explicitly scoped this out and deferred it (specs/vertical-multi-caret/spec.mdOut of Scope and Intentional Divergences #1; specs/decisions.md DEC-006). The architectural review on PR #133 confirmed deferral is additive — nothing in PR #133 needs undoing.

(a) Column-select during drag

Alt+drag should optionally produce a selection per row (VS Code's Shift+Alt+drag semantics — typing replaces the column), not just carets.

The per-caret edit pipeline already supports this — every MultiCaret* op (MultiCaretInsert/DeleteLeft/DeleteRight/NewLine/InsertTab/Unindent) already branches on caret.SelectionAnchor_document.Replace(...). So "type to replace a column" is free once selection anchors are set. The work is placement + rendering. Concrete seams (all additive):

  • Editor.MultiCaret.cs AddAdditionalCaretAt(int offset) → add an overload AddAdditionalCaretAt(int offset, int selectionAnchorOffset) that also sets CaretInfo.SelectionAnchor (the field already exists). Preserves the PR Implements vertical multi-caret support #133 DoD invariant ("only AddAdditionalCaretAt/NormalizeAdditionalCarets mutate _additionalCarets") via overload, not violation.
  • Editor.MultiCaret.cs SetVerticalCaretsFromViewRows(anchorViewRow, activeViewRow, viewColumn) → thread an active column through (anchor column = press X, already stored in _columnDragAnchor; active column = current drag X). Per row, set a selection from anchor-col offset to active-col offset instead of a bare caret. The rebuild-from-scratch-per-drag-event design already in place is exactly right for live widen/narrow.
  • Editor.cs TryGetVerticalOffset(...) → call it twice per target row (anchor column and active column) to form each row's selection range. Reused as-is. Short rows clamp to line end (see parity D-rules); the visual columns stay sticky/virtual so re-widening re-extends them — no padding is written to the document.
  • Editor.Mouse.cs — same DragMode.ColumnCarets state; _columnDragAnchor already captures the full press point. No new drag state. (Decide interaction with the open Alt+Click add-caret alias spec decision — see deviation D2.)
  • Keyboard column-select path — register Editor-local commands for column-select up/down/left/right/page and bind them to Ctrl+Shift+Alt+Arrow / PgUp/PgDn (mac Cmd+…) through the configurable Editor.DefaultKeyBindings, exactly as Ctrl+Alt+↑/↓ is bound. The handler reuses the same TryGetVerticalOffset + sticky-virtual-column machinery as the drag path (anchor column fixed at the primary's column when the gesture starts; active column moves with each keypress), so it shares the row-selection builder with the mouse path. Command-id note: like InsertCaretAbove/Below, these have no Command enum member — register via the same sanctioned path PR Implements vertical multi-caret support #133 used post-TG#5318 (real members if added, else tracked by Need mechanism for view-defined Commands - full routing/config/bubbling/bridging/localization parity Terminal.Gui#5320); do not cast magic ints.
  • NormalizeAdditionalCarets / ClearAdditionalCarets / Esc — unchanged (offset-keyed dedupe still valid for distinct-line column rows; clearing a CaretInfo already clears its SelectionAnchor).

Modifier stays Alt (not Shift+Alt) for the terminal-compat reasons in DEC-006; configurable mouse modifiers tracked upstream by gui-cs/Terminal.Gui#4888.

(b) Render additional-caret selections (pre-existing gap)

Rendering/MultiCaretRenderer.cs paints additional caret cells (reverse-video + blink, post-PR #133) only. It does not paint additional carets' selection spans. Additional-caret SelectionAnchor is consumed by the edit pipeline but never drawn — so the multi-caret spec's FR-005 ("paints all carets and selections") is only partially true today, independent of vertical multi-caret. Any additional-caret selection (column-select, or future per-caret selection gestures) is invisible until this is fixed.

  • Add a per-caret selection IBackgroundRenderer (or extend the existing selection renderer to also iterate _additionalCarets whose SelectionAnchor is set), scoped per visual-line segment like MultiCaretRenderer already does for word-wrap.
  • This is a multi-caret completeness item; (a) cannot be user-visible without it.

(c) Carried-forward: selection-preservation parity for multi-caret Tab/Shift+Tab (PR #133 loose end)

Surfaced while fixing the Codex P1 in PR #133: multi-caret Tab / Shift+Tab block-indent calls ClearAdditionalCaretSelections () and collapses the primary selection after the edit, whereas the single-caret IndentSelectedLines path preserves it (via SetSelectionRangePreservingDirection). This is cosmetic only while additional carets are point-only — but (a) makes per-row selections a first-class column primitive, at which point a column selection vanishing the instant the user presses Tab is a real defect. It also depends on (b) (the selection must render to be observable). Hence it belongs to this PR.

Required: multi-caret Tab / Shift+Tab preserves the primary selection and every per-caret selection across the block-indent, mirroring IndentSelectedLines' SetSelectionRangePreservingDirection behavior per caret. Add a regression test alongside tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs. Tracked in spec: specs/vertical-multi-caret/spec.md § Out of Scope → Column / box selection (listed as a required deliverable of this PR) and specs/multi-caret/spec.md § Out of Scope.

Behavioral parity with VS Code (and explicit deviations)

Rule: any column/box-select end-user behavior not listed in the Deviations table below must behave exactly as VS Code does. If implementation forces a new deviation, it must be added to the table (with category + rationale) before merge — never shipped silently.

Must match VS Code (behavior, not keys)

  • Zero-width vs. ranged. A vertical drag with no horizontal extent ⇒ a bare caret per row (already shipped, PR Implements vertical multi-caret support #133). A drag with horizontal extent ⇒ one selection per row. (VS Code: identical.)
  • Typing replaces the column. Typing (or a 1-line paste) over a ranged column replaces each row's selection; over a zero-width column it inserts at each caret. Exactly one undo step for the whole column edit. (VS Code: identical.)
  • Bidirectional / reversed box. Dragging the active column to the left of the anchor selects leftward on every row (caret on the low side); dragging back across the anchor flips direction; widen/narrow tracks the pointer live. (VS Code: identical.)
  • Short lines clamp, never pad. On rows shorter than the box, the per-row selection/caret clamps to the line's real end. No padding or virtual spaces are written to the document. The anchor and active visual columns are sticky (reuse the vertical-caret sticky-virtual-column machinery) so widening later re-extends the short rows. (VS Code: identical — the box column is virtual; the buffer is untouched until the user types.)
  • Esc / plain click collapses to the primary caret / clears the block. (VS Code: Esc collapses multi-cursor — same effect.)
  • Ctrl+Alt+↑/↓ add-caret-above/below already matches VS Code's editor.action.insertCursorAbove/Below (shipped PR Implements vertical multi-caret support #133). No deviation.
  • Keyboard column-select. Ctrl+Shift+Alt+↑/↓/←/→ and Ctrl+Shift+Alt+PgUp/PgDn create/extend a column selection from the keyboard, matching VS Code's Cursor Column Select commands (mac: Cmd+Shift+Alt+…). In scope and must match VS Code — TG implements the Kitty keyboard protocol (progressive enhancement / CSI-u), which disambiguates and delivers these four-modifier chords to the app. On a terminal that does not negotiate the protocol the chord is simply unavailable and the binding is user-overridable — the same environmental bound that applies to every advanced chord (and to the chords PR Implements vertical multi-caret support #133 already ships); it is not a designed behavioral deviation.

Deviations — where & why (each MUST be documented in Docs/Help/multi-caret.md and the spec Comparison table)

# Behavior VS Code This editor Why Category
D1 Start column/box select (mouse) Shift+Alt+drag Alt+drag Windows Terminal / the xterm family reserve Shift+drag for the terminal's own forced/rectangular selection while an app has mouse mode on, so Shift+Alt+drag never reaches the app; Alt+drag is forwarded. End-user capability is identical; only the modifier differs. Terminal incompatibility — DEC-006; configurable parity tracked by gui-cs/Terminal.Gui#4888
D2 Add caret at click Alt+Click Ctrl+Click (existing) Pre-existing Editor binding; and Alt is now the column-drag modifier (D1), so any future Alt+Click alias must be disambiguated from Alt+drag by a movement threshold. Capability (click to add a caret) is preserved; only the modifier differs. Terminal incompatibility (knock-on of D1) + binding history; Alt+Click alias is an open spec decision
D3 Keyboard column-select (Ctrl+Shift+Alt+Arrow / PgUp/PgDn) Supported WITHDRAWN — not a deviation; in scope, matches VS Code Originally listed as terminal-incompat. Corrected: TG implements the Kitty keyboard protocol, so this chord is deliverable to the TUI. Keyboard column-select is now a Must match VS Code item (see above), not a deviation. Legacy terminals lacking the protocol: chord unavailable / user-rebindable — an environmental bound shared by all advanced chords (including those PR #133 ships), not a product deviation. ID kept (not renumbered) to preserve references. Not a deviation — reclassified after the Kitty-protocol correction
D4 Column Selection Mode (sticky toggle; ordinary click/arrows do column-select until turned off; Selection menu + status-bar indicator) Supported Out of scope A modal input state with menu/status-bar UI is far larger than the drag gesture and not required for column-edit parity. Scope — separate issue if wanted
D5 Multi-cursor clipboard paste distribution (editor.multiCursorPaste: N clipboard lines → N cursors one-per-cursor; else full at each) "spread" by default Deferred — separate follow-up, not blocking this PR Clipboard distribution is orthogonal to creating/rendering the column selection (this issue's focus). Typing/replace parity is in scope; paste-distribution is its own behavior and must be tracked so VS Code column-edit parity is eventually complete — not silently dropped. Scope — must be filed as its own tracked follow-up

Definition of done

  • Alt+drag column select: zero-width ⇒ caret per row, ranged ⇒ selection per row; typing / 1-line paste replaces each row; one undo step — behavior matches VS Code.
  • Bidirectional/reversed box: dragging left of, and back across, the anchor column matches VS Code (per-row direction follows the pointer; live widen/narrow).
  • Short rows clamp to line end with no document padding; anchor & active visual columns stay sticky so re-widening re-extends short rows (matches VS Code's virtual-column box).
  • Additional-caret selections render (fixes (b); regression test at the rendering boundary).
  • Multi-caret Tab/Shift+Tab preserves the primary and per-caret selections (fixes (c); parity with single-caret IndentSelectedLines; carried forward from PR Implements vertical multi-caret support #133) — regression test added.
  • Esc collapses to the primary caret; plain click clears (matches VS Code).
  • Keyboard column-select (Ctrl+Shift+Alt+Arrow / PgUp/PgDn, mac Cmd+…) creates/extends a column selection matching VS Code, bound via Editor.DefaultKeyBindings, delivered through TG's Kitty-keyboard-protocol support; commands registered via the post-TG#5318 sanctioned path (no magic-int casts). If split out for sizing it is a tracked follow-up, never a silent drop.
  • Each real deviation D1, D2, D4, D5 is documented in Docs/Help/multi-caret.md and the specs/vertical-multi-caret/spec.md "Comparison with VS Code" table; the D3 row records why keyboard column-select is not a deviation (Kitty protocol). No undocumented behavioral deviation ships.
  • D5 (multi-cursor paste distribution) is filed as its own tracked follow-up issue.
  • examples/ted demonstrates it end-to-end (R9 — keybindings discoverable, help text updated).
  • Failing-first integration tests; full suites green; dotnet format + jb cleanup clean.
  • Docs: specs/vertical-multi-caret/spec.md (move column-select out of Out of Scope; add D1–D5 to the Comparison/Deviations table), specs/multi-caret/spec.md, Docs/Help/multi-caret.md, specs/public-api.md if surface changes.

Refs: #124, PR #133, specs/vertical-multi-caret/spec.md, specs/multi-caret/spec.md, specs/decisions.md DEC-006, gui-cs/Terminal.Gui#4888.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions