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 (
|
require (
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -14,7 +15,6 @@ require (
|
|||||||
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // 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/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/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
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 {
|
switch m.mode {
|
||||||
case NormalMode:
|
case action.NormalMode:
|
||||||
// Block cursor for normal mode
|
// Block cursor for normal mode
|
||||||
return lipgloss.NewStyle().Reverse(true)
|
return lipgloss.NewStyle().Reverse(true)
|
||||||
case InsertMode:
|
case action.InsertMode:
|
||||||
// Bar/underline for insert mode
|
// Bar/underline for insert mode
|
||||||
return lipgloss.NewStyle().Underline(true)
|
return lipgloss.NewStyle().Underline(true)
|
||||||
case CommandMode:
|
case action.CommandMode:
|
||||||
return lipgloss.NewStyle()
|
return lipgloss.NewStyle()
|
||||||
// case VisualMode:
|
|
||||||
// // Colored block for visual mode
|
|
||||||
// return lipgloss.NewStyle().
|
|
||||||
// Background(lipgloss.Color("62")).
|
|
||||||
// Foreground(lipgloss.Color("230"))
|
|
||||||
default:
|
default:
|
||||||
return lipgloss.NewStyle().Reverse(true)
|
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")
|
fg := lipgloss.Color("243")
|
||||||
if currentLine {
|
if currentLine {
|
||||||
fg = lipgloss.Color("#d69d00")
|
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) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@ -11,11 +14,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case NormalMode:
|
case action.NormalMode:
|
||||||
return m, m.input.Handle(&m, msg.String())
|
return m, m.input.Handle(&m, msg.String())
|
||||||
|
|
||||||
// TODO: This should be handled elsewhere
|
// TODO: This should be handled elsewhere
|
||||||
case InsertMode:
|
case action.InsertMode:
|
||||||
key := msg.String()
|
key := msg.String()
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
@ -28,7 +31,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.cursor.x > 0 {
|
if m.cursor.x > 0 {
|
||||||
m.cursor.x--
|
m.cursor.x--
|
||||||
}
|
}
|
||||||
m.mode = NormalMode
|
m.mode = action.NormalMode
|
||||||
m.insertCount = 0
|
m.insertCount = 0
|
||||||
m.insertKeys = nil
|
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.insertKeys = append(m.insertKeys, key)
|
||||||
m.processInsertKey(key)
|
m.processInsertKey(key)
|
||||||
}
|
}
|
||||||
case CommandMode:
|
case action.CommandMode:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.mode = NormalMode
|
m.mode = action.NormalMode
|
||||||
m.command = ""
|
m.command = ""
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -1,11 +1,13 @@
|
|||||||
package main
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m Model) View() string {
|
||||||
var view strings.Builder
|
var view strings.Builder
|
||||||
|
|
||||||
for y := 0; y < m.win_h-1; y++ {
|
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))
|
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
|
||||||
|
|
||||||
// TODO: Do we need to do offset calculation?
|
|
||||||
|
|
||||||
runes := []rune(m.lines[y])
|
runes := []rune(m.lines[y])
|
||||||
for x := 0; x <= len(runes); x++ {
|
for x := 0; x <= len(runes); x++ {
|
||||||
if m.cursor.y == y && m.cursor.x == x {
|
if m.cursor.y == y && m.cursor.x == x {
|
||||||
@ -59,19 +59,19 @@ func (m model) View() string {
|
|||||||
// Draw status bar
|
// Draw status bar
|
||||||
var modeString string
|
var modeString string
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case NormalMode:
|
case action.NormalMode:
|
||||||
modeString = "NORMAL"
|
modeString = "NORMAL"
|
||||||
case InsertMode:
|
case action.InsertMode:
|
||||||
modeString = "INSERT"
|
modeString = "INSERT"
|
||||||
case CommandMode:
|
case action.CommandMode:
|
||||||
modeString = "COMMAND"
|
modeString = "COMMAND"
|
||||||
}
|
}
|
||||||
|
|
||||||
var bar string
|
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)
|
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 {
|
} 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.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
|
type InputState int
|
||||||
|
|
||||||
@ -11,24 +14,29 @@ const (
|
|||||||
StateMotionCount
|
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
|
state InputState
|
||||||
count1 int
|
count1 int
|
||||||
count2 int
|
count2 int
|
||||||
operator Operator
|
operator action.Operator
|
||||||
operatorKey string // track which key started operator (for dd, yy, cc)
|
operatorKey string // track which key started operator (for dd, yy, cc)
|
||||||
buffer string // for display (what user has typed)
|
buffer string // for display (what user has typed)
|
||||||
pending string // partial key sequence (e.g., "g" waiting for second key)
|
pending string // partial key sequence (e.g., "g" waiting for second key)
|
||||||
keymap *Keymap
|
keymap *Keymap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInputHandler() *InputHandler {
|
func NewHandler() *Handler {
|
||||||
return &InputHandler{
|
return &Handler{
|
||||||
keymap: NewNormalKeymap(),
|
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
|
// ESC always resets everything
|
||||||
if key == "esc" {
|
if key == "esc" {
|
||||||
h.Reset()
|
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
|
// 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 {
|
switch h.state {
|
||||||
case StateReady, StateCount:
|
case StateReady, StateCount:
|
||||||
return h.handleInitial(m, kind, binding, key)
|
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
|
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()
|
count := h.effectiveCount()
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case "motion":
|
case "motion":
|
||||||
motion := binding.(Motion)
|
mot := binding.(action.Motion)
|
||||||
if r, ok := motion.(Repeatable); ok {
|
if r, ok := mot.(action.Repeatable); ok {
|
||||||
motion = r.WithCount(count).(Motion)
|
mot = r.WithCount(count).(action.Motion)
|
||||||
}
|
}
|
||||||
cmd := motion.Execute(m)
|
cmd := mot.Execute(m)
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
case "operator":
|
case "operator":
|
||||||
h.operator = binding.(Operator)
|
h.operator = binding.(action.Operator)
|
||||||
h.operatorKey = key
|
h.operatorKey = key
|
||||||
h.state = StateOperatorPending
|
h.state = StateOperatorPending
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "action":
|
case "action":
|
||||||
action := binding.(Action)
|
act := binding.(action.Action)
|
||||||
if r, ok := action.(Repeatable); ok {
|
if r, ok := act.(action.Repeatable); ok {
|
||||||
action = r.WithCount(count)
|
act = r.WithCount(count)
|
||||||
}
|
}
|
||||||
cmd := action.Execute(m)
|
cmd := act.Execute(m)
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@ -118,7 +126,7 @@ func (h *InputHandler) handleInitial(m *model, kind string, binding any, key str
|
|||||||
return nil
|
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()
|
count := h.effectiveCount()
|
||||||
|
|
||||||
// dd, yy, cc - same operator key pressed twice
|
// 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
|
// Motion after operator
|
||||||
if kind == "motion" {
|
if kind == "motion" {
|
||||||
motion := binding.(Motion)
|
mot := binding.(action.Motion)
|
||||||
if r, ok := motion.(Repeatable); ok {
|
if r, ok := mot.(action.Repeatable); ok {
|
||||||
motion = r.WithCount(count).(Motion)
|
mot = r.WithCount(count).(action.Motion)
|
||||||
}
|
}
|
||||||
// Get range here
|
// Get range here
|
||||||
start := m.getCursorPosition()
|
pg := m.(PositionGetter)
|
||||||
motion.Execute(m)
|
start := pg.GetCursorPosition()
|
||||||
end := m.getCursorPosition()
|
mot.Execute(m)
|
||||||
|
end := pg.GetCursorPosition()
|
||||||
cmd := h.operator.Operate(m, start, end)
|
cmd := h.operator.Operate(m, start, end)
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return cmd
|
return cmd
|
||||||
@ -147,7 +156,7 @@ func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, k
|
|||||||
return nil
|
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' {
|
if len(key) != 1 || key[0] < '0' || key[0] > '9' {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -172,14 +181,14 @@ func (h *InputHandler) tryAccumulateCount(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *InputHandler) currentCount() int {
|
func (h *Handler) currentCount() int {
|
||||||
if h.state == StateOperatorPending || h.state == StateMotionCount {
|
if h.state == StateOperatorPending || h.state == StateMotionCount {
|
||||||
return h.count2
|
return h.count2
|
||||||
}
|
}
|
||||||
return h.count1
|
return h.count1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *InputHandler) effectiveCount() int {
|
func (h *Handler) effectiveCount() int {
|
||||||
c1, c2 := h.count1, h.count2
|
c1, c2 := h.count1, h.count2
|
||||||
if c1 == 0 {
|
if c1 == 0 {
|
||||||
c1 = 1
|
c1 = 1
|
||||||
@ -190,7 +199,7 @@ func (h *InputHandler) effectiveCount() int {
|
|||||||
return c1 * c2
|
return c1 * c2
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *InputHandler) Reset() {
|
func (h *Handler) Reset() {
|
||||||
h.state = StateReady
|
h.state = StateReady
|
||||||
h.count1 = 0
|
h.count1 = 0
|
||||||
h.count2 = 0
|
h.count2 = 0
|
||||||
@ -200,6 +209,6 @@ func (h *InputHandler) Reset() {
|
|||||||
h.pending = ""
|
h.pending = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *InputHandler) Pending() string {
|
func (h *Handler) Pending() string {
|
||||||
return h.buffer
|
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