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"); + }); +});