Huge module refactor. Much needed, but it came much more complex

This commit is contained in:
Hayden Hargreaves 2026-02-10 11:04:24 -07:00
parent 51e20aa87d
commit 2438da08d4
26 changed files with 1753 additions and 1613 deletions

View File

@ -1,29 +0,0 @@
package main
import tea "github.com/charmbracelet/bubbletea"
// Action is the base interface - anything executable
type Action interface {
Execute(m *model) tea.Cmd
}
// Motion moves the cursor and returns the range covered
type Motion interface {
Action
}
// Operator acts on a range (delete, yank, change)
type Operator interface {
Operate(m *model, start, end Position) tea.Cmd
// DoublePress handles dd, yy, cc (line-wise)
DoublePress(m *model) tea.Cmd
}
// Repeatable actions track count
type Repeatable interface {
WithCount(n int) Action
}
type Position struct {
Line, Col int
}

15
cmd/gim/main.go Normal file
View File

@ -0,0 +1,15 @@
package main
import (
"git.gophernest.net/azpect/TextEditor/internal/editor"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
// TODO: Not how this should work, of course
lines := []string{"Hello world", "line 2", "line 3", "line 4", "line 5"}
tea.NewProgram(
editor.NewModel(lines),
tea.WithAltScreen(),
).Run()
}

2
go.mod
View File

@ -5,6 +5,7 @@ go 1.25.5
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c
)
require (
@ -14,7 +15,6 @@ require (
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect

2
go.sum
View File

@ -50,7 +50,5 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.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=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=

64
internal/action/action.go Normal file
View File

@ -0,0 +1,64 @@
package action
import tea "github.com/charmbracelet/bubbletea"
// Mode constants for editor mode
type Mode int
const (
NormalMode Mode = iota
InsertMode
CommandMode
)
// Model defines the interface for editor state that actions can modify
type Model interface {
// Text buffer
Lines() []string
Line(idx int) string
SetLine(idx int, content string)
InsertLine(idx int, content string)
DeleteLine(idx int)
LineCount() int
// Cursor
CursorX() int
CursorY() int
SetCursorX(x int)
SetCursorY(y int)
ClampCursorX()
// Mode
Mode() Mode
SetMode(mode Mode)
// Insert recording (for count replay)
SetInsertRecording(count int, action Action)
}
// Position represents a location in the buffer
type Position struct {
Line, Col int
}
// Action is the base interface - anything executable
type Action interface {
Execute(m Model) tea.Cmd
}
// Motion moves the cursor and returns the range covered
type Motion interface {
Action
}
// Operator acts on a range (delete, yank, change)
type Operator interface {
Operate(m Model, start, end Position) tea.Cmd
// DoublePress handles dd, yy, cc (line-wise)
DoublePress(m Model) tea.Cmd
}
// Repeatable actions track count
type Repeatable interface {
WithCount(n int) Action
}

23
internal/action/delete.go Normal file
View File

@ -0,0 +1,23 @@
package action
import tea "github.com/charmbracelet/bubbletea"
// DeleteChar implements Action (x)
type DeleteChar struct {
Count int
}
func (a DeleteChar) Execute(m Model) tea.Cmd {
pos := m.CursorX()
line := m.Line(m.CursorY())
for i := 0; i < a.Count && pos < len(line); i++ {
line = line[:pos] + line[pos+1:]
m.SetLine(m.CursorY(), line)
}
return nil
}
func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{Count: n}
}

123
internal/action/insert.go Normal file
View File

@ -0,0 +1,123 @@
package action
import tea "github.com/charmbracelet/bubbletea"
// EnterInsert implements Action (i)
type EnterInsert struct {
Count int
}
func (a EnterInsert) Execute(m Model) tea.Cmd {
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a EnterInsert) WithCount(n int) Action {
return EnterInsert{Count: n}
}
// EnterInsertAfter implements Action (a)
type EnterInsertAfter struct {
Count int
}
func (a EnterInsertAfter) Execute(m Model) tea.Cmd {
m.SetCursorX(m.CursorX() + 1)
m.ClampCursorX()
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a EnterInsertAfter) WithCount(n int) Action {
return EnterInsertAfter{Count: n}
}
// EnterInsertLineStart implements Action (I)
type EnterInsertLineStart struct {
Count int
}
func (a EnterInsertLineStart) Execute(m Model) tea.Cmd {
m.SetCursorX(0)
m.ClampCursorX()
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a EnterInsertLineStart) WithCount(n int) Action {
return EnterInsertLineStart{Count: n}
}
// EnterInsertLineEnd implements Action (A)
type EnterInsertLineEnd struct {
Count int
}
func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd {
m.SetCursorX(len(m.Line(m.CursorY())))
m.ClampCursorX()
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a EnterInsertLineEnd) WithCount(n int) Action {
return EnterInsertLineEnd{Count: n}
}
// OpenLineBelow implements Action (o)
type OpenLineBelow struct {
Count int
}
func (a OpenLineBelow) Execute(m Model) tea.Cmd {
pos := m.CursorY()
if pos >= m.LineCount() {
m.InsertLine(m.LineCount(), "")
} else {
m.InsertLine(pos+1, "")
}
m.SetCursorY(m.CursorY() + 1)
m.SetCursorX(0)
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a OpenLineBelow) WithCount(n int) Action {
return OpenLineBelow{Count: n}
}
// OpenLineAbove implements Action (O)
type OpenLineAbove struct {
Count int
}
func (a OpenLineAbove) Execute(m Model) tea.Cmd {
pos := m.CursorY()
m.InsertLine(pos, "")
m.SetCursorX(0)
// Start recording
m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode)
return nil
}
func (a OpenLineAbove) WithCount(n int) Action {
return OpenLineAbove{Count: n}
}

10
internal/action/misc.go Normal file
View File

@ -0,0 +1,10 @@
package action
import tea "github.com/charmbracelet/bubbletea"
// Quit implements Action (ctrl+c)
type Quit struct{}
func (a Quit) Execute(m Model) tea.Cmd {
return tea.Quit
}

View File

@ -0,0 +1,56 @@
package editor
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// sendKeys sends a sequence of keys to the test model
func sendKeys(tm *teatest.TestModel, keys ...string) {
for _, key := range keys {
switch key {
case "esc":
tm.Send(tea.KeyMsg{Type: tea.KeyEscape})
case "enter":
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
case "backspace":
tm.Send(tea.KeyMsg{Type: tea.KeyBackspace})
case "ctrl+c":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
case "ctrl+d":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlD})
default:
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
}
}
}
// newTestModel creates a test model with default content
func newTestModel(t *testing.T) *teatest.TestModel {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"}
return teatest.NewTestModel(t, NewModel(lines), teatest.WithInitialTermSize(80, 24))
}
func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel {
return teatest.NewTestModel(t, NewModel(lines), teatest.WithInitialTermSize(80, 24))
}
func newTestModelWithCursorPos(t *testing.T, pos action.Position) *teatest.TestModel {
lines := []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"}
return teatest.NewTestModel(t, NewModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24))
}
func newTestModelWithCursorPosAndLines(t *testing.T, lines []string, pos action.Position) *teatest.TestModel {
return teatest.NewTestModel(t, NewModelWithPos(lines, pos), teatest.WithInitialTermSize(80, 24))
}
// getFinalModel extracts the final model state (sends ctrl+c to quit first)
func getFinalModel(t *testing.T, tm *teatest.TestModel) Model {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
return fm.(Model)
}

View File

@ -0,0 +1,88 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
func TestDeleteChar(t *testing.T) {
t.Run("test 'x' deletes character under cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.lines[0] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.lines[0])
}
})
t.Run("test 'x' in middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
}
})
t.Run("test 'x' at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.lines[0] != "hell" {
t.Errorf("lines[0] = %q, want 'hell'", m.lines[0])
}
})
t.Run("test 'xx' deletes two characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x", "x")
m := getFinalModel(t, tm)
if m.lines[0] != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.lines[0])
}
})
}
func TestDeleteCharWithCount(t *testing.T) {
t.Run("test '3x' deletes three characters", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "x")
m := getFinalModel(t, tm)
if m.lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
}
})
t.Run("test '10x' with overflow", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "1", "0", "x")
m := getFinalModel(t, tm)
if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0])
}
})
t.Run("test '2x' from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "x")
m := getFinalModel(t, tm)
if m.lines[0] != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0])
}
})
}

View File

@ -0,0 +1,394 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// --- Insert Mode Entry Tests ---
func TestEnterInsert(t *testing.T) {
t.Run("test 'i' enters insert mode", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "i")
m := getFinalModel(t, tm)
if m.mode != action.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
}
})
t.Run("test 'i' insert at beginning", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
}
})
t.Run("test 'i' insert in middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
}
})
t.Run("test 'i' cursor moves back on esc", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
}
})
}
func TestEnterInsertAfter(t *testing.T) {
t.Run("test 'a' enters insert mode", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "a")
m := getFinalModel(t, tm)
if m.mode != action.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
}
})
t.Run("test 'a' inserts after cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hXello" {
t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0])
}
})
t.Run("test 'a' from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "helXlo" {
t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0])
}
})
}
func TestEnterInsertLineStart(t *testing.T) {
t.Run("test 'I' enters insert mode at line start", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
}
})
t.Run("test 'I' from end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
}
})
}
func TestEnterInsertLineEnd(t *testing.T) {
t.Run("test 'A' enters insert mode at line end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
}
})
t.Run("test 'A' from middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
}
})
}
// --- Open Line Tests ---
func TestOpenLineBelow(t *testing.T) {
t.Run("test 'o' creates line below", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines))
}
if m.lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
}
})
t.Run("test 'o' from middle of file", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines))
}
if m.lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
}
})
t.Run("test 'o' at end of file", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines))
}
if m.lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
}
})
t.Run("test 'o' cursor moves to new line", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "o", "esc")
m := getFinalModel(t, tm)
if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
}
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
}
func TestOpenLineBelowWithCount(t *testing.T) {
t.Run("test '3o' creates 3 lines", func(t *testing.T) {
lines := []string{"line 1"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "o", "x", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines))
}
for i := 1; i <= 3; i++ {
if m.lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
}
}
})
t.Run("test '2o' with multiple chars", func(t *testing.T) {
lines := []string{"line 1"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "2", "o", "a", "b", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines))
}
if m.lines[1] != "ab" {
t.Errorf("lines[1] = %q, want 'ab'", m.lines[1])
}
if m.lines[2] != "ab" {
t.Errorf("lines[2] = %q, want 'ab'", m.lines[2])
}
})
}
func TestOpenLineAbove(t *testing.T) {
t.Run("test 'O' creates line above", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "O", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines))
}
if m.lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
}
})
t.Run("test 'O' at top of file", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "O", "n", "e", "w", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines))
}
if m.lines[0] != "new" {
t.Errorf("lines[0] = %q, want 'new'", m.lines[0])
}
})
t.Run("test 'O' cursor at start of new line", func(t *testing.T) {
lines := []string{"line 1", "line 2"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "O", "esc")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
}
func TestOpenLineAboveWithCount(t *testing.T) {
t.Run("test '3O' creates 3 lines above", func(t *testing.T) {
lines := []string{"line 1"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "3", "O", "x", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines))
}
for i := 0; i < 3; i++ {
if m.lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
}
}
})
}
// --- Insert Mode Special Keys ---
func TestInsertModeEnter(t *testing.T) {
t.Run("test enter splits line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines))
}
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
if m.lines[1] != " world" {
t.Errorf("lines[1] = %q, want ' world'", m.lines[1])
}
})
t.Run("test enter at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines))
}
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
if m.lines[1] != "" {
t.Errorf("lines[1] = %q, want ''", m.lines[1])
}
})
t.Run("test enter at start of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines))
}
if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0])
}
if m.lines[1] != "hello" {
t.Errorf("lines[1] = %q, want 'hello'", m.lines[1])
}
})
}
func TestInsertModeBackspace(t *testing.T) {
t.Run("test backspace deletes character", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
}
})
t.Run("test backspace at start of line joins lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines))
}
if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
}
})
t.Run("test backspace at start of first line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
}
})
t.Run("test multiple backspaces", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc")
m := getFinalModel(t, tm)
if m.lines[0] != "he" {
t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
}
})
}

