▍ humdrum codex / glint v1.0.2

feat: editor buffer, ops, and styled view

15134d8732dd0f02c78683d48fb7e59bcc7b8414
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 21:36

parent 4af618ab

4 files changed

go.mod +8 −2
@@ -4,20 +4,26 @@ go 1.26.4
 
 require (
 	github.com/BurntSushi/toml v1.6.0
+	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/lipgloss v1.1.0
 )
 
 require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
-	github.com/charmbracelet/x/ansi v0.8.0 // indirect
+	github.com/charmbracelet/x/ansi v0.10.1 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/sys v0.36.0 // indirect
+	golang.org/x/text v0.3.8 // indirect
 )
go.sum +17 −4
@@ -2,22 +2,32 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -27,6 +37,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
internal/editor/editor.go +253 −0
@@ -0,0 +1,253 @@
+package editor
+
+import (
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// Position is a cursor location in rune coordinates.
+type Position struct {
+	Row, Col int
+}
+
+// Editor is a self-contained markdown text buffer. It knows nothing about
+// files or the vault; the app wires load and save around it.
+type Editor struct {
+	Lines  []string
+	Cursor Position
+	Scroll int
+	Dirty  bool
+	Width  int
+	Height int // visible text rows
+	theme  Theme
+}
+
+// New returns an empty editor with one blank line and the default theme.
+func New() *Editor {
+	return &Editor{
+		Lines:  []string{""},
+		theme:  DefaultDarkTheme(),
+		Width:  80,
+		Height: 24,
+	}
+}
+
+// SetContent replaces the buffer, resetting cursor, scroll, and dirty state.
+func (e *Editor) SetContent(b []byte) {
+	text := strings.ReplaceAll(string(b), "\r\n", "\n")
+	e.Lines = strings.Split(text, "\n")
+	if len(e.Lines) == 0 {
+		e.Lines = []string{""}
+	}
+	e.Cursor = Position{}
+	e.Scroll = 0
+	e.Dirty = false
+}
+
+// Bytes serializes the buffer with \n line separators.
+func (e *Editor) Bytes() []byte {
+	return []byte(strings.Join(e.Lines, "\n"))
+}
+
+// SetSize records the viewport dimensions (h = visible text rows).
+func (e *Editor) SetSize(w, h int) {
+	e.Width = w
+	if h < 1 {
+		h = 1
+	}
+	e.Height = h
+	e.followCursor()
+}
+
+func (e *Editor) curLine() []rune { return []rune(e.Lines[e.Cursor.Row]) }
+func (e *Editor) setLine(rs []rune) { e.Lines[e.Cursor.Row] = string(rs) }
+
+// InsertRune inserts r at the cursor and advances it.
+func (e *Editor) InsertRune(r rune) {
+	rs := e.curLine()
+	col := clamp(e.Cursor.Col, 0, len(rs))
+	rs = append(rs[:col], append([]rune{r}, rs[col:]...)...)
+	e.setLine(rs)
+	e.Cursor.Col = col + 1
+	e.Dirty = true
+}
+
+// InsertNewline splits the current line at the cursor.
+func (e *Editor) InsertNewline() {
+	rs := e.curLine()
+	col := clamp(e.Cursor.Col, 0, len(rs))
+	left, right := string(rs[:col]), string(rs[col:])
+	e.Lines[e.Cursor.Row] = left
+	rest := append([]string{right}, e.Lines[e.Cursor.Row+1:]...)
+	e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
+	e.Cursor.Row++
+	e.Cursor.Col = 0
+	e.Dirty = true
+	e.followCursor()
+}
+
+// Backspace deletes the rune before the cursor, joining lines at column 0.
+func (e *Editor) Backspace() {
+	if e.Cursor.Col > 0 {
+		rs := e.curLine()
+		rs = append(rs[:e.Cursor.Col-1], rs[e.Cursor.Col:]...)
+		e.setLine(rs)
+		e.Cursor.Col--
+		e.Dirty = true
+		return
+	}
+	if e.Cursor.Row == 0 {
+		return
+	}
+	prev := []rune(e.Lines[e.Cursor.Row-1])
+	joinCol := len(prev)
+	merged := string(prev) + e.Lines[e.Cursor.Row]
+	e.Lines[e.Cursor.Row-1] = merged
+	e.Lines = append(e.Lines[:e.Cursor.Row], e.Lines[e.Cursor.Row+1:]...)
+	e.Cursor.Row--
+	e.Cursor.Col = joinCol
+	e.Dirty = true
+	e.followCursor()
+}
+
+// Delete removes the rune at the cursor, joining the next line at end-of-line.
+func (e *Editor) Delete() {
+	rs := e.curLine()
+	if e.Cursor.Col < len(rs) {
+		rs = append(rs[:e.Cursor.Col], rs[e.Cursor.Col+1:]...)
+		e.setLine(rs)
+		e.Dirty = true
+		return
+	}
+	if e.Cursor.Row >= len(e.Lines)-1 {
+		return
+	}
+	e.Lines[e.Cursor.Row] = e.Lines[e.Cursor.Row] + e.Lines[e.Cursor.Row+1]
+	e.Lines = append(e.Lines[:e.Cursor.Row+1], e.Lines[e.Cursor.Row+2:]...)
+	e.Dirty = true
+}
+
+// MoveLeft moves one rune left, wrapping to the end of the previous line.
+func (e *Editor) MoveLeft() {
+	if e.Cursor.Col > 0 {
+		e.Cursor.Col--
+	} else if e.Cursor.Row > 0 {
+		e.Cursor.Row--
+		e.Cursor.Col = len([]rune(e.Lines[e.Cursor.Row]))
+	}
+	e.followCursor()
+}
+
+// MoveRight moves one rune right, wrapping to the start of the next line.
+func (e *Editor) MoveRight() {
+	if e.Cursor.Col < len(e.curLine()) {
+		e.Cursor.Col++
+	} else if e.Cursor.Row < len(e.Lines)-1 {
+		e.Cursor.Row++
+		e.Cursor.Col = 0
+	}
+	e.followCursor()
+}
+
+// MoveUp moves to the previous line, clamping the column.
+func (e *Editor) MoveUp() {
+	if e.Cursor.Row > 0 {
+		e.Cursor.Row--
+		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	}
+	e.followCursor()
+}
+
+// MoveDown moves to the next line, clamping the column.
+func (e *Editor) MoveDown() {
+	if e.Cursor.Row < len(e.Lines)-1 {
+		e.Cursor.Row++
+		e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+	}
+	e.followCursor()
+}
+
+// MoveHome moves to column 0; MoveEnd to end of line.
+func (e *Editor) MoveHome() { e.Cursor.Col = 0 }
+func (e *Editor) MoveEnd()  { e.Cursor.Col = len(e.curLine()) }
+
+// followCursor scrolls the viewport so the cursor row stays visible.
+func (e *Editor) followCursor() {
+	if e.Cursor.Row < e.Scroll {
+		e.Scroll = e.Cursor.Row
+	}
+	if e.Cursor.Row >= e.Scroll+e.Height {
+		e.Scroll = e.Cursor.Row - e.Height + 1
+	}
+	if e.Scroll < 0 {
+		e.Scroll = 0
+	}
+}
+
+// HandleKey maps a key message to a buffer operation.
+func (e *Editor) HandleKey(k tea.KeyMsg) {
+	switch k.Type {
+	case tea.KeyRunes, tea.KeySpace:
+		for _, r := range k.Runes {
+			e.InsertRune(r)
+		}
+		if k.Type == tea.KeySpace {
+			e.InsertRune(' ')
+		}
+	case tea.KeyEnter:
+		e.InsertNewline()
+	case tea.KeyBackspace:
+		e.Backspace()
+	case tea.KeyDelete:
+		e.Delete()
+	case tea.KeyLeft:
+		e.MoveLeft()
+	case tea.KeyRight:
+		e.MoveRight()
+	case tea.KeyUp:
+		e.MoveUp()
+	case tea.KeyDown:
+		e.MoveDown()
+	case tea.KeyHome:
+		e.MoveHome()
+	case tea.KeyEnd:
+		e.MoveEnd()
+	case tea.KeyTab:
+		e.InsertRune('\t')
+	}
+}
+
+// View renders the visible rows, styled, with the cursor drawn on its row.
+func (e *Editor) View() string {
+	cursorStyle := lipgloss.NewStyle().Reverse(true)
+	all := ScanLines(e.Lines, e.theme)
+	var b strings.Builder
+	end := e.Scroll + e.Height
+	if end > len(e.Lines) {
+		end = len(e.Lines)
+	}
+	for row := e.Scroll; row < end; row++ {
+		if row == e.Cursor.Row {
+			b.WriteString(renderSpansCursor(all[row], e.Cursor.Col, cursorStyle))
+		} else {
+			b.WriteString(renderSpans(all[row]))
+		}
+		b.WriteByte('\n')
+	}
+	for row := end; row < e.Scroll+e.Height; row++ {
+		b.WriteByte('\n')
+	}
+	return b.String()
+}
+
+func clamp(v, lo, hi int) int {
+	if v < lo {
+		return lo
+	}
+	if v > hi {
+		return hi
+	}
+	return v
+}
internal/editor/editor_test.go +220 −0
@@ -0,0 +1,220 @@
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newEditorWith(lines ...string) *Editor {
+	e := New()
+	e.Lines = append([]string{}, lines...)
+	e.SetSize(80, 10)
+	return e
+}
+
+func TestSetContentSplitsLines(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("a\nb\nc"))
+	if len(e.Lines) != 3 || e.Lines[1] != "b" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Dirty {
+		t.Error("SetContent should clear Dirty")
+	}
+}
+
+func TestBytesRoundTrip(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("x\ny"))
+	if string(e.Bytes()) != "x\ny" {
+		t.Errorf("Bytes = %q", string(e.Bytes()))
+	}
+}
+
+func TestInsertRune(t *testing.T) {
+	e := newEditorWith("ac")
+	e.Cursor = Position{Row: 0, Col: 1}
+	e.InsertRune('b')
+	if e.Lines[0] != "abc" {
+		t.Errorf("Lines[0] = %q, want abc", e.Lines[0])
+	}
+	if e.Cursor.Col != 2 {
+		t.Errorf("Cursor.Col = %d, want 2", e.Cursor.Col)
+	}
+	if !e.Dirty {
+		t.Error("insert should set Dirty")
+	}
+}
+
+func TestInsertNewlineSplitsLine(t *testing.T) {
+	e := newEditorWith("abcd")
+	e.Cursor = Position{Row: 0, Col: 2}
+	e.InsertNewline()
+	if len(e.Lines) != 2 || e.Lines[0] != "ab" || e.Lines[1] != "cd" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Cursor.Row != 1 || e.Cursor.Col != 0 {
+		t.Errorf("Cursor = %+v, want {1 0}", e.Cursor)
+	}
+}
+
+func TestBackspaceJoinsLines(t *testing.T) {
+	e := newEditorWith("ab", "cd")
+	e.Cursor = Position{Row: 1, Col: 0}
+	e.Backspace()
+	if len(e.Lines) != 1 || e.Lines[0] != "abcd" {
+		t.Fatalf("Lines = %v", e.Lines)
+	}
+	if e.Cursor.Row != 0 || e.Cursor.Col != 2 {
+		t.Errorf("Cursor = %+v, want {0 2}", e.Cursor)
+	}
+}
+
+func TestBackspaceWithinLine(t *testing.T) {
+	e := newEditorWith("abc")
+	e.Cursor = Position{Row: 0, Col: 2}
+	e.Backspace()
+	if e.Lines[0] != "ac" || e.Cursor.Col != 1 {
+		t.Errorf("Lines[0]=%q Cursor.Col=%d", e.Lines[0], e.Cursor.Col)
+	}
+}
+
+func TestMovementClampsAndCrossesLines(t *testing.T) {
+	e := newEditorWith("ab", "cde")
+	e.Cursor = Position{Row: 0, Col: 2} // end of "ab"
+	e.MoveRight()                       // wraps to start of next line
+	if e.Cursor != (Position{Row: 1, Col: 0}) {
+		t.Errorf("after MoveRight: %+v", e.Cursor)
+	}
+	e.MoveLeft() // back to end of "ab"
+	if e.Cursor != (Position{Row: 0, Col: 2}) {
+		t.Errorf("after MoveLeft: %+v", e.Cursor)
+	}
+	e.MoveEnd()
+	e.MoveDown() // col clamps to len("cde")=3
+	if e.Cursor != (Position{Row: 1, Col: 2}) {
+		t.Errorf("after MoveDown: %+v, want {1 2}", e.Cursor)
+	}
+}
+
+func TestScrollFollowsCursorDown(t *testing.T) {
+	e := New()
+	e.SetSize(80, 3) // 3 visible rows
+	for i := 0; i < 10; i++ {
+		e.Lines = append(e.Lines, "x")
+	}
+	e.Cursor = Position{Row: 0, Col: 0}
+	for i := 0; i < 9; i++ {
+		e.MoveDown()
+	}
+	// cursor at row 9 must be visible within [Scroll, Scroll+3)
+	if e.Cursor.Row < e.Scroll || e.Cursor.Row >= e.Scroll+e.Height {
+		t.Errorf("cursor row %d not in viewport [%d,%d)", e.Cursor.Row, e.Scroll, e.Scroll+e.Height)
+	}
+}
+
+func TestHandleKeyInsertsRunes(t *testing.T) {
+	e := newEditorWith("")
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}})
+	if e.Lines[0] != "hi" {
+		t.Errorf("Lines[0] = %q, want hi", e.Lines[0])
+	}
+}
+
+func TestViewRendersVisibleRowsOnly(t *testing.T) {
+	e := New()
+	e.SetSize(80, 2)
+	e.Lines = []string{"one", "two", "three"}
+	e.Cursor = Position{Row: 0, Col: 0}
+	view := e.View()
+	// 2 visible rows -> exactly 2 newline-terminated lines
+	if got := countByte(view, '\n'); got != 2 {
+		t.Errorf("view has %d newlines, want 2", got)
+	}
+}
+
+func countByte(s string, b byte) int {
+	n := 0
+	for i := 0; i < len(s); i++ {
+		if s[i] == b {
+			n++
+		}
+	}
+	return n
+}
+
+func TestHandleKeySpaceInsertsSingleSpace(t *testing.T) {
+	e := newEditorWith("ab")
+	e.Cursor = Position{Row: 0, Col: 1}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeySpace})
+	if e.Lines[0] != "a b" {
+		t.Errorf("Lines[0] = %q, want 'a b'", e.Lines[0])
+	}
+}
+
+func TestDeleteWithinLine(t *testing.T) {
+	e := newEditorWith("abc")
+	e.Cursor = Position{Row: 0, Col: 1}
+	e.Delete()
+	if e.Lines[0] != "ac" {
+		t.Errorf("Lines[0] = %q, want 'ac'", e.Lines[0])
+	}
+	if e.Cursor.Col != 1 {
+		t.Errorf("Cursor.Col = %d, want 1", e.Cursor.Col)
+	}
+	if !e.Dirty {
+		t.Error("Delete should set Dirty")
+	}
+}
+
+func TestDeleteAtEndOfLastLineIsNoop(t *testing.T) {
+	e := newEditorWith("ab")
+	e.Cursor = Position{Row: 0, Col: 2}
+	original := e.Lines[0]
+	e.Delete()
+	if e.Lines[0] != original {
+		t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original)
+	}
+	if len(e.Lines) != 1 {
+		t.Errorf("len(Lines) = %d, want 1", len(e.Lines))
+	}
+}
+
+func TestDeleteJoinsNextLine(t *testing.T) {
+	e := newEditorWith("ab", "cd")
+	e.Cursor = Position{Row: 0, Col: 2}
+	e.Delete()
+	if e.Lines[0] != "abcd" {
+		t.Errorf("Lines[0] = %q, want 'abcd'", e.Lines[0])
+	}
+	if len(e.Lines) != 1 {
+		t.Errorf("len(Lines) = %d, want 1", len(e.Lines))
+	}
+}
+
+func TestBackspaceAtOriginIsNoop(t *testing.T) {
+	e := newEditorWith("ab")
+	e.Cursor = Position{Row: 0, Col: 0}
+	original := e.Lines[0]
+	e.Backspace()
+	if e.Lines[0] != original {
+		t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original)
+	}
+	if e.Cursor != (Position{Row: 0, Col: 0}) {
+		t.Errorf("Cursor = %+v, want {0 0}", e.Cursor)
+	}
+}
+
+func TestMoveHomeAndEnd(t *testing.T) {
+	e := newEditorWith("hello")
+	e.Cursor = Position{Row: 0, Col: 3}
+	e.MoveHome()
+	if e.Cursor.Col != 0 {
+		t.Errorf("after MoveHome: Cursor.Col = %d, want 0", e.Cursor.Col)
+	}
+	e.MoveEnd()
+	if e.Cursor.Col != 5 {
+		t.Errorf("after MoveEnd: Cursor.Col = %d, want 5", e.Cursor.Col)
+	}
+}