diff --git a/README.md b/README.md index 36607ec..399cc8f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ For a user-facing editor built on this library, se [clet](https:/github.com/gui- # Status -**Alpha*, shipped 2026-05-12 of the `develop` rolling pre-release stream. Se [`specs/plan.md`](specs/plan.md) for the beta roadmap and remaining work (multi-caret is the headline item still in flight; `[Obsolete]` on TG `TextView` lands with the beta). +**Alpha**, shipped 2026-05-12 of the `develop` rolling pre-release stream. See [`specs/plan.md`](specs/plan.md) for the beta roadmap and remaining work (`[Obsolete]` on TG `TextView` lands with the beta). ## Inherited from Terminal.Gui @@ -54,6 +54,7 @@ For a user-facing editor built on this library, se [clet](https:/github.com/gui- - Clipboard: `Command.Cut`, `Command.Copy`, `Command.Paste`; selection-aware, single-step undo, aborts cut if the clipboard write fails. Uses TG's `IClipboard`, so cut/copy/paste interoperates with whatever the OS clipboard contains. - Undo / redo with sane granularity (`Command.Undo`, `Command.Redo`). Compound operations (Enter + auto-indent, replace-all, paste over selection) collapse into one undo step via `Document.RunUpdate ()`. - Read-only mode: `Editor.ReadOnly = true` blocks edits, undo/redo, and clipboard mutations while keeping navigation and selection live. +- Multi-caret editing: **Ctrl+Click** to place additional carets; type, Backspace, Delete, or Enter at all of them simultaneously. Escape collapses back to one caret. Every multi-caret operation is a single undo step. See [`examples/ted/docs/multi-caret.md`](examples/ted/docs/multi-caret.md) for details. ### Indentation & tabs diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 75d0ef3..4c1e414 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -373,7 +373,14 @@ private void UpdateLocShortcut () else { DocumentLine line = document.GetLineByOffset (Editor.CaretOffset); - LocShortcut.Title = FormatLoc (line.LineNumber, Editor.CaretOffset - line.Offset + 1); + var loc = FormatLoc (line.LineNumber, Editor.CaretOffset - line.Offset + 1); + + if (Editor.HasMultipleCarets) + { + loc += $" ({Editor.AdditionalCaretOffsets.Count + 1} carets)"; + } + + LocShortcut.Title = loc; } LocShortcut.SetNeedsDraw (); diff --git a/examples/ted/docs/multi-caret.md b/examples/ted/docs/multi-caret.md new file mode 100644 index 0000000..3ecd70d --- /dev/null +++ b/examples/ted/docs/multi-caret.md @@ -0,0 +1,49 @@ +# Multi-Caret Editing + +Place multiple carets in the document and type, delete, or press Enter at all of them simultaneously. Every multi-caret operation is a single undo step. + +## Adding and removing carets + +| Action | Effect | +|---|---| +| **Ctrl+Click** | Toggle an additional caret at the clicked position. Click an existing additional caret to remove it. | +| **Escape** | Collapse back to the primary caret (clears all additional carets). | + +The primary caret (the one controlled by normal navigation keys) is never removed by Ctrl+Click. + +## Editing with multiple carets + +Once two or more carets are active, the following operations apply at every caret position simultaneously: + +- **Typing** — inserts the character at each caret. +- **Enter** — inserts a newline and applies the active `IndentationStrategy` at each caret (same as single-caret auto-indent). +- **Backspace** — performs smart indentation-unit delete when the caret is inside leading whitespace; otherwise deletes one character left. +- **Delete** — removes one character to the right of each caret. + +All edits are wrapped in a single `Document.RunUpdate` scope, so **Undo (Ctrl+Z)** reverts the entire multi-caret operation in one step. + +## 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)"). + +## Programmatic API + +```csharp +// Add or toggle a caret at a document offset. +editor.ToggleCaretAt (offset); + +// Query state. +bool multi = editor.HasMultipleCarets; +IReadOnlyList offsets = editor.AdditionalCaretOffsets; + +// Collapse back to the primary caret. +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). + +## Limitations (current alpha) + +- 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. diff --git a/specs/public-api.md b/specs/public-api.md index a32cdc4..cc58068 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -23,6 +23,9 @@ public class Editor : View // --- Multi-caret --- public IReadOnlyList AdditionalCaretOffsets { get; } // multi-caret + public bool HasMultipleCarets { get; } // multi-caret + public void ToggleCaretAt (int offset); // multi-caret (Ctrl+Click toggle) + public void ClearAdditionalCarets (); // multi-caret (Esc collapse) // --- Display --- public bool ShowLineNumbers { get; set; } // exists @@ -38,6 +41,7 @@ public class Editor : View // --- Rendering pipeline (rendering-pipeline ✅) --- public IList LineTransformers { get; } // exists (codex merge) public IList BackgroundRenderers { get; } // exists (codex merge) + public IList OverlayRenderers { get; } // multi-caret (drawn after elements) // --- Syntax highlighting (syntax-colorizer ✅) --- public IHighlightingDefinition? HighlightingDefinition { get; set; } // exists (syntax-colorizer) @@ -87,6 +91,11 @@ public interface IBackgroundRenderer { void Draw (View host, CellVisualLine line, int row, Rectangle viewport); } + +public interface IOverlayRenderer +{ + void Draw (View host, CellVisualLine line, int row, Rectangle viewport); +} ``` ## Change Log diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 94cfe4a..78232a1 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -149,10 +149,10 @@ private void CreateCommandsAndBindings () return true; }); - // Editing — selection-aware - AddCommand (Command.NewLine, InsertNewLineWithAutoIndent); - AddCommand (Command.DeleteCharLeft, DeleteLeft); - AddCommand (Command.DeleteCharRight, DeleteRight); + // Editing — selection-aware (multi-caret aware) + AddCommand (Command.NewLine, MultiCaretNewLine); + AddCommand (Command.DeleteCharLeft, MultiCaretDeleteLeft); + AddCommand (Command.DeleteCharRight, MultiCaretDeleteRight); // History AddCommand (Command.Undo, () => diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 6dcc2fc..d6b6fd3 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -144,6 +144,11 @@ private void DrawWrappedLines (Rectangle viewport, Attribute normal, Attribute s element.Draw (this, 0, row, 0, viewport.Width); } + + foreach (IOverlayRenderer renderer in OverlayRenderers) + { + renderer.Draw (this, visualLine, row, viewport); + } } } @@ -359,6 +364,11 @@ private void DrawVisualLine ( element.Draw (this, 0, row, visibleStart, visibleEnd); } + + foreach (IOverlayRenderer renderer in OverlayRenderers) + { + renderer.Draw (this, visualLine, row, Viewport); + } } private void UpdateCursor () diff --git a/src/Terminal.Gui.Editor/Editor.Indentation.cs b/src/Terminal.Gui.Editor/Editor.Indentation.cs index 15fe0f4..87b8263 100644 --- a/src/Terminal.Gui.Editor/Editor.Indentation.cs +++ b/src/Terminal.Gui.Editor/Editor.Indentation.cs @@ -125,16 +125,25 @@ private void IndentSelectedLines () private bool TryDeleteIndentationLeft () { - if (_document is null || CaretOffset == 0) + return TryDeleteIndentationLeftAt (CaretOffset); + } + + /// + /// Attempts smart-indentation backspace at the given . If the offset + /// sits exactly at the end of leading whitespace and aligns to an indentation boundary, the last + /// complete indentation unit is removed. Returns if handled. + /// + private bool TryDeleteIndentationLeftAt (int offset) + { + if (_document is null || offset == 0) { return false; } - var caretOffset = CaretOffset; - DocumentLine line = _document.GetLineByOffset (caretOffset); + DocumentLine line = _document.GetLineByOffset (offset); ISegment leadingWhitespace = TextUtilities.GetLeadingWhitespace (_document, line); - if (leadingWhitespace.Length == 0 || caretOffset != leadingWhitespace.EndOffset) + if (leadingWhitespace.Length == 0 || offset != leadingWhitespace.EndOffset) { return false; } @@ -147,7 +156,7 @@ private bool TryDeleteIndentationLeft () { ISegment segment = TextUtilities.GetSingleIndentationSegment (_document, scanOffset, IndentationSize); - if (segment.Length == 0 || scanOffset + segment.Length > caretOffset) + if (segment.Length == 0 || scanOffset + segment.Length > offset) { break; } @@ -156,7 +165,7 @@ private bool TryDeleteIndentationLeft () scanOffset += segment.Length; } - if (scanOffset != caretOffset || lastSegment.length == 0) + if (scanOffset != offset || lastSegment.length == 0) { return false; } diff --git a/src/Terminal.Gui.Editor/Editor.Keyboard.cs b/src/Terminal.Gui.Editor/Editor.Keyboard.cs index 4d70e4e..63f89ad 100644 --- a/src/Terminal.Gui.Editor/Editor.Keyboard.cs +++ b/src/Terminal.Gui.Editor/Editor.Keyboard.cs @@ -13,6 +13,13 @@ public partial class Editor /// protected override bool OnKeyDownNotHandled (Key key) { + if (key == Key.Esc && HasMultipleCarets) + { + ClearAdditionalCarets (); + + return true; + } + if (key == Key.Tab) { return InsertTab (); @@ -68,6 +75,13 @@ protected override bool OnKeyDownNotHandled (Key key) return true; } + if (HasMultipleCarets) + { + MultiCaretInsert (rune.ToString ()); + + return true; + } + if (HasSelection) { ReplaceSelection (rune.ToString ()); diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index af220c0..6f96888 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -59,13 +59,19 @@ protected override bool OnMouseEvent (Mouse mouse) } var offset = MousePositionToOffset (pos); + var ctrl = mouse.Flags.HasFlag (MouseFlags.Ctrl); - if (shift) + if (ctrl) + { + ToggleCaretAt (offset); + } + else if (shift) { ExtendCaretTo (offset); } else { + ClearAdditionalCarets (); ClearSelection (); CaretOffset = offset; } diff --git a/src/Terminal.Gui.Editor/Editor.MultiCaret.cs b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs new file mode 100644 index 0000000..87fd694 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.MultiCaret.cs @@ -0,0 +1,383 @@ +using Terminal.Gui.Document; + +namespace Terminal.Gui.Editor; + +/// +/// Multi-caret state and operations. Additional carets are backed by +/// instances (same as the primary caret) so they track document edits automatically. Each +/// additional caret carries its own selection anchor. Editing commands iterate all carets in +/// descending offset order inside a single scope so that +/// undo collapses the whole operation into one step. +/// +public partial class Editor +{ + private readonly List _additionalCarets = []; + + /// Gets the offsets of all additional carets (excludes the primary). + public IReadOnlyList AdditionalCaretOffsets => _additionalCarets + .Where (c => c.CaretAnchor is { IsDeleted: false }) + .Select (c => c.CaretAnchor!.Offset) + .ToList (); + + /// True when there are additional carets beyond the primary. + 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). + /// + public void ToggleCaretAt (int offset) + { + if (_document is null) + { + return; + } + + 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) + { + _additionalCarets.RemoveAt (i); + SetNeedsDraw (); + + return; + } + } + + // Add a new additional caret. + TextAnchor caretAnchor = CreateCaretAnchor (offset); + _additionalCarets.Add (new CaretInfo { CaretAnchor = caretAnchor }); + SetNeedsDraw (); + } + + /// Removes all additional carets, leaving only the primary. + public void ClearAdditionalCarets () + { + if (_additionalCarets.Count == 0) + { + return; + } + + _additionalCarets.Clear (); + 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 + /// invalidate later positions. + /// + private List GetAllCaretsDescending () + { + List result = [new CaretEditInfo { Offset = CaretOffset, IsPrimary = true }]; + + foreach (CaretInfo caret in _additionalCarets) + { + if (caret.CaretAnchor is { IsDeleted: false } anchor) + { + result.Add (new CaretEditInfo + { + Offset = anchor.Offset, + IsPrimary = false, + SelectionAnchor = caret.SelectionAnchor + }); + } + } + + result.Sort ((a, b) => b.Offset.CompareTo (a.Offset)); + + return result; + } + + /// + /// Executes a multi-caret insert. Each caret has inserted at its + /// position. Wrapped in a single undo scope. + /// + private bool? MultiCaretInsert (string text) + { + if (ReadOnly || _document is null) + { + return true; + } + + if (!HasMultipleCarets) + { + return InsertOrReplace (text); + } + + using (_document.RunUpdate ()) + { + List carets = GetAllCaretsDescending (); + + foreach (CaretEditInfo caret in carets) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + ReplaceSelection (text); + } + else + { + _document.Insert (CaretOffset, text); + } + } + else + { + 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, text); + + continue; + } + } + + _document.Insert (caret.Offset, text); + } + } + } + + // Clear per-caret selections after edit. + // Editing collapses per-caret selections, matching single-caret behavior. + ClearAdditionalCaretSelections (); + + return true; + } + + /// + /// Executes a multi-caret delete-left (backspace). Each caret deletes one indentation unit + /// (when at a leading-whitespace boundary) or one character to its left, matching single-caret + /// smart-backspace behavior. Wrapped in a single undo scope. + /// + private bool? MultiCaretDeleteLeft () + { + if (ReadOnly || _document is null) + { + return true; + } + + if (!HasMultipleCarets) + { + return DeleteLeft (); + } + + using (_document.RunUpdate ()) + { + List carets = GetAllCaretsDescending (); + + foreach (CaretEditInfo caret in carets) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + ReplaceSelection (string.Empty); + } + else if (!TryDeleteIndentationLeftAt (CaretOffset) && CaretOffset > 0) + { + _document.Remove (CaretOffset - 1, 1); + } + } + else + { + 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, string.Empty); + + continue; + } + } + + if (!TryDeleteIndentationLeftAt (caret.Offset) && caret.Offset > 0) + { + _document.Remove (caret.Offset - 1, 1); + } + } + } + } + + ClearAdditionalCaretSelections (); + + return true; + } + + /// + /// Executes a multi-caret delete-right (delete key). Each caret deletes one character to its right. + /// Wrapped in a single undo scope. + /// + private bool? MultiCaretDeleteRight () + { + if (ReadOnly || _document is null) + { + return true; + } + + if (!HasMultipleCarets) + { + return DeleteRight (); + } + + using (_document.RunUpdate ()) + { + List carets = GetAllCaretsDescending (); + + foreach (CaretEditInfo caret in carets) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + ReplaceSelection (string.Empty); + } + else if (CaretOffset < _document.TextLength) + { + _document.Remove (CaretOffset, 1); + } + } + else + { + 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, string.Empty); + + continue; + } + } + + if (caret.Offset < _document.TextLength) + { + _document.Remove (caret.Offset, 1); + } + } + } + } + + ClearAdditionalCaretSelections (); + + return true; + } + + /// + /// Multi-caret newline insert. Each caret gets a newline followed by auto-indent + /// (when is set), matching single-caret Enter behavior. + /// + private bool? MultiCaretNewLine () + { + if (ReadOnly || _document is null) + { + return true; + } + + if (!HasMultipleCarets) + { + return InsertNewLineWithAutoIndent (); + } + + using (_document.RunUpdate ()) + { + List carets = GetAllCaretsDescending (); + + foreach (CaretEditInfo caret in carets) + { + if (caret.IsPrimary) + { + if (HasSelection) + { + ReplaceSelection ("\n"); + } + else + { + _document.Insert (CaretOffset, "\n"); + } + + if (IndentationStrategy is { } strategy) + { + DocumentLine newLine = _document.GetLineByOffset (CaretOffset); + strategy.IndentLine (_document, newLine); + } + } + else + { + 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, "\n"); + + // Apply indentation to the new line after selection replacement. + if (IndentationStrategy is { } selStrategy) + { + DocumentLine newLine = _document.GetLineByOffset (selStart + 1); + selStrategy.IndentLine (_document, newLine); + } + + continue; + } + } + + _document.Insert (caret.Offset, "\n"); + + if (IndentationStrategy is { } caretStrategy) + { + DocumentLine newLine = _document.GetLineByOffset (caret.Offset + 1); + caretStrategy.IndentLine (_document, newLine); + } + } + } + } + + ClearAdditionalCaretSelections (); + + return true; + } + + private void ClearAdditionalCaretSelections () + { + foreach (CaretInfo caret in _additionalCarets) + { + caret.SelectionAnchor = null; + } + } + + /// Holds anchor state for one additional caret. + private sealed class CaretInfo + { + public TextAnchor? CaretAnchor { get; set; } + public TextAnchor? SelectionAnchor { get; set; } + } + + /// Transient struct used during multi-caret edit iteration. + private readonly struct CaretEditInfo + { + public required int Offset { get; init; } + public required bool IsPrimary { get; init; } + public TextAnchor? SelectionAnchor { get; init; } + } +} diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index 691e79a..4b5470a 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -72,6 +72,7 @@ public Editor () { CanFocus = true; CreateCommandsAndBindings (); + OverlayRenderers.Add (new Rendering.MultiCaretRenderer (this)); Document = new TextDocument (); } @@ -102,6 +103,7 @@ public TextDocument? Document _caretAnchor = CreateCaretAnchor (caretOffset); _lastKnownCaretOffset = caretOffset; _selectionAnchor = null; + _additionalCarets.Clear (); ClearVisualLineCaches (); _cachedVisibleLineNumbers = null; _maxWidthDirty = true; @@ -292,6 +294,9 @@ public bool WordWrap /// Background renderers drawn before visual-line elements. public IList BackgroundRenderers { get; } = []; + /// Overlay renderers drawn after visual-line elements (on top of text). + public IList OverlayRenderers { get; } = []; + /// /// Gets or sets the that tracks collapsible regions. /// Setting this installs a and subscribes to fold change events. @@ -411,6 +416,7 @@ protected override void Dispose (bool disposing) _lastKnownCaretOffset = CaretOffset; _caretAnchor = null; _selectionAnchor = null; + _additionalCarets.Clear (); } base.Dispose (disposing); diff --git a/src/Terminal.Gui.Editor/Rendering/IOverlayRenderer.cs b/src/Terminal.Gui.Editor/Rendering/IOverlayRenderer.cs new file mode 100644 index 0000000..664413f --- /dev/null +++ b/src/Terminal.Gui.Editor/Rendering/IOverlayRenderer.cs @@ -0,0 +1,10 @@ +using System.Drawing; +using Terminal.Gui.ViewBase; + +namespace Terminal.Gui.Editor.Rendering; + +/// Draws cell overlays on top of (after) a visual line's elements. +public interface IOverlayRenderer +{ + void Draw (View host, CellVisualLine line, int row, Rectangle viewport); +} diff --git a/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs b/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs new file mode 100644 index 0000000..d6ff08b --- /dev/null +++ b/src/Terminal.Gui.Editor/Rendering/MultiCaretRenderer.cs @@ -0,0 +1,102 @@ +using System.Drawing; +using System.Text; +using Terminal.Gui.Document; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace Terminal.Gui.Editor.Rendering; + +/// +/// Renders additional (non-primary) caret positions as inverted-attribute cells. +/// Installed automatically by when multi-caret mode is active. +/// +public sealed class MultiCaretRenderer : IOverlayRenderer +{ + private readonly Editor _editor; + + /// Initializes a new bound to the given editor. + public MultiCaretRenderer (Editor editor) + { + _editor = editor; + } + + /// + public void Draw (View host, CellVisualLine line, int row, Rectangle viewport) + { + if (!_editor.HasMultipleCarets || _editor.Document is null) + { + return; + } + + // Use the visual line's element range to scope correctly in word-wrap mode, + // where a CellVisualLine represents only one wrapped segment of a DocumentLine. + var hasElements = line.Elements.Count > 0; + var segStart = hasElements ? line.Elements[0].DocumentOffset : line.DocumentLine.Offset; + var segEnd = hasElements ? line.Elements[^1].DocumentEndOffset : line.DocumentLine.EndOffset; + + Attribute normal = host.GetAttributeForRole (VisualRole.Normal); + + // Invert foreground/background to distinguish additional carets from selection. + Attribute caretAttr = new (normal.Background, normal.Foreground); + + foreach (var offset in _editor.AdditionalCaretOffsets) + { + if (!IsOffsetInSegment (offset, segStart, segEnd, line.DocumentLine.EndOffset)) + { + continue; + } + + var colInLine = offset - line.DocumentLine.Offset; + var visualCol = line.GetVisualColumn (colInLine); + var col = visualCol - viewport.X; + + if (col < 0 || col >= viewport.Width) + { + continue; + } + + host.SetAttribute (caretAttr); + host.Move (col, row); + host.AddRune (offset < segEnd ? GetRuneAt (offset) : new Rune (' ')); + } + } + + /// + /// Determines whether falls within the given segment range. + /// Allows offset == segEnd only when the segment ends at the true line end + /// (not a word-wrap boundary), so carets at end-of-line are visible without duplicates. + /// + internal static bool IsOffsetInSegment (int offset, int segStart, int segEnd, int lineEndOffset) + { + if (offset < segStart || offset > segEnd) + { + return false; + } + + // At the segment boundary: allow only if this is the true end-of-line, not a wrap break. + if (offset == segEnd && segEnd != lineEndOffset) + { + return false; + } + + return true; + } + + private Rune GetRuneAt (int offset) + { + var ch = _editor.Document!.GetCharAt (offset); + + if (char.IsHighSurrogate (ch) + && offset + 1 < _editor.Document.TextLength) + { + var lo = _editor.Document.GetCharAt (offset + 1); + + return char.IsLowSurrogate (lo) + ? new Rune (char.ConvertToUtf32 (ch, lo)) + : new Rune (' '); + } + + return char.IsSurrogate (ch) ? new Rune (' ') : new Rune (ch); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs index 2104949..5fa41bf 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorRenderingTests.cs @@ -372,4 +372,72 @@ public async Task UseThemeBackground_Defaults_To_True () Assert.True (fx.Top.Editor.UseThemeBackground); } + + [Fact] + public async Task MultiCaret_Renders_Inverted_Attribute_On_Text () + { + // P1: MultiCaretRenderer must draw AFTER text elements so that the inverted caret + // cell is not overwritten by the subsequent element.Draw call. + await using AppFixture fx = new (() => new EditorTestHost ("abcdef")); + + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.ToggleCaretAt (3); // additional caret on 'd' + fx.Render (); + + Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); + Attribute caretAttr = new (normal.Background, normal.Foreground); + + // The cell at column 3 ('d') should have the inverted attribute, not the normal one. + Cell cell = fx.Driver.Contents![0, 3]; + Assert.Equal ("d", cell.Grapheme); + Assert.Equal (caretAttr, cell.Attribute); + } + + [Fact] + public async Task MultiCaret_Renders_At_EndOfLine_Without_Crash () + { + // CR feedback: offset >= segEnd excluded carets at EOL. + // This test verifies the renderer does not crash when a caret is placed at EOL. + // The actual attribute verification is in unit tests (IsOffsetInSegment_Correctly_Filters_Offsets). + await using AppFixture fx = new (() => new EditorTestHost ("abc")); + + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.ToggleCaretAt (3); // EOL position + fx.Render (); + + // No crash — the renderer successfully processed the EOL caret. + Cell cell = fx.Driver.Contents![0, 3]; + Assert.Equal (" ", cell.Grapheme); + } + + [Fact] + public async Task MultiCaret_WordWrap_No_Duplicate_At_Boundary () + { + // P2: At a wrap boundary, offset == segEnd of one segment AND offset == segStart of the next. + // With exclusive bound check (>=), the caret should only appear on the second row (segStart), + // not duplicated on both rows. + await using AppFixture fx = new (() => + { + EditorTestHost host = new ("abcde fghij"); + host.Editor.WordWrap = true; + + return host; + }, width: 10, height: 5); + + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + // Place caret at offset 6 which is 'f' — the start of the second wrapped segment. + fx.Top.Editor.ToggleCaretAt (6); + fx.Render (); + + Attribute normal = fx.Top.Editor.GetAttributeForRole (VisualRole.Normal); + Attribute caretAttr = new (normal.Background, normal.Foreground); + + // Row 1, col 0 should show the caret attribute on 'f'. + Cell row1FirstCol = fx.Driver.Contents![1, 0]; + Assert.Equal ("f", row1FirstCol.Grapheme); + Assert.Equal (caretAttr, row1FirstCol.Attribute); + } } diff --git a/tests/Terminal.Gui.Editor.Tests/EditorMultiCaretTests.cs b/tests/Terminal.Gui.Editor.Tests/EditorMultiCaretTests.cs new file mode 100644 index 0000000..6deb8c9 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/EditorMultiCaretTests.cs @@ -0,0 +1,213 @@ +// Copilot - claude-sonnet-4 + +using Terminal.Gui.Document; +using Terminal.Gui.Editor; +using Terminal.Gui.Editor.Rendering; +using Terminal.Gui.Input; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +/// +/// Tests for multi-caret editing logic — caret management, multi-caret insert/delete, +/// and undo collapse. +/// +public class EditorMultiCaretTests +{ + [Fact] + public void ToggleCaretAt_Adds_Additional_Caret () + { + Editor editor = new () { Document = new TextDocument ("hello world") }; + editor.CaretOffset = 0; + + editor.ToggleCaretAt (5); + + Assert.True (editor.HasMultipleCarets); + Assert.Single (editor.AdditionalCaretOffsets); + Assert.Equal (5, editor.AdditionalCaretOffsets[0]); + } + + [Fact] + public void ToggleCaretAt_Same_Offset_Removes_Caret () + { + Editor editor = new () { Document = new TextDocument ("hello world") }; + editor.CaretOffset = 0; + + editor.ToggleCaretAt (5); + Assert.True (editor.HasMultipleCarets); + + editor.ToggleCaretAt (5); + Assert.False (editor.HasMultipleCarets); + Assert.Empty (editor.AdditionalCaretOffsets); + } + + [Fact] + public void ToggleCaretAt_On_Primary_Is_NoOp () + { + Editor editor = new () { Document = new TextDocument ("hello world") }; + editor.CaretOffset = 3; + + editor.ToggleCaretAt (3); + + Assert.False (editor.HasMultipleCarets); + } + + [Fact] + public void ClearAdditionalCarets_Removes_All () + { + Editor editor = new () { Document = new TextDocument ("hello world") }; + editor.CaretOffset = 0; + + editor.ToggleCaretAt (3); + editor.ToggleCaretAt (7); + Assert.Equal (2, editor.AdditionalCaretOffsets.Count); + + editor.ClearAdditionalCarets (); + Assert.False (editor.HasMultipleCarets); + } + + [Fact] + public void MultiCaret_Insert_At_Multiple_Positions () + { + Editor editor = new () { Document = new TextDocument ("ab") }; + editor.CaretOffset = 0; + editor.ToggleCaretAt (2); + + // Verify anchor-based tracking: inserting at the higher offset first + // then the lower offset demonstrates that anchors shift correctly. + TextDocument doc = editor.Document!; + doc.Insert (2, "x"); // insert at the higher offset first + doc.Insert (0, "x"); // insert at the lower offset — anchor at 2 has shifted to 3+ + + Assert.Equal ("xabx", doc.Text); + } + + [Fact] + public void MultiCaret_Undo_Collapses_To_Single_Step () + { + Editor editor = new () { Document = new TextDocument ("aabb") }; + editor.CaretOffset = 0; + editor.ToggleCaretAt (2); + + // Perform a multi-caret edit (insert 'X' at both positions). + // The implementation wraps in RunUpdate() so undo collapses. + using (editor.Document!.RunUpdate ()) + { + editor.Document.Insert (2, "X"); // higher offset first + editor.Document.Insert (0, "X"); // lower offset + } + + Assert.Equal ("XaaXbb", editor.Document.Text); + + // One undo should revert both insertions. + editor.Document.UndoStack.Undo (); + Assert.Equal ("aabb", editor.Document.Text); + } + + [Fact] + public void AdditionalCarets_Track_Insertions_Via_Anchors () + { + Editor editor = new () { Document = new TextDocument ("hello") }; + editor.CaretOffset = 0; + editor.ToggleCaretAt (3); + + // Insert text before the additional caret — anchor should shift. + editor.Document!.Insert (1, "XX"); + + // Original additional caret was at offset 3. After inserting 2 chars at offset 1, + // it should now be at offset 5. + Assert.Equal (5, editor.AdditionalCaretOffsets[0]); + } + + [Fact] + public void Document_Swap_Clears_Additional_Carets () + { + Editor editor = new () { Document = new TextDocument ("first") }; + editor.CaretOffset = 0; + editor.ToggleCaretAt (3); + Assert.True (editor.HasMultipleCarets); + + editor.Document = new TextDocument ("second"); + Assert.False (editor.HasMultipleCarets); + } + + [Fact] + public void Multiple_Additional_Carets_Sorted_Descending () + { + Editor editor = new () { Document = new TextDocument ("abcdefghij") }; + editor.CaretOffset = 0; + editor.ToggleCaretAt (3); + editor.ToggleCaretAt (7); + editor.ToggleCaretAt (5); + + IReadOnlyList offsets = editor.AdditionalCaretOffsets; + Assert.Equal (3, offsets.Count); + // Offsets are reported in the order they were added (implementation detail) + // but GetAllCaretsDescending sorts them for editing. + Assert.Contains (3, offsets); + Assert.Contains (5, offsets); + Assert.Contains (7, offsets); + } + + [Fact] + public void MultiCaret_NewLine_Applies_IndentationStrategy () + { + // CR feedback: MultiCaretNewLine must apply IndentationStrategy like single-caret Enter. + Editor editor = new () + { + Document = new TextDocument (" line1\n line2"), + IndentationStrategy = new Terminal.Gui.Text.Indentation.DefaultIndentationStrategy () + }; + + // Primary at end of " line1" (offset 9), additional at end of " line2" (offset 19) + editor.CaretOffset = 9; + editor.ToggleCaretAt (19); + + // Invoke the actual Command.NewLine which routes through MultiCaretNewLine. + editor.InvokeCommand (Command.NewLine); + + // After Enter with auto-indent, new lines should copy indentation from previous line. + // " line1" → " line1\n " and " line2" → " line2\n " + var text = editor.Document!.Text; + Assert.Contains (" line1\n ", text); + Assert.Contains (" line2\n ", text); + } + + [Fact] + public void MultiCaret_Backspace_Uses_Smart_Indentation_Delete () + { + // CR feedback: MultiCaretDeleteLeft must use TryDeleteIndentationLeft like single-caret. + Editor editor = new () + { + Document = new TextDocument (" a\n b"), + IndentationSize = 4 + }; + + // Primary caret at offset 4 (right after " " on line 1), + // additional caret at offset 10 (right after " " on line 2 — line2 starts at offset 6) + editor.CaretOffset = 4; + editor.ToggleCaretAt (10); + + // Invoke actual Command.DeleteCharLeft which routes through MultiCaretDeleteLeft. + editor.InvokeCommand (Command.DeleteCharLeft); + + // Smart backspace should delete full indentation unit (4 spaces), not just 1 char. + Assert.Equal ("a\nb", editor.Document!.Text); + } + + [Theory] + [InlineData (3, 0, 3, 3, true)] // offset == segEnd == lineEnd → EOL caret, should be included + [InlineData (0, 0, 3, 3, true)] // offset at start, within segment + [InlineData (2, 0, 3, 3, true)] // offset within segment + [InlineData (4, 0, 3, 3, false)] // offset past segEnd → excluded + [InlineData (5, 0, 3, 10, false)] // offset past segEnd (non-last segment) → excluded + [InlineData (3, 0, 3, 10, false)] // offset == segEnd but segEnd != lineEnd (wrap boundary) → excluded + [InlineData (0, 0, 0, 0, true)] // empty line: offset 0 == segEnd == lineEnd → included + public void IsOffsetInSegment_Correctly_Filters_Offsets ( + int offset, int segStart, int segEnd, int lineEndOffset, bool expected) + { + // CR feedback: offset >= segEnd excluded carets at EOL, making them invisible. + // IsOffsetInSegment must include offset == segEnd when it's the true line end. + Assert.Equal (expected, MultiCaretRenderer.IsOffsetInSegment (offset, segStart, segEnd, lineEndOffset)); + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/SearchHitRendererTests.cs b/tests/Terminal.Gui.Editor.Tests/SearchHitRendererTests.cs index cd85733..84ce0be 100644 --- a/tests/Terminal.Gui.Editor.Tests/SearchHitRendererTests.cs +++ b/tests/Terminal.Gui.Editor.Tests/SearchHitRendererTests.cs @@ -17,12 +17,11 @@ public void SearchStrategy_Setter_Registers_Renderer () { Editor editor = new () { Document = new TextDocument ("hello world hello") }; - Assert.Empty (editor.BackgroundRenderers); + Assert.DoesNotContain (editor.BackgroundRenderers, r => r is SearchHitRenderer); editor.SearchStrategy = SearchStrategyFactory.Create ("hello", false, false, SearchMode.Normal); - Assert.Single (editor.BackgroundRenderers); - Assert.IsType (editor.BackgroundRenderers[0]); + Assert.Contains (editor.BackgroundRenderers, r => r is SearchHitRenderer); } [Fact] @@ -31,11 +30,11 @@ public void SearchStrategy_Null_Unregisters_Renderer () Editor editor = new () { Document = new TextDocument ("hello world hello") }; editor.SearchStrategy = SearchStrategyFactory.Create ("hello", false, false, SearchMode.Normal); - Assert.Single (editor.BackgroundRenderers); + Assert.Contains (editor.BackgroundRenderers, r => r is SearchHitRenderer); editor.SearchStrategy = null; - Assert.Empty (editor.BackgroundRenderers); + Assert.DoesNotContain (editor.BackgroundRenderers, r => r is SearchHitRenderer); } [Fact] @@ -43,12 +42,13 @@ public void SearchStrategy_Change_Reuses_Renderer () { Editor editor = new () { Document = new TextDocument ("hello world hello") }; editor.SearchStrategy = SearchStrategyFactory.Create ("hello", false, false, SearchMode.Normal); - IBackgroundRenderer first = editor.BackgroundRenderers[0]; + IBackgroundRenderer first = editor.BackgroundRenderers.First (r => r is SearchHitRenderer); editor.SearchStrategy = SearchStrategyFactory.Create ("world", false, false, SearchMode.Normal); - Assert.Single (editor.BackgroundRenderers); - Assert.Same (first, editor.BackgroundRenderers[0]); + SearchHitRenderer? second = editor.BackgroundRenderers.OfType ().SingleOrDefault (); + Assert.NotNull (second); + Assert.Same (first, second); } [Fact] @@ -83,7 +83,6 @@ public void Document_Change_Invalidates_Renderer_Cache () // We verify indirectly: the renderer is still registered but the document changed. editor.Document!.Insert (0, "x"); - Assert.Single (editor.BackgroundRenderers); - Assert.IsType (editor.BackgroundRenderers[0]); + Assert.Contains (editor.BackgroundRenderers, r => r is SearchHitRenderer); } }