diff --git a/Docs/Help/multi-caret.md b/Docs/Help/multi-caret.md index f890c30..c7159d0 100644 --- a/Docs/Help/multi-caret.md +++ b/Docs/Help/multi-caret.md @@ -9,7 +9,8 @@ Place multiple carets in the document and type, delete, or press Enter at all of | **Ctrl+Click** | Toggle an additional caret at the clicked position. Click an existing additional caret to remove it. | | **Ctrl+Alt+↑** | Add a caret on the line above the topmost caret, at the sticky visual column (VS Code parity). | | **Ctrl+Alt+↓** | Add a caret on the line below the bottommost caret, at the sticky visual column (VS Code parity). | -| **Alt + drag** | Build a vertical column of carets from the press row through the drag row at the press column (carets only). | +| **Alt + drag** | Build a vertical column from the press row through the drag row. Zero horizontal extent creates carets; horizontal extent creates one selection per row. | +| **Ctrl+Shift+Alt+↑/↓/←/→** | Create or extend a keyboard column selection. `PgUp` / `PgDn` extend by one viewport. | | **Escape** | Collapse back to the primary caret (clears all additional carets). | `Ctrl+Alt+↑/↓` track a *sticky visual column*: a short or tab-indented intervening line doesn't lose the column — the next long-enough line restores it. The chords are configurable per platform via `Editor.DefaultKeyBindings`; a terminal or window manager that grabs `Ctrl+Alt+arrow` is handled by remapping in config, not a separate built-in chord. @@ -29,7 +30,7 @@ All edits are wrapped in a single `Document.RunUpdate` scope, so **Undo (Ctrl+Z) ## Visual feedback -Additional carets are rendered as blinking, reverse-video cells by the `MultiCaretRenderer` (an `IOverlayRenderer`). The status bar in `ted` shows the total caret count when in multi-caret mode (e.g. "Ln 4, Col 1 (3 carets)"). +Additional carets are rendered as blinking, reverse-video cells by the `MultiCaretRenderer` (an `IOverlayRenderer`). Additional-caret selections render with the same active selection role as the primary selection. The status bar in `ted` shows the total caret count when in multi-caret mode (e.g. "Ln 4, Col 1 (3 carets)"). ## Programmatic API @@ -47,10 +48,19 @@ editor.ClearAdditionalCarets (); Additional carets are backed by `TextAnchor` instances, so they track insertions and deletions elsewhere in the document automatically (same mechanism as the primary caret). +## VS Code parity and intentional deviations + +Column selection matches VS Code behavior: typing over a ranged column replaces each row's selection in one undo step; short rows clamp to the real line end without writing padding; dragging left of the anchor reverses the selection direction; `Esc` or a plain click collapses back to the primary caret. + +Intentional deviations: + +- **D1 — mouse modifier**: VS Code starts column selection with `Shift+Alt`+drag; this editor uses **`Alt`+drag** because Windows Terminal and xterm-family terminals reserve `Shift`+drag for terminal-side forced/block selection while an app has mouse mode enabled. Configurable mouse modifiers are tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). +- **D2 — add caret at click**: VS Code uses `Alt`+Click; this editor keeps the existing **`Ctrl`+Click** binding. `Alt` is the column-drag modifier, so an `Alt`+Click alias would need drag-threshold disambiguation. +- **D3 — keyboard column-select**: not a deviation. `Ctrl+Shift+Alt+Arrow` and `Ctrl+Shift+Alt+PgUp/PgDn` match VS Code behavior when the terminal delivers the chord (TG's Kitty keyboard protocol support makes this available on capable terminals). +- **D4 — sticky Column Selection Mode**: VS Code's modal toggle is out of scope; this editor implements the drag and keyboard gestures, not a persistent mode with menu/status UI. +- **D5 — multi-cursor paste distribution**: VS Code can distribute N clipboard lines over N cursors. This is deferred as a separate follow-up; typing and one-line paste replacement are covered here. + ## Limitations (current alpha) -- Selection is not yet per-caret; only the primary caret carries a selection. - Find/Replace operates on the primary caret only. -- `Alt`+drag produces a column of *carets*, not a column *selection*. To replace a column, drag to place the carets, then `Shift+→`/`←` to grow each caret's selection, then type. Per-row column selection during the drag is the planned follow-up. -- The column-drag modifier is `Alt`, not VS Code's `Shift+Alt`: terminals reserve `Shift`+drag for their own forced/block text selection while an app reads the mouse, so `Shift+Alt`+drag never reaches the editor. Making the mouse modifier user-configurable (to opt back into `Shift+Alt`) is tracked by [gui-cs/Terminal.Gui#4888](https://github.com/gui-cs/Terminal.Gui/issues/4888). - Toggling Word Wrap while a vertical block is live dismisses the block. diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index c1c78af..5ca9cf7 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -142,11 +142,8 @@ public TedApp (bool readOnly = false, string? configPath = null) } }; ShowTabsCheckBox.Value = Editor.ShowTabs ? CheckState.Checked : CheckState.UnChecked; - PreviewCheckBox.ValueChanged += (_, e) => - { - ToggleMarkdownPreview (); - _previewMarkdownMenuItem.Title = ToggleTitle (e.NewValue == CheckState.Checked, "_Preview Markdown"); - }; + OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) { MouseHighlightStates = MouseState.None }; + LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None }; LoadStatusSpinner = new SpinnerView { Style = new SpinnerStyle.Aesthetic (), @@ -154,21 +151,25 @@ public TedApp (bool readOnly = false, string? configPath = null) AutoSpin = false, Visible = false }; + LoadSpinnerShortcut = new Shortcut + { + CommandView = LoadStatusSpinner, + Title = string.Empty, + MouseHighlightStates = MouseState.None + }; + PreviewCheckBox.ValueChanged += (_, e) => + { + ToggleMarkdownPreview (); + _previewMarkdownMenuItem.Title = ToggleTitle (e.NewValue == CheckState.Checked, "_Preview Markdown"); + }; StatusBar statusBar = new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, - LoadSpinnerShortcut = new Shortcut - { - CommandView = LoadStatusSpinner, - Title = string.Empty, - MouseHighlightStates = MouseState.None - }, - OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) - { MouseHighlightStates = MouseState.None }, - LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) - { MouseHighlightStates = MouseState.None } + LoadSpinnerShortcut, + OverwriteShortcut, + LocShortcut ]) { AlignmentModes = AlignmentModes.IgnoreFirstOrLast diff --git a/specs/multi-caret/spec.md b/specs/multi-caret/spec.md index 8b3c047..a370ae3 100644 --- a/specs/multi-caret/spec.md +++ b/specs/multi-caret/spec.md @@ -55,7 +55,7 @@ Add multi-caret support to the Editor. Expose `IReadOnlyList AdditionalCare ## Out of Scope -- Column/block selection mode — the "multi-select" follow-up PR. **When built, it must also close the carried-forward selection-preservation gap:** multi-caret `Tab`/`Shift+Tab` block-indent must preserve the primary *and* per-caret selections (parity with the single-caret `IndentSelectedLines` path; today `ClearAdditionalCaretSelections ()` collapses them post-edit). See `specs/vertical-multi-caret/spec.md` § Out of Scope → *Column / box selection* for the full requirement. +- Sticky Column Selection Mode — VS Code's modal toggle where ordinary clicks/arrows keep column-select behavior until disabled. The drag and keyboard column-selection gestures are in scope for `specs/vertical-multi-caret/spec.md`; the persistent mode with menu/status UI is a separate follow-up. - Multi-caret find/replace ## Notes diff --git a/specs/overwrite-mode/spec.md b/specs/overwrite-mode/spec.md index 8e8465b..5b3b203 100644 --- a/specs/overwrite-mode/spec.md +++ b/specs/overwrite-mode/spec.md @@ -1,7 +1,7 @@ # Overwrite (Insert-Replace) Mode -**Status**: Implemented -**Issue**: [#146](https://github.com/gui-cs/Editor/issues/146) +**Status**: Implemented +**Issue**: [#146](https://github.com/gui-cs/Editor/issues/146) **Updated**: 2026-05-17 ## Summary diff --git a/specs/public-api.md b/specs/public-api.md index b00cf8d..eb42a2d 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -36,10 +36,9 @@ public class Editor : View public void ToggleCaretAt (int offset); // multi-caret (Ctrl+Click toggle) public void ClearAdditionalCarets (); // multi-caret (Esc collapse) // vertical-multi-caret adds NO new public API: Ctrl+Alt+CursorUp / Ctrl+Alt+CursorDown - // create a vertically-aligned column of carets at the sticky visual column, and - // Alt + LeftButton drag builds a column of carets (carets only). Both reuse the - // existing AdditionalCaretOffsets / HasMultipleCarets / ClearAdditionalCarets surface and - // are bound through the configurable Editor.DefaultKeyBindings ([ConfigurationProperty]). + // create a vertically-aligned column of carets at the sticky visual column; Alt + LeftButton + // drag and Ctrl+Shift+Alt+Arrow/Page create column selections. All reuse the existing + // AdditionalCaretOffsets / HasMultipleCarets / ClearAdditionalCarets surface. // --- Display --- public bool ShowLineNumbers { get; set; } // exists @@ -157,5 +156,6 @@ public readonly record struct TextDocumentProgress ( | 2026-05-11 | ReadOnly property landed on Editor | read-only | | 2026-05-12 | `ISearchStrategy?` `SearchStrategy { get; set; }` landed on Editor; string-based FindNext/FindPrevious/ReplaceNext/ReplaceAll overloads retained as convenience wrappers | find-and-replace | | 2026-05-16 | Vertical multi-caret keybindings (`Ctrl+Alt+CursorUp/Down`, `Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret | +| 2026-05-17 | Column selection during `Alt+Drag` and `Ctrl+Shift+Alt+Arrow/Page` added without new public Editor API | vertical-multi-caret | | 2026-05-17 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io | | 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time | diff --git a/specs/textview-parity-gap/spec.md b/specs/textview-parity-gap/spec.md index 9d4b0ab..f80c0de 100644 --- a/specs/textview-parity-gap/spec.md +++ b/specs/textview-parity-gap/spec.md @@ -58,7 +58,7 @@ exposed today. ### 2. Overwrite / insert-replace mode → [#146](https://github.com/gui-cs/Editor/issues/146) -**TextView**: `Used` flag + `Command.ToggleOverwrite` (Insert key), `Command.EnableOverwrite`, +**TextView**: overwrite-mode state + `Command.ToggleOverwrite` (Insert key), `Command.EnableOverwrite`, `Command.DisableOverwrite`; a distinct caret rendering for overwrite; typing replaces the rune under the caret instead of inserting. diff --git a/specs/vertical-multi-caret/spec.md b/specs/vertical-multi-caret/spec.md index 9dca444..7b17631 100644 --- a/specs/vertical-multi-caret/spec.md +++ b/specs/vertical-multi-caret/spec.md @@ -26,7 +26,8 @@ Extend the existing multi-caret machinery (`AdditionalCaretOffsets`, `HasMultipleCarets`, `ToggleCaretAt`, `ClearAdditionalCarets`) with two ergonomic ways to create a **vertically-aligned column of carets** anchored on the same visual column across consecutive lines: 1. **Keyboard**: `Ctrl+Alt+Up` / `Ctrl+Alt+Down` extends the caret block one line above the topmost / below the bottommost caret, landing on the same sticky virtual column. Matches VS Code's `editor.action.insertCursorAbove` / `Below`. -2. **Mouse**: `Alt + LeftButton drag` creates a column of carets spanning the anchor row → active row at the press column. (VS Code uses `Shift+Alt` for its column-select drag; this editor uses `Alt` because a TUI runs inside a terminal that reserves `Shift`+drag — see the Amendment above. This spec ships carets-only first; selection-per-row is a follow-up — see Out of Scope.) +2. **Mouse**: `Alt + LeftButton drag` creates a column spanning the anchor row → active row. Zero horizontal extent creates carets; horizontal extent creates one selection per row. (VS Code uses `Shift+Alt` for its column-select drag; this editor uses `Alt` because a TUI runs inside a terminal that reserves `Shift`+drag — see the Amendment above.) +3. **Keyboard column-select**: `Ctrl+Shift+Alt+Arrow` and `Ctrl+Shift+Alt+PgUp/PgDn` create or extend a column selection, matching VS Code behavior when the terminal delivers the chord. Both flows produce a primary caret plus zero or more additional carets, all sharing the multi-caret edit pipeline (single `Document.OpenUpdateScope ()` → one undo step, R5). @@ -174,12 +175,11 @@ Also add a tightly-scoped unit test (`Terminal.Gui.Editor.Tests`) for the visual ## Out of Scope -- **Column / box selection** (i.e. `Alt+Drag` producing a *selection per row* the way VS Code's `Shift+Alt+drag` does, rather than carets only). This is the natural follow-up — **the "multi-select" PR**. Per-caret selection in the existing multi-caret pipeline already works; column-extend during drag needs a new code path that extends each caret's selection anchor as the drag widens/narrows. Ship the carets-only flow first. - - **Required of that PR — selection-preservation parity (carried forward from PR #133):** multi-caret `Tab` / `Shift+Tab` block-indent currently calls `ClearAdditionalCaretSelections ()` and collapses the **primary** selection after the edit, whereas the single-caret `IndentSelectedLines` path preserves it. That is cosmetic only while carets are point-only; once this PR makes per-row *selections* a first-class column primitive, surviving a block-indent becomes load-bearing. The multi-select PR **must** restore parity: multi-caret `Tab` / `Shift+Tab` preserves the primary selection **and** every per-caret selection across the block-indent (mirror `IndentSelectedLines`' `SetSelectionRangePreservingDirection` behavior per caret), with a regression test alongside `EditorMultiCaretIndentTests`. - **Find/replace across multi-caret selections**. Already excluded by multi-caret spec. - **Reflowing the vertical block under WordWrap toggling**. If the user toggles `WordWrap` while a vertical block is live, the block is dismissed. This spec does not introduce reflow semantics. - **A ted UI menu / dialog**. The keybindings ship discoverable via help text only. - **Changing the existing `Ctrl+Click` add-caret-at-click binding to `Alt+Click`** to match VS Code / VS. Tracked as the *Alt+Click alias* open decision below. +- **Sticky Column Selection Mode** (VS Code's modal toggle with menu/status indicator) and multi-cursor paste distribution are separate follow-ups. ## Reference behavior from PR #125 @@ -197,7 +197,8 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher |---|---|---|---| | Add caret above / below | `Ctrl+Alt+Up` / `Ctrl+Alt+Down` (Win/Linux); `Cmd+Opt+Up/Down` (Mac) | `Alt+Shift+Up` / `Alt+Shift+Down` (`Edit.InsertCaretAbove` / `Below`) | **Match VS Code**: `Ctrl+Alt+Up` / `Ctrl+Alt+Down` | | Add caret at click | `Alt+Click` | `Alt+Click` (multi-cursor placement) | `Ctrl+Click` (existing — see multi-caret spec and *Alt+Click alias* open decision below) | -| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **`Alt + drag` produces *carets per row, no selection***. Modifier is `Alt` (not `Shift+Alt`) because the terminal reserves `Shift`+drag (Amendment / DEC-006 / TG#4888); column-select semantics out of scope this iteration | +| Column / box selection by drag | `Shift+Alt + drag` produces a *selection per row* (column select) | `Shift+Alt + drag` produces a column / box selection | **Match behavior**: ranged `Alt + drag` produces a selection per row; zero-width `Alt + drag` produces carets. Modifier differs (D1) because the terminal reserves `Shift`+drag. | +| Keyboard column-select | `Ctrl+Shift+Alt+Arrow` / `PgUp` / `PgDn` | Supported | **Match behavior**: `Ctrl+Shift+Alt+Arrow` / `PgUp` / `PgDn` create/extend a column selection when the terminal delivers the chord (D3 withdrawn). | | Esc collapses to primary caret | Yes | Yes | Match (*Esc dismisses the vertical block* scenario) | | Sticky desired column through short lines | Yes | Yes | Match (*Sticky virtual column survives a short intervening line* scenario / *Sticky column through short lines* requirement) | | Tab inserts at every caret, single undo | Yes | Yes | Match (*Tab inserts at every caret* / *Tab keeps the column aligned* requirements) | @@ -207,11 +208,13 @@ Cross-walk of every user-facing behavior against the two reference editors. Wher ### Intentional divergences (and why) -1. **`Alt+drag` produces carets only, not a column selection — and uses `Alt`, not VS Code's `Shift+Alt`.** Two divergences here: (a) VS Code's `Shift+Alt+drag` creates a *selection per row* (typing replaces a column of text); this spec ships only the carets-per-row variant first — the full column-select is the natural follow-up; per-caret selection already works in the pipeline, but extend-during-drag is a new code path. (b) The modifier is `Alt`, not `Shift+Alt`, because the terminal eats `Shift`+drag (see the Amendment; configurable parity tracked by gui-cs/Terminal.Gui#4888). **User-visible consequence**: to "replace" a column, the user must `Alt`-drag, then `Shift+Right`/`Left` to grow each caret's selection, then type. Document this in ted help. - -2. **`Ctrl+Click` vs `Alt+Click` for "add caret at click".** Existing multi-caret on `develop` uses `Ctrl+Click`. VS Code and VS use `Alt+Click`. Changing the existing binding is out of scope for this spec — flagged as the *Alt+Click alias* open decision below. - -3. **WordWrap toggle dismisses the block.** Both reference editors preserve carets through a wrap toggle. We dismiss because the carets' wrap-row positions are no longer well-defined under the new wrap state and we don't want to silently snap them to surprising offsets. Could revisit post-beta. +| # | Behavior | VS Code | This editor | Why | Category | +|---|---|---|---|---|---| +| **D1** | Start column/box select (mouse) | `Shift+Alt`+drag | **`Alt`+drag** | Windows Terminal / xterm-family terminals reserve `Shift`+drag for terminal-side forced/block selection while an app has mouse mode on, so `Shift+Alt`+drag never reaches the editor; `Alt`+drag is forwarded. | Terminal incompatibility — DEC-006; configurable parity tracked by gui-cs/Terminal.Gui#4888 | +| **D2** | Add caret at click | `Alt`+Click | **`Ctrl`+Click** (existing) | Pre-existing Editor binding; `Alt` is now the column-drag modifier, so a future `Alt`+Click alias needs drag-threshold disambiguation. | Terminal incompatibility knock-on + binding history | +| ~~**D3**~~ | Keyboard column-select (`Ctrl+Shift+Alt+Arrow` / `PgUp`/`PgDn`) | Supported | **WITHDRAWN — not a deviation; in scope, matches VS Code** | TG's Kitty keyboard protocol support can deliver the four-modifier chord. Legacy terminals that do not negotiate it share the same environmental limitation as other advanced chords. | Not a deviation | +| **D4** | Column Selection Mode (sticky toggle, menu/status UI) | Supported | **Out of scope** | A persistent modal input state is larger than the drag/keyboard gestures and not required for column-edit parity. | Scope | +| **D5** | Multi-cursor clipboard paste distribution | `"spread"` by default | **Deferred** | Clipboard distribution is orthogonal to creating/rendering column selections; typing and one-line paste replacement are covered here. | Scope — separate follow-up | ### Behaviors we match deliberately @@ -244,4 +247,4 @@ These were open in earlier drafts of this spec and are now resolved. - This spec rebuilds the user-facing functionality of PR #125 from the tests it shipped; it is not a "fix-forward" of that branch. The intended workflow is: open a new branch from `develop`, port the PR #125 tests verbatim (renaming as needed, **including swapping the key combos to the VS Code chords**), confirm they fail, then write the implementation against the requirements above. - The visual-line cache fix (see *Cache invalidation on offset shift* requirement and the *Tab twice* scenario) is the most subtle defect the test set exposes. Treat it as the riskiest piece — write the unit test in `Terminal.Gui.Editor.Tests` before touching the cache. - R5 (single `Document.OpenUpdateScope ()` per multi-caret edit) is non-negotiable. Tab at N carets is one undo step, not N. -- R8: append two lines to `specs/public-api.md` describing the new keybindings. No new public Editor API is introduced by this spec — the existing `AdditionalCaretOffsets` / `HasMultipleCarets` / `ToggleCaretAt` / `ClearAdditionalCarets` surface is sufficient. \ No newline at end of file +- R8: append two lines to `specs/public-api.md` describing the new keybindings. No new public Editor API is introduced by this spec — the existing `AdditionalCaretOffsets` / `HasMultipleCarets` / `ToggleCaretAt` / `ClearAdditionalCarets` surface is sufficient. diff --git a/src/Terminal.Gui.Editor/Editor.Designable.cs b/src/Terminal.Gui.Editor/Editor.Designable.cs index 0b61719..823478b 100644 --- a/src/Terminal.Gui.Editor/Editor.Designable.cs +++ b/src/Terminal.Gui.Editor/Editor.Designable.cs @@ -4,6 +4,10 @@ namespace Terminal.Gui.Editor; +/// +/// Design-time support for so designers can preview representative +/// content with highlighting and line numbers enabled. +/// public partial class Editor : IDesignable { /// diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 80bda8f..c88a27a 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -197,6 +197,8 @@ private CellVisualLine BuildWrappedSegmentVisualLine ( } } + ApplyAdditionalCaretSelections (visualLine, selected); + return visualLine; } @@ -382,12 +384,44 @@ private void UpdateCursor () } Point screen = ViewportToScreen (new Point (col, row)); - CursorStyle style = OverwriteMode ? CursorStyle.SteadyBlock : - Cursor.Style == CursorStyle.Hidden ? CursorStyle.Default : Cursor.Style; + CursorStyle style; + + if (OverwriteMode) + { + style = CursorStyle.SteadyBlock; + } + else if (Cursor.Style == CursorStyle.Hidden) + { + style = CursorStyle.Default; + } + else + { + style = Cursor.Style; + } + Cursor = Cursor with { Position = screen, Style = style }; } + + private void ApplyAdditionalCaretSelections (CellVisualLine visualLine, Attribute selected) + { + if (!HasAdditionalCaretSelections ()) + { + return; + } + + foreach ((int start, int end) selection in AdditionalCaretSelectionRanges ()) + { + foreach (CellVisualLineElement element in visualLine.Elements) + { + if (element.DocumentOffset < selection.end && element.DocumentEndOffset > selection.start) + { + element.Attribute = selected; + } + } + } + } } diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 28e6afd..3ecccdf 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -38,6 +38,11 @@ protected override bool OnKeyDown (Key key) /// protected override bool OnKeyDownNotHandled (Key key) { + if (TryHandleKeyboardColumnSelect (key)) + { + return true; + } + if (key == Key.Esc && HasMultipleCarets) { ClearAdditionalCarets (); @@ -83,4 +88,60 @@ protected override bool OnKeyDownNotHandled (Key key) return true; } + + private bool TryHandleKeyboardColumnSelect (Key key) + { + if (!key.IsCtrl || !key.IsShift || !key.IsAlt) + { + return false; + } + + Key baseKey = key.NoCtrl.NoShift.NoAlt; + + if (baseKey == Key.CursorUp) + { + ColumnSelectByKeyboard (-1, 0); + + return true; + } + + if (baseKey == Key.CursorDown) + { + ColumnSelectByKeyboard (1, 0); + + return true; + } + + if (baseKey == Key.CursorLeft) + { + ColumnSelectByKeyboard (0, -1); + + return true; + } + + if (baseKey == Key.CursorRight) + { + ColumnSelectByKeyboard (0, 1); + + return true; + } + + var pageDelta = Math.Max (1, Viewport.Height); + + if (baseKey == Key.PageUp) + { + ColumnSelectByKeyboard (-pageDelta, 0); + + return true; + } + + if (baseKey == Key.PageDown) + { + ColumnSelectByKeyboard (pageDelta, 0); + + return true; + } + + return false; + } } diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index 0e9eeb1..3ca361c 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -71,7 +71,7 @@ protected override bool OnMouseEvent (Mouse mouse) switch (_dragMode) { case DragMode.ColumnCarets: - SetVerticalCaretsFromViewRows (_columnDragAnchor.Y, pos.Y, _columnDragAnchor.X); + SetVerticalCaretsFromViewRows (_columnDragAnchor.Y, pos.Y, _columnDragAnchor.X, pos.X); return true; @@ -104,7 +104,7 @@ protected override bool OnMouseEvent (Mouse mouse) { _dragMode = DragMode.ColumnCarets; _columnDragAnchor = pos; - SetVerticalCaretsFromViewRows (pos.Y, pos.Y, pos.X); + SetVerticalCaretsFromViewRows (pos.Y, pos.Y, pos.X, pos.X); } else if (ctrl) { diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs index f1df656..ce213b0 100644 --- a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -13,6 +13,10 @@ namespace Terminal.Gui.Editor; public partial class Editor { private readonly List _additionalCarets = []; + private int? _keyboardColumnSelectionAnchorOffset; + private int _keyboardColumnSelectionActiveColumn; + private int _keyboardColumnSelectionActiveRowDelta; + private int _keyboardColumnSelectionAnchorColumn; private int _verticalCaretKeyboardDirection; /// Gets the offsets of all additional carets (excludes the primary). @@ -69,6 +73,11 @@ public void ToggleCaretAt (int offset) /// rather than normalized after the fact. /// private void AddAdditionalCaretAt (int offset) + { + AddAdditionalCaretAt (offset, null); + } + + private void AddAdditionalCaretAt (int offset, int? selectionAnchorOffset) { if (_document is null) { @@ -90,7 +99,22 @@ private void AddAdditionalCaretAt (int offset) } } - _additionalCarets.Add (new CaretInfo { CaretAnchor = CreateCaretAnchor (offset) }); + TextAnchor? selectionAnchor = null; + + if (selectionAnchorOffset is { } anchorOffset) + { + anchorOffset = Math.Clamp (anchorOffset, 0, _document.TextLength); + + if (anchorOffset != offset) + { + selectionAnchor = CreateSelectionAnchor (anchorOffset); + selectionAnchor.MovementType = anchorOffset <= offset + ? AnchorMovementType.AfterInsertion + : AnchorMovementType.BeforeInsertion; + } + } + + _additionalCarets.Add (new CaretInfo { CaretAnchor = CreateCaretAnchor (offset), SelectionAnchor = selectionAnchor }); SetNeedsDraw (); } @@ -195,21 +219,22 @@ private void NormalizeAdditionalCarets () /// scratch on every drag event so the end state is identical to a single press at the /// final position. /// - private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int viewColumn) + private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow, int anchorViewColumn, + int activeViewColumn) { if (_document is null) { return; } - var primaryOffset = MousePositionToOffset (new Point (viewColumn, anchorViewRow)); + var primaryAnchorOffset = MousePositionToOffset (new Point (anchorViewColumn, anchorViewRow)); + var primaryOffset = MousePositionToOffset (new Point (activeViewColumn, anchorViewRow)); _verticalCaretKeyboardDirection = 0; ClearSelection (); ClearAdditionalCarets (); - // The CaretOffset setter resets the sticky column from the anchor row's primary. - CaretOffset = primaryOffset; + SetPrimaryColumnSelection (primaryAnchorOffset, primaryOffset); var top = Math.Min (anchorViewRow, activeViewRow); var bottom = Math.Max (anchorViewRow, activeViewRow); @@ -221,17 +246,117 @@ private void SetVerticalCaretsFromViewRows (int anchorViewRow, int activeViewRow continue; } - AddAdditionalCaretAt (MousePositionToOffset (new Point (viewColumn, row))); + var rowAnchorOffset = MousePositionToOffset (new Point (anchorViewColumn, row)); + var rowActiveOffset = MousePositionToOffset (new Point (activeViewColumn, row)); + + AddAdditionalCaretAt (rowActiveOffset, rowAnchorOffset); + } + + SetNeedsDraw (); + } + + private bool ColumnSelectByKeyboard (int rowDelta, int columnDelta) + { + if (_document is null) + { + return true; + } + + if (_keyboardColumnSelectionAnchorOffset is null) + { + _keyboardColumnSelectionAnchorOffset = CaretOffset; + _keyboardColumnSelectionAnchorColumn = GetVisualColumnForOffset (CaretOffset); + _keyboardColumnSelectionActiveColumn = _keyboardColumnSelectionAnchorColumn; + _keyboardColumnSelectionActiveRowDelta = 0; + } + + var nextRowDelta = _keyboardColumnSelectionActiveRowDelta + rowDelta; + var nextActiveColumn = Math.Max (0, _keyboardColumnSelectionActiveColumn + columnDelta); + var anchorOffset = _keyboardColumnSelectionAnchorOffset.Value; + + if (!TryGetVerticalOffset (anchorOffset, nextRowDelta, nextActiveColumn, out _)) + { + return true; + } + + _keyboardColumnSelectionActiveRowDelta = nextRowDelta; + _keyboardColumnSelectionActiveColumn = nextActiveColumn; + SetVerticalSelectionsFromAnchorOffset ( + anchorOffset, + _keyboardColumnSelectionActiveRowDelta, + _keyboardColumnSelectionAnchorColumn, + _keyboardColumnSelectionActiveColumn); + _keyboardColumnSelectionAnchorOffset = anchorOffset; + + return true; + } + + private void SetVerticalSelectionsFromAnchorOffset ( + int anchorOffset, + int activeRowDelta, + int anchorColumn, + int activeColumn) + { + if (_document is null) + { + return; + } + + _verticalCaretKeyboardDirection = 0; + ClearSelection (); + ClearAdditionalCarets (); + + if (!TryGetVerticalOffset (anchorOffset, 0, anchorColumn, out var primaryAnchorOffset) + || !TryGetVerticalOffset (anchorOffset, 0, activeColumn, out var primaryActiveOffset)) + { + return; + } + + SetPrimaryColumnSelection (primaryAnchorOffset, primaryActiveOffset); + + var firstDelta = Math.Min (0, activeRowDelta); + var lastDelta = Math.Max (0, activeRowDelta); + + for (var delta = firstDelta; delta <= lastDelta; delta++) + { + if (delta == 0) + { + continue; + } + + if (TryGetVerticalOffset (anchorOffset, delta, anchorColumn, out var rowAnchorOffset) + && TryGetVerticalOffset (anchorOffset, delta, activeColumn, out var rowActiveOffset)) + { + AddAdditionalCaretAt (rowActiveOffset, rowAnchorOffset); + } } SetNeedsDraw (); } + private void SetPrimaryColumnSelection (int anchorOffset, int activeOffset) + { + CaretOffset = activeOffset; + + if (anchorOffset == activeOffset) + { + ClearSelection (); + + return; + } + + _selectionAnchor = CreateSelectionAnchor (anchorOffset); + RefreshSelectionAnchorMovement (); + SelectionChanged?.Invoke (this, EventArgs.Empty); + SetNeedsDraw (); + } + /// Removes all additional carets, leaving only the primary. public void ClearAdditionalCarets () { var had = _additionalCarets.Count > 0; _verticalCaretKeyboardDirection = 0; + _keyboardColumnSelectionAnchorOffset = null; if (had) { @@ -604,10 +729,10 @@ private List LinesInRange (int start, int end) } /// - /// Tab at every caret, one undo scope. Per caret: a selection that spans multiple lines - /// block-indents every line it touches (never replace/delete it — that was the - /// Codex P1 data-loss bug); a single-line selection is type-over-replaced with a tab; a - /// caret with no selection gets a tab inserted at its own visual column via + /// Tab at every caret, one undo scope. Per caret: any selection + /// block-indents every line it touches and preserves the selection (never + /// replace/delete it — that was the Codex P1 data-loss bug and the column-selection + /// follow-up); a caret with no selection gets a tab inserted at its own visual column via /// so the column stays aligned across repeated /// presses. Block-indent lines are deduped; every edit is applied strictly /// high-offset-first so an earlier edit doesn't shift a not-yet-applied offset. Caller @@ -624,18 +749,11 @@ private bool MultiCaretInsertTab () { if (TryGetCaretSelectionRange (caret, out int selStart, out int selEnd)) { - if (RangeSpansMultipleLines (selStart, selEnd)) + foreach (DocumentLine line in LinesInRange (selStart, selEnd)) { - foreach (DocumentLine line in LinesInRange (selStart, selEnd)) - { - indentLineOffsets.Add (line.Offset); - } - - continue; + indentLineOffsets.Add (line.Offset); } - edits.Add ((selStart, selEnd - selStart, GetTabInsertionText (selStart))); - continue; } @@ -664,8 +782,6 @@ private bool MultiCaretInsertTab () } } - ClearAdditionalCaretSelections (); - return true; } @@ -718,11 +834,44 @@ private bool MultiCaretUnindent () } } - ClearAdditionalCaretSelections (); - return true; } + internal bool HasAdditionalCaretSelections () + { + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is { IsDeleted: false } caretAnchor + && caret.SelectionAnchor is { IsDeleted: false } selectionAnchor + && caretAnchor.Offset != selectionAnchor.Offset) + { + return true; + } + } + + return false; + } + + internal IReadOnlyList<(int start, int end)> AdditionalCaretSelectionRanges () + { + List<(int start, int end)> ranges = []; + + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is not { IsDeleted: false } caretAnchor + || caret.SelectionAnchor is not { IsDeleted: false } selectionAnchor + || caretAnchor.Offset == selectionAnchor.Offset) + { + continue; + } + + ranges.Add ((Math.Min (caretAnchor.Offset, selectionAnchor.Offset), + Math.Max (caretAnchor.Offset, selectionAnchor.Offset))); + } + + return ranges; + } + private bool TryRemoveEdgeCaret (int direction) { var candidateIndex = -1; diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 574e494..91b2bb2 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -1151,7 +1151,7 @@ private CellVisualLine GetOrBuildDrawVisualLine ( private bool IsDrawCacheEligible (IReadOnlyList? segments, int selStart, int selEnd) { - return segments is null && selStart >= selEnd && LineTransformers.Count == 0; + return segments is null && selStart >= selEnd && !HasAdditionalCaretSelections () && LineTransformers.Count == 0; } private CellVisualLine BuildVisualLine ( @@ -1173,7 +1173,14 @@ private CellVisualLine BuildVisualLine ( selectionEnd, LineTransformers); - return _visualLineBuilder.Build (line, context); + CellVisualLine visualLine = _visualLineBuilder.Build (line, context); + + if (selectedAttribute.HasValue) + { + ApplyAdditionalCaretSelections (visualLine, selectedAttribute.Value); + } + + return visualLine; } private void EnsureCaretVisible () diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs index a4c9c48..48dff1a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMouseTests.cs @@ -497,6 +497,50 @@ public async Task AltDrag_Adds_Vertically_Aligned_Carets () Assert.False (fx.Top.Editor.HasSelection); } + [Fact] + public async Task AltDrag_With_Horizontal_Extent_Replaces_Column_On_Type () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (3, 2)); + + Assert.True (fx.Top.Editor.HasSelection); + Assert.True (fx.Top.Editor.HasMultipleCarets); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + + [Fact] + public async Task AltDrag_Reversed_Column_Replaces_Leftward_Range_On_Type () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (3, 0), new (1, 2)); + + Assert.True (fx.Top.Editor.HasSelection); + Assert.Equal (1, fx.Top.Editor.CaretOffset); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + + [Fact] + public async Task AltDrag_Column_Selection_Clamps_Short_Lines_Without_Padding () + { + await using AppFixture fx = new (() => new ("abcd\na\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (4, 2)); + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("ax\nax\nax", fx.Top.Editor.Document!.Text); + } + [Fact] public async Task CtrlClick_After_VerticalCarets_Uses_Click_Position_When_PositionReport_Arrives_First () { @@ -540,4 +584,34 @@ private static void InjectClick (AppFixture fx, Point pos) new () { ScreenPosition = pos, Flags = MouseFlags.LeftButtonPressed, Timestamp = BaseTime }, Direct); } + + private static void InjectAltDrag (AppFixture fx, Point press, Point drag) + { + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = press, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = BaseTime.AddMilliseconds (40) + }, + Direct); + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs index 22bfd44..858db0a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorMultiCaretIndentTests.cs @@ -1,5 +1,6 @@ // Claude - claude-opus-4-7 +using System.Drawing; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; using Terminal.Gui.Testing; @@ -19,6 +20,7 @@ namespace Terminal.Gui.Editor.IntegrationTests; public class EditorMultiCaretIndentTests { private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + private static readonly DateTime BaseTime = new (2025, 1, 1, 12, 0, 0); [Fact] public async Task Tab_MultilineSelection_Plus_PointCaret_BlockIndents_Does_Not_Delete () @@ -62,4 +64,41 @@ public async Task ShiftTab_MultilineSelection_Plus_PointCaret_BlockUnindents () fx.Top.Editor.Document.UndoStack.Undo (); Assert.Equal ("\talpha\n\tbeta\n\tgamma\n\tdelta", fx.Top.Editor.Document.Text); } + + [Fact] + public async Task Tab_ColumnSelection_Preserves_PerCaret_Selections_After_BlockIndent () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + InjectAltDrag (fx, new (1, 0), new (3, 2)); + fx.Injector.InjectKey (Key.Tab, Direct); + + Assert.Equal ("\tabcd\n\tabcd\n\tabcd", fx.Top.Editor.Document!.Text); + + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("\taxd\n\taxd\n\taxd", fx.Top.Editor.Document.Text); + } + + private static void InjectAltDrag (AppFixture fx, Point press, Point drag) + { + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = press, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = drag, + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index c26d94c..3b735b5 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -26,6 +26,7 @@ namespace Terminal.Gui.Editor.IntegrationTests; public class EditorRenderingTests { private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + private static readonly DateTime BaseTime = new (2025, 1, 1, 12, 0, 0); [Fact] public async Task Unselected_Text_Uses_Normal_Role_Not_Editable () @@ -224,6 +225,40 @@ public async Task Selection_Overrides_Syntax_Highlighting () Assert.Equal (active, cell.Attribute); } + [Fact] + public async Task Additional_Caret_Selections_Render_With_Active_Role () + { + await using AppFixture fx = new (() => new EditorTestHost ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (1, 0), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.Alt, + Timestamp = BaseTime + }, + Direct); + + fx.Injector.InjectMouse ( + new () + { + ScreenPosition = new (3, 2), + Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport | MouseFlags.Alt, + Timestamp = BaseTime.AddMilliseconds (20) + }, + Direct); + + fx.Render (); + + Attribute active = fx.Top.Editor.GetAttributeForRole (VisualRole.Active); + + Assert.Equal ("b", fx.Driver.Contents![1, 1].Grapheme); + Assert.Equal (active, fx.Driver.Contents[1, 1].Attribute); + Assert.Equal ("c", fx.Driver.Contents[2, 2].Grapheme); + Assert.Equal (active, fx.Driver.Contents[2, 2].Attribute); + } + [Fact] public async Task Tab_Then_Emoji_Renders_At_Correct_Cells () { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs index 233a054..aaffc8a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTests.cs @@ -374,6 +374,22 @@ public async Task CtrlAltDown_Preserves_Column_With_Tabs () Assert.Contains ("a\tbcde\na\tbcde\n".Length + 3, fx.Top.Editor.AdditionalCaretOffsets); } + [Fact] + public async Task CtrlShiftAlt_ColumnSelect_Extends_Right_And_Down_Then_Replaces_Column () + { + await using AppFixture fx = new (() => new ("abcd\nabcd\nabcd")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 1; + + fx.Injector.InjectKey (Key.CursorRight.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorRight.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.CursorDown.WithCtrl.WithShift.WithAlt, Direct); + fx.Injector.InjectKey (Key.X, Direct); + + Assert.Equal ("axd\naxd\naxd", fx.Top.Editor.Document!.Text); + } + [Fact] public async Task Esc_Dismisses_MultiCaret_And_Down_Can_Move_Past_Previous_Block () { @@ -532,5 +548,6 @@ public async Task Primary_Caret_Is_Visible_After_Exiting_MultiCaret () // The primary caret is the terminal cursor. After dismissing the block it must still be // drawn (visible, not the hidden default cursor) and positioned on the primary offset. Assert.True (fx.Top.Editor.Cursor.IsVisible); + Assert.Equal (CursorStyle.Default, fx.Top.Editor.Cursor.Style); } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index b9d2747..94182df 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -756,11 +756,11 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes () ImmutableList expected = ThemeManager.GetThemeNames (); Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme."); - List actual = fx.Top.ThemeDropDown.Source!.ToList () + List actualThemeNames = fx.Top.ThemeDropDown.Source!.ToList () .Cast () .ToList (); - Assert.Equal (expected, actual); + Assert.Equal (expected, actualThemeNames); } [Fact]