diff --git a/README.md b/README.md
index 068305a..730b37b 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,7 @@ cp bin/ttt ~/.local/bin/
- **Undo/redo** (Ctrl+Z/Y) via a command-pattern undo stack
- **`.editorconfig` support** — indent size is picked up automatically per file
- **Indent detection** — auto-detects indentation from file content; manual override via the status bar indent picker
+- **Multi-cursor editing** — Ctrl+D to select next occurrence, Ctrl+K L to select all occurrences, Alt+Click to add cursors; typing, backspace, delete, and enter work at all positions simultaneously
- **Mouse support** — click to position cursor, click tabs, drag sidebar/panel dividers, right-click context menus
- **Auto-completion** — LSP-powered completions with live filtering, debounce, and auto-import support
- **Signature help** — parameter hints shown automatically on `(` and `,`
@@ -412,6 +413,12 @@ All keybindings are customizable via `keybindings.json`. Supports chord sequence
| Ctrl+X | Cut |
| Ctrl+V | Paste |
| Ctrl+G | Go to line |
+| | **Multi-Cursor** |
+| Ctrl+D | Select next occurrence |
+| Ctrl+K L | Select all occurrences |
+| Alt+Click | Add cursor at click position |
+| Ctrl+K U | Undo last cursor addition |
+| Escape | Collapse to single cursor |
| | **Search** |
| Ctrl+F | Find |
| Ctrl+H | Find and replace |
diff --git a/cmd/ttt/commands.go b/cmd/ttt/commands.go
index 3411d0c..ceda2b8 100644
--- a/cmd/ttt/commands.go
+++ b/cmd/ttt/commands.go
@@ -200,6 +200,10 @@ func registerEditorCommands(reg *command.Registry, app *App, running *bool, quit
app.DismissAutocomplete()
return
}
+ if app.editorGroup.IsMultiCursorActive() {
+ app.editorGroup.CollapseMultiCursor()
+ return
+ }
app.FocusEditor()
},
})
@@ -521,6 +525,19 @@ func registerEditorCommands(reg *command.Registry, app *App, running *bool, quit
Handler: func() { app.editorGroup.DeleteWordRight() },
})
+ reg.Register(command.Command{
+ ID: "multicursor.selectNext", Title: "Add Next Occurrence",
+ Handler: func() { app.editorGroup.SelectNextOccurrence() },
+ })
+ reg.Register(command.Command{
+ ID: "multicursor.selectAll", Title: "Select All Occurrences",
+ Handler: func() { app.editorGroup.SelectAllOccurrences() },
+ })
+ reg.Register(command.Command{
+ ID: "multicursor.undoCursor", Title: "Undo Last Cursor",
+ Handler: func() { app.editorGroup.UndoLastCursor() },
+ })
+
reg.Register(command.Command{
ID: "editor.quit", Title: "Quit",
Handler: func() {
diff --git a/cmd/ttt/eventloop.go b/cmd/ttt/eventloop.go
index 59887fb..e871037 100644
--- a/cmd/ttt/eventloop.go
+++ b/cmd/ttt/eventloop.go
@@ -37,6 +37,7 @@ func runEventLoop(
app.status.Line = line
app.status.Col = col
app.status.Dirty = app.editorGroup.IsDirty()
+ app.status.CursorCount = app.editorGroup.MultiCursorCount()
app.explorer.ActiveFile = filePath
if app.editorGroup.Editor != nil && app.editorGroup.Editor.Highlighter != nil {
diff --git a/cmd/ttt/menus.go b/cmd/ttt/menus.go
index 9fbd711..b6f6e1d 100644
--- a/cmd/ttt/menus.go
+++ b/cmd/ttt/menus.go
@@ -39,6 +39,10 @@ var menuBarMenus = [][]ui.ContextMenuItem{
// Selection
{
{Label: "Select All", Command: "editor.selectAll"},
+ ui.MenuSep(),
+ {Label: "Add Next Occurrence", Command: "multicursor.selectNext"},
+ {Label: "Select All Occurrences", Command: "multicursor.selectAll"},
+ {Label: "Undo Last Cursor", Command: "multicursor.undoCursor"},
},
// View
{
diff --git a/docs-web/src/content/docs/getting-started/introduction.md b/docs-web/src/content/docs/getting-started/introduction.md
index 73f24db..09e2dec 100644
--- a/docs-web/src/content/docs/getting-started/introduction.md
+++ b/docs-web/src/content/docs/getting-started/introduction.md
@@ -11,6 +11,7 @@ TTT (Terminal Text Tool) is a fully-featured code editor that runs in your termi
- **Single binary** built with Go, no runtime dependencies
- **Zero config** out of the box, but fully customizable
+- **Multi-cursor editing** with Ctrl+D, Ctrl+K L, and Alt+Click
- **LSP support** for language-aware editing (completions, diagnostics, formatting, rename, references)
- **Integrated terminal** with full VT escape sequence support
- **Git integration** with staging, committing, and diff view
diff --git a/docs-web/src/content/docs/getting-started/quick-start.md b/docs-web/src/content/docs/getting-started/quick-start.md
index 8e254b3..3cfb1a4 100644
--- a/docs-web/src/content/docs/getting-started/quick-start.md
+++ b/docs-web/src/content/docs/getting-started/quick-start.md
@@ -30,6 +30,8 @@ ttt --workspace project.ttt # loads a saved workspace file
- **Ctrl+C / Ctrl+X / Ctrl+V** for copy/cut/paste
- **Ctrl+F** to find, **Ctrl+H** to find and replace
- **Ctrl+A** to select all
+- **Ctrl+D** to select the next occurrence (multi-cursor)
+- **Alt+Click** to add cursors at multiple positions
## Tabs
diff --git a/docs-web/src/content/docs/guides/editor.md b/docs-web/src/content/docs/guides/editor.md
index c12258c..6669ab5 100644
--- a/docs-web/src/content/docs/guides/editor.md
+++ b/docs-web/src/content/docs/guides/editor.md
@@ -31,6 +31,28 @@ Press **Ctrl+G** to open the Go to Line dialog.
- **Ctrl+Z** to undo, **Ctrl+Y** to redo
- Uses a command-pattern undo stack for reliable history tracking
+## Multi-Cursor Editing
+
+TTT supports editing with multiple cursors simultaneously. Place cursors at multiple locations and type, delete, or insert lines — all cursors act in parallel.
+
+### Adding Cursors
+
+- **Ctrl+D** — select the current word (or extend the current selection) and add a cursor at the next occurrence
+- **Ctrl+K L** — select all occurrences of the current word/selection at once
+- **Alt+Click** — add a cursor at the clicked position
+
+### Removing Cursors
+
+- **Ctrl+K U** — undo the last cursor addition
+- **Escape** — collapse back to a single cursor (when multiple cursors are active)
+
+### Behavior
+
+- Typing, backspace, delete, and enter work at all cursor positions simultaneously
+- Undo/redo groups multi-cursor edits into a single action
+- The status bar shows the cursor count (e.g., "3 cursors") when multiple cursors are active
+- All cursors render as solid blocks
+
## Indentation
TTT supports `.editorconfig` files and picks up indent size automatically per file. It also auto-detects indentation from file content. You can manually override indentation via the status bar indent picker.
diff --git a/docs-web/src/content/docs/guides/keybindings.md b/docs-web/src/content/docs/guides/keybindings.md
index 9381b02..359ee5c 100644
--- a/docs-web/src/content/docs/guides/keybindings.md
+++ b/docs-web/src/content/docs/guides/keybindings.md
@@ -36,6 +36,16 @@ All keybindings are customizable via `keybindings.json`. TTT supports chord sequ
| Ctrl+V | Paste |
| Ctrl+G | Go to line |
+### Multi-Cursor
+
+| Shortcut | Action |
+|----------|--------|
+| Ctrl+D | Select next occurrence |
+| Ctrl+K L | Select all occurrences |
+| Alt+Click | Add cursor at click position |
+| Ctrl+K U | Undo last cursor addition |
+| Escape | Collapse to single cursor |
+
### Search
| Shortcut | Action |
diff --git a/docs-web/src/content/docs/index.mdx b/docs-web/src/content/docs/index.mdx
index 6e1743d..55a9e1b 100644
--- a/docs-web/src/content/docs/index.mdx
+++ b/docs-web/src/content/docs/index.mdx
@@ -33,6 +33,9 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
Open multiple project directories in a single session with per-folder git status and search.
+
+ Place cursors at multiple locations with Ctrl+D, Ctrl+K L, or Alt+Click. Type, delete, and insert at all positions simultaneously.
+
10 built-in themes with full customization. Every color in the editor is configurable via JSON.
diff --git a/docs-web/src/content/docs/reference/keybindings.md b/docs-web/src/content/docs/reference/keybindings.md
index 3afcc76..be51e95 100644
--- a/docs-web/src/content/docs/reference/keybindings.md
+++ b/docs-web/src/content/docs/reference/keybindings.md
@@ -45,6 +45,16 @@ All keybindings can be customized in `~/.config/ttt/keybindings.json`.
| Alt+Delete | `editor.deleteWordRight` | Delete word right |
| Ctrl+Delete | `editor.deleteWordRight` | Delete word right |
+## Multi-Cursor
+
+| Shortcut | Command | Description |
+|----------|---------|-------------|
+| Ctrl+D | `editor.selectNextOccurrence` | Select next occurrence of current word/selection |
+| Ctrl+K L | `editor.selectAllOccurrences` | Select all occurrences at once |
+| Alt+Click | *(mouse)* | Add cursor at click position |
+| Ctrl+K U | `editor.undoCursor` | Undo last cursor addition |
+| Escape | `editor.focus` | Collapse to single cursor (when multiple cursors exist) |
+
## Search
| Shortcut | Command | Description |
diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go
index 6d16222..6446035 100644
--- a/internal/config/keybindings.go
+++ b/internal/config/keybindings.go
@@ -174,6 +174,9 @@ func DefaultKeybindings() []KeyBinding {
{Key: "ctrl+l x", Command: "editor.fixAll"},
{Key: "ctrl+l r", Command: "editor.findReferences"},
{Key: "ctrl+l t", Command: "editor.goToTypeDefinition"},
+ {Key: "ctrl+d", Command: "multicursor.selectNext"},
+ {Key: "ctrl+k l", Command: "multicursor.selectAll"},
+ {Key: "ctrl+k u", Command: "multicursor.undoCursor"},
{Key: "alt+up", Command: "editor.moveLineUp"},
{Key: "alt+down", Command: "editor.moveLineDown"},
{Key: "alt+shift+up", Command: "editor.duplicateLine"},
diff --git a/internal/core/multicursor/multicursor.go b/internal/core/multicursor/multicursor.go
new file mode 100644
index 0000000..f38d3e0
--- /dev/null
+++ b/internal/core/multicursor/multicursor.go
@@ -0,0 +1,133 @@
+package multicursor
+
+import (
+ "sort"
+
+ "github.com/eugenioenko/ttt/internal/core/selection"
+)
+
+type CursorState struct {
+ Line, Col, Goal int
+ Sel selection.Selection
+}
+
+type MultiCursor struct {
+ Cursors []CursorState
+ Primary int
+ history []CursorState
+}
+
+func New(line, col int) *MultiCursor {
+ return &MultiCursor{
+ Cursors: []CursorState{{Line: line, Col: col}},
+ Primary: 0,
+ }
+}
+
+func (mc *MultiCursor) IsMulti() bool {
+ return len(mc.Cursors) > 1
+}
+
+func (mc *MultiCursor) PrimaryCursor() CursorState {
+ if mc.Primary >= 0 && mc.Primary < len(mc.Cursors) {
+ return mc.Cursors[mc.Primary]
+ }
+ return CursorState{}
+}
+
+func (mc *MultiCursor) Add(line, col int) {
+ for _, c := range mc.Cursors {
+ if c.Line == line && c.Col == col {
+ return
+ }
+ }
+ cs := CursorState{Line: line, Col: col}
+ mc.history = append(mc.history, cs)
+ mc.Cursors = append(mc.Cursors, cs)
+ mc.Sort()
+}
+
+func (mc *MultiCursor) AddWithSelection(line, col int, sel selection.Selection) {
+ for _, c := range mc.Cursors {
+ if c.Line == line && c.Col == col {
+ return
+ }
+ }
+ cs := CursorState{Line: line, Col: col, Sel: sel}
+ mc.history = append(mc.history, cs)
+ mc.Cursors = append(mc.Cursors, cs)
+ mc.Sort()
+}
+
+func (mc *MultiCursor) RemoveLast() (CursorState, bool) {
+ if len(mc.history) == 0 || len(mc.Cursors) <= 1 {
+ return CursorState{}, false
+ }
+ last := mc.history[len(mc.history)-1]
+ mc.history = mc.history[:len(mc.history)-1]
+ for i, c := range mc.Cursors {
+ if c.Line == last.Line && c.Col == last.Col {
+ mc.Cursors = append(mc.Cursors[:i], mc.Cursors[i+1:]...)
+ if mc.Primary >= len(mc.Cursors) {
+ mc.Primary = len(mc.Cursors) - 1
+ }
+ return last, true
+ }
+ }
+ return last, false
+}
+
+func (mc *MultiCursor) CollapseToSingle() {
+ if len(mc.Cursors) == 0 {
+ return
+ }
+ primary := mc.PrimaryCursor()
+ primary.Sel.Clear()
+ mc.Cursors = []CursorState{primary}
+ mc.Primary = 0
+ mc.history = nil
+}
+
+func (mc *MultiCursor) Sort() {
+ if mc.Primary < 0 || mc.Primary >= len(mc.Cursors) {
+ mc.Primary = 0
+ return
+ }
+ p := mc.Cursors[mc.Primary]
+ sort.SliceStable(mc.Cursors, func(i, j int) bool {
+ a, b := mc.Cursors[i], mc.Cursors[j]
+ if a.Line != b.Line {
+ return a.Line < b.Line
+ }
+ return a.Col < b.Col
+ })
+ for i, c := range mc.Cursors {
+ if c.Line == p.Line && c.Col == p.Col {
+ mc.Primary = i
+ break
+ }
+ }
+}
+
+func (mc *MultiCursor) Deduplicate() {
+ if len(mc.Cursors) <= 1 {
+ return
+ }
+ mc.Sort()
+ p := mc.Cursors[mc.Primary]
+ unique := mc.Cursors[:1]
+ for i := 1; i < len(mc.Cursors); i++ {
+ prev := unique[len(unique)-1]
+ if mc.Cursors[i].Line != prev.Line || mc.Cursors[i].Col != prev.Col {
+ unique = append(unique, mc.Cursors[i])
+ }
+ }
+ mc.Cursors = unique
+ mc.Primary = 0
+ for i, c := range mc.Cursors {
+ if c.Line == p.Line && c.Col == p.Col {
+ mc.Primary = i
+ break
+ }
+ }
+}
diff --git a/internal/core/multicursor/multicursor_test.go b/internal/core/multicursor/multicursor_test.go
new file mode 100644
index 0000000..8f2a7e7
--- /dev/null
+++ b/internal/core/multicursor/multicursor_test.go
@@ -0,0 +1,112 @@
+package multicursor
+
+import (
+ "testing"
+
+ "github.com/eugenioenko/ttt/internal/core/selection"
+)
+
+func TestNewMultiCursor(t *testing.T) {
+ mc := New(5, 10)
+ if len(mc.Cursors) != 1 {
+ t.Fatalf("expected 1 cursor, got %d", len(mc.Cursors))
+ }
+ if mc.Cursors[0].Line != 5 || mc.Cursors[0].Col != 10 {
+ t.Errorf("expected (5,10), got (%d,%d)", mc.Cursors[0].Line, mc.Cursors[0].Col)
+ }
+ if mc.IsMulti() {
+ t.Error("single cursor should not be multi")
+ }
+}
+
+func TestAddAndSort(t *testing.T) {
+ mc := New(5, 10)
+ mc.Add(2, 3)
+ mc.Add(5, 5)
+ if len(mc.Cursors) != 3 {
+ t.Fatalf("expected 3 cursors, got %d", len(mc.Cursors))
+ }
+ if !mc.IsMulti() {
+ t.Error("should be multi with 3 cursors")
+ }
+ if mc.Cursors[0].Line != 2 || mc.Cursors[0].Col != 3 {
+ t.Errorf("first cursor should be (2,3), got (%d,%d)", mc.Cursors[0].Line, mc.Cursors[0].Col)
+ }
+ if mc.Cursors[1].Line != 5 || mc.Cursors[1].Col != 5 {
+ t.Errorf("second cursor should be (5,5), got (%d,%d)", mc.Cursors[1].Line, mc.Cursors[1].Col)
+ }
+ if mc.Cursors[2].Line != 5 || mc.Cursors[2].Col != 10 {
+ t.Errorf("third cursor should be (5,10), got (%d,%d)", mc.Cursors[2].Line, mc.Cursors[2].Col)
+ }
+ p := mc.PrimaryCursor()
+ if p.Line != 5 || p.Col != 10 {
+ t.Errorf("primary should remain (5,10), got (%d,%d)", p.Line, p.Col)
+ }
+}
+
+func TestAddDuplicate(t *testing.T) {
+ mc := New(1, 1)
+ mc.Add(1, 1)
+ if len(mc.Cursors) != 1 {
+ t.Errorf("duplicate should not be added, got %d cursors", len(mc.Cursors))
+ }
+}
+
+func TestRemoveLast(t *testing.T) {
+ mc := New(1, 0)
+ mc.Add(3, 0)
+ mc.Add(5, 0)
+ removed, ok := mc.RemoveLast()
+ if !ok {
+ t.Fatal("RemoveLast should succeed")
+ }
+ if removed.Line != 5 {
+ t.Errorf("expected removed line 5, got %d", removed.Line)
+ }
+ if len(mc.Cursors) != 2 {
+ t.Errorf("expected 2 cursors, got %d", len(mc.Cursors))
+ }
+ removed, ok = mc.RemoveLast()
+ if !ok || removed.Line != 3 {
+ t.Errorf("expected removed line 3, got line %d ok=%v", removed.Line, ok)
+ }
+ _, ok = mc.RemoveLast()
+ if ok {
+ t.Error("should not remove last remaining cursor")
+ }
+}
+
+func TestCollapseToSingle(t *testing.T) {
+ mc := New(1, 0)
+ mc.Add(3, 5)
+ mc.Add(7, 2)
+ mc.CollapseToSingle()
+ if len(mc.Cursors) != 1 {
+ t.Fatalf("expected 1 cursor, got %d", len(mc.Cursors))
+ }
+ if mc.IsMulti() {
+ t.Error("should not be multi after collapse")
+ }
+}
+
+func TestDeduplicate(t *testing.T) {
+ mc := New(1, 0)
+ mc.Cursors = append(mc.Cursors, CursorState{Line: 1, Col: 0})
+ mc.Cursors = append(mc.Cursors, CursorState{Line: 2, Col: 0})
+ mc.Deduplicate()
+ if len(mc.Cursors) != 2 {
+ t.Errorf("expected 2 cursors after dedup, got %d", len(mc.Cursors))
+ }
+}
+
+func TestAddWithSelection(t *testing.T) {
+ mc := New(0, 0)
+ sel := selection.Selection{Active: true, Anchor: selection.Position{Line: 1, Col: 0}}
+ mc.AddWithSelection(1, 5, sel)
+ if len(mc.Cursors) != 2 {
+ t.Fatalf("expected 2 cursors, got %d", len(mc.Cursors))
+ }
+ if !mc.Cursors[1].Sel.Active {
+ t.Error("second cursor should have active selection")
+ }
+}
diff --git a/internal/core/undo/undo.go b/internal/core/undo/undo.go
index 180caa7..a29b8a2 100644
--- a/internal/core/undo/undo.go
+++ b/internal/core/undo/undo.go
@@ -417,6 +417,22 @@ func (c *PasteCommand) Undo(b *buffer.Buffer) {
b.Dirty = true
}
+type BatchCommand struct {
+ Commands []EditCommand
+}
+
+func (c *BatchCommand) Apply(b *buffer.Buffer) {
+ for _, cmd := range c.Commands {
+ cmd.Apply(b)
+ }
+}
+
+func (c *BatchCommand) Undo(b *buffer.Buffer) {
+ for i := len(c.Commands) - 1; i >= 0; i-- {
+ c.Commands[i].Undo(b)
+ }
+}
+
func splitLines(s string) []string {
var lines []string
start := 0
diff --git a/internal/core/undo/undo_test.go b/internal/core/undo/undo_test.go
index 17ed4dc..de97de1 100644
--- a/internal/core/undo/undo_test.go
+++ b/internal/core/undo/undo_test.go
@@ -89,6 +89,49 @@ func TestDeleteSelectionCommand(t *testing.T) {
}
}
+func TestBatchCommand(t *testing.T) {
+ b := &buffer.Buffer{Lines: []string{"abc"}}
+ batch := &BatchCommand{
+ Commands: []EditCommand{
+ &InsertRuneCommand{Line: 0, Col: 0, Rune: 'X'},
+ &InsertRuneCommand{Line: 0, Col: 1, Rune: 'Y'},
+ &InsertRuneCommand{Line: 0, Col: 2, Rune: 'Z'},
+ },
+ }
+ batch.Apply(b)
+ if b.Lines[0] != "XYZabc" {
+ t.Errorf("expected 'XYZabc', got '%s'", b.Lines[0])
+ }
+ batch.Undo(b)
+ if b.Lines[0] != "abc" {
+ t.Errorf("expected 'abc' after undo, got '%s'", b.Lines[0])
+ }
+}
+
+func TestBatchCommandUndoStack(t *testing.T) {
+ b := &buffer.Buffer{Lines: []string{"hello"}}
+ s := &UndoStack{}
+ batch := &BatchCommand{
+ Commands: []EditCommand{
+ &InsertRuneCommand{Line: 0, Col: 5, Rune: '!'},
+ &InsertRuneCommand{Line: 0, Col: 6, Rune: '!'},
+ },
+ }
+ batch.Apply(b)
+ s.Push(batch)
+ if b.Lines[0] != "hello!!" {
+ t.Errorf("expected 'hello!!', got '%s'", b.Lines[0])
+ }
+ s.Undo(b)
+ if b.Lines[0] != "hello" {
+ t.Errorf("expected 'hello' after single undo, got '%s'", b.Lines[0])
+ }
+ s.Redo(b)
+ if b.Lines[0] != "hello!!" {
+ t.Errorf("expected 'hello!!' after redo, got '%s'", b.Lines[0])
+ }
+}
+
func TestUndoStack(t *testing.T) {
b := &buffer.Buffer{Lines: []string{"abc"}}
s := &UndoStack{}
diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go
index 5248056..b603451 100644
--- a/internal/ui/editor_group.go
+++ b/internal/ui/editor_group.go
@@ -11,6 +11,7 @@ import (
"github.com/eugenioenko/ttt/internal/core/cursor"
"github.com/eugenioenko/ttt/internal/core/diff"
"github.com/eugenioenko/ttt/internal/core/highlight"
+ "github.com/eugenioenko/ttt/internal/core/multicursor"
"github.com/eugenioenko/ttt/internal/core/selection"
"github.com/eugenioenko/ttt/internal/core/undo"
"github.com/eugenioenko/ttt/internal/term"
@@ -45,6 +46,7 @@ type editorTab struct {
Vp *view.Viewport
Undo *undo.UndoStack
Sel *selection.Selection
+ Multi *multicursor.MultiCursor
Highlighter *highlight.Highlighter
Diagnostics []Diagnostic
TabSize int
@@ -231,6 +233,9 @@ func (g *EditorGroupWidget) IsEditorActive() bool {
func (g *EditorGroupWidget) CursorPosition() (int, int, bool) {
if g.IsEditorActive() {
+ if g.Editor.isMultiActive() {
+ return 0, 0, false
+ }
return g.Editor.CursorX, g.Editor.CursorY, true
}
return 0, 0, false
@@ -245,11 +250,18 @@ func (g *EditorGroupWidget) SetTabSize(size int) {
func (g *EditorGroupWidget) SwitchTab(idx int) {
if idx >= 0 && idx < len(g.tabs) {
+ g.saveMultiState()
g.active = idx
g.syncTabs()
}
}
+func (g *EditorGroupWidget) saveMultiState() {
+ if t := g.activeTab(); t != nil && t.Content == nil {
+ t.Multi = g.Editor.Multi
+ }
+}
+
func (g *EditorGroupWidget) NextTab() {
if len(g.tabs) > 1 {
g.SwitchTab((g.active + 1) % len(g.tabs))
@@ -584,6 +596,44 @@ func (g *EditorGroupWidget) DeleteWordRight() {
}
}
+func (g *EditorGroupWidget) SelectNextOccurrence() {
+ if g.IsEditorActive() {
+ g.Editor.SelectNextOccurrence()
+ g.saveMultiState()
+ }
+}
+
+func (g *EditorGroupWidget) SelectAllOccurrences() {
+ if g.IsEditorActive() {
+ g.Editor.SelectAllOccurrences()
+ g.saveMultiState()
+ }
+}
+
+func (g *EditorGroupWidget) UndoLastCursor() {
+ if g.IsEditorActive() {
+ g.Editor.UndoLastCursor()
+ g.saveMultiState()
+ }
+}
+
+func (g *EditorGroupWidget) IsMultiCursorActive() bool {
+ return g.IsEditorActive() && g.Editor.isMultiActive()
+}
+
+func (g *EditorGroupWidget) MultiCursorCount() int {
+ if g.IsEditorActive() && g.Editor.Multi != nil {
+ return len(g.Editor.Multi.Cursors)
+ }
+ return 1
+}
+
+func (g *EditorGroupWidget) CollapseMultiCursor() {
+ if g.IsEditorActive() {
+ g.Editor.collapseMulti()
+ }
+}
+
func (g *EditorGroupWidget) Copy() {
t := g.activeTab()
if t == nil || t.Content != nil || t.Sel == nil || !t.Sel.Active {
@@ -626,6 +676,7 @@ func (g *EditorGroupWidget) syncTabs() {
g.Editor.Viewport = t.Vp
g.Editor.Undo = t.Undo
g.Editor.Selection = t.Sel
+ g.Editor.Multi = t.Multi
g.Editor.Highlighter = t.Highlighter
g.Editor.Diagnostics = t.Diagnostics
if t.TabSize > 0 {
@@ -735,5 +786,7 @@ func (g *EditorGroupWidget) HandleEvent(ev tcell.Event) EventResult {
if t.Content != nil {
return t.Content.HandleEvent(ev)
}
- return g.Editor.HandleEvent(ev)
+ result = g.Editor.HandleEvent(ev)
+ g.saveMultiState()
+ return result
}
diff --git a/internal/ui/editor_widget.go b/internal/ui/editor_widget.go
index 3663670..56ff657 100644
--- a/internal/ui/editor_widget.go
+++ b/internal/ui/editor_widget.go
@@ -7,6 +7,7 @@ import (
"github.com/eugenioenko/ttt/internal/core/buffer"
"github.com/eugenioenko/ttt/internal/core/cursor"
"github.com/eugenioenko/ttt/internal/core/highlight"
+ "github.com/eugenioenko/ttt/internal/core/multicursor"
"github.com/eugenioenko/ttt/internal/core/selection"
"github.com/eugenioenko/ttt/internal/core/undo"
"github.com/eugenioenko/ttt/internal/term"
@@ -36,9 +37,11 @@ type EditorPaneWidget struct {
lastClickCol int
clickCount int
mouseDown bool
- scrollbar Scrollbar
- Diagnostics []Diagnostic
- OnChange func()
+ scrollbar Scrollbar
+ Diagnostics []Diagnostic
+ OnChange func()
+ Multi *multicursor.MultiCursor
+ multiSearchWord string
}
func NewEditorPaneWidget(buf *buffer.Buffer, cur *cursor.Cursor, vp *view.Viewport) *EditorPaneWidget {
@@ -82,6 +85,13 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) {
sel := e.Selection
hasSel := sel != nil && sel.Active
+ multiActive := e.isMultiActive()
+ var allCursors []multicursor.CursorState
+ if multiActive {
+ e.syncToMulti()
+ allCursors = e.Multi.Cursors
+ }
+
hasSearch := len(e.SearchMatches) > 0
matchLine, matchCol, hasMatch := e.findMatchingBracket()
@@ -140,15 +150,48 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) {
}
isSearchHighlight := style == term.StyleSearchActive || style == term.StyleSearchMatch
bgStyle := term.Style(0)
- if hasSel && sel.Contains(lineIdx, colIdx, e.Cursor.Line, e.Cursor.Col) {
+ inAnySel := false
+ if multiActive {
+ for _, mc := range allCursors {
+ if mc.Sel.Active && mc.Sel.Contains(lineIdx, colIdx, mc.Line, mc.Col) {
+ bgStyle = term.StyleSelection
+ inAnySel = true
+ break
+ }
+ }
+ } else if hasSel && sel.Contains(lineIdx, colIdx, e.Cursor.Line, e.Cursor.Col) {
bgStyle = term.StyleSelection
- } else if lineIdx == e.Cursor.Line && !hasSel && !isSearchHighlight {
- bgStyle = term.StyleActiveLine
+ inAnySel = true
+ }
+ if !inAnySel {
+ isCursorLine := false
+ if multiActive {
+ for _, mc := range allCursors {
+ if mc.Line == lineIdx {
+ isCursorLine = true
+ break
+ }
+ }
+ } else {
+ isCursorLine = lineIdx == e.Cursor.Line && !hasSel
+ }
+ if isCursorLine && !isSearchHighlight {
+ bgStyle = term.StyleActiveLine
+ }
}
if hasMatch && ((lineIdx == e.Cursor.Line && colIdx == e.Cursor.Col) ||
(lineIdx == matchLine && colIdx == matchCol)) {
bgStyle = term.StyleBracketMatch
}
+ if multiActive {
+ for _, mc := range allCursors {
+ if mc.Line == lineIdx && mc.Col == colIdx {
+ style = term.StyleSelection
+ bgStyle = 0
+ break
+ }
+ }
+ }
ulStyle := e.diagStyleAt(lineIdx, colIdx)
surface.SetCell(gutterW+x, y, term.Cell{Ch: ch, Style: style, BgStyle: bgStyle, UlStyle: ulStyle})
}
@@ -322,9 +365,23 @@ func (e *EditorPaneWidget) HandleEvent(ev tcell.Event) EventResult {
mx, my := mev.Position()
line, col := e.mouseToPos(r, mx, my)
+ isAlt := mev.Modifiers()&tcell.ModAlt != 0
+ if isAlt && !e.mouseDown {
+ e.ensureMulti()
+ e.syncToMulti()
+ e.Multi.Add(line, col)
+ e.syncFromMulti()
+ e.scrollViewport()
+ return EventConsumed
+ }
+
if !e.mouseDown {
e.mouseDown = true
+ if e.isMultiActive() {
+ e.collapseMulti()
+ }
+
now := time.Now().UnixMilli()
if now-e.lastClickTime < 400 && line == e.lastClickLine && col == e.lastClickCol {
e.clickCount++
@@ -375,48 +432,118 @@ func (e *EditorPaneWidget) HandleEvent(ev tcell.Event) EventResult {
shift := kev.Modifiers()&tcell.ModShift != 0
hasSel := e.Selection != nil && e.Selection.Active
+ multi := e.isMultiActive()
+
switch kev.Key() {
case tcell.KeyUp:
- e.startOrExtendSelection(shift)
- if e.Cursor.Line > 0 {
- e.Cursor.Line--
- lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
- if e.Cursor.Col > lineLen {
- e.Cursor.Col = lineLen
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ if cs.Line > 0 {
+ cs.Line--
+ lineLen := len([]rune(e.Buf.Lines[cs.Line]))
+ if cs.Col > lineLen {
+ cs.Col = lineLen
+ }
+ }
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ if e.Cursor.Line > 0 {
+ e.Cursor.Line--
+ lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if e.Cursor.Col > lineLen {
+ e.Cursor.Col = lineLen
+ }
}
}
case tcell.KeyDown:
- e.startOrExtendSelection(shift)
- if e.Cursor.Line < len(e.Buf.Lines)-1 {
- e.Cursor.Line++
- lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
- if e.Cursor.Col > lineLen {
- e.Cursor.Col = lineLen
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ if cs.Line < len(e.Buf.Lines)-1 {
+ cs.Line++
+ lineLen := len([]rune(e.Buf.Lines[cs.Line]))
+ if cs.Col > lineLen {
+ cs.Col = lineLen
+ }
+ }
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ if e.Cursor.Line < len(e.Buf.Lines)-1 {
+ e.Cursor.Line++
+ lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if e.Cursor.Col > lineLen {
+ e.Cursor.Col = lineLen
+ }
}
}
case tcell.KeyLeft:
- e.startOrExtendSelection(shift)
- if e.Cursor.Col > 0 {
- e.Cursor.Col--
- } else if e.Cursor.Line > 0 {
- e.Cursor.Line--
- e.Cursor.Col = len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ if cs.Col > 0 {
+ cs.Col--
+ } else if cs.Line > 0 {
+ cs.Line--
+ cs.Col = len([]rune(e.Buf.Lines[cs.Line]))
+ }
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ if e.Cursor.Col > 0 {
+ e.Cursor.Col--
+ } else if e.Cursor.Line > 0 {
+ e.Cursor.Line--
+ e.Cursor.Col = len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ }
}
case tcell.KeyRight:
- e.startOrExtendSelection(shift)
- lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
- if e.Cursor.Col < lineLen {
- e.Cursor.Col++
- } else if e.Cursor.Line < len(e.Buf.Lines)-1 {
- e.Cursor.Line++
- e.Cursor.Col = 0
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ lineLen := len([]rune(e.Buf.Lines[cs.Line]))
+ if cs.Col < lineLen {
+ cs.Col++
+ } else if cs.Line < len(e.Buf.Lines)-1 {
+ cs.Line++
+ cs.Col = 0
+ }
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if e.Cursor.Col < lineLen {
+ e.Cursor.Col++
+ } else if e.Cursor.Line < len(e.Buf.Lines)-1 {
+ e.Cursor.Line++
+ e.Cursor.Col = 0
+ }
}
case tcell.KeyHome:
- e.startOrExtendSelection(shift)
- e.SmartHome()
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ runes := []rune(e.Buf.Lines[cs.Line])
+ firstNonSpace := 0
+ for firstNonSpace < len(runes) && (runes[firstNonSpace] == ' ' || runes[firstNonSpace] == '\t') {
+ firstNonSpace++
+ }
+ if cs.Col == firstNonSpace {
+ cs.Col = 0
+ } else {
+ cs.Col = firstNonSpace
+ }
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ e.SmartHome()
+ }
case tcell.KeyEnd:
- e.startOrExtendSelection(shift)
- e.Cursor.Col = len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if multi {
+ e.multiMoveAll(func(cs *multicursor.CursorState) {
+ cs.Col = len([]rune(e.Buf.Lines[cs.Line]))
+ })
+ } else {
+ e.startOrExtendSelection(shift)
+ e.Cursor.Col = len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ }
case tcell.KeyPgUp:
e.startOrExtendSelection(shift)
e.Cursor.Line -= e.Viewport.Height
@@ -438,106 +565,120 @@ func (e *EditorPaneWidget) HandleEvent(ev tcell.Event) EventResult {
e.Cursor.Col = lineLen
}
case tcell.KeyEnter:
- if hasSel {
- e.deleteSelection()
- }
- col := e.Cursor.Col
- if col < 0 {
- col = 0
- }
- lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
- if col > lineLen {
- col = lineLen
- }
- line := e.Buf.Lines[e.Cursor.Line]
- indent := leadingWhitespace(line)
- runes := []rune(line)
- charBefore := ' '
- if col > 0 && col <= len(runes) {
- charBefore = runes[col-1]
- }
- charAfter := ' '
- if col < len(runes) {
- charAfter = runes[col]
- }
- extraIndent := charBefore == '{' || charBefore == '(' || charBefore == '[' || charBefore == ':'
- e.exec(&undo.SplitLineCommand{Line: e.Cursor.Line, Col: col})
- e.Cursor.Line++
- e.Cursor.Col = 0
- tabSize := e.TabSize
- if tabSize <= 0 {
- tabSize = 4
- }
- newIndent := indent
- if extraIndent {
- newIndent += strings.Repeat(" ", tabSize)
- }
- if len(newIndent) > 0 {
- e.exec(&undo.InsertStringCommand{Line: e.Cursor.Line, Col: 0, Text: newIndent})
- e.Cursor.Col = len([]rune(newIndent))
- }
- if extraIndent && (charAfter == '}' || charAfter == ')' || charAfter == ']') {
- e.exec(&undo.SplitLineCommand{Line: e.Cursor.Line, Col: e.Cursor.Col})
- e.exec(&undo.InsertStringCommand{Line: e.Cursor.Line + 1, Col: 0, Text: indent})
- }
- case tcell.KeyBackspace, tcell.KeyBackspace2:
- if hasSel {
- e.deleteSelection()
- } else if e.Cursor.Col > 0 {
- runes := []rune(e.Buf.Lines[e.Cursor.Line])
- if e.Cursor.Col > len(runes) {
- e.Cursor.Col = len(runes)
- }
- inLeadingWhitespace := true
- for i := 0; i < e.Cursor.Col && i < len(runes); i++ {
- if runes[i] != ' ' && runes[i] != '\t' {
- inLeadingWhitespace = false
- break
- }
+ if multi {
+ e.multiExecEnter()
+ } else {
+ if hasSel {
+ e.deleteSelection()
+ }
+ col := e.Cursor.Col
+ if col < 0 {
+ col = 0
+ }
+ lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if col > lineLen {
+ col = lineLen
}
+ line := e.Buf.Lines[e.Cursor.Line]
+ indent := leadingWhitespace(line)
+ runes := []rune(line)
+ charBefore := ' '
+ if col > 0 && col <= len(runes) {
+ charBefore = runes[col-1]
+ }
+ charAfter := ' '
+ if col < len(runes) {
+ charAfter = runes[col]
+ }
+ extraIndent := charBefore == '{' || charBefore == '(' || charBefore == '[' || charBefore == ':'
+ e.exec(&undo.SplitLineCommand{Line: e.Cursor.Line, Col: col})
+ e.Cursor.Line++
+ e.Cursor.Col = 0
tabSize := e.TabSize
if tabSize <= 0 {
tabSize = 4
}
- if inLeadingWhitespace && e.Cursor.Col > 1 && runes[e.Cursor.Col-1] == ' ' {
- target := ((e.Cursor.Col - 1) / tabSize) * tabSize
- if target == e.Cursor.Col {
- target -= tabSize
+ newIndent := indent
+ if extraIndent {
+ newIndent += strings.Repeat(" ", tabSize)
+ }
+ if len(newIndent) > 0 {
+ e.exec(&undo.InsertStringCommand{Line: e.Cursor.Line, Col: 0, Text: newIndent})
+ e.Cursor.Col = len([]rune(newIndent))
+ }
+ if extraIndent && (charAfter == '}' || charAfter == ')' || charAfter == ']') {
+ e.exec(&undo.SplitLineCommand{Line: e.Cursor.Line, Col: e.Cursor.Col})
+ e.exec(&undo.InsertStringCommand{Line: e.Cursor.Line + 1, Col: 0, Text: indent})
+ }
+ }
+ case tcell.KeyBackspace, tcell.KeyBackspace2:
+ if multi {
+ e.multiExecBackspace()
+ } else {
+ if hasSel {
+ e.deleteSelection()
+ } else if e.Cursor.Col > 0 {
+ runes := []rune(e.Buf.Lines[e.Cursor.Line])
+ if e.Cursor.Col > len(runes) {
+ e.Cursor.Col = len(runes)
}
- if target < 0 {
- target = 0
+ inLeadingWhitespace := true
+ for i := 0; i < e.Cursor.Col && i < len(runes); i++ {
+ if runes[i] != ' ' && runes[i] != '\t' {
+ inLeadingWhitespace = false
+ break
+ }
}
- e.exec(&undo.DeleteSelectionCommand{
- StartLine: e.Cursor.Line, StartCol: target,
- EndLine: e.Cursor.Line, EndCol: e.Cursor.Col,
- })
- e.Cursor.Col = target
- } else {
- e.exec(&undo.DeleteRuneCommand{Line: e.Cursor.Line, Col: e.Cursor.Col - 1})
- e.Cursor.Col--
+ tabSize := e.TabSize
+ if tabSize <= 0 {
+ tabSize = 4
+ }
+ if inLeadingWhitespace && e.Cursor.Col > 1 && runes[e.Cursor.Col-1] == ' ' {
+ target := ((e.Cursor.Col - 1) / tabSize) * tabSize
+ if target == e.Cursor.Col {
+ target -= tabSize
+ }
+ if target < 0 {
+ target = 0
+ }
+ e.exec(&undo.DeleteSelectionCommand{
+ StartLine: e.Cursor.Line, StartCol: target,
+ EndLine: e.Cursor.Line, EndCol: e.Cursor.Col,
+ })
+ e.Cursor.Col = target
+ } else {
+ e.exec(&undo.DeleteRuneCommand{Line: e.Cursor.Line, Col: e.Cursor.Col - 1})
+ e.Cursor.Col--
+ }
+ } else if e.Cursor.Line > 0 {
+ cmd := &undo.JoinLineCommand{Line: e.Cursor.Line}
+ e.exec(cmd)
+ e.Cursor.Line--
+ e.Cursor.Col = cmd.PrevLen
}
- } else if e.Cursor.Line > 0 {
- cmd := &undo.JoinLineCommand{Line: e.Cursor.Line}
- e.exec(cmd)
- e.Cursor.Line--
- e.Cursor.Col = cmd.PrevLen
}
case tcell.KeyDelete:
- if hasSel {
- e.deleteSelection()
+ if multi {
+ e.multiExecDelete()
} else {
- lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
- if e.Cursor.Col < lineLen {
- e.exec(&undo.DeleteRuneCommand{Line: e.Cursor.Line, Col: e.Cursor.Col})
- } else if e.Cursor.Line < len(e.Buf.Lines)-1 {
- e.exec(&undo.JoinLineCommand{Line: e.Cursor.Line + 1})
+ if hasSel {
+ e.deleteSelection()
+ } else {
+ lineLen := len([]rune(e.Buf.Lines[e.Cursor.Line]))
+ if e.Cursor.Col < lineLen {
+ e.exec(&undo.DeleteRuneCommand{Line: e.Cursor.Line, Col: e.Cursor.Col})
+ } else if e.Cursor.Line < len(e.Buf.Lines)-1 {
+ e.exec(&undo.JoinLineCommand{Line: e.Cursor.Line + 1})
+ }
}
}
case tcell.KeyRune:
if kev.Modifiers() == 0 {
r := kev.Rune()
if r != 0 {
- if hasSel {
+ if multi {
+ e.multiExecRune(r)
+ } else if hasSel {
if closing, ok := autoPairs[r]; ok {
e.deleteSelection()
e.exec(&undo.InsertRuneCommand{Line: e.Cursor.Line, Col: e.Cursor.Col, Rune: r})
@@ -1018,3 +1159,365 @@ func (e *EditorPaneWidget) SmartHome() {
e.clampCursor()
e.scrollViewport()
}
+
+func (e *EditorPaneWidget) ensureMulti() {
+ if e.Multi == nil {
+ e.Multi = multicursor.New(e.Cursor.Line, e.Cursor.Col)
+ if e.Selection != nil && e.Selection.Active {
+ e.Multi.Cursors[0].Sel = *e.Selection
+ }
+ }
+}
+
+func (e *EditorPaneWidget) syncFromMulti() {
+ if e.Multi == nil || len(e.Multi.Cursors) == 0 {
+ return
+ }
+ p := e.Multi.PrimaryCursor()
+ e.Cursor.Line = p.Line
+ e.Cursor.Col = p.Col
+ if e.Selection != nil {
+ *e.Selection = p.Sel
+ }
+}
+
+func (e *EditorPaneWidget) syncToMulti() {
+ if e.Multi == nil || len(e.Multi.Cursors) == 0 {
+ return
+ }
+ c := &e.Multi.Cursors[e.Multi.Primary]
+ c.Line = e.Cursor.Line
+ c.Col = e.Cursor.Col
+ if e.Selection != nil {
+ c.Sel = *e.Selection
+ }
+}
+
+func (e *EditorPaneWidget) isMultiActive() bool {
+ return e.Multi != nil && e.Multi.IsMulti()
+}
+
+func (e *EditorPaneWidget) collapseMulti() {
+ if e.Multi == nil {
+ return
+ }
+ e.Multi.CollapseToSingle()
+ e.syncFromMulti()
+ e.Multi = nil
+ e.multiSearchWord = ""
+}
+
+func (e *EditorPaneWidget) multiExecRune(r rune) {
+ e.syncToMulti()
+ var cmds []undo.EditCommand
+ for i := len(e.Multi.Cursors) - 1; i >= 0; i-- {
+ cs := &e.Multi.Cursors[i]
+ if cs.Sel.Active {
+ start, end := cs.Sel.Range(cs.Line, cs.Col)
+ delCmd := &undo.DeleteSelectionCommand{
+ StartLine: start.Line, StartCol: start.Col,
+ EndLine: end.Line, EndCol: end.Col,
+ }
+ delCmd.Apply(e.Buf)
+ cmds = append(cmds, delCmd)
+ cs.Line = start.Line
+ cs.Col = start.Col
+ cs.Sel.Clear()
+ e.adjustLaterCursors(i, start, end)
+ }
+ insertCol := cs.Col
+ cmd := &undo.InsertRuneCommand{Line: cs.Line, Col: insertCol, Rune: r}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ cs.Col++
+ e.shiftLaterCursors(i, cs.Line, insertCol, 1)
+ }
+ if e.Undo != nil {
+ e.Undo.Push(&undo.BatchCommand{Commands: cmds})
+ }
+ e.syncFromMulti()
+ if e.OnChange != nil {
+ e.OnChange()
+ }
+}
+
+func (e *EditorPaneWidget) multiExecBackspace() {
+ e.syncToMulti()
+ var cmds []undo.EditCommand
+ for i := len(e.Multi.Cursors) - 1; i >= 0; i-- {
+ cs := &e.Multi.Cursors[i]
+ if cs.Sel.Active {
+ start, end := cs.Sel.Range(cs.Line, cs.Col)
+ delCmd := &undo.DeleteSelectionCommand{
+ StartLine: start.Line, StartCol: start.Col,
+ EndLine: end.Line, EndCol: end.Col,
+ }
+ delCmd.Apply(e.Buf)
+ cmds = append(cmds, delCmd)
+ cs.Line = start.Line
+ cs.Col = start.Col
+ cs.Sel.Clear()
+ e.adjustLaterCursors(i, start, end)
+ continue
+ }
+ if cs.Col > 0 {
+ cmd := &undo.DeleteRuneCommand{Line: cs.Line, Col: cs.Col - 1}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ cs.Col--
+ e.shiftLaterCursors(i, cs.Line, cs.Col, -1)
+ } else if cs.Line > 0 {
+ prevLen := len([]rune(e.Buf.Lines[cs.Line-1]))
+ cmd := &undo.JoinLineCommand{Line: cs.Line}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ e.shiftLaterLines(i, cs.Line, -1)
+ cs.Line--
+ cs.Col = prevLen
+ }
+ }
+ if len(cmds) > 0 {
+ if e.Undo != nil {
+ e.Undo.Push(&undo.BatchCommand{Commands: cmds})
+ }
+ if e.OnChange != nil {
+ e.OnChange()
+ }
+ }
+ e.Multi.Deduplicate()
+ e.syncFromMulti()
+}
+
+func (e *EditorPaneWidget) multiExecDelete() {
+ e.syncToMulti()
+ var cmds []undo.EditCommand
+ for i := len(e.Multi.Cursors) - 1; i >= 0; i-- {
+ cs := &e.Multi.Cursors[i]
+ if cs.Sel.Active {
+ start, end := cs.Sel.Range(cs.Line, cs.Col)
+ delCmd := &undo.DeleteSelectionCommand{
+ StartLine: start.Line, StartCol: start.Col,
+ EndLine: end.Line, EndCol: end.Col,
+ }
+ delCmd.Apply(e.Buf)
+ cmds = append(cmds, delCmd)
+ cs.Line = start.Line
+ cs.Col = start.Col
+ cs.Sel.Clear()
+ e.adjustLaterCursors(i, start, end)
+ continue
+ }
+ lineLen := len([]rune(e.Buf.Lines[cs.Line]))
+ if cs.Col < lineLen {
+ cmd := &undo.DeleteRuneCommand{Line: cs.Line, Col: cs.Col}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ e.shiftLaterCursors(i, cs.Line, cs.Col, -1)
+ } else if cs.Line < len(e.Buf.Lines)-1 {
+ cmd := &undo.JoinLineCommand{Line: cs.Line + 1}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ e.shiftLaterLines(i, cs.Line+1, -1)
+ }
+ }
+ if len(cmds) > 0 {
+ if e.Undo != nil {
+ e.Undo.Push(&undo.BatchCommand{Commands: cmds})
+ }
+ if e.OnChange != nil {
+ e.OnChange()
+ }
+ }
+ e.Multi.Deduplicate()
+ e.syncFromMulti()
+}
+
+func (e *EditorPaneWidget) multiExecEnter() {
+ e.syncToMulti()
+ var cmds []undo.EditCommand
+ for i := len(e.Multi.Cursors) - 1; i >= 0; i-- {
+ cs := &e.Multi.Cursors[i]
+ if cs.Sel.Active {
+ start, end := cs.Sel.Range(cs.Line, cs.Col)
+ delCmd := &undo.DeleteSelectionCommand{
+ StartLine: start.Line, StartCol: start.Col,
+ EndLine: end.Line, EndCol: end.Col,
+ }
+ delCmd.Apply(e.Buf)
+ cmds = append(cmds, delCmd)
+ cs.Line = start.Line
+ cs.Col = start.Col
+ cs.Sel.Clear()
+ e.adjustLaterCursors(i, start, end)
+ }
+ cmd := &undo.SplitLineCommand{Line: cs.Line, Col: cs.Col}
+ cmd.Apply(e.Buf)
+ cmds = append(cmds, cmd)
+ e.shiftLaterLines(i, cs.Line, 1)
+ cs.Line++
+ cs.Col = 0
+ }
+ if e.Undo != nil {
+ e.Undo.Push(&undo.BatchCommand{Commands: cmds})
+ }
+ e.syncFromMulti()
+ if e.OnChange != nil {
+ e.OnChange()
+ }
+}
+
+func (e *EditorPaneWidget) adjustLaterCursors(editedIdx int, start, end selection.Position) {
+ for j := editedIdx + 1; j < len(e.Multi.Cursors); j++ {
+ cs := &e.Multi.Cursors[j]
+ if start.Line == end.Line {
+ if cs.Line == start.Line && cs.Col >= end.Col {
+ cs.Col -= end.Col - start.Col
+ }
+ } else {
+ if cs.Line == end.Line {
+ cs.Col = start.Col + (cs.Col - end.Col)
+ cs.Line = start.Line
+ } else if cs.Line > end.Line {
+ cs.Line -= end.Line - start.Line
+ }
+ }
+ }
+}
+
+func (e *EditorPaneWidget) shiftLaterCursors(editedIdx, line, col, delta int) {
+ for j := editedIdx + 1; j < len(e.Multi.Cursors); j++ {
+ cs := &e.Multi.Cursors[j]
+ if cs.Line == line && cs.Col >= col {
+ cs.Col += delta
+ if cs.Col < 0 {
+ cs.Col = 0
+ }
+ }
+ }
+}
+
+func (e *EditorPaneWidget) shiftLaterLines(editedIdx, fromLine, delta int) {
+ for j := editedIdx + 1; j < len(e.Multi.Cursors); j++ {
+ if e.Multi.Cursors[j].Line >= fromLine {
+ e.Multi.Cursors[j].Line += delta
+ }
+ }
+}
+
+func (e *EditorPaneWidget) multiMoveAll(moveFn func(cs *multicursor.CursorState)) {
+ e.syncToMulti()
+ for i := range e.Multi.Cursors {
+ e.Multi.Cursors[i].Sel.Clear()
+ moveFn(&e.Multi.Cursors[i])
+ }
+ e.Multi.Deduplicate()
+ e.syncFromMulti()
+}
+
+func (e *EditorPaneWidget) SelectNextOccurrence() {
+ if e.Selection == nil {
+ return
+ }
+ word := ""
+ if e.Selection.Active {
+ word = e.Selection.Text(e.Buf.Lines, e.Cursor.Line, e.Cursor.Col)
+ }
+ if word == "" {
+ e.selectWord(e.Cursor.Line, e.Cursor.Col)
+ if e.Selection.Active {
+ e.multiSearchWord = e.Selection.Text(e.Buf.Lines, e.Cursor.Line, e.Cursor.Col)
+ }
+ e.ensureMulti()
+ e.syncToMulti()
+ return
+ }
+ if e.multiSearchWord == "" {
+ e.multiSearchWord = word
+ }
+ e.ensureMulti()
+ e.syncToMulti()
+
+ searchWord := e.multiSearchWord
+ lastCursor := e.Multi.Cursors[len(e.Multi.Cursors)-1]
+ startLine := lastCursor.Line
+ startCol := lastCursor.Col
+
+ for line := startLine; line < len(e.Buf.Lines)+startLine; line++ {
+ l := line % len(e.Buf.Lines)
+ runes := []rune(e.Buf.Lines[l])
+ searchRunes := []rune(searchWord)
+ fromCol := 0
+ if l == startLine {
+ fromCol = startCol
+ }
+ for col := fromCol; col <= len(runes)-len(searchRunes); col++ {
+ if string(runes[col:col+len(searchRunes)]) == searchWord {
+ already := false
+ for _, c := range e.Multi.Cursors {
+ s, end := c.Sel.Range(c.Line, c.Col)
+ if s.Line == l && s.Col == col && end.Col == col+len(searchRunes) {
+ already = true
+ break
+ }
+ }
+ if already {
+ continue
+ }
+ sel := selection.Selection{Active: true, Anchor: selection.Position{Line: l, Col: col}}
+ e.Multi.AddWithSelection(l, col+len(searchRunes), sel)
+ e.syncFromMulti()
+ e.scrollViewport()
+ return
+ }
+ }
+ }
+}
+
+func (e *EditorPaneWidget) SelectAllOccurrences() {
+ if e.Selection == nil {
+ return
+ }
+ word := ""
+ if e.Selection.Active {
+ word = e.Selection.Text(e.Buf.Lines, e.Cursor.Line, e.Cursor.Col)
+ }
+ if word == "" {
+ e.selectWord(e.Cursor.Line, e.Cursor.Col)
+ if e.Selection.Active {
+ word = e.Selection.Text(e.Buf.Lines, e.Cursor.Line, e.Cursor.Col)
+ }
+ }
+ if word == "" {
+ return
+ }
+ e.multiSearchWord = word
+ e.ensureMulti()
+ e.syncToMulti()
+
+ searchRunes := []rune(word)
+ for line := 0; line < len(e.Buf.Lines); line++ {
+ runes := []rune(e.Buf.Lines[line])
+ for col := 0; col <= len(runes)-len(searchRunes); col++ {
+ if string(runes[col:col+len(searchRunes)]) == word {
+ sel := selection.Selection{Active: true, Anchor: selection.Position{Line: line, Col: col}}
+ e.Multi.AddWithSelection(line, col+len(searchRunes), sel)
+ }
+ }
+ }
+ e.syncFromMulti()
+}
+
+func (e *EditorPaneWidget) UndoLastCursor() {
+ if e.Multi == nil || !e.Multi.IsMulti() {
+ return
+ }
+ e.Multi.RemoveLast()
+ if !e.Multi.IsMulti() {
+ e.syncFromMulti()
+ e.Multi = nil
+ e.multiSearchWord = ""
+ } else {
+ e.syncFromMulti()
+ }
+ e.scrollViewport()
+}
diff --git a/internal/ui/statusbar_widget.go b/internal/ui/statusbar_widget.go
index f39518c..8d6a09d 100644
--- a/internal/ui/statusbar_widget.go
+++ b/internal/ui/statusbar_widget.go
@@ -57,7 +57,11 @@ func (s *StatusBarWidget) Render(surface *RenderSurface) {
id string
}
var right []segment
- right = append(right, segment{fmt.Sprintf("Ln %d, Col %d", st.Line+1, st.Col+1), "pos"})
+ posText := fmt.Sprintf("Ln %d, Col %d", st.Line+1, st.Col+1)
+ if st.CursorCount > 1 {
+ posText += fmt.Sprintf(" (%d cursors)", st.CursorCount)
+ }
+ right = append(right, segment{posText, "pos"})
if st.TabSize > 0 {
right = append(right, segment{fmt.Sprintf("Spaces: %d", st.TabSize), "indent"})
}
diff --git a/internal/view/statusbar.go b/internal/view/statusbar.go
index 906da70..9d0d586 100644
--- a/internal/view/statusbar.go
+++ b/internal/view/statusbar.go
@@ -35,6 +35,7 @@ type StatusBar struct {
Language string
LSP bool
TabSize int
+ CursorCount int
Notification string
NotifyLevel NotifyLevel
NotifyExpiry time.Time
diff --git a/tests/functional/multi-cursor.test.js b/tests/functional/multi-cursor.test.js
new file mode 100644
index 0000000..7d54af0
--- /dev/null
+++ b/tests/functional/multi-cursor.test.js
@@ -0,0 +1,167 @@
+import { describe, it, expect, afterEach } from "vitest";
+import * as tui from "./tui.js";
+import { createTempDir, createTempFile, cleanupDir, readFile } from "./helpers.js";
+
+let dir;
+
+afterEach(() => {
+ tui.kill();
+ if (dir) cleanupDir(dir);
+});
+
+describe("multi-cursor", () => {
+ it("should select next occurrence with ctrl+d and type at all cursors", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "ctrld.txt", "foo bar foo baz foo");
+
+ tui.start(file);
+ tui.waitFor("foo bar foo");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ tui.type("qux");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ expect(content).toBe("qux bar qux baz qux\n");
+ });
+
+ it("should select all occurrences with command palette and replace", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "selall.txt", "cat dog cat bird cat");
+
+ tui.start(file);
+ tui.waitFor("cat dog");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ tui.exec("Select All Occurrences");
+ tui.waitStable();
+
+ tui.type("pet");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ expect(content).toBe("pet dog pet bird pet\n");
+ });
+
+ it("should undo multi-cursor edit as single step", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "undo.txt", "aa bb aa");
+
+ tui.start(file);
+ tui.waitFor("aa bb aa");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ tui.type("cc");
+ tui.waitStable();
+
+ tui.press("ctrl+z");
+ tui.waitStable();
+ tui.press("ctrl+z");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ expect(content).toContain("aa");
+ expect(content).not.toContain("cc");
+ });
+
+ it("should collapse multi-cursor on escape", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "escape.txt", "xx yy xx");
+
+ tui.start(file);
+ tui.waitFor("xx yy xx");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ tui.press("escape");
+ tui.waitStable();
+
+ tui.type("Z");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ // Only one Z should be inserted (single cursor after escape)
+ const zCount = (content.match(/Z/g) || []).length;
+ expect(zCount).toBe(1);
+ });
+
+ it("should backspace at all cursors", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "bksp.txt", "ABC DEF ABC");
+
+ tui.start(file);
+ tui.waitFor("ABC DEF ABC");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ // Cursors have "ABC" selected at both positions, type to replace
+ tui.press("delete");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ expect(content).toBe(" DEF \n");
+ });
+
+ it("should handle multi-cursor on multiline file", () => {
+ dir = createTempDir();
+ const file = createTempFile(dir, "multiline.txt", "var x = 1;\nvar y = 2;\nvar z = 3;");
+
+ tui.start(file);
+ tui.waitFor("var x");
+
+ tui.press("home");
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+ tui.press("ctrl+d");
+ tui.waitStable();
+
+ tui.type("let");
+ tui.waitStable();
+
+ tui.press("ctrl+s");
+ tui.waitStable();
+
+ const content = readFile(file);
+ expect(content).toBe("let x = 1;\nlet y = 2;\nlet z = 3;\n");
+ });
+});