Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
50496a1
Initial plan
Copilot May 13, 2026
5033d60
feat: add multi-caret editing support (Ctrl+Click, per-caret insert/d…
Copilot May 13, 2026
7467d18
fix: address code review feedback - readonly struct, inverted caret a…
Copilot May 13, 2026
a4f97bd
fix: comply with constitution — R1 (IBackgroundRenderer), R8 (spec br…
Copilot May 13, 2026
62a99f1
fix: multi-caret Enter applies IndentationStrategy, Backspace uses sm…
Copilot May 13, 2026
7d003d9
test: use InvokeCommand to exercise actual MultiCaretNewLine/DeleteLe…
Copilot May 13, 2026
1fb077a
Merge remote-tracking branch 'origin/develop' into copilot/add-multi-…
Copilot May 13, 2026
94518ba
Merge branch 'develop' into copilot/add-multi-caret-support
tig May 14, 2026
09fbd24
fix: update SearchHitRendererTests to account for MultiCaretRenderer …
Copilot May 14, 2026
49b0bf7
fix: handle surrogate pairs in MultiCaretRenderer and scope to wrappe…
Copilot May 14, 2026
e92d447
refactor: extract GetRuneAt helper and deduplicate element range check
Copilot May 14, 2026
0d460ec
Merge branch 'develop' into copilot/add-multi-caret-support
tig May 14, 2026
39ee69f
docs: document multi-caret in README and add examples/ted/docs/multi-…
Copilot May 14, 2026
757f1b4
fix: MultiCaretRenderer as IOverlayRenderer (draws after elements) + …
Copilot May 14, 2026
20ceab0
Merge branch 'develop' into copilot/add-multi-caret-support
tig May 14, 2026
90323b8
fix: render additional carets at EOL positions (extract IsOffsetInSeg…
Copilot May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
9 changes: 8 additions & 1 deletion examples/ted/TedApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ();
Expand Down
49 changes: 49 additions & 0 deletions examples/ted/docs/multi-caret.md
Original file line number Diff line number Diff line change
@@ -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<int> 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.
9 changes: 9 additions & 0 deletions specs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public class Editor : View

// --- Multi-caret ---
public IReadOnlyList<int> 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
Expand All @@ -38,6 +41,7 @@ public class Editor : View
// --- Rendering pipeline (rendering-pipeline ✅) ---
public IList<IVisualLineTransformer> LineTransformers { get; } // exists (codex merge)
public IList<IBackgroundRenderer> BackgroundRenderers { get; } // exists (codex merge)
public IList<IOverlayRenderer> OverlayRenderers { get; } // multi-caret (drawn after elements)

// --- Syntax highlighting (syntax-colorizer ✅) ---
public IHighlightingDefinition? HighlightingDefinition { get; set; } // exists (syntax-colorizer)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/Terminal.Gui.Editor/Editor.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, () =>
Expand Down
10 changes: 10 additions & 0 deletions src/Terminal.Gui.Editor/Editor.Drawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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 ()
Expand Down
21 changes: 15 additions & 6 deletions src/Terminal.Gui.Editor/Editor.Indentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,25 @@ private void IndentSelectedLines ()

private bool TryDeleteIndentationLeft ()
{
if (_document is null || CaretOffset == 0)
return TryDeleteIndentationLeftAt (CaretOffset);
}

/// <summary>
/// Attempts smart-indentation backspace at the given <paramref name="offset" />. 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 <see langword="true" /> if handled.
/// </summary>
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;
}
Expand All @@ -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;
}
Expand All @@ -156,7 +165,7 @@ private bool TryDeleteIndentationLeft ()
scanOffset += segment.Length;
}

if (scanOffset != caretOffset || lastSegment.length == 0)
if (scanOffset != offset || lastSegment.length == 0)
{
return false;
}
Expand Down
14 changes: 14 additions & 0 deletions src/Terminal.Gui.Editor/Editor.Keyboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ public partial class Editor
/// <inheritdoc />
protected override bool OnKeyDownNotHandled (Key key)
{
if (key == Key.Esc && HasMultipleCarets)
{
ClearAdditionalCarets ();

return true;
}

if (key == Key.Tab)
{
return InsertTab ();
Expand Down Expand Up @@ -68,6 +75,13 @@ protected override bool OnKeyDownNotHandled (Key key)
return true;
}

if (HasMultipleCarets)
{
MultiCaretInsert (rune.ToString ());

return true;
}

if (HasSelection)
{
ReplaceSelection (rune.ToString ());
Expand Down
8 changes: 7 additions & 1 deletion src/Terminal.Gui.Editor/Editor.Mouse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading