▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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