View File

@ -0,0 +1,277 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
func TestMoveDown(t *testing.T) {
t.Run("test 'j'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "j")
m := getFinalModel(t, tm)
if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
}
})
t.Run("test 'jjjj'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "j", "j", "j", "j")
m := getFinalModel(t, tm)
if m.cursor.y != 4 {
t.Errorf("cursor.y = %d, want 4", m.cursor.y)
}
})
t.Run("test 'jjjjjjjjj's with overflow", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "j", "j", "j", "j", "j", "j", "j", "j", "j")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
}
})
}
func TestMoveDownWithCount(t *testing.T) {
t.Run("test '3j'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "3", "j")
m := getFinalModel(t, tm)
if m.cursor.y != 3 {
t.Errorf("cursor.y = %d, want 3", m.cursor.y)
}
})
t.Run("test '10j' with overflow", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "1", "0", "j")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
}
})
}
func TestMoveDownWithOverflow(t *testing.T) {
lines := []string{"long line", "small"}
t.Run("test 'j' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 8, Line: 0})
sendKeys(tm, "j")
m := getFinalModel(t, tm)
want := len(lines[1])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test 'j' without overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "j")
m := getFinalModel(t, tm)
if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
}
})
}
func TestMoveUp(t *testing.T) {
t.Run("test 'k'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "k")
m := getFinalModel(t, tm)
if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
}
})
t.Run("test 'kkkk'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 4})
sendKeys(tm, "k", "k", "k", "k")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
t.Run("test 'k' at top (no movement)", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "k", "k", "k")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
}
func TestMoveUpWithCount(t *testing.T) {
t.Run("test '3k'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "3", "k")
m := getFinalModel(t, tm)
if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
}
})
t.Run("test '10k' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3})
sendKeys(tm, "1", "0", "k")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
}
func TestMoveUpWithOverflow(t *testing.T) {
lines := []string{"small", "long line"}
t.Run("test 'k' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "k")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test 'k' without overflow", func(t *testing.T) {
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "k")
m := getFinalModel(t, tm)
if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
}
})
}
func TestMoveRight(t *testing.T) {
t.Run("test 'l'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "l")
m := getFinalModel(t, tm)
if m.cursor.x != 1 {
t.Errorf("cursor.x = %d, want 1", m.cursor.x)
}
})
t.Run("test 'llll'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "l", "l", "l", "l")
m := getFinalModel(t, tm)
if m.cursor.x != 4 {
t.Errorf("cursor.x = %d, want 4", m.cursor.x)
}
})
t.Run("test 'l' at end of line (no movement past end)", func(t *testing.T) {
lines := []string{"abc"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "l", "l", "l", "l", "l", "l")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
}
func TestMoveRightWithCount(t *testing.T) {
t.Run("test '3l'", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "3", "l")
m := getFinalModel(t, tm)
if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
}
})
t.Run("test '10l' with overflow", func(t *testing.T) {
lines := []string{"short"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "1", "0", "l")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
}
func TestMoveLeft(t *testing.T) {
t.Run("test 'h'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "h")
m := getFinalModel(t, tm)
if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
}
})
t.Run("test 'hhhh'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 4, Line: 0})
sendKeys(tm, "h", "h", "h", "h")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test 'h' at start (no movement)", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "h", "h", "h")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
}
func TestMoveLeftWithCount(t *testing.T) {
t.Run("test '3h'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 5, Line: 0})
sendKeys(tm, "3", "h")
m := getFinalModel(t, tm)
if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
}
})
t.Run("test '10h' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "1", "0", "h")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
}

View File

