Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `,`
Expand Down Expand Up @@ -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 |
Expand Down
17 changes: 17 additions & 0 deletions cmd/ttt/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions cmd/ttt/eventloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/ttt/menus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs-web/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions docs-web/src/content/docs/guides/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs-web/src/content/docs/guides/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions docs-web/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
<Card title="Multi-Folder Workspaces" icon="open-book">
Open multiple project directories in a single session with per-folder git status and search.
</Card>
<Card title="Multi-Cursor Editing" icon="pencil">
Place cursors at multiple locations with Ctrl+D, Ctrl+K L, or Alt+Click. Type, delete, and insert at all positions simultaneously.
</Card>
<Card title="Theming" icon="star">
10 built-in themes with full customization. Every color in the editor is configurable via JSON.
</Card>
Expand Down
10 changes: 10 additions & 0 deletions docs-web/src/content/docs/reference/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions internal/config/keybindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
133 changes: 133 additions & 0 deletions internal/core/multicursor/multicursor.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading