Huge module refactor. Much needed, but it came much more complex
This commit is contained in:
parent
51e20aa87d
commit
2438da08d4
29
actions.go
29
actions.go
@ -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
15
cmd/gim/main.go
Normal 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
2
go.mod
@ -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
2
go.sum
@ -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
64
internal/action/action.go
Normal 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
23
internal/action/delete.go
Normal 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
123
internal/action/insert.go
Normal 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
10
internal/action/misc.go
Normal 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
|
||||
}
|
||||
56
internal/editor/helpers_test.go
Normal file
56
internal/editor/helpers_test.go
Normal 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)
|
||||
}
|
||||
88
internal/editor/integration_delete_test.go
Normal file
88
internal/editor/integration_delete_test.go
Normal 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
394
internal/editor/integration_insert_test.go
Normal file
394
internal/editor/integration_insert_test.go
Normal 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
277
internal/editor/integration_motion_basic_test.go
Normal file
277
internal/editor/integration_motion_basic_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
229
internal/editor/integration_motion_jump_test.go
Normal file
229
internal/editor/integration_motion_jump_test.go
Normal 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
211
internal/editor/model.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
@ -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:
|
||||
@ -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)
|
||||
@ -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
76
internal/input/keymap.go
Normal 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
75
internal/motion/basic.go
Normal 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
42
internal/motion/jump.go
Normal 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
|
||||
}
|
||||
76
keymap.go
76
keymap.go
@ -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
11
main.go
@ -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
150
model.go
@ -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
268
motion.go
@ -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}
|
||||
}
|
||||
1017
motion_test.go
1017
motion_test.go
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user