@ -0,0 +1,229 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// --- G and gg Tests ---
func TestMoveToBottom(t *testing.T) {
t.Run("test 'G' from top", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
}
})
t.Run("test 'G' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
}
})
t.Run("test 'G' already at bottom", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
}
})
t.Run("test 'G' clamps cursor.x", func(t *testing.T) {
lines := []string{"long line here", "short"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
}
want := len(lines[1])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test 'G' on single line file", func(t *testing.T) {
lines := []string{"only line"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "G")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
}
func TestMoveToTop(t *testing.T) {
t.Run("test 'gg' from bottom", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
t.Run("test 'gg' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3})
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
t.Run("test 'gg' already at top", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
})
t.Run("test 'gg' clamps cursor.x", func(t *testing.T) {
lines := []string{"short", "long line here"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "g", "g")
m := getFinalModel(t, tm)
if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
}
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
}
// --- 0 and $ Tests ---
func TestMoveToLineStart(t *testing.T) {
t.Run("test '0' from middle of line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test '0' from end of line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test '0' already at start", func(t *testing.T) {
tm := newTestModel(t)
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test '0' on empty line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test '0' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 2})
sendKeys(tm, "0")
m := getFinalModel(t, tm)
if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
}
})
}
func TestMoveToLineEnd(t *testing.T) {
t.Run("test '$' from start of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test '$' from middle of line", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test '$' already at end", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithCursorPosAndLines(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
}
})
t.Run("test '$' on empty line", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "$")
m := getFinalModel(t, tm)
if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
}
})
t.Run("test '$' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "$")
m := getFinalModel(t, tm)
if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
}
})
}

211
internal/editor/model.go Normal file
View File

@ -0,0 +1,211 @@
package editor
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/input"
tea "github.com/charmbracelet/bubbletea"
)
type cursor struct {
x int
y int
}
type Model struct {
lines []string
cursor cursor
s_gutter int
mode action.Mode
win_h int
win_w int
command string
input *input.Handler
// Insert repetition
insertCount int
insertKeys []string
insertAction action.Action
}
func NewModel(lines []string) Model {
return Model{
lines: lines,
cursor: cursor{
x: 0,
y: 0,
},
s_gutter: 5,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
}
}
func NewModelWithPos(lines []string, pos action.Position) Model {
return Model{
lines: lines,
cursor: cursor{
x: pos.Col,
y: pos.Line,
},
s_gutter: 5,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
}
}
func (m Model) Init() tea.Cmd {
return nil
}
// Implement action.Model interface
func (m *Model) Lines() []string {
return m.lines
}
func (m *Model) Line(idx int) string {
if idx < 0 || idx >= len(m.lines) {
return ""
}
return m.lines[idx]
}
func (m *Model) SetLine(idx int, content string) {
if idx >= 0 && idx < len(m.lines) {
m.lines[idx] = content
}
}
func (m *Model) InsertLine(idx int, content string) {
if idx < 0 {
idx = 0
}
if idx > len(m.lines) {
idx = len(m.lines)
}
m.lines = append(m.lines[:idx], append([]string{content}, m.lines[idx:]...)...)
}
func (m *Model) DeleteLine(idx int) {
if idx >= 0 && idx < len(m.lines) {
m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
}
}
func (m *Model) LineCount() int {
return len(m.lines)
}
func (m *Model) CursorX() int {
return m.cursor.x
}
func (m *Model) CursorY() int {
return m.cursor.y
}
func (m *Model) SetCursorX(x int) {
m.cursor.x = x
}
func (m *Model) SetCursorY(y int) {
m.cursor.y = y
}
func (m *Model) ClampCursorX() {
lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 {
m.cursor.x = 0
} else if m.cursor.x >= lineLen {
m.cursor.x = lineLen
}
}
func (m *Model) Mode() action.Mode {
return m.mode
}
func (m *Model) SetMode(mode action.Mode) {
m.mode = mode
}
func (m *Model) SetInsertRecording(count int, act action.Action) {
m.insertCount = count
m.insertKeys = []string{}
m.insertAction = act
}
func (m *Model) GetCursorPosition() action.Position {
return action.Position{Line: m.cursor.y, Col: m.cursor.x}
}
func (m *Model) replayInsert() {
// Replay (count - 1) more times
for i := 1; i < m.insertCount; i++ {
// For 'o' and 'O', we need to create a new line first
switch m.insertAction.(type) {
case action.OpenLineBelow:
pos := m.cursor.y
m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...)
m.cursor.y++
m.cursor.x = 0
case action.OpenLineAbove:
pos := m.cursor.y
m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...)
m.cursor.x = 0
// 'i' and 'a' don't need setup - just replay keys
}
// Replay each recorded keystroke
for _, key := range m.insertKeys {
m.processInsertKey(key)
}
}
}
func (m *Model) processInsertKey(key string) {
x := m.CursorX()
y := m.CursorY()
l := m.Line(y)
switch key {
case "enter":
// Simple case, at end, just create a line
if x == len(l) {
m.InsertLine(y+1, "")
// otherwise, splice
} else {
m.SetLine(y, l[:x])
m.InsertLine(y+1, l[x:])
}
m.SetCursorY(y + 1)
m.SetCursorX(0)
case "backspace":
if x > 0 {
m.SetLine(y, l[:x-1]+l[x:])
m.SetCursorX(x - 1)
} else if y > 0 {
prevLine := m.Line(y - 1)
newX := len(prevLine)
m.SetLine(y-1, prevLine+l)
m.DeleteLine(y)
m.SetCursorY(y - 1)
m.SetCursorX(newX)
}
// Regular character
default:
if x < len(l) {
m.SetLine(y, l[:x]+key+l[x:])
} else {
m.SetLine(y, l+key)
}
m.SetCursorX(x + len(key))
}
}

View File

