From a70545be1eeb2bc445afab9aed79e3b75b06070e Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Thu, 28 May 2026 20:34:12 -0700 Subject: [PATCH 1/4] feat: multi-cursor editing (Ctrl+D, Ctrl+K L, Alt+Click, Escape) Add full multi-cursor support: select next/all occurrences, add cursors via Alt+Click, type/delete/enter at all cursors simultaneously with single-step undo via BatchCommand grouping. Co-Authored-By: Claude Opus 4.6 --- cmd/ttt/commands.go | 17 + cmd/ttt/eventloop.go | 1 + cmd/ttt/menus.go | 4 + internal/config/keybindings.go | 3 + internal/core/multicursor/multicursor.go | 133 ++++ internal/core/multicursor/multicursor_test.go | 112 +++ internal/core/undo/undo.go | 16 + internal/core/undo/undo_test.go | 43 + internal/ui/editor_group.go | 45 ++ internal/ui/editor_widget.go | 740 +++++++++++++++--- internal/ui/statusbar_widget.go | 6 +- internal/view/statusbar.go | 1 + 12 files changed, 1002 insertions(+), 119 deletions(-) create mode 100644 internal/core/multicursor/multicursor.go create mode 100644 internal/core/multicursor/multicursor_test.go 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/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..9a103f9 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 @@ -245,11 +247,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 +593,41 @@ func (g *EditorGroupWidget) DeleteWordRight() { } } +func (g *EditorGroupWidget) SelectNextOccurrence() { + if g.IsEditorActive() { + g.Editor.SelectNextOccurrence() + } +} + +func (g *EditorGroupWidget) SelectAllOccurrences() { + if g.IsEditorActive() { + g.Editor.SelectAllOccurrences() + } +} + +func (g *EditorGroupWidget) UndoLastCursor() { + if g.IsEditorActive() { + g.Editor.UndoLastCursor() + } +} + +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 +670,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 { diff --git a/internal/ui/editor_widget.go b/internal/ui/editor_widget.go index 3663670..47ae6ff 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,47 @@ 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 && !inAnySel { + for _, mc := range allCursors { + if mc.Line == lineIdx && mc.Col == colIdx { + bgStyle = term.StyleSelection + break + } + } + } ulStyle := e.diagStyleAt(lineIdx, colIdx) surface.SetCell(gutterW+x, y, term.Cell{Ch: ch, Style: style, BgStyle: bgStyle, UlStyle: ulStyle}) } @@ -322,9 +364,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 +431,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 +564,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 +1158,367 @@ 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.adjustCursorsAfterDelete(i, start, end) + } + cmd := &undo.InsertRuneCommand{Line: cs.Line, Col: cs.Col, Rune: r} + cmd.Apply(e.Buf) + cmds = append(cmds, cmd) + cs.Col++ + e.adjustCursorsAfterInsert(i, cs.Line, 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.adjustCursorsAfterDelete(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.adjustCursorsAfterInsert(i, cs.Line, -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) + cs.Line-- + cs.Col = prevLen + e.adjustCursorsAfterJoinLine(i, cs.Line+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) 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.adjustCursorsAfterDelete(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.adjustCursorsAfterInsert(i, cs.Line, -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.adjustCursorsAfterJoinLine(i, cs.Line+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.adjustCursorsAfterDelete(i, start, end) + } + cmd := &undo.SplitLineCommand{Line: cs.Line, Col: cs.Col} + cmd.Apply(e.Buf) + cmds = append(cmds, cmd) + cs.Line++ + cs.Col = 0 + e.adjustCursorsAfterSplitLine(i, cs.Line-1) + } + if e.Undo != nil { + e.Undo.Push(&undo.BatchCommand{Commands: cmds}) + } + e.syncFromMulti() + if e.OnChange != nil { + e.OnChange() + } +} + +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) adjustCursorsAfterInsert(editedIdx, editedLine, colDelta int) { + for j := editedIdx - 1; j >= 0; j-- { + if e.Multi.Cursors[j].Line == editedLine { + e.Multi.Cursors[j].Col += colDelta + if e.Multi.Cursors[j].Col < 0 { + e.Multi.Cursors[j].Col = 0 + } + } + } +} + +func (e *EditorPaneWidget) adjustCursorsAfterDelete(editedIdx int, start, end selection.Position) { + for j := editedIdx - 1; j >= 0; j-- { + cs := &e.Multi.Cursors[j] + if cs.Line > end.Line { + cs.Line -= end.Line - start.Line + } else if cs.Line == end.Line && cs.Col >= end.Col { + cs.Col = start.Col + (cs.Col - end.Col) + cs.Line = start.Line + } else if cs.Line == start.Line && cs.Col > start.Col { + cs.Col = start.Col + } + } +} + +func (e *EditorPaneWidget) adjustCursorsAfterJoinLine(editedIdx, joinedLine int) { + for j := editedIdx - 1; j >= 0; j-- { + if e.Multi.Cursors[j].Line >= joinedLine { + e.Multi.Cursors[j].Line-- + } + } +} + +func (e *EditorPaneWidget) adjustCursorsAfterSplitLine(editedIdx, splitLine int) { + for j := editedIdx - 1; j >= 0; j-- { + if e.Multi.Cursors[j].Line > splitLine { + e.Multi.Cursors[j].Line++ + } + } +} + +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 From ba49fd9af20335cdea1c52426fc4d5186b1d6d44 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Thu, 28 May 2026 21:26:20 -0700 Subject: [PATCH 2/4] fix: multi-cursor position tracking and tab state persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented multi-cursor edits from working: 1. syncTabs() in Render() overwrote Editor.Multi on every redraw, losing cursor state set by SelectNextOccurrence(). Fixed by saving Multi back to the tab after mutations and after HandleEvent. 2. Cursor position adjustments went the wrong direction — adjusting unprocessed cursors (earlier positions) instead of already-processed ones (later positions). Rewrote adjust helpers to fix later-index cursors after each edit in the right-to-left loop. Co-Authored-By: Claude Opus 4.6 --- internal/ui/editor_group.go | 7 +- internal/ui/editor_widget.go | 88 +++++++------- tests/functional/multi-cursor.test.js | 167 ++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 46 deletions(-) create mode 100644 tests/functional/multi-cursor.test.js diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index 9a103f9..17d1b7f 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -596,18 +596,21 @@ 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() } } @@ -780,5 +783,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 47ae6ff..e5eaaf8 100644 --- a/internal/ui/editor_widget.go +++ b/internal/ui/editor_widget.go @@ -1222,13 +1222,14 @@ func (e *EditorPaneWidget) multiExecRune(r rune) { cs.Line = start.Line cs.Col = start.Col cs.Sel.Clear() - e.adjustCursorsAfterDelete(i, start, end) + e.adjustLaterCursors(i, start, end) } - cmd := &undo.InsertRuneCommand{Line: cs.Line, Col: cs.Col, Rune: r} + insertCol := cs.Col + cmd := &undo.InsertRuneCommand{Line: cs.Line, Col: insertCol, Rune: r} cmd.Apply(e.Buf) cmds = append(cmds, cmd) cs.Col++ - e.adjustCursorsAfterInsert(i, cs.Line, 1) + e.shiftLaterCursors(i, cs.Line, insertCol, 1) } if e.Undo != nil { e.Undo.Push(&undo.BatchCommand{Commands: cmds}) @@ -1255,7 +1256,7 @@ func (e *EditorPaneWidget) multiExecBackspace() { cs.Line = start.Line cs.Col = start.Col cs.Sel.Clear() - e.adjustCursorsAfterDelete(i, start, end) + e.adjustLaterCursors(i, start, end) continue } if cs.Col > 0 { @@ -1263,15 +1264,15 @@ func (e *EditorPaneWidget) multiExecBackspace() { cmd.Apply(e.Buf) cmds = append(cmds, cmd) cs.Col-- - e.adjustCursorsAfterInsert(i, cs.Line, -1) + 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 - e.adjustCursorsAfterJoinLine(i, cs.Line+1) } } if len(cmds) > 0 { @@ -1302,7 +1303,7 @@ func (e *EditorPaneWidget) multiExecDelete() { cs.Line = start.Line cs.Col = start.Col cs.Sel.Clear() - e.adjustCursorsAfterDelete(i, start, end) + e.adjustLaterCursors(i, start, end) continue } lineLen := len([]rune(e.Buf.Lines[cs.Line])) @@ -1310,12 +1311,12 @@ func (e *EditorPaneWidget) multiExecDelete() { cmd := &undo.DeleteRuneCommand{Line: cs.Line, Col: cs.Col} cmd.Apply(e.Buf) cmds = append(cmds, cmd) - e.adjustCursorsAfterInsert(i, cs.Line, -1) + 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.adjustCursorsAfterJoinLine(i, cs.Line+1) + e.shiftLaterLines(i, cs.Line+1, -1) } } if len(cmds) > 0 { @@ -1346,14 +1347,14 @@ func (e *EditorPaneWidget) multiExecEnter() { cs.Line = start.Line cs.Col = start.Col cs.Sel.Clear() - e.adjustCursorsAfterDelete(i, start, end) + 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 - e.adjustCursorsAfterSplitLine(i, cs.Line-1) } if e.Undo != nil { e.Undo.Push(&undo.BatchCommand{Commands: cmds}) @@ -1364,55 +1365,52 @@ func (e *EditorPaneWidget) multiExecEnter() { } } -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) adjustCursorsAfterInsert(editedIdx, editedLine, colDelta int) { - for j := editedIdx - 1; j >= 0; j-- { - if e.Multi.Cursors[j].Line == editedLine { - e.Multi.Cursors[j].Col += colDelta - if e.Multi.Cursors[j].Col < 0 { - e.Multi.Cursors[j].Col = 0 +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) adjustCursorsAfterDelete(editedIdx int, start, end selection.Position) { - for j := editedIdx - 1; j >= 0; j-- { +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 > end.Line { - cs.Line -= end.Line - start.Line - } else if cs.Line == end.Line && cs.Col >= end.Col { - cs.Col = start.Col + (cs.Col - end.Col) - cs.Line = start.Line - } else if cs.Line == start.Line && cs.Col > start.Col { - cs.Col = start.Col + if cs.Line == line && cs.Col >= col { + cs.Col += delta + if cs.Col < 0 { + cs.Col = 0 + } } } } -func (e *EditorPaneWidget) adjustCursorsAfterJoinLine(editedIdx, joinedLine int) { - for j := editedIdx - 1; j >= 0; j-- { - if e.Multi.Cursors[j].Line >= joinedLine { - e.Multi.Cursors[j].Line-- +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) adjustCursorsAfterSplitLine(editedIdx, splitLine int) { - for j := editedIdx - 1; j >= 0; j-- { - if e.Multi.Cursors[j].Line > splitLine { - e.Multi.Cursors[j].Line++ - } +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() { 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"); + }); +}); From 93c1800810a152d5e7ba1de79d03a5659e2d8f96 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 29 May 2026 11:09:07 -0700 Subject: [PATCH 3/4] fix: render multi-cursor positions as solid blocks Use reverse style for all cursor positions so they appear as solid blocks of the editor foreground color. Hide the hardware cursor when multi-cursor is active so all cursors render uniformly. Co-Authored-By: Claude Opus 4.6 --- internal/ui/editor_group.go | 3 +++ internal/ui/editor_widget.go | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index 17d1b7f..b603451 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -233,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 diff --git a/internal/ui/editor_widget.go b/internal/ui/editor_widget.go index e5eaaf8..56ff657 100644 --- a/internal/ui/editor_widget.go +++ b/internal/ui/editor_widget.go @@ -183,10 +183,11 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) { (lineIdx == matchLine && colIdx == matchCol)) { bgStyle = term.StyleBracketMatch } - if multiActive && !inAnySel { + if multiActive { for _, mc := range allCursors { if mc.Line == lineIdx && mc.Col == colIdx { - bgStyle = term.StyleSelection + style = term.StyleSelection + bgStyle = 0 break } } From 5eaedb07bd96357966ef478a4c8fd20fd1f8ae5f Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 29 May 2026 11:15:54 -0700 Subject: [PATCH 4/4] docs: add multi-cursor editing documentation Update README and docs-web with multi-cursor keybindings and behavior: Ctrl+D, Ctrl+K L, Alt+Click, Ctrl+K U, Escape. Co-Authored-By: Claude Opus 4.6 --- README.md | 7 ++++++ .../docs/getting-started/introduction.md | 1 + .../docs/getting-started/quick-start.md | 2 ++ docs-web/src/content/docs/guides/editor.md | 22 +++++++++++++++++++ .../src/content/docs/guides/keybindings.md | 10 +++++++++ docs-web/src/content/docs/index.mdx | 3 +++ .../src/content/docs/reference/keybindings.md | 10 +++++++++ 7 files changed, 55 insertions(+) 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/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 |