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