feat: text selection — keyboard select, copy/cut/paste, delete
58b346d4abd051870facf2e06ef6ceff7a17b041
humdrum <me@humdrum.me> · 2026-06-29 09:14
parent d538465a
feat: text selection — keyboard select, copy/cut/paste, delete - Editor selection model (anchor + cursor): Shift+Left/Right/Up/Down/Home/End and Ctrl+Shift+Left/Right extend the selection; plain moves collapse it. - Selection is highlighted (SelFg/SelBg) per visual row, multi-line aware. - Typing, Enter, Tab, Backspace, and Delete replace/remove the selection. - Ctrl+C copy, Ctrl+X cut, Ctrl+V paste via the system clipboard (atotto). - Adds SelectedText/DeleteSelection/InsertText and selection rendering.
5 files changed
go.mod +1 −1
@@ -4,6 +4,7 @@ go 1.26.4
require (
github.com/BurntSushi/toml v1.6.0
+ github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
@@ -14,7 +15,6 @@ )
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
- github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
internal/app/app.go +43 −0
@@ -16,6 +16,7 @@ "glint/internal/picker"
"glint/internal/preview"
"glint/internal/theme"
+ "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -234,6 +235,12 @@ case tea.KeyCtrlN:
return a.newFile(a.currentDir(), discardNew)
case tea.KeyCtrlB:
return a.newFile(a.cfg.InboxRoot(), discardInbox)
+ case tea.KeyCtrlC:
+ return a.copySelection(false)
+ case tea.KeyCtrlX:
+ return a.copySelection(true)
+ case tea.KeyCtrlV:
+ return a.paste()
case tea.KeyEsc:
a.mode = ModeEditor
return a, nil
@@ -367,6 +374,42 @@ }
if err := a.Load(p); err != nil {
a.status = "Open failed: " + err.Error()
}
+ return a, nil
+}
+
+// copySelection copies the editor selection to the clipboard, deleting it when
+// cut is true. No-op outside the editor or with no selection.
+func (a *App) copySelection(cut bool) (tea.Model, tea.Cmd) {
+ if a.mode != ModeEditor {
+ return a, nil
+ }
+ text := a.editor.SelectedText()
+ if text == "" {
+ return a, nil
+ }
+ if err := clipboard.WriteAll(text); err != nil {
+ a.status = "Copy failed: " + err.Error()
+ return a, nil
+ }
+ if cut {
+ a.editor.DeleteSelection()
+ a.status = "Cut"
+ } else {
+ a.status = "Copied"
+ }
+ return a, nil
+}
+
+// paste inserts the clipboard contents at the cursor (replacing any selection).
+func (a *App) paste() (tea.Model, tea.Cmd) {
+ if a.mode != ModeEditor {
+ return a, nil
+ }
+ text, err := clipboard.ReadAll()
+ if err != nil || text == "" {
+ return a, nil
+ }
+ a.editor.InsertText(text)
return a, nil
}
internal/editor/editor.go +68 −11
@@ -24,6 +24,7 @@ Dirty bool
Width int
Height int // visible text rows
goalCol int // remembered visual column for vertical moves
+ anchor *Position // selection anchor; nil = no selection
theme theme.Theme
}
@@ -429,6 +430,7 @@
// HandleKey maps a key message to a buffer operation.
func (e *Editor) HandleKey(k tea.KeyMsg) {
switch k.Type {
+ // Text input — replaces any active selection.
case tea.KeyRunes:
// Option-as-Meta terminals send Option+Left/Right as Alt+b / Alt+f
// (readline word motion). Handle those and never insert an Alt-runed key.
@@ -443,11 +445,12 @@ e.DeleteWordRight()
}
return
}
+ e.replaceSelection()
for _, r := range k.Runes {
e.InsertRune(r)
}
case tea.KeySpace:
- // Real bubbletea reports a space as KeySpace with Runes == [' '].
+ e.replaceSelection()
if len(k.Runes) == 0 {
e.InsertRune(' ')
}
@@ -455,54 +458,104 @@ for _, r := range k.Runes {
e.InsertRune(r)
}
case tea.KeyEnter:
+ e.replaceSelection()
e.InsertNewline()
+ case tea.KeyTab:
+ e.replaceSelection()
+ e.InsertRune('\t')
+
+ // Deletion — a selection is removed wholesale.
case tea.KeyBackspace:
+ if e.DeleteSelection() {
+ return
+ }
if k.Alt {
e.DeleteWordLeft()
} else {
e.Backspace()
}
case tea.KeyDelete:
+ if e.DeleteSelection() {
+ return
+ }
e.Delete()
+ case tea.KeyCtrlU:
+ e.KillToLineStart()
+ case tea.KeyCtrlK:
+ e.KillToLineEnd()
+ case tea.KeyCtrlW:
+ e.DeleteWordLeft()
+
+ // Plain movement collapses the selection.
case tea.KeyLeft:
+ e.ClearSelection()
if k.Alt {
e.MoveWordLeft()
} else {
e.MoveLeft()
}
case tea.KeyRight:
+ e.ClearSelection()
if k.Alt {
e.MoveWordRight()
} else {
e.MoveRight()
}
case tea.KeyUp:
+ e.ClearSelection()
e.MoveUp()
case tea.KeyDown:
+ e.ClearSelection()
e.MoveDown()
case tea.KeyHome:
+ e.ClearSelection()
e.MoveHome()
case tea.KeyEnd:
+ e.ClearSelection()
e.MoveEnd()
case tea.KeyCtrlHome:
+ e.ClearSelection()
e.MoveDocStart()
case tea.KeyCtrlEnd:
+ e.ClearSelection()
e.MoveDocEnd()
- case tea.KeyTab:
- e.InsertRune('\t')
- case tea.KeyCtrlU:
- e.KillToLineStart()
- case tea.KeyCtrlK:
- e.KillToLineEnd()
- case tea.KeyCtrlW:
- e.DeleteWordLeft()
+
+ // Shift movement extends the selection.
+ case tea.KeyShiftLeft:
+ e.startSelection()
+ e.MoveLeft()
+ case tea.KeyShiftRight:
+ e.startSelection()
+ e.MoveRight()
+ case tea.KeyShiftUp:
+ e.startSelection()
+ e.MoveUp()
+ case tea.KeyShiftDown:
+ e.startSelection()
+ e.MoveDown()
+ case tea.KeyShiftHome:
+ e.startSelection()
+ e.MoveHome()
+ case tea.KeyShiftEnd:
+ e.startSelection()
+ e.MoveEnd()
+ case tea.KeyCtrlShiftLeft:
+ e.startSelection()
+ e.MoveWordLeft()
+ case tea.KeyCtrlShiftRight:
+ e.startSelection()
+ e.MoveWordRight()
}
}
+
+// replaceSelection deletes the selection (if any) so the next insert overwrites it.
+func (e *Editor) replaceSelection() { e.DeleteSelection() }
// View renders the visible visual rows, styled, with a themed cursor cell on
// the cursor's visual row. Output is e.Width columns wide; the app adds margins.
func (e *Editor) View() string {
cursorStyle := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.Pointer)
+ selStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg)
rows := e.buildVisual()
ci := cursorVIndex(rows, e.Cursor)
var b strings.Builder
@@ -511,10 +564,14 @@ if end > len(rows) {
end = len(rows)
}
for r := e.Scroll; r < end; r++ {
+ spans := rows[r].spans
+ if a, bb, ok := e.selectionForRow(rows[r]); ok {
+ spans = overlaySelection(spans, a, bb, selStyle)
+ }
if r == ci {
- b.WriteString(renderSpansCursor(rows[r].spans, e.Cursor.Col-rows[r].start, cursorStyle))
+ b.WriteString(renderSpansCursor(spans, e.Cursor.Col-rows[r].start, cursorStyle))
} else {
- b.WriteString(renderSpans(rows[r].spans))
+ b.WriteString(renderSpans(spans))
}
b.WriteByte('\n')
}
internal/editor/editor_test.go +53 −0
@@ -425,3 +425,56 @@ if e.Cursor != (Position{Row: 0, Col: 0}) {
t.Errorf("Ctrl+Home → %+v, want {0 0}", e.Cursor)
}
}
+
+func TestShiftSelectAndReplace(t *testing.T) {
+ e := newEditorWith("hello world")
+ e.Cursor = Position{Row: 0, Col: 0}
+ for i := 0; i < 5; i++ {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+ }
+ if !e.HasSelection() || e.SelectedText() != "hello" {
+ t.Fatalf("selection = %q, want hello", e.SelectedText())
+ }
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("X")})
+ if e.Lines[0] != "X world" {
+ t.Errorf("typing over selection → %q, want 'X world'", e.Lines[0])
+ }
+ if e.HasSelection() {
+ t.Error("selection should clear after replace")
+ }
+}
+
+func TestBackspaceDeletesSelection(t *testing.T) {
+ e := newEditorWith("abcdef")
+ e.Cursor = Position{Row: 0, Col: 2}
+ for i := 0; i < 3; i++ {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight}) // select "cde"
+ }
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace})
+ if e.Lines[0] != "abf" {
+ t.Errorf("backspace over selection → %q, want abf", e.Lines[0])
+ }
+}
+
+func TestMultilineSelectedTextAndDelete(t *testing.T) {
+ e := newEditorWith("abc", "def", "ghi")
+ e.anchor = &Position{Row: 0, Col: 1}
+ e.Cursor = Position{Row: 2, Col: 2}
+ if got := e.SelectedText(); got != "bc\ndef\ngh" {
+ t.Errorf("SelectedText = %q, want 'bc\\ndef\\ngh'", got)
+ }
+ e.DeleteSelection()
+ if len(e.Lines) != 1 || e.Lines[0] != "ai" {
+ t.Errorf("after delete: %v, want [ai]", e.Lines)
+ }
+}
+
+func TestPlainMoveClearsSelection(t *testing.T) {
+ e := newEditorWith("hello")
+ e.Cursor = Position{Row: 0, Col: 0}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRight}) // plain move collapses
+ if e.HasSelection() {
+ t.Error("plain move should clear selection")
+ }
+}
internal/editor/selection.go +140 −0
@@ -0,0 +1,140 @@
+package editor
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// selectionForRow returns the row-local rune range [a,b) of vr that is selected.
+func (e *Editor) selectionForRow(vr vrow) (a, b int, ok bool) {
+ start, end, has := e.selRange()
+ if !has || vr.logRow < start.Row || vr.logRow > end.Row {
+ return 0, 0, false
+ }
+ rowStart, rowEnd := vr.start, vr.start+vr.runes
+ lo, hi := rowStart, rowEnd
+ if vr.logRow == start.Row && start.Col > lo {
+ lo = start.Col
+ }
+ if vr.logRow == end.Row && end.Col < hi {
+ hi = end.Col
+ }
+ if lo >= hi {
+ return 0, 0, false
+ }
+ return lo - rowStart, hi - rowStart, true
+}
+
+// overlaySelection restyles the runes in [a,b) of the row's spans with sel,
+// keeping the rest as-is. The concatenated text is unchanged.
+func overlaySelection(spans []Span, a, b int, sel lipgloss.Style) []Span {
+ total := 0
+ for _, sp := range spans {
+ total += len([]rune(sp.Text))
+ }
+ out := sliceSpans(spans, 0, a)
+ mid := sliceSpans(spans, a, b)
+ for i := range mid {
+ mid[i].Style = sel
+ }
+ out = append(out, mid...)
+ return append(out, sliceSpans(spans, b, total)...)
+}
+
+// before reports whether position a comes before b in the document.
+func (a Position) before(b Position) bool {
+ if a.Row != b.Row {
+ return a.Row < b.Row
+ }
+ return a.Col < b.Col
+}
+
+// HasSelection reports whether a non-empty selection is active.
+func (e *Editor) HasSelection() bool {
+ return e.anchor != nil && *e.anchor != e.Cursor
+}
+
+// ClearSelection drops any active selection.
+func (e *Editor) ClearSelection() { e.anchor = nil }
+
+// startSelection anchors a selection at the current cursor if none is active.
+func (e *Editor) startSelection() {
+ if e.anchor == nil {
+ c := e.Cursor
+ e.anchor = &c
+ }
+}
+
+// selRange returns the ordered selection bounds and whether a selection exists.
+func (e *Editor) selRange() (start, end Position, ok bool) {
+ if !e.HasSelection() {
+ return Position{}, Position{}, false
+ }
+ a, b := *e.anchor, e.Cursor
+ if b.before(a) {
+ a, b = b, a
+ }
+ return a, b, true
+}
+
+// SelectedText returns the selected text (with newlines for multi-line spans),
+// or "" when there is no selection.
+func (e *Editor) SelectedText() string {
+ start, end, ok := e.selRange()
+ if !ok {
+ return ""
+ }
+ if start.Row == end.Row {
+ r := []rune(e.Lines[start.Row])
+ return string(r[start.Col:end.Col])
+ }
+ var b strings.Builder
+ first := []rune(e.Lines[start.Row])
+ b.WriteString(string(first[start.Col:]))
+ for row := start.Row + 1; row < end.Row; row++ {
+ b.WriteByte('\n')
+ b.WriteString(e.Lines[row])
+ }
+ b.WriteByte('\n')
+ last := []rune(e.Lines[end.Row])
+ b.WriteString(string(last[:end.Col]))
+ return b.String()
+}
+
+// DeleteSelection removes the selected text, places the cursor at its start, and
+// clears the selection. Returns true if anything was deleted.
+func (e *Editor) DeleteSelection() bool {
+ start, end, ok := e.selRange()
+ if !ok {
+ return false
+ }
+ first := []rune(e.Lines[start.Row])
+ last := []rune(e.Lines[end.Row])
+ merged := string(first[:start.Col]) + string(last[end.Col:])
+ e.Lines[start.Row] = merged
+ if end.Row > start.Row {
+ e.Lines = append(e.Lines[:start.Row+1], e.Lines[end.Row+1:]...)
+ }
+ e.Cursor = start
+ e.anchor = nil
+ e.Dirty = true
+ e.setGoal()
+ e.followCursor()
+ return true
+}
+
+// InsertText inserts s at the cursor (replacing any selection), handling
+// newlines — used by paste.
+func (e *Editor) InsertText(s string) {
+ if e.HasSelection() {
+ e.DeleteSelection()
+ }
+ for _, r := range s {
+ if r == '\n' {
+ e.InsertNewline()
+ } else {
+ e.InsertRune(r)
+ }
+ }
+}