@ -1,28 +1,26 @@
package main
package editor
import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/lipgloss"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
func (m model) cursorStyle() lipgloss.Style {
func (m Model) cursorStyle() lipgloss.Style {
switch m.mode {
case NormalMode:
case action.NormalMode:
// Block cursor for normal mode
return lipgloss.NewStyle().Reverse(true)
case InsertMode:
case action.InsertMode:
// Bar/underline for insert mode
return lipgloss.NewStyle().Underline(true)
case CommandMode:
case action.CommandMode:
return lipgloss.NewStyle()
// case VisualMode:
// // Colored block for visual mode
// return lipgloss.NewStyle().
// Background(lipgloss.Color("62")).
// Foreground(lipgloss.Color("230"))
default:
return lipgloss.NewStyle().Reverse(true)
}
}
func (m model) gutterStyle(currentLine bool) lipgloss.Style {
func (m Model) gutterStyle(currentLine bool) lipgloss.Style {
fg := lipgloss.Color("243")
if currentLine {
fg = lipgloss.Color("#d69d00")

View File

@ -1,8 +1,11 @@
package main
package editor
import tea "github.com/charmbracelet/bubbletea"
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea"
)
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -11,11 +14,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch m.mode {
case NormalMode:
case action.NormalMode:
return m, m.input.Handle(&m, msg.String())
// TODO: This should be handled elsewhere
case InsertMode:
case action.InsertMode:
key := msg.String()
switch key {
@ -28,7 +31,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor.x > 0 {
m.cursor.x--
}
m.mode = NormalMode
m.mode = action.NormalMode
m.insertCount = 0
m.insertKeys = nil
@ -40,10 +43,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.insertKeys = append(m.insertKeys, key)
m.processInsertKey(key)
}
case CommandMode:
case action.CommandMode:
switch msg.String() {
case "esc":
m.mode = NormalMode
m.mode = action.NormalMode
m.command = ""
default:

View File

@ -1,11 +1,13 @@
package main
package editor
import (
"fmt"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
func (m model) View() string {
func (m Model) View() string {
var view strings.Builder
for y := 0; y < m.win_h-1; y++ {
@ -34,8 +36,6 @@ func (m model) View() string {
}
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
// TODO: Do we need to do offset calculation?
runes := []rune(m.lines[y])
for x := 0; x <= len(runes); x++ {
if m.cursor.y == y && m.cursor.x == x {
@ -59,19 +59,19 @@ func (m model) View() string {
// Draw status bar
var modeString string
switch m.mode {
case NormalMode:
case action.NormalMode:
modeString = "NORMAL"
case InsertMode:
case action.InsertMode:
modeString = "INSERT"
case CommandMode:
case action.CommandMode:
modeString = "COMMAND"
}
var bar string
if m.mode == CommandMode {
if m.mode == action.CommandMode {
bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) %s ", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.command)
} else {
bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.buffer, m.insertKeys, m.insertCount)
bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s | %+v | %d", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.Pending(), m.insertKeys, m.insertCount)
}
view.WriteString(bar)

View File

@ -1,6 +1,9 @@
package main
package input
import tea "github.com/charmbracelet/bubbletea"
import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
type InputState int
@ -11,24 +14,29 @@ const (
StateMotionCount
)
type InputHandler struct {
// PositionGetter is used to get cursor position for operator ranges
type PositionGetter interface {
GetCursorPosition() action.Position
}
type Handler struct {
state InputState
count1 int
count2 int
operator Operator
operator action.Operator
operatorKey string // track which key started operator (for dd, yy, cc)
buffer string // for display (what user has typed)
pending string // partial key sequence (e.g., "g" waiting for second key)
keymap *Keymap
}
func NewInputHandler() *InputHandler {
return &InputHandler{
func NewHandler() *Handler {
return &Handler{
keymap: NewNormalKeymap(),
}
}
func (h *InputHandler) Handle(m *model, key string) tea.Cmd {
func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// ESC always resets everything
if key == "esc" {
h.Reset()
@ -74,7 +82,7 @@ func (h *InputHandler) Handle(m *model, key string) tea.Cmd {
}
// dispatch routes to the right handler based on current state
func (h *InputHandler) dispatch(m *model, kind string, binding any, key string) tea.Cmd {
func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd {
switch h.state {
case StateReady, StateCount:
return h.handleInitial(m, kind, binding, key)
@ -85,31 +93,31 @@ func (h *InputHandler) dispatch(m *model, kind string, binding any, key string)
return nil
}
func (h *InputHandler) handleInitial(m *model, kind string, binding any, key string) tea.Cmd {
func (h *Handler) handleInitial(m action.Model, kind string, binding any, key string) tea.Cmd {
count := h.effectiveCount()
switch kind {
case "motion":
motion := binding.(Motion)
if r, ok := motion.(Repeatable); ok {
motion = r.WithCount(count).(Motion)
mot := binding.(action.Motion)
if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion)
}
cmd := motion.Execute(m)
cmd := mot.Execute(m)
h.Reset()
return cmd
case "operator":
h.operator = binding.(Operator)
h.operator = binding.(action.Operator)
h.operatorKey = key
h.state = StateOperatorPending
return nil
case "action":
action := binding.(Action)
if r, ok := action.(Repeatable); ok {
action = r.WithCount(count)
act := binding.(action.Action)
if r, ok := act.(action.Repeatable); ok {
act = r.WithCount(count)
}
cmd := action.Execute(m)
cmd := act.Execute(m)
h.Reset()
return cmd
}
@ -118,7 +126,7 @@ func (h *InputHandler) handleInitial(m *model, kind string, binding any, key str
return nil
}
func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, key string) tea.Cmd {
func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, key string) tea.Cmd {
count := h.effectiveCount()
// dd, yy, cc - same operator key pressed twice
@ -130,14 +138,15 @@ func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, k
// Motion after operator
if kind == "motion" {
motion := binding.(Motion)
if r, ok := motion.(Repeatable); ok {
motion = r.WithCount(count).(Motion)
mot := binding.(action.Motion)
if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion)
}
// Get range here
start := m.getCursorPosition()
motion.Execute(m)
end := m.getCursorPosition()
pg := m.(PositionGetter)
start := pg.GetCursorPosition()
mot.Execute(m)
end := pg.GetCursorPosition()
cmd := h.operator.Operate(m, start, end)
h.Reset()
return cmd
@ -147,7 +156,7 @@ func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, k
return nil
}
func (h *InputHandler) tryAccumulateCount(key string) bool {
func (h *Handler) tryAccumulateCount(key string) bool {
if len(key) != 1 || key[0] < '0' || key[0] > '9' {
return false
}
@ -172,14 +181,14 @@ func (h *InputHandler) tryAccumulateCount(key string) bool {
return true
}
func (h *InputHandler) currentCount() int {
func (h *Handler) currentCount() int {
if h.state == StateOperatorPending || h.state == StateMotionCount {
return h.count2
}
return h.count1
}
func (h *InputHandler) effectiveCount() int {
func (h *Handler) effectiveCount() int {
c1, c2 := h.count1, h.count2
if c1 == 0 {
c1 = 1
@ -190,7 +199,7 @@ func (h *InputHandler) effectiveCount() int {
return c1 * c2
}
func (h *InputHandler) Reset() {
func (h *Handler) Reset() {
h.state = StateReady
h.count1 = 0
h.count2 = 0
@ -200,6 +209,6 @@ func (h *InputHandler) Reset() {
h.pending = ""
}
func (h *InputHandler) Pending() string {
func (h *Handler) Pending() string {
return h.buffer
}

76
internal/input/keymap.go Normal file
View File

@ -0,0 +1,76 @@
package input
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/motion"
)
type Keymap struct {
motions map[string]action.Motion
operators map[string]action.Operator
actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
}
func NewNormalKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"j": motion.MoveDown{Count: 1},
"k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1},
"G": motion.MoveToBottom{},
"gg": motion.MoveToTop{}, // multi-key example
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
},
operators: map[string]action.Operator{
// "d": DeleteOp{},
// "c": ChangeOp{},
// "y": YankOp{},
},
actions: map[string]action.Action{
"i": action.EnterInsert{},
"a": action.EnterInsertAfter{},
"I": action.EnterInsertLineStart{},
"A": action.EnterInsertLineEnd{},
"o": action.OpenLineBelow{},
"O": action.OpenLineAbove{},
"x": action.DeleteChar{Count: 1},
"ctrl+c": action.Quit{},
},
}
}
// Lookup returns what type of binding a key is
func (km *Keymap) Lookup(key string) (kind string, value any) {
if m, ok := km.motions[key]; ok {
return "motion", m
}
if o, ok := km.operators[key]; ok {
return "operator", o
}
if a, ok := km.actions[key]; ok {
return "action", a
}
return "", nil
}
// HasPrefix returns true if any binding starts with this prefix
func (km *Keymap) HasPrefix(prefix string) bool {
for key := range km.motions {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
for key := range km.operators {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
for key := range km.actions {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
return false
}

75
internal/motion/basic.go Normal file
View File

@ -0,0 +1,75 @@
package motion
import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// MoveDown implements Motion (j)
type MoveDown struct {
Count int
}
func (a MoveDown) Execute(m action.Model) tea.Cmd {
for i := 0; i < a.Count && m.CursorY() < m.LineCount()-1; i++ {
m.SetCursorY(m.CursorY() + 1)
}
m.ClampCursorX()
return nil
}
func (a MoveDown) WithCount(n int) action.Action {
return MoveDown{Count: n}
}
// MoveUp implements Motion (k)
type MoveUp struct {
Count int
}
func (a MoveUp) Execute(m action.Model) tea.Cmd {
for i := 0; i < a.Count && m.CursorY() > 0; i++ {
m.SetCursorY(m.CursorY() - 1)
}
m.ClampCursorX()
return nil
}
func (a MoveUp) WithCount(n int) action.Action {
return MoveUp{Count: n}
}
// MoveLeft implements Motion (h)
type MoveLeft struct {
Count int
}
func (a MoveLeft) Execute(m action.Model) tea.Cmd {
for i := 0; i < a.Count && m.CursorX() > 0; i++ {
m.SetCursorX(m.CursorX() - 1)
}
m.ClampCursorX()
return nil
}
func (a MoveLeft) WithCount(n int) action.Action {
return MoveLeft{Count: n}
}
// MoveRight implements Motion (l)
type MoveRight struct {
Count int
}
func (a MoveRight) Execute(m action.Model) tea.Cmd {
lineLen := len(m.Line(m.CursorY()))
for i := 0; i < a.Count && m.CursorX() <= lineLen; i++ {
m.SetCursorX(m.CursorX() + 1)
}
m.ClampCursorX()
return nil
}
func (a MoveRight) WithCount(n int) action.Action {
return MoveRight{Count: n}
}

42
internal/motion/jump.go Normal file
View File

@ -0,0 +1,42 @@
package motion
import (
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
)
// MoveToTop implements Motion (gg)
type MoveToTop struct{}
func (a MoveToTop) Execute(m action.Model) tea.Cmd {
m.SetCursorY(0)
m.ClampCursorX()
return nil
}
// MoveToBottom implements Motion (G)
type MoveToBottom struct{}
func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
m.SetCursorY(m.LineCount() - 1)
m.ClampCursorX()
return nil
}
// MoveToLineStart implements Motion (0)
type MoveToLineStart struct{}
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
m.SetCursorX(0)
m.ClampCursorX()
return nil
}
// MoveToLineEnd implements Motion ($)
type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
m.SetCursorX(len(m.Line(m.CursorY())))
m.ClampCursorX()
return nil
}

View File

@ -1,76 +0,0 @@
package main
type Keymap struct {
motions map[string]Motion
operators map[string]Operator
actions map[string]Action // standalone actions: i.e., 'i', 'a'
}
func NewNormalKeymap() *Keymap {
return &Keymap{
motions: map[string]Motion{
"j": MoveDown{1},
"k": MoveUp{1},
"h": MoveLeft{1},
"l": MoveRight{1},
"G": MoveToBottom{},
"gg": MoveToTop{}, // multi-key example
// "w": MoveWord{1},
// "b": MoveWordBack{1},
"0": MoveToLineStart{},
"$": MoveToLineEnd{},
},
operators: map[string]Operator{
// "d": DeleteOp{},
// "c": ChangeOp{},
// "y": YankOp{},
},
actions: map[string]Action{
"i": EnterInsert{},
"a": EnterInsertAfter{},
"I": EnterInsertLineStart{},
"A": EnterInsertLineEnd{},
"o": OpenLineBelow{},
"O": OpenLineAbove{},
"x": DeleteChar{1},
// "p": Paste{},
// "u": Undo{},
// "ctrl+r": Redo{},
"ctrl+c": Quit{},
},
}
}
// Lookup returns what type of binding a key is
func (km *Keymap) Lookup(key string) (kind string, value any) {
if m, ok := km.motions[key]; ok {
return "motion", m
}
if o, ok := km.operators[key]; ok {
return "operator", o
}
if a, ok := km.actions[key]; ok {
return "action", a
}
return "", nil
}
// HasPrefix returns true if any binding starts with this prefix
func (km *Keymap) HasPrefix(prefix string) bool {
for key := range km.motions {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
for key := range km.operators {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
for key := range km.actions {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
return false
}

11
main.go
View File

@ -1,11 +0,0 @@
package main
import tea "github.com/charmbracelet/bubbletea"
func main() {
lines := []string{"Hello world", "line 2", "line 3", "line 4", "line 5"}
tea.NewProgram(
newModel(lines),
tea.WithAltScreen(),
).Run()
}

150
model.go
View File

@ -1,150 +0,0 @@
package main
import tea "github.com/charmbracelet/bubbletea"
type mode int
const (
NormalMode mode = iota
InsertMode
CommandMode
)
type cursor struct {
x int
y int
}
type model struct {
lines []string
cursor cursor
s_gutter int
mode mode
win_h int
win_w int
command string
input *InputHandler
// Insert repetition
insertCount int
insertKeys []string
insertAction Action
}
func newModel(lines []string) model {
return model{
lines: lines,
cursor: cursor{
x: 0,
y: 0,
},
s_gutter: 5,
mode: NormalMode,
command: "",
input: NewInputHandler(),
}
}
func newModelWithPos(lines []string, pos Position) model {
return model{
lines: lines,
cursor: cursor{
x: pos.Col,
y: pos.Line,
},
s_gutter: 5,
mode: NormalMode,
command: "",
input: NewInputHandler(),
}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m *model) clampCursorX() {
lineLen := len(m.lines[m.cursor.y])
if lineLen == 0 {
m.cursor.x = 0
} else if m.cursor.x >= lineLen {
m.cursor.x = lineLen
}
}
func (m model) getCursorPosition() Position {
return Position{Line: m.cursor.y, Col: m.cursor.x}
}
func (m *model) replayInsert() {
// Replay (count - 1) more times
for i := 1; i < m.insertCount; i++ {
// For 'o' and 'O', we need to create a new line first
switch m.insertAction.(type) {
case OpenLineBelow:
pos := m.cursor.y
m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...)
m.cursor.y++
m.cursor.x = 0
case OpenLineAbove:
pos := m.cursor.y
m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...)
m.cursor.x = 0
// 'i' and 'a' don't need setup - just replay keys
}
// Replay each recorded keystroke
for _, key := range m.insertKeys {
m.processInsertKey(key)
}
}
}
func (m *model) processInsertKey(key string) {
switch key {
case "enter":
y := m.cursor.y
x := m.cursor.x
// Simple case, at end, just create a line
if x == len(m.lines[y]) {
m.lines = append(m.lines[:y+1], append([]string{""}, m.lines[y+1:]...)...)
// otherwise, splice
} else {
l := m.lines[y]
m.lines[y] = l[:x]
m.lines = append(m.lines[:y+1], append([]string{l[x:]}, m.lines[y+1:]...)...)
}
m.cursor.y++
m.cursor.x = 0
case "backspace":
x := m.cursor.x
y := m.cursor.y
l := m.lines[y]
if x > 0 {
m.lines[y] = l[:x-1] + l[x:]
m.cursor.x--
} else if y > 0 {
newX := len(m.lines[y-1])
m.lines[y-1] = m.lines[y-1] + l
m.lines = append(m.lines[:y], m.lines[y+1:]...)
m.cursor.y--
m.cursor.x = newX
}
default:
// Regular character
x := m.cursor.x
y := m.cursor.y
l := m.lines[y]
if x < len(l) {
m.lines[y] = l[:x] + key + l[x:]
} else {
m.lines[y] = l + key
}
m.cursor.x += len(key)
}
}

268
motion.go
View File

@ -1,268 +0,0 @@
package main
import tea "github.com/charmbracelet/bubbletea"
// MoveDown implements Motion
type MoveDown struct {
count int
}
func (a MoveDown) Execute(m *model) tea.Cmd {
for i := 0; i < a.count && m.cursor.y < len(m.lines)-1; i++ {
m.cursor.y++
}
m.clampCursorX()
return nil
}
func (a MoveDown) WithCount(n int) Action {
return MoveDown{count: n}
}
// MoveUp implements Motion
type MoveUp struct {
count int
}
func (a MoveUp) Execute(m *model) tea.Cmd {
for i := 0; i < a.count && m.cursor.y > 0; i++ {
m.cursor.y--
}
m.clampCursorX()
return nil
}
func (a MoveUp) WithCount(n int) Action {
return MoveUp{count: n}
}
type MoveLeft struct {
count int
}
func (a MoveLeft) Execute(m *model) tea.Cmd {
for i := 0; i < a.count && m.cursor.x > 0; i++ {
m.cursor.x--
}
m.clampCursorX()
return nil
}
func (a MoveLeft) WithCount(n int) Action {
return MoveLeft{count: n}
}
type MoveRight struct {
count int
}
func (a MoveRight) Execute(m *model) tea.Cmd {
lineLen := len(m.lines[m.cursor.y])
for i := 0; i < a.count && m.cursor.x <= lineLen; i++ {
m.cursor.x++
}
m.clampCursorX()
return nil
}
func (a MoveRight) WithCount(n int) Action {
return MoveRight{count: n}
}
type EnterInsert struct {
count int
}
func (a EnterInsert) Execute(m *model) tea.Cmd {
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a EnterInsert) WithCount(n int) Action {
return EnterInsert{count: n}
}
type EnterInsertAfter struct {
count int
}
func (a EnterInsertAfter) Execute(m *model) tea.Cmd {
m.cursor.x++
m.clampCursorX()
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a EnterInsertAfter) WithCount(n int) Action {
return EnterInsertAfter{count: n}
}
type Quit struct{}
func (a Quit) Execute(m *model) tea.Cmd {
return tea.Quit
}
// MoveToTop implements Motion (gg)
type MoveToTop struct{}
func (a MoveToTop) Execute(m *model) tea.Cmd {
m.cursor.y = 0
m.clampCursorX()
return nil
}
// MoveToBottom implements Motion (G)
type MoveToBottom struct{}
func (a MoveToBottom) Execute(m *model) tea.Cmd {
m.cursor.y = len(m.lines) - 1
m.clampCursorX()
return nil
}
type EnterInsertLineStart struct {
count int
}
func (a EnterInsertLineStart) Execute(m *model) tea.Cmd {
m.cursor.x = 0
m.clampCursorX()
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a EnterInsertLineStart) WithCount(n int) Action {
return EnterInsertLineStart{count: n}
}
type EnterInsertLineEnd struct {
count int
}
func (a EnterInsertLineEnd) Execute(m *model) tea.Cmd {
m.cursor.x = len(m.lines[m.cursor.y])
m.clampCursorX()
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a EnterInsertLineEnd) WithCount(n int) Action {
return EnterInsertLineEnd{count: n}
}
type MoveToLineStart struct{}
func (a MoveToLineStart) Execute(m *model) tea.Cmd {
m.cursor.x = 0
m.clampCursorX()
return nil
}
type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m *model) tea.Cmd {
m.cursor.x = len(m.lines[m.cursor.y])
m.clampCursorX()
return nil
}
// TODO: Count
type OpenLineBelow struct {
count int
}
func (a OpenLineBelow) Execute(m *model) tea.Cmd {
pos := m.cursor.y
if pos >= len(m.lines) {
m.lines = append(m.lines, "")
} else {
m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...)
}
m.cursor.y++
m.cursor.x = 0
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a OpenLineBelow) WithCount(n int) Action {
return OpenLineBelow{count: n}
}
type OpenLineAbove struct {
count int
}
func (a OpenLineAbove) Execute(m *model) tea.Cmd {
pos := m.cursor.y
if pos <= 0 {
m.lines = append([]string{""}, m.lines...)
} else {
m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...)
}
m.cursor.x = 0
// Start recording
m.insertCount = a.count
m.insertKeys = []string{}
m.insertAction = a
m.mode = InsertMode
return nil
}
func (a OpenLineAbove) WithCount(n int) Action {
return OpenLineAbove{count: n}
}
// TODO: Visual mode
type DeleteChar struct {
count int
}
func (a DeleteChar) Execute(m *model) tea.Cmd {
pos := m.cursor.x
for i := 0; i < a.count && m.cursor.x < len(m.lines[m.cursor.y]); i++ {
line := m.lines[m.cursor.y]
m.lines[m.cursor.y] = line[:pos] + line[pos+1:]
}
return nil
}
func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{count: n}
}

File diff suppressed because it is too large Load Diff