Compare commits

..

No commits in common. "f12ce37bebe7f7a1d29aaff36714e8a83e5f700f" and "db52b63db1bbd173ff318490e0ce4fcbb0ba0db9" have entirely different histories.

61 changed files with 3860 additions and 11262 deletions

View File

@ -1,30 +0,0 @@
name: Run Test Suite
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod # Use mod file directly
cache: false
# go-version: "1.25.5" # Pin version
- name: Install dependencies
run: go mod download
- name: Run tests
run: go test -v ./...

View File

@ -74,8 +74,8 @@
- [x] `yy` - Yank line (double press) - [x] `yy` - Yank line (double press)
### Not Implemented ### Not Implemented
- [x] `c` - Change operator - [ ] `c` - Change operator
- [x] `cc` - Change line - [ ] `cc` - Change line
- [ ] `>` - Indent right - [ ] `>` - Indent right
- [ ] `<` - Indent left - [ ] `<` - Indent left
- [ ] `=` - Auto-indent - [ ] `=` - Auto-indent
@ -96,9 +96,9 @@
- [x] `A` - Insert at end of line - [x] `A` - Insert at end of line
- [x] `o` - Open line below - [x] `o` - Open line below
- [x] `O` - Open line above - [x] `O` - Open line above
- [x] `s` - Substitute character (delete + insert) - [ ] `s` - Substitute character (delete + insert)
- [x] `S` - Substitute line (delete line + insert) - [ ] `S` - Substitute line (delete line + insert)
- [x] `C` - Change to end of line - [ ] `C` - Change to end of line
- [ ] `gi` - Insert at last insert position - [ ] `gi` - Insert at last insert position
### Delete Actions ### Delete Actions
@ -213,11 +213,11 @@
- [x] `:set number!` - Toggle line numbers - [x] `:set number!` - Toggle line numbers
- [x] `:set tabstop=N` - Set tab width - [x] `:set tabstop=N` - Set tab width
- [x] `:register {name}` - Show register contents - [x] `:register {name}` - Show register contents
- [x] `:w` - Write file - [ ] `:w` - Write file
- [x] `:q` - Quit - [ ] `:q` - Quit
- [x] `:wq` - Write and quit - [ ] `:wq` - Write and quit
- [x] `:q!` - Force quit - [ ] `:q!` - Force quit
- [x] `:e {file}` - Edit file - [ ] `:e {file}` - Edit file
- [ ] `:bn` / `:bp` - Next/previous buffer - [ ] `:bn` / `:bp` - Next/previous buffer
- [ ] `:{range}` - Go to line - [ ] `:{range}` - Go to line
- [ ] `:%s/old/new/g` - Search and replace - [ ] `:%s/old/new/g` - Search and replace
@ -264,15 +264,15 @@
Buffers are in-memory representations of files. A buffer exists for each open file. Buffers are in-memory representations of files. A buffer exists for each open file.
### Buffer Model ### Buffer Model
- [x] Buffer struct (id, filename, lines, modified flag, cursor position) - [ ] Buffer struct (id, filename, lines, modified flag, cursor position)
- [ ] Buffer list/manager - [ ] Buffer list/manager
- [x] Current buffer tracking - [ ] Current buffer tracking
- [ ] Buffer-local settings (tabstop, filetype, etc.) - [ ] Buffer-local settings (tabstop, filetype, etc.)
- [x] Modified/dirty state tracking - [ ] Modified/dirty state tracking
- [x] Read-only buffer support - [ ] Read-only buffer support
### Buffer Navigation ### Buffer Navigation
- [x] `:e {file}` - Edit file (open in new buffer or switch to existing) - [ ] `:e {file}` - Edit file (open in new buffer or switch to existing)
- [ ] `:bn` / `:bnext` - Next buffer - [ ] `:bn` / `:bnext` - Next buffer
- [ ] `:bp` / `:bprev` - Previous buffer - [ ] `:bp` / `:bprev` - Previous buffer
- [ ] `:b {name}` - Switch to buffer by name (partial match) - [ ] `:b {name}` - Switch to buffer by name (partial match)
@ -286,9 +286,9 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [ ] `:bd` / `:bdelete` - Delete buffer (close file) - [ ] `:bd` / `:bdelete` - Delete buffer (close file)
- [ ] `:bd!` - Force delete buffer (discard changes) - [ ] `:bd!` - Force delete buffer (discard changes)
- [ ] `:bw` / `:bwipeout` - Wipe buffer (remove completely) - [ ] `:bw` / `:bwipeout` - Wipe buffer (remove completely)
- [x] `:w` - Write current buffer to file - [ ] `:w` - Write current buffer to file
- [x] `:w {file}` - Write buffer to specific file - [ ] `:w {file}` - Write buffer to specific file
- [x] `:wa` - Write all modified buffers - [ ] `:wa` - Write all modified buffers
- [ ] `:sav {file}` - Save as (write to new file, switch to it) - [ ] `:sav {file}` - Save as (write to new file, switch to it)
### Buffer State ### Buffer State
@ -309,8 +309,8 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Hidden Buffers ### Hidden Buffers
- [ ] `:set hidden` - Allow switching with unsaved changes - [ ] `:set hidden` - Allow switching with unsaved changes
- [x] Prompt to save when closing modified buffer - [ ] Prompt to save when closing modified buffer
- [x] `:q` behavior with modified buffers - [ ] `:q` behavior with modified buffers
### Argument List (Advanced) ### Argument List (Advanced)
- [ ] `:args` - Show argument list - [ ] `:args` - Show argument list
@ -381,8 +381,8 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [ ] Color column (ruler) - [ ] Color column (ruler)
### Files ### Files
- [x] File reading - [ ] File reading
- [x] File writing - [ ] File writing
- [ ] Auto-save - [ ] Auto-save
- [ ] Backup files - [ ] Backup files
- [ ] Swap files - [ ] Swap files
@ -405,49 +405,9 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
--- ---
### Well Tested - Editor Core ## Testing Coverage
#### Command Execution (179 tests) ### Well Tested
- [x] Command parsing and validation
- [x] Command lookup and prefix matching
- [x] Force flag handling (!)
- [x] Write commands (`:w`, `:w {file}`, `:w!`)
- [x] Write all commands (`:wa`, `:wall`, `:wa!`)
- [x] Quit commands (`:q`, `:q!`, `:qa`, `:qa!`)
- [x] Write-quit commands (`:wq`, `:wq!`, `:wqa`, `:wqa!`)
- [x] Edit command (`:e {file}`)
- [x] Register display (`:register`, `:reg {name}`)
- [x] Set commands (`:set number`, `:set tabstop=N`, etc.)
- [x] Setting lookup and validation
- [x] Buffer-level readonly protection
- [x] Scratch buffer write protection
- [x] Force write bypassing readonly/scratch checks
- [x] Multiple buffer write operations
- [x] File write error handling (permissions, paths)
- [x] Modified buffer tracking
- [x] Unicode filename and content handling
- [x] Edge cases (empty args, long filenames, special chars)
#### Program Initialization (70 tests)
- [x] Empty program creation
- [x] File program with nonexistent files (new file buffers)
- [x] File program with existing files (content loading)
- [x] Line ending handling (Unix `\n`, Windows `\r\n`, mixed)
- [x] Tab to space conversion based on TabStop
- [x] Unicode content preservation (CJK, emoji)
- [x] File extension and type detection
- [x] Buffer state initialization (flags, metadata)
- [x] Large file handling (10,000+ lines)
- [x] Long line handling (10,000+ chars)
- [x] Empty file handling
- [x] Builder pattern method chaining
- [x] Program option accumulation
- [x] Model state defaults (settings, registers, mode)
- [x] Error handling (permissions, invalid paths)
- [x] Integration workflows (end-to-end)
- [x] Edge cases (empty filenames, relative paths, dot files)
### Moderately Tested
- [x] Basic motions (h, j, k, l) - [x] Basic motions (h, j, k, l)
- [x] Word motions (w, e, b) - [x] Word motions (w, e, b)
- [x] Jump motions (G, gg, 0, $, _, ^, |) - [x] Jump motions (G, gg, 0, $, _, ^, |)
@ -455,12 +415,10 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [x] Delete operator (d, dd) - [x] Delete operator (d, dd)
- [x] Yank operator (y, yy) - [x] Yank operator (y, yy)
- [x] Paste actions (p, P) - [x] Paste actions (p, P)
- [x] Change operator (c, cc, C)
- [x] Substitute action (s, S)
- [x] Insert mode entry (i, a, I, A, o, O) - [x] Insert mode entry (i, a, I, A, o, O)
- [x] Insert mode editing (enter, backspace, delete, tab, ctrl+w) - [x] Insert mode editing (enter, backspace, delete, tab, ctrl+w)
- [x] Visual modes (v, V, ctrl+v) - [x] Visual modes (v, V, ctrl+v)
- [x] Visual mode with motions - [x] Visual mode with motions
- [x] Delete actions (x, D) - [x] Delete actions (x, D)
- [x] Command mode basics
- [x] Register behavior - [x] Register behavior

View File

@ -1,32 +1,24 @@
package main package main
import ( import (
"os" "fmt"
"git.gophernest.net/azpect/TextEditor/internal/program" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/editor"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// main: Entry point for the Gim text editor. Creates a buffer and window, func generateLines(n int) []string {
// initializes the editor model, and runs the BubbleTea TUI program. lines := make([]string, n)
func main() { for i := range n {
// <exe> <filename> lines[i] = fmt.Sprintf("line %d", i+1)
args := os.Args[1:]
var prog *tea.Program
if len(args) < 1 {
prog = program.NewProgramBuilder().
EmptyProgram().
WithOpt(tea.WithAltScreen()).
Build()
} else {
prog = program.NewProgramBuilder().
FileProgram(args[0]).
WithOpt(tea.WithAltScreen()).
Build()
}
if _, err := prog.Run(); err != nil {
panic(err)
} }
return lines
}
func main() {
tea.NewProgram(
editor.NewModel(generateLines(64), action.Position{Line: 0, Col: 0}),
tea.WithAltScreen(),
).Run()
} }

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

@ -0,0 +1,152 @@
package action
import (
tea "github.com/charmbracelet/bubbletea"
)
// Mode constants for editor mode
type Mode int
const (
NormalMode Mode = iota
InsertMode
CommandMode
VisualMode
VisualLineMode
VisualBlockMode
)
func (m Mode) ToString() string {
switch m {
case NormalMode:
return "NORMAL"
case InsertMode:
return "INSERT"
case CommandMode:
return "COMMAND"
case VisualMode:
return "VISUAL"
case VisualLineMode:
return "V-LINE"
case VisualBlockMode:
return "V-BLOCK"
default:
return "-----"
}
}
func (m Mode) IsVisualMode() bool {
return m == VisualMode ||
m == VisualLineMode ||
m == VisualBlockMode
}
// 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()
// Window
ScrollY() int
SetScrollY(y int)
WinH() int
WinW() int
ViewPortH() int
// Anchor
AnchorX() int
AnchorY() int
SetAnchorX(x int)
SetAnchorY(y int)
// Insert
InsertKeys() []string
SetInsertKeys(keys []string)
// Command mode
Command() string
SetCommand(cmd string)
CommandCursor() int
SetCommandCursor(cur int)
CommandError() error
SetCommandError(err error)
CommandOutput() string
SetCommandOutput(out string)
// Settings
Settings() Settings
SetSettings(s Settings)
// Registers
Registers() map[rune]Register
GetRegister(name rune) (Register, bool)
SetRegister(name rune, t RegisterType, cnt []string) error
UpdateDefaultRegister(t RegisterType, cnt []string)
// Mode
Mode() Mode
SetMode(mode Mode)
// Insert recording (for count replay)
SetInsertRecording(count int, action Action)
// ExitInsertMode handles replay, cursor step-back, and mode transition on esc
ExitInsertMode()
}
// Position represents a location in the buffer
type Position struct {
Line, Col int
}
// MotionType indicates how a motion operates
type MotionType int
const (
CharwiseExclusive MotionType = iota // w, b, h, l, 0, ^ - end position not included
CharwiseInclusive // e, $, f - end position is included
Linewise // j, k, G, gg, {, } - operates on whole lines
)
// IsCharwise returns true if the motion type is character-based (not linewise)
func (mt MotionType) IsCharwise() bool {
return mt == CharwiseExclusive || mt == CharwiseInclusive
}
// 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
Type() MotionType
}
// Operator acts on a range (delete, yank, change)
type Operator interface {
Operate(m Model, start, end Position, mtype MotionType) tea.Cmd
}
// DoublePresser is an optional interface for operators that support double-press (dd, yy, cc)
type DoublePresser interface {
DoublePress(m Model, count int) tea.Cmd
}
// Repeatable actions track count
type Repeatable interface {
WithCount(n int) Action
}

View File

@ -1,33 +1,26 @@
package action package action
import ( import tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// ChangeToEndOfLine implements Action (C) - changes from cursor to end of line // ChangeToEndOfLine implements Action (C) - changes from cursor to end of line
type ChangeToEndOfLine struct { type ChangeToEndOfLine struct {
Count int Count int
} }
// ChangeToEndOfLine.Execute: Changes from cursor to end of line and enters insert mode (C key).
func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd { func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() pos := m.CursorX()
buf := m.ActiveBuffer() line := m.Line(m.CursorY())
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
// Save deleted text to register // Save deleted text to register
if pos < len(line) { if pos < len(line) {
m.UpdateDefaultRegister(core.CharwiseRegister, []string{line[pos:]}) m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos:]})
} }
// Delete to end of line // Delete to end of line
buf.SetLine(win.Cursor.Line, line[:pos]) m.SetLine(m.CursorY(), line[:pos])
// Enter insert mode // Enter insert mode
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
@ -35,7 +28,6 @@ func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd {
// Ensure ChangeToEndOfLine implements Repeatable // Ensure ChangeToEndOfLine implements Repeatable
var _ Repeatable = ChangeToEndOfLine{} var _ Repeatable = ChangeToEndOfLine{}
// ChangeToEndOfLine.WithCount: Returns a new ChangeToEndOfLine with the given count.
func (a ChangeToEndOfLine) WithCount(n int) Action { func (a ChangeToEndOfLine) WithCount(n int) Action {
return ChangeToEndOfLine{Count: n} return ChangeToEndOfLine{Count: n}
} }
@ -45,27 +37,23 @@ type SubstituteChar struct {
Count int Count int
} }
// SubstituteChar.Execute: Deletes Count characters and enters insert mode (s key).
func (a SubstituteChar) Execute(m Model) tea.Cmd { func (a SubstituteChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() pos := m.CursorX()
buf := m.ActiveBuffer() line := m.Line(m.CursorY())
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
// Calculate how many chars to delete (limited by line length) // Calculate how many chars to delete (limited by line length)
count := min(a.Count, len(line)-pos) count := min(a.Count, len(line)-pos)
if count > 0 { if count > 0 {
// Save deleted text to register // Save deleted text to register
m.UpdateDefaultRegister(core.CharwiseRegister, []string{line[pos : pos+count]}) m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos : pos+count]})
// Delete the characters // Delete the characters
buf.SetLine(win.Cursor.Line, line[:pos]+line[pos+count:]) m.SetLine(m.CursorY(), line[:pos]+line[pos+count:])
} }
// Enter insert mode // Enter insert mode
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
@ -73,7 +61,6 @@ func (a SubstituteChar) Execute(m Model) tea.Cmd {
// Ensure SubstituteChar implements Repeatable // Ensure SubstituteChar implements Repeatable
var _ Repeatable = SubstituteChar{} var _ Repeatable = SubstituteChar{}
// SubstituteChar.WithCount: Returns a new SubstituteChar with the given count.
func (a SubstituteChar) WithCount(n int) Action { func (a SubstituteChar) WithCount(n int) Action {
return SubstituteChar{Count: n} return SubstituteChar{Count: n}
} }
@ -83,36 +70,33 @@ type SubstituteLine struct {
Count int Count int
} }
// SubstituteLine.Execute: Clears Count lines and enters insert mode (S key).
func (a SubstituteLine) Execute(m Model) tea.Cmd { func (a SubstituteLine) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() y := m.CursorY()
buf := m.ActiveBuffer()
y := win.Cursor.Line
// Calculate how many lines to substitute // Calculate how many lines to substitute
count := min(a.Count, buf.LineCount()-y) count := min(a.Count, m.LineCount()-y)
var lines []string var lines []string
// Collect and delete lines // Collect and delete lines
for range count { for range count {
lines = append(lines, buf.Lines[y]) lines = append(lines, m.Line(y))
buf.DeleteLine(y) m.DeleteLine(y)
} }
// Save deleted lines to register // Save deleted lines to register
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(LinewiseRegister, lines)
// Insert empty line at original position // Insert empty line at original position
insertY := min(y, buf.LineCount()) insertY := min(y, m.LineCount())
buf.InsertLine(insertY, "") m.InsertLine(insertY, "")
// Position cursor // Position cursor
win.SetCursorPos(insertY, 0) m.SetCursorY(insertY)
m.SetCursorX(0)
// Enter insert mode // Enter insert mode
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
@ -120,7 +104,6 @@ func (a SubstituteLine) Execute(m Model) tea.Cmd {
// Ensure SubstituteLine implements Repeatable // Ensure SubstituteLine implements Repeatable
var _ Repeatable = SubstituteLine{} var _ Repeatable = SubstituteLine{}
// SubstituteLine.WithCount: Returns a new SubstituteLine with the given count.
func (a SubstituteLine) WithCount(n int) Action { func (a SubstituteLine) WithCount(n int) Action {
return SubstituteLine{Count: n} return SubstituteLine{Count: n}
} }

View File

@ -1,29 +1,24 @@
package action package action
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// ExitCommandMode implements Action - exits command mode and returns to normal mode.
type ExitCommandMode struct{} type ExitCommandMode struct{}
// ExitCommandMode.Execute: Exits command mode and returns to normal mode (Esc key).
func (a ExitCommandMode) Execute(m Model) tea.Cmd { func (a ExitCommandMode) Execute(m Model) tea.Cmd {
m.SetCommandCursor(0) m.SetCommandCursor(0)
m.SetCommand("") m.SetCommand("")
m.SetCommandOutput("") m.SetCommandOutput("")
m.SetCommandError(nil) m.SetCommandError(nil)
m.SetMode(core.NormalMode) m.SetMode(NormalMode)
return nil return nil
} }
// InsertCommandChar implements Action - inserts a character in command mode.
type InsertCommandChar struct { type InsertCommandChar struct {
Char string Char string
} }
// InsertCommandChar.Execute: Inserts a character at the command cursor position.
func (a InsertCommandChar) Execute(m Model) tea.Cmd { func (a InsertCommandChar) Execute(m Model) tea.Cmd {
cur := m.CommandCursor() cur := m.CommandCursor()
cmd := m.Command() cmd := m.Command()
@ -33,10 +28,8 @@ func (a InsertCommandChar) Execute(m Model) tea.Cmd {
return nil return nil
} }
// CommandBackspace implements Action - deletes character before cursor in command mode.
type CommandBackspace struct{} type CommandBackspace struct{}
// CommandBackspace.Execute: Deletes the character before the command cursor (Backspace key).
func (a CommandBackspace) Execute(m Model) tea.Cmd { func (a CommandBackspace) Execute(m Model) tea.Cmd {
cur := m.CommandCursor() cur := m.CommandCursor()
cmd := m.Command() cmd := m.Command()
@ -49,10 +42,8 @@ func (a CommandBackspace) Execute(m Model) tea.Cmd {
return nil return nil
} }
// CommandDelete implements Action - deletes character at cursor in command mode.
type CommandDelete struct{} type CommandDelete struct{}
// CommandDelete.Execute: Deletes the character at the command cursor (Delete key).
func (a CommandDelete) Execute(m Model) tea.Cmd { func (a CommandDelete) Execute(m Model) tea.Cmd {
cur := m.CommandCursor() cur := m.CommandCursor()
cmd := m.Command() cmd := m.Command()
@ -71,10 +62,8 @@ func (a CommandDelete) Execute(m Model) tea.Cmd {
return nil return nil
} }
// CommandDeletePreviousWord implements Action - deletes word before cursor in command mode.
type CommandDeletePreviousWord struct{} type CommandDeletePreviousWord struct{}
// CommandDeletePreviousWord.Execute: Deletes the word before the command cursor (Ctrl+W).
func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd { func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd {
cur := m.CommandCursor() cur := m.CommandCursor()
cmd := m.Command() cmd := m.Command()
@ -112,24 +101,22 @@ func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd {
return nil return nil
} }
// CommandExecute implements Action - executes the command line.
type CommandExecute struct { type CommandExecute struct {
Registry CommandRegistry Registry CommandRegistry
} }
// CommandRegistry: Interface for executing commands. // CommandRegistry interface for executing commands
type CommandRegistry interface { type CommandRegistry interface {
Execute(m Model, cmdLine string) (tea.Cmd, error) Execute(m Model, cmdLine string) (tea.Cmd, error)
} }
// CommandExecute.Execute: Executes the command line (Enter key).
func (a CommandExecute) Execute(m Model) tea.Cmd { func (a CommandExecute) Execute(m Model) tea.Cmd {
cmdLine := m.Command() cmdLine := m.Command()
// Clear command state and return to normal mode // Clear command state and return to normal mode
m.SetCommandCursor(0) m.SetCommandCursor(0)
m.SetCommandError(nil) m.SetCommandError(nil)
m.SetMode(core.NormalMode) m.SetMode(NormalMode)
if a.Registry == nil || cmdLine == "" { if a.Registry == nil || cmdLine == "" {
return nil return nil
@ -137,6 +124,7 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
cmd, err := a.Registry.Execute(m, cmdLine) cmd, err := a.Registry.Execute(m, cmdLine)
if err != nil { if err != nil {
// TODO: Display error message to user
m.SetCommandError(err) m.SetCommandError(err)
return nil return nil
} }

View File

@ -7,80 +7,70 @@ type DeleteChar struct {
Count int Count int
} }
// DeleteChar.Execute: Deletes Count characters at the cursor position (x key).
func (a DeleteChar) Execute(m Model) tea.Cmd { func (a DeleteChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() pos := m.CursorX()
buf := m.ActiveBuffer() line := m.Line(m.CursorY())
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
for i := 0; i < a.Count && pos < len(line); i++ { for i := 0; i < a.Count && pos < len(line); i++ {
line = line[:pos] + line[pos+1:] line = line[:pos] + line[pos+1:]
buf.SetLine(win.Cursor.Line, line) m.SetLine(m.CursorY(), line)
} }
return nil return nil
} }
// DeleteChar.WithCount: Returns a new DeleteChar with the given count.
func (a DeleteChar) WithCount(n int) Action { func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{Count: n} return DeleteChar{Count: n}
} }
// DeleteToEndOfLine implements Action (D) - deletes from cursor to end of line
// and optionally Count-1 additional lines below.
type DeleteToEndOfLine struct { type DeleteToEndOfLine struct {
Count int Count int
} }
// DeleteToEndOfLine.Execute: Deletes from cursor to end of line and Count-1 lines below (D key).
func (a DeleteToEndOfLine) Execute(m Model) tea.Cmd { func (a DeleteToEndOfLine) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// Delete to end of line // Delete to end of line
pos := win.Cursor.Col pos := m.CursorX()
line := buf.Lines[win.Cursor.Line] line := m.Line(m.CursorY())
buf.SetLine(win.Cursor.Line, line[:pos]) m.SetLine(m.CursorY(), line[:pos])
win.SetCursorCol(pos - 1) m.SetCursorX(pos - 1)
// If count is greater, than we will delete the next N - 1 lines below // If count is greater, than we will delete the next N - 1 lines below
initY := win.Cursor.Line initY := m.CursorY()
if a.Count > 1 { if a.Count > 1 {
// Copied from `internal/operator/delete.go` // Copied from `internal/operator/delete.go`
opCount := min(a.Count-1, buf.LineCount()-win.Cursor.Line) opCount := min(a.Count-1, m.LineCount()-m.CursorY())
// Down one // Down one
win.SetCursorLine(initY + 1) m.SetCursorY(initY + 1)
for range opCount { for range opCount {
y := win.Cursor.Line // Changed from the copied code y := m.CursorY() // Changed from the copied code
// Stop if were on the starting line // Stop if were on the starting line
if y == initY { if y == initY {
break break
} }
buf.DeleteLine(y) m.DeleteLine(y)
if buf.LineCount() == 0 { if m.LineCount() == 0 {
buf.InsertLine(0, "") m.InsertLine(0, "")
} }
if y >= buf.LineCount() { if y >= m.LineCount() {
y = buf.LineCount() - 1 y = m.LineCount() - 1
} }
win.SetCursorLine(y) m.SetCursorY(y)
m.ClampCursorX()
} }
} }
win.SetCursorLine(initY) m.SetCursorY(initY)
m.ClampCursorX()
return nil return nil
} }
// DeleteToEndOfLine.WithCount: Returns a new DeleteToEndOfLine with the given count.
func (a DeleteToEndOfLine) WithCount(n int) Action { func (a DeleteToEndOfLine) WithCount(n int) Action {
return DeleteToEndOfLine{Count: n} return DeleteToEndOfLine{Count: n}
} }

View File

@ -3,7 +3,6 @@ package action
import ( import (
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -12,15 +11,13 @@ type EnterInsert struct {
Count int Count int
} }
// EnterInsert.Execute: Enters insert mode at the cursor position (i key).
func (a EnterInsert) Execute(m Model) tea.Cmd { func (a EnterInsert) Execute(m Model) tea.Cmd {
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// EnterInsert.WithCount: Returns a new EnterInsert with the given count.
func (a EnterInsert) WithCount(n int) Action { func (a EnterInsert) WithCount(n int) Action {
return EnterInsert{Count: n} return EnterInsert{Count: n}
} }
@ -30,18 +27,16 @@ type EnterInsertAfter struct {
Count int Count int
} }
// EnterInsertAfter.Execute: Enters insert mode after the cursor position (a key).
func (a EnterInsertAfter) Execute(m Model) tea.Cmd { func (a EnterInsertAfter) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorX(m.CursorX() + 1)
win.SetCursorCol(win.Cursor.Col + 1) m.ClampCursorX()
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// EnterInsertAfter.WithCount: Returns a new EnterInsertAfter with the given count.
func (a EnterInsertAfter) WithCount(n int) Action { func (a EnterInsertAfter) WithCount(n int) Action {
return EnterInsertAfter{Count: n} return EnterInsertAfter{Count: n}
} }
@ -51,18 +46,16 @@ type EnterInsertLineStart struct {
Count int Count int
} }
// EnterInsertLineStart.Execute: Enters insert mode at the start of the line (I key).
func (a EnterInsertLineStart) Execute(m Model) tea.Cmd { func (a EnterInsertLineStart) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorX(0)
win.SetCursorCol(0) m.ClampCursorX()
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// EnterInsertLineStart.WithCount: Returns a new EnterInsertLineStart with the given count.
func (a EnterInsertLineStart) WithCount(n int) Action { func (a EnterInsertLineStart) WithCount(n int) Action {
return EnterInsertLineStart{Count: n} return EnterInsertLineStart{Count: n}
} }
@ -72,19 +65,16 @@ type EnterInsertLineEnd struct {
Count int Count int
} }
// EnterInsertLineEnd.Execute: Enters insert mode at the end of the line (A key).
func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd { func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorX(len(m.Line(m.CursorY())))
buf := m.ActiveBuffer() m.ClampCursorX()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// EnterInsertLineEnd.WithCount: Returns a new EnterInsertLineEnd with the given count.
func (a EnterInsertLineEnd) WithCount(n int) Action { func (a EnterInsertLineEnd) WithCount(n int) Action {
return EnterInsertLineEnd{Count: n} return EnterInsertLineEnd{Count: n}
} }
@ -94,28 +84,24 @@ type OpenLineBelow struct {
Count int Count int
} }
// OpenLineBelow.Execute: Opens a new line below the cursor and enters insert mode (o key).
func (a OpenLineBelow) Execute(m Model) tea.Cmd { func (a OpenLineBelow) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() pos := m.CursorY()
buf := m.ActiveBuffer()
pos := win.Cursor.Line if pos >= m.LineCount() {
m.InsertLine(m.LineCount(), "")
if pos >= buf.LineCount() {
buf.InsertLine(buf.LineCount(), "")
} else { } else {
buf.InsertLine(pos+1, "") m.InsertLine(pos+1, "")
} }
win.SetCursorPos(win.Cursor.Line+1, 0) m.SetCursorY(m.CursorY() + 1)
m.SetCursorX(0)
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// OpenLineBelow.WithCount: Returns a new OpenLineBelow with the given count.
func (a OpenLineBelow) WithCount(n int) Action { func (a OpenLineBelow) WithCount(n int) Action {
return OpenLineBelow{Count: n} return OpenLineBelow{Count: n}
} }
@ -125,22 +111,17 @@ type OpenLineAbove struct {
Count int Count int
} }
// OpenLineAbove.Execute: Opens a new line above the cursor and enters insert mode (O key).
func (a OpenLineAbove) Execute(m Model) tea.Cmd { func (a OpenLineAbove) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() pos := m.CursorY()
buf := m.ActiveBuffer() m.InsertLine(pos, "")
m.SetCursorX(0)
pos := win.Cursor.Line
buf.InsertLine(pos, "")
win.SetCursorCol(0)
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(core.InsertMode) m.SetMode(InsertMode)
return nil return nil
} }
// OpenLineAbove.WithCount: Returns a new OpenLineAbove with the given count.
func (a OpenLineAbove) WithCount(n int) Action { func (a OpenLineAbove) WithCount(n int) Action {
return OpenLineAbove{Count: n} return OpenLineAbove{Count: n}
} }
@ -152,61 +133,51 @@ type InsertChar struct {
Char string Char string
} }
// InsertChar.Execute: Inserts a single character at the cursor position.
func (a InsertChar) Execute(m Model) tea.Cmd { func (a InsertChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() l := m.Line(y)
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x < len(l) { if x < len(l) {
buf.SetLine(y, l[:x]+a.Char+l[x:]) m.SetLine(y, l[:x]+a.Char+l[x:])
} else { } else {
buf.SetLine(y, l+a.Char) m.SetLine(y, l+a.Char)
} }
win.SetCursorCol(x + len(a.Char)) m.SetCursorX(x + len(a.Char))
return nil return nil
} }
// InsertNewline splits the current line at the cursor (enter key) // InsertNewline splits the current line at the cursor (enter key)
type InsertNewline struct{} type InsertNewline struct{}
// InsertNewline.Execute: Splits the current line at the cursor (Enter key).
func (a InsertNewline) Execute(m Model) tea.Cmd { func (a InsertNewline) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() l := m.Line(y)
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x == len(l) { if x == len(l) {
buf.InsertLine(y+1, "") m.InsertLine(y+1, "")
} else { } else {
buf.SetLine(y, l[:x]) m.SetLine(y, l[:x])
buf.InsertLine(y+1, l[x:]) m.InsertLine(y+1, l[x:])
} }
win.SetCursorPos(y+1, 0) m.SetCursorY(y + 1)
m.SetCursorX(0)
return nil return nil
} }
// InsertBackspace deletes the character before the cursor // InsertBackspace deletes the character before the cursor
type InsertBackspace struct{} type InsertBackspace struct{}
// InsertBackspace.Execute: Deletes the character before the cursor (Backspace key).
func (a InsertBackspace) Execute(m Model) tea.Cmd { func (a InsertBackspace) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() l := m.Line(y)
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x > 0 { if x > 0 {
buf.SetLine(y, l[:x-1]+l[x:]) m.SetLine(y, l[:x-1]+l[x:])
win.SetCursorCol(x - 1) m.SetCursorX(x - 1)
} else if y > 0 { } else if y > 0 {
prevLine := buf.Lines[y-1] prevLine := m.Line(y - 1)
newX := len(prevLine) newX := len(prevLine)
buf.SetLine(y-1, prevLine+l) m.SetLine(y-1, prevLine+l)
buf.DeleteLine(y) m.DeleteLine(y)
win.SetCursorPos(y-1, newX) m.SetCursorY(y - 1)
m.SetCursorX(newX)
} }
return nil return nil
} }
@ -214,19 +185,15 @@ func (a InsertBackspace) Execute(m Model) tea.Cmd {
// InsertDelete deletes the character under/after the cursor (delete key) // InsertDelete deletes the character under/after the cursor (delete key)
type InsertDelete struct{} type InsertDelete struct{}
// InsertDelete.Execute: Deletes the character at the cursor position (Delete key).
func (a InsertDelete) Execute(m Model) tea.Cmd { func (a InsertDelete) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() l := m.Line(y)
if x == len(l) && y < m.LineCount()-1 {
x, y := win.Cursor.Col, win.Cursor.Line nextLine := m.Line(y + 1)
l := buf.Lines[y] m.SetLine(y, l+nextLine)
if x == len(l) && y < buf.LineCount()-1 { m.DeleteLine(y + 1)
nextLine := buf.Lines[y+1]
buf.SetLine(y, l+nextLine)
buf.DeleteLine(y + 1)
} else if x < len(l) { } else if x < len(l) {
buf.SetLine(y, l[:x]+l[x+1:]) m.SetLine(y, l[:x]+l[x+1:])
} }
return nil return nil
} }
@ -234,28 +201,22 @@ func (a InsertDelete) Execute(m Model) tea.Cmd {
// InsertTab inserts spaces equal to the tab size // InsertTab inserts spaces equal to the tab size
type InsertTab struct{} type InsertTab struct{}
// InsertTab.Execute: Inserts spaces equal to the tab size (Tab key).
func (a InsertTab) Execute(m Model) tea.Cmd { func (a InsertTab) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() l := m.Line(y)
tabs := strings.Repeat(" ", m.Settings().TabSize)
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
tabs := strings.Repeat(" ", m.Settings().TabStop)
if x < len(l) { if x < len(l) {
buf.SetLine(y, l[:x]+tabs+l[x:]) m.SetLine(y, l[:x]+tabs+l[x:])
} else { } else {
buf.SetLine(y, l+tabs) m.SetLine(y, l+tabs)
} }
win.SetCursorCol(x + len(tabs)) m.SetCursorX(x + len(tabs))
return nil return nil
} }
// InsertDeletePreviousWord deletes the word before the cursor (ctrl+w) // InsertDeletePreviousWord deletes the word before the cursor (ctrl+w)
type InsertDeletePreviousWord struct{} type InsertDeletePreviousWord struct{}
// isWordChar: Returns true if the character is a word character (alphanumeric
// or underscore).
func isWordChar(c byte) bool { func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') || return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || (c >= 'A' && c <= 'Z') ||
@ -263,28 +224,23 @@ func isWordChar(c byte) bool {
c == '_' c == '_'
} }
// isPunctuation: Returns true if the character is punctuation (not whitespace
// and not a word character).
func isPunctuation(c byte) bool { func isPunctuation(c byte) bool {
return c != ' ' && c != '\t' && !isWordChar(c) return c != ' ' && c != '\t' && !isWordChar(c)
} }
// InsertDeletePreviousWord.Execute: Deletes the word before the cursor (Ctrl+W).
func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd { func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() x, y := m.CursorX(), m.CursorY()
buf := m.ActiveBuffer() line := m.Line(y)
x, y := win.Cursor.Col, win.Cursor.Line
line := buf.Lines[y]
// At start of line: merge with previous line (same as backspace) // At start of line: merge with previous line (same as backspace)
if x == 0 { if x == 0 {
if y > 0 { if y > 0 {
prevLine := buf.Lines[y-1] prevLine := m.Line(y - 1)
newX := len(prevLine) newX := len(prevLine)
buf.SetLine(y-1, prevLine+line) m.SetLine(y-1, prevLine+line)
buf.DeleteLine(y) m.DeleteLine(y)
win.SetCursorPos(y-1, newX) m.SetCursorY(y - 1)
m.SetCursorX(newX)
} }
return nil return nil
} }
@ -298,8 +254,8 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
newX-- newX--
} }
buf.SetLine(y, line[:newX]+line[x:]) m.SetLine(y, line[:newX]+line[x:])
win.SetCursorCol(newX) m.SetCursorX(newX)
return nil return nil
} }
@ -315,8 +271,8 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
} }
// Delete everything from newX up to x in one operation // Delete everything from newX up to x in one operation
buf.SetLine(y, line[:newX]+line[x:]) m.SetLine(y, line[:newX]+line[x:])
win.SetCursorCol(newX) m.SetCursorX(newX)
return nil return nil
} }

View File

@ -1,85 +0,0 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// Model defines the interface for editor state that actions can modify
type Model interface {
// ==================================================
// Core Data Access
// ==================================================
Windows() []*core.Window
ActiveWindow() *core.Window
Buffers() []*core.Buffer
SetBuffers(bufs []*core.Buffer)
ActiveBuffer() *core.Buffer
// ==================================================
// Insert Mode State
// ==================================================
InsertKeys() []string
SetInsertKeys(keys []string)
// Insert recording (for count replay)
SetInsertRecording(count int, action Action)
// ExitInsertMode handles replay, cursor step-back, and mode transition on esc
ExitInsertMode()
// ==================================================
// Command Mode State
// ==================================================
Command() string
SetCommand(cmd string)
CommandCursor() int
SetCommandCursor(cur int)
CommandError() error
SetCommandError(err error)
CommandOutput() string
SetCommandOutput(out string)
// ==================================================
// Editor-wide State
// ==================================================
Mode() core.Mode
SetMode(mode core.Mode)
Settings() core.EditorSettings
SetSettings(s core.EditorSettings)
// ==================================================
// Registers
// ==================================================
Registers() map[rune]core.Register
GetRegister(name rune) (core.Register, bool)
SetRegister(name rune, t core.RegisterType, cnt []string) error
UpdateDefaultRegister(t core.RegisterType, cnt []string)
}
// 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
Type() core.MotionType
}
// Operator acts on a range (delete, yank, change)
type Operator interface {
Operate(m Model, start, end core.Position, mtype core.MotionType) tea.Cmd
}
// DoublePresser is an optional interface for operators that support double-press (dd, yy, cc)
type DoublePresser interface {
DoublePress(m Model, count int) tea.Cmd
}
// Repeatable actions track count
type Repeatable interface {
WithCount(n int) Action
}

View File

@ -1,24 +1,19 @@
package action package action
import ( import tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// Quit implements Action (ctrl+c) // Quit implements Action (ctrl+c)
type Quit struct{} type Quit struct{}
// Quit.Execute: Quits the editor (Ctrl+C).
func (a Quit) Execute(m Model) tea.Cmd { func (a Quit) Execute(m Model) tea.Cmd {
return tea.Quit return tea.Quit
} }
// EnterComandMode implements Action (:) - enters command mode. // Quit implements Action (:)
type EnterComandMode struct{} type EnterComandMode struct{}
// EnterComandMode.Execute: Enters command mode (: key).
func (a EnterComandMode) Execute(m Model) tea.Cmd { func (a EnterComandMode) Execute(m Model) tea.Cmd {
m.SetMode(core.CommandMode) m.SetMode(CommandMode)
m.SetCommand("") m.SetCommand("")
m.SetCommandOutput("") m.SetCommandOutput("")
m.SetCommandError(nil) m.SetCommandError(nil)
@ -26,38 +21,32 @@ func (a EnterComandMode) Execute(m Model) tea.Cmd {
return nil return nil
} }
// EnterVisualMode implements Action (v) - enters visual character mode. // Quit implements Action (v)
type EnterVisualMode struct{} type EnterVisualMode struct{}
// EnterVisualMode.Execute: Enters visual character mode (v key).
func (a EnterVisualMode) Execute(m Model) tea.Cmd { func (a EnterVisualMode) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetAnchorX(m.CursorX())
win.SetAnchorCol(win.Cursor.Col) m.SetAnchorY(m.CursorY())
win.SetAnchorLine(win.Cursor.Line) m.SetMode(VisualMode)
m.SetMode(core.VisualMode)
return nil return nil
} }
// EnterVisualLineMode implements Action (V) - enters visual line mode. // Quit implements Action (V)
type EnterVisualLineMode struct{} type EnterVisualLineMode struct{}
// EnterVisualLineMode.Execute: Enters visual line mode (V key).
func (a EnterVisualLineMode) Execute(m Model) tea.Cmd { func (a EnterVisualLineMode) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetAnchorX(m.CursorX())
win.SetAnchorCol(win.Cursor.Col) m.SetAnchorY(m.CursorY())
win.SetAnchorLine(win.Cursor.Line) m.SetMode(VisualLineMode)
m.SetMode(core.VisualLineMode)
return nil return nil
} }
// EnterVisualBlockMode implements Action (Ctrl+V) - enters visual block mode. // Quit implements Action (ctrl+v)
type EnterVisualBlockMode struct{} type EnterVisualBlockMode struct{}
// EnterVisualBlockMode.Execute: Enters visual block mode (Ctrl+V).
func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd { func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
win := m.ActiveWindow() m.SetAnchorX(m.CursorX())
win.SetAnchorCol(win.Cursor.Col) m.SetAnchorY(m.CursorY())
win.SetAnchorLine(win.Cursor.Line) m.SetMode(VisualBlockMode)
m.SetMode(core.VisualBlockMode)
return nil return nil
} }

View File

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -13,11 +12,7 @@ type Paste struct {
Count int Count int
} }
// Paste.Execute: Pastes register content after the cursor position (p key).
func (a Paste) Execute(m Model) tea.Cmd { func (a Paste) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// Get reg // Get reg
reg, found := m.GetRegister('"') reg, found := m.GetRegister('"')
if !found { if !found {
@ -31,25 +26,25 @@ func (a Paste) Execute(m Model) tea.Cmd {
} }
switch reg.Type { switch reg.Type {
case core.LinewiseRegister: case LinewiseRegister:
{ {
initY := win.Cursor.Line initY := m.CursorY()
lines := reg.Content lines := reg.Content
insertPos := initY + 1 insertPos := initY + 1
// Run count times // Run count times
for range a.Count { for range a.Count {
for _, line := range lines { for _, line := range lines {
buf.InsertLine(insertPos, line) m.InsertLine(insertPos, line)
insertPos++ insertPos++
} }
} }
if buf.LineCount() > 1 { if m.LineCount() > 1 {
win.SetCursorLine(initY + 1) m.SetCursorY(initY + 1)
} }
} }
case core.CharwiseRegister: case CharwiseRegister:
{ {
lines := reg.Content lines := reg.Content
@ -59,21 +54,22 @@ func (a Paste) Execute(m Model) tea.Cmd {
break break
} }
x := win.Cursor.Col x := m.CursorX()
y := win.Cursor.Line y := m.CursorY()
cnt := strings.Repeat(lines[0], max(1, a.Count)) cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y] curLine := m.Line(y)
// Catch edge cases, end of line, start of blank line // Catch edge cases, end of line, start of blank line
insertAt := min(x+1, len(curLine)) insertAt := min(x+1, len(curLine))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:] newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine) m.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt)) m.SetCursorX(x + len(cnt))
m.ClampCursorX()
} }
default: default:
m.SetCommandError(fmt.Errorf("core.Register type is not implemented.")) m.SetCommandError(fmt.Errorf("Register type is not implemented."))
} }
return nil return nil
@ -82,7 +78,6 @@ func (a Paste) Execute(m Model) tea.Cmd {
// Ensure Paste implements Repeatable // Ensure Paste implements Repeatable
var _ Repeatable = Paste{} var _ Repeatable = Paste{}
// Paste.WithCount: Returns a new Paste with the given count.
func (a Paste) WithCount(n int) Action { func (a Paste) WithCount(n int) Action {
return Paste{Count: n} return Paste{Count: n}
} }
@ -92,11 +87,7 @@ type PasteBefore struct {
Count int Count int
} }
// PasteBefore.Execute: Pastes register content before the cursor position (P key).
func (a PasteBefore) Execute(m Model) tea.Cmd { func (a PasteBefore) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// Get reg // Get reg
reg, found := m.GetRegister('"') reg, found := m.GetRegister('"')
if !found { if !found {
@ -105,21 +96,21 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
} }
switch reg.Type { switch reg.Type {
case core.LinewiseRegister: case LinewiseRegister:
{ {
initY := win.Cursor.Line initY := m.CursorY()
lines := reg.Content lines := reg.Content
insertPos := initY // Leave here, this will effectively move the lines below insertPos := initY // Leave here, this will effectively move the lines below
// Run count times // Run count times
for range a.Count { for range a.Count {
for _, line := range lines { for _, line := range lines {
buf.InsertLine(insertPos, line) m.InsertLine(insertPos, line)
insertPos++ insertPos++
} }
} }
} }
case core.CharwiseRegister: case CharwiseRegister:
{ {
lines := reg.Content lines := reg.Content
@ -129,21 +120,22 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
break break
} }
x := win.Cursor.Col x := m.CursorX()
y := win.Cursor.Line y := m.CursorY()
cnt := strings.Repeat(lines[0], max(1, a.Count)) cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y] curLine := m.Line(y)
// Catch edge cases, end of line, start of blank line // Catch edge cases, end of line, start of blank line
insertAt := min(x, len(curLine)) insertAt := min(x, len(curLine))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:] newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine) m.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt)) m.SetCursorX(x + len(cnt))
m.ClampCursorX()
} }
default: default:
m.SetCommandError(fmt.Errorf("core.Register type is not implemented.")) m.SetCommandError(fmt.Errorf("Register type is not implemented."))
} }
return nil return nil
@ -152,7 +144,6 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
// Ensure PasteBefore implements Repeatable // Ensure PasteBefore implements Repeatable
var _ Repeatable = PasteBefore{} var _ Repeatable = PasteBefore{}
// PasteBefore.WithCount: Returns a new PasteBefore with the given count.
func (a PasteBefore) WithCount(n int) Action { func (a PasteBefore) WithCount(n int) Action {
return PasteBefore{Count: n} return PasteBefore{Count: n}
} }
@ -162,7 +153,6 @@ type VisualPaste struct {
Count int Count int
} }
// VisualPaste.Execute: Replaces visual selection with register content (p in visual mode).
func (a VisualPaste) Execute(m Model) tea.Cmd { func (a VisualPaste) Execute(m Model) tea.Cmd {
// Get register content to paste // Get register content to paste
reg, found := m.GetRegister('"') reg, found := m.GetRegister('"')
@ -177,26 +167,27 @@ func (a VisualPaste) Execute(m Model) tea.Cmd {
start, end := normalizeSelection(m) start, end := normalizeSelection(m)
switch mode { switch mode {
case core.VisualMode: case VisualMode:
visualCharPaste(m, reg, start, end) visualCharPaste(m, reg, start, end)
case core.VisualBlockMode: case VisualBlockMode:
visualBlockPaste(m, reg, start, end) visualBlockPaste(m, reg, start, end)
case core.VisualLineMode: case VisualLineMode:
visualLinePaste(m, reg, start, end) visualLinePaste(m, reg, start, end)
} }
// Exit visual mode // Exit visual mode
m.SetMode(core.NormalMode) m.SetMode(NormalMode)
return nil return nil
} }
// normalizeSelection: Returns start and end positions with start always before end. // normalizeSelection returns start and end positions with start always before end
func normalizeSelection(m Model) (core.Position, core.Position) { func normalizeSelection(m Model) (Position, Position) {
win := m.ActiveWindow() anchorX, anchorY := m.AnchorX(), m.AnchorY()
cursorX, cursorY := m.CursorX(), m.CursorY()
start := core.Position{Line: win.Anchor.Line, Col: win.Anchor.Col} start := Position{Line: anchorY, Col: anchorX}
end := core.Position{Line: win.Cursor.Line, Col: win.Cursor.Col} end := Position{Line: cursorY, Col: cursorX}
// Normalize so start is always before end // Normalize so start is always before end
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
@ -206,11 +197,8 @@ func normalizeSelection(m Model) (core.Position, core.Position) {
return start, end return start, end
} }
// visualCharPaste: Handles paste operation in visual (character) mode. // visualCharPaste handles paste in visual (character) mode
func visualCharPaste(m Model, reg core.Register, start, end core.Position) { func visualCharPaste(m Model, reg Register, start, end Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// First, extract the text that will be deleted (to save to register) // First, extract the text that will be deleted (to save to register)
deletedText := extractCharSelection(m, start, end) deletedText := extractCharSelection(m, start, end)
@ -220,57 +208,56 @@ func visualCharPaste(m Model, reg core.Register, start, end core.Position) {
// Insert the register content at start position // Insert the register content at start position
if len(reg.Content) == 0 { if len(reg.Content) == 0 {
// Empty register - just delete (already done) // Empty register - just delete (already done)
} else if reg.Type == core.CharwiseRegister { } else if reg.Type == CharwiseRegister {
// Charwise paste: insert text at cursor position // Charwise paste: insert text at cursor position
if len(reg.Content) == 1 { if len(reg.Content) == 1 {
line := buf.Lines[start.Line] line := m.Line(start.Line)
insertAt := min(start.Col, len(line)) insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + reg.Content[0] + line[insertAt:] newLine := line[:insertAt] + reg.Content[0] + line[insertAt:]
buf.SetLine(start.Line, newLine) m.SetLine(start.Line, newLine)
// Cursor at end of pasted text // Cursor at end of pasted text
win.SetCursorCol(insertAt + len(reg.Content[0]) - 1) m.SetCursorX(insertAt + len(reg.Content[0]) - 1)
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
} }
} else if reg.Type == core.LinewiseRegister { } else if reg.Type == LinewiseRegister {
// Linewise paste in visual char mode: replace selection with lines // Linewise paste in visual char mode: replace selection with lines
// Insert each line from register // Insert each line from register
for i, content := range reg.Content { for i, content := range reg.Content {
if i == 0 { if i == 0 {
// First line: insert at start position // First line: insert at start position
line := buf.Lines[start.Line] line := m.Line(start.Line)
insertAt := min(start.Col, len(line)) insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + content newLine := line[:insertAt] + content
if len(reg.Content) == 1 { if len(reg.Content) == 1 {
// Single line register - append rest of line // Single line register - append rest of line
newLine += line[insertAt:] newLine += line[insertAt:]
} }
buf.SetLine(start.Line, newLine) m.SetLine(start.Line, newLine)
} else { } else {
// Subsequent lines: insert new lines // Subsequent lines: insert new lines
buf.InsertLine(start.Line+i, content) m.InsertLine(start.Line+i, content)
} }
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(start.Col) m.SetCursorX(start.Col)
} }
m.ClampCursorX()
// Update register with deleted text // Update register with deleted text
m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText}) m.UpdateDefaultRegister(CharwiseRegister, []string{deletedText})
} }
// visualBlockPaste: Handles paste operation in visual block mode. // visualBlockPaste handles paste in visual block mode
func visualBlockPaste(m Model, reg core.Register, start, end core.Position) { func visualBlockPaste(m Model, reg Register, start, end Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
startCol := min(start.Col, end.Col) startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col) endCol := max(start.Col, end.Col)
// Extract deleted text (for register) // Extract deleted text (for register)
var deletedLines []string var deletedLines []string
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y] line := m.Line(y)
if startCol < len(line) { if startCol < len(line) {
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
deletedLines = append(deletedLines, line[startCol:ec]) deletedLines = append(deletedLines, line[startCol:ec])
@ -281,97 +268,94 @@ func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
// Delete the block selection // Delete the block selection
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y] line := m.Line(y)
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
buf.SetLine(y, line[:startCol]+line[ec:]) m.SetLine(y, line[:startCol]+line[ec:])
} }
// Insert register content // Insert register content
if len(reg.Content) > 0 { if len(reg.Content) > 0 {
pasteContent := reg.Content[0] pasteContent := reg.Content[0]
if reg.Type == core.LinewiseRegister && len(reg.Content) > 0 { if reg.Type == LinewiseRegister && len(reg.Content) > 0 {
pasteContent = reg.Content[0] pasteContent = reg.Content[0]
} }
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y] line := m.Line(y)
insertAt := min(startCol, len(line)) insertAt := min(startCol, len(line))
// Pad with spaces if needed // Pad with spaces if needed
for len(line) < insertAt { for len(line) < insertAt {
line += " " line += " "
} }
newLine := line[:insertAt] + pasteContent + line[insertAt:] newLine := line[:insertAt] + pasteContent + line[insertAt:]
buf.SetLine(y, newLine) m.SetLine(y, newLine)
} }
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(startCol) m.SetCursorX(startCol)
m.ClampCursorX()
// Update register with deleted block text (joined) // Update register with deleted block text (joined)
m.UpdateDefaultRegister(core.CharwiseRegister, []string{strings.Join(deletedLines, "\n")}) m.UpdateDefaultRegister(CharwiseRegister, []string{strings.Join(deletedLines, "\n")})
} }
// visualLinePaste: Handles paste operation in visual line mode. // visualLinePaste handles paste in visual line mode
func visualLinePaste(m Model, reg core.Register, start, end core.Position) { func visualLinePaste(m Model, reg Register, start, end Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// Extract deleted lines (for register) // Extract deleted lines (for register)
var deletedLines []string var deletedLines []string
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
deletedLines = append(deletedLines, buf.Lines[y]) deletedLines = append(deletedLines, m.Line(y))
} }
// Delete the selected lines (from end to start to preserve indices) // Delete the selected lines (from end to start to preserve indices)
for y := end.Line; y >= start.Line; y-- { for y := end.Line; y >= start.Line; y-- {
buf.DeleteLine(y) m.DeleteLine(y)
} }
// Insert register content // Insert register content
if len(reg.Content) == 0 { if len(reg.Content) == 0 {
// Empty register - ensure at least one empty line exists // Empty register - ensure at least one empty line exists
if buf.LineCount() == 0 { if m.LineCount() == 0 {
buf.InsertLine(0, "") m.InsertLine(0, "")
} }
} else if reg.Type == core.LinewiseRegister { } else if reg.Type == LinewiseRegister {
// Linewise register: insert each line // Linewise register: insert each line
insertPos := start.Line insertPos := start.Line
for _, content := range reg.Content { for _, content := range reg.Content {
buf.InsertLine(insertPos, content) m.InsertLine(insertPos, content)
insertPos++ insertPos++
} }
} else { } else {
// Charwise register: insert as a single line // Charwise register: insert as a single line
buf.InsertLine(start.Line, reg.Content[0]) m.InsertLine(start.Line, reg.Content[0])
} }
// Ensure we have at least one line // Ensure we have at least one line
if buf.LineCount() == 0 { if m.LineCount() == 0 {
buf.InsertLine(0, "") m.InsertLine(0, "")
} }
// core.Position cursor at start of pasted content // Position cursor at start of pasted content
y := start.Line y := start.Line
if y >= buf.LineCount() { if y >= m.LineCount() {
y = buf.LineCount() - 1 y = m.LineCount() - 1
} }
win.SetCursorLine(y) m.SetCursorY(y)
win.SetCursorCol(0) m.SetCursorX(0)
m.ClampCursorX()
// Update register with deleted lines // Update register with deleted lines
m.UpdateDefaultRegister(core.LinewiseRegister, deletedLines) m.UpdateDefaultRegister(LinewiseRegister, deletedLines)
} }
// extractCharSelection: Extracts text from a character selection range. // extractCharSelection extracts text from a character selection
func extractCharSelection(m Model, start, end core.Position) string { func extractCharSelection(m Model, start, end Position) string {
buf := m.ActiveBuffer()
if start.Line == end.Line { if start.Line == end.Line {
line := buf.Lines[start.Line] line := m.Line(start.Line)
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
startCol := min(start.Col, len(line)) startCol := min(start.Col, len(line))
if startCol >= endCol { if startCol >= endCol {
@ -384,7 +368,7 @@ func extractCharSelection(m Model, start, end core.Position) string {
var result strings.Builder var result strings.Builder
// First line: from start.Col to end // First line: from start.Col to end
firstLine := buf.Lines[start.Line] firstLine := m.Line(start.Line)
if start.Col < len(firstLine) { if start.Col < len(firstLine) {
result.WriteString(firstLine[start.Col:]) result.WriteString(firstLine[start.Col:])
} }
@ -392,30 +376,27 @@ func extractCharSelection(m Model, start, end core.Position) string {
// Middle lines: entire lines // Middle lines: entire lines
for y := start.Line + 1; y < end.Line; y++ { for y := start.Line + 1; y < end.Line; y++ {
result.WriteString(buf.Lines[y]) result.WriteString(m.Line(y))
result.WriteString("\n") result.WriteString("\n")
} }
// Last line: from beginning to end.Col // Last line: from beginning to end.Col
lastLine := buf.Lines[end.Line] lastLine := m.Line(end.Line)
endCol := min(end.Col+1, len(lastLine)) endCol := min(end.Col+1, len(lastLine))
result.WriteString(lastLine[:endCol]) result.WriteString(lastLine[:endCol])
return result.String() return result.String()
} }
// deleteCharSelectionForPaste: Deletes a character selection for paste operations. // deleteCharSelectionForPaste deletes a character selection (similar to operator/delete.go)
func deleteCharSelectionForPaste(m Model, start, end core.Position) { func deleteCharSelectionForPaste(m Model, start, end Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if start.Line == end.Line { if start.Line == end.Line {
line := buf.Lines[start.Line] line := m.Line(start.Line)
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
buf.SetLine(start.Line, line[:start.Col]+line[endCol:]) m.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := buf.Lines[start.Line] startLine := m.Line(start.Line)
endLine := buf.Lines[end.Line] endLine := m.Line(end.Line)
prefix := "" prefix := ""
if start.Col < len(startLine) { if start.Col < len(startLine) {
@ -429,19 +410,18 @@ func deleteCharSelectionForPaste(m Model, start, end core.Position) {
// Delete from end back to start to preserve indices // Delete from end back to start to preserve indices
for i := end.Line; i >= start.Line; i-- { for i := end.Line; i >= start.Line; i-- {
buf.DeleteLine(i) m.DeleteLine(i)
} }
buf.InsertLine(start.Line, prefix+suffix) m.InsertLine(start.Line, prefix+suffix)
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(start.Col) m.SetCursorX(start.Col)
} }
// Ensure VisualPaste implements Repeatable // Ensure VisualPaste implements Repeatable
var _ Repeatable = VisualPaste{} var _ Repeatable = VisualPaste{}
// VisualPaste.WithCount: Returns a new VisualPaste with the given count.
func (a VisualPaste) WithCount(n int) Action { func (a VisualPaste) WithCount(n int) Action {
return VisualPaste{Count: n} return VisualPaste{Count: n}
} }

View File

@ -0,0 +1,75 @@
package action
type RegisterType int
const (
CharwiseRegister RegisterType = iota
LinewiseRegister
BlockwiseRegister
)
type Register struct {
Type RegisterType
Content []string
}
func DefaultRegisters() map[rune]Register {
reg := make(map[rune]Register)
addSpecialRegisters(reg)
addNamedRegisters(reg)
addNumberedRegisters(reg)
return reg
}
func addNamedRegisters(reg map[rune]Register) {
name := 'a'
for name <= 'z' {
reg[name] = emptyRegister()
name++
}
}
func addNumberedRegisters(reg map[rune]Register) {
name := '0'
for name <= '9' {
reg[name] = emptyRegister()
name++
}
}
func addSpecialRegisters(reg map[rune]Register) {
// Unnamed (default)
reg['"'] = emptyRegister()
// Black hole (readonly)
reg['_'] = emptyRegister()
// System clipboard
reg['*'] = emptyRegister()
// Small delete? Expression?
// Last inserted text (readonly)
reg['.'] = emptyRegister()
// Current file name (readonly)
reg['%'] = emptyRegister()
// Last executed command (readonly)
reg[':'] = emptyRegister()
// Alternate (previous) file (readonly)
reg['#'] = emptyRegister()
}
func emptyRegister() Register {
return Register{
Type: CharwiseRegister,
Content: []string{},
}
}

View File

@ -0,0 +1,20 @@
package action
type Settings struct {
Number bool
RelativeNumber bool
GutterSize int
TabSize int
ScrollOff int
// TODO: Colors
}
func NewDefaultSettings() Settings {
return Settings{
Number: true,
RelativeNumber: true,
GutterSize: 5,
TabSize: 2,
ScrollOff: 8,
}
}

2
internal/action/tmp Normal file
View File

@ -0,0 +1,2 @@
hello

View File

@ -1,222 +1,60 @@
package command package command
import ( import (
"bufio"
"errors"
"fmt" "fmt"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// QuitMsg: Message signaling the application should quit. // QuitMsg signals the application should quit
type QuitMsg struct{} type QuitMsg struct{}
// ErrorMsg: Message signaling an error to display. // ErrorMsg signals an error to display
type ErrorMsg struct { type ErrorMsg struct {
Err error Err error
} }
// -------------------------------------------------- // cmdQuit handles :quit / :q
// Quit Commands func cmdQuit(m action.Model, args []string) tea.Cmd {
// -------------------------------------------------- return func() tea.Msg {
return tea.Quit()
// cmdQuit: Handles :quit / :q command.
func cmdQuit(m action.Model, args []string, force bool) tea.Cmd {
// :q! forces quit, ignoring unsaved changes
if force {
return tea.Quit
} }
bufs := m.Buffers()
// Cannot exit if any buffer has unsaved changes
for _, buf := range bufs {
if buf.Modified {
m.SetCommandError(fmt.Errorf("unsaved changes to '%s'", buf.Filename))
m.ActiveWindow().SetBuffer(buf)
return nil
}
}
return tea.Quit
} }
// cmdQuitAll: Handles :qall / :qa command. // cmdQuitAll handles :qall / :qa
func cmdQuitAll(m action.Model, args []string, force bool) tea.Cmd { func cmdQuitAll(m action.Model, args []string) tea.Cmd {
// TODO: Until splits are implemented, this is the same as cmdQuit return func() tea.Msg {
return cmdQuit(m, args, force) return tea.Quit()
}
} }
// -------------------------------------------------- // cmdWrite handles :write / :w
// File Commands (write & edit) func cmdWrite(m action.Model, args []string) tea.Cmd {
// -------------------------------------------------- // TODO: Implement file saving
// If args provided, save to that filename
// cmdWrite: Handles :write / :w command // Otherwise save to current file
func cmdWrite(m action.Model, args []string, force bool) tea.Cmd {
buf := m.ActiveBuffer()
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandError(err)
}
return cmd
}
// cmdWriteAll: Handles :wall / :wa command
func cmdWriteAll(m action.Model, args []string, force bool) tea.Cmd {
var cmds []tea.Cmd
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Modified {
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandError(err)
return nil
}
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// cmdWriteQuit: Handles :wq command
func cmdWriteQuit(m action.Model, args []string, force bool) tea.Cmd {
buf := m.ActiveBuffer()
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandError(err)
return cmd
}
return tea.Batch(cmd, tea.Quit)
}
// cmdWriteQuitAll: Handles :wqall / :wqa / :xa command.
// Writes all modified buffers then quits.
func cmdWriteQuitAll(m action.Model, args []string, force bool) tea.Cmd {
var cmds []tea.Cmd
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Modified {
cmd, err := writeBuffer(m, buf, args, force)
if err != nil {
m.SetCommandError(err)
return nil
}
cmds = append(cmds, cmd)
}
}
cmds = append(cmds, tea.Quit)
return tea.Batch(cmds...)
}
// cmdEdit: Handles :edit / :e
func cmdEdit(m action.Model, args []string, force bool) tea.Cmd {
// must have arguments, cant edit nothing
if len(args) < 1 {
m.SetCommandError(fmt.Errorf(":edit requires an argument"))
return nil
}
// Vim's Approach:
// " When you do :edit filename.txt
// 1. Check if file exists and is readable (if not, open new buffer)
// 2. Detect file encoding (UTF-8, etc.)
// 3. Read entire file into memory
// 4. Split by line endings (respecting fileformat)
// 5. Create new buffer with these lines
// 6. Set buffer metadata:
// - buftype = "" (normal file)
// - modified = 0 (not modified)
// - fileformat = "unix" | "dos" | "mac"
// - fileencoding = "utf-8" (etc.)
filename := args[0]
ext := filepath.Ext(filename)
// If the buffer already exists, just switch to it.
bufs := m.Buffers()
for _, buf := range bufs {
if buf.Filename == filename {
m.ActiveWindow().SetBuffer(buf)
return nil
}
}
file, err := os.Open(filename)
notFound := errors.Is(err, os.ErrNotExist)
if err != nil && !notFound {
m.SetCommandError(err)
return nil
}
if file != nil {
defer file.Close()
}
// Create a buffer with the new file name, writing the file will
// handle the saving logic
if notFound {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
Listed().
Loaded().
Build()
m.SetBuffers(append(m.Buffers(), &buf))
m.ActiveWindow().SetBuffer(&buf)
// Need to adjust the cursor when we make a new file
m.ActiveWindow().ClampCursor()
return nil
}
var lines []string
// BUG: We are unable to open and edit files owned by root. How do we handle that?
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSuffix(line, "\r")
// BUG: This is bad, we don't want to this, but we have to
cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", m.Settings().TabStop))
lines = append(lines, cleaned)
}
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
WithLines(lines).
Listed().
Loaded().
Build()
m.SetBuffers(append(m.Buffers(), &buf))
m.ActiveWindow().SetBuffer(&buf)
return nil return nil
} }
// -------------------------------------------------- // cmdWriteAll handles :wall / :wa
// Register Commands func cmdWriteAll(m action.Model, args []string) tea.Cmd {
// -------------------------------------------------- // TODO: Implement saving all buffers
return nil
}
// cmdRegisters: Handles :register command (debug - displays register content). // cmdWriteQuit handles :wq
func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd { func cmdWriteQuit(m action.Model, args []string) tea.Cmd {
// TODO: Save then quit
return func() tea.Msg {
return tea.Quit()
}
}
// cmdRegisters handles :register
func cmdRegisters(m action.Model, args []string) tea.Cmd {
// TODO: This is temporary, for debugging // TODO: This is temporary, for debugging
if len(args) < 1 { if len(args) < 1 {
m.SetCommandError(fmt.Errorf("Please provide a name. Register dump not yet implemented.")) m.SetCommandError(fmt.Errorf("Please provide a name. Register dump not yet implemented."))
@ -241,11 +79,7 @@ func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd {
return nil return nil
} }
// -------------------------------------------------- // cmdSet handles :set option[=value]
// Settings Commands
// --------------------------------------------------
// cmdSet: Handles :set option[=value] command for configuring editor settings.
// Examples: // Examples:
// //
// :set number - enable number // :set number - enable number
@ -253,7 +87,7 @@ func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd {
// :set number! - toggle number // :set number! - toggle number
// :set tabstop=4 - set tabstop to 4 // :set tabstop=4 - set tabstop to 4
// :set ts=4 - set tabstop to 4 (abbreviation) // :set ts=4 - set tabstop to 4 (abbreviation)
func cmdSet(m action.Model, args []string, force bool) tea.Cmd { func cmdSet(m action.Model, args []string) tea.Cmd {
if len(args) == 0 { if len(args) == 0 {
out := fmt.Sprintf("%+v", m.Settings()) out := fmt.Sprintf("%+v", m.Settings())
m.SetCommandOutput(out) m.SetCommandOutput(out)
@ -270,16 +104,15 @@ func cmdSet(m action.Model, args []string, force bool) tea.Cmd {
return nil return nil
} }
// Setting: Represents a configurable editor option. // Setting represents a configurable option
type Setting struct { type Setting struct {
Name string Name string
ShortForm string ShortForm string
Type SettingType Type SettingType
Get func(m action.Model) any Get func(s action.Settings) any
Set func(m action.Model, val any) Set func(m action.Model, val any)
} }
// SettingType: Enumeration of setting value types.
type SettingType int type SettingType int
const ( const (
@ -288,72 +121,54 @@ const (
StringSetting StringSetting
) )
// settingsMap defines all available settings (both global and window-local) // settingsMap defines all available settings
var settingsMap = []Setting{ var settingsMap = []Setting{
// Global editor settings
{
Name: "tabstop",
ShortForm: "ts",
Type: IntSetting,
Get: func(m action.Model) any { return m.Settings().TabStop },
Set: func(m action.Model, val any) {
s := m.Settings()
s.TabStop = val.(int)
m.SetSettings(s)
},
},
// Window-local settings
{ {
Name: "number", Name: "number",
ShortForm: "nu", ShortForm: "nu",
Type: BoolSetting, Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.Number }, Get: func(s action.Settings) any { return s.Number },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
w := m.ActiveWindow() s := m.Settings()
o := w.Options s.Number = val.(bool)
o.Number = val.(bool) m.SetSettings(s)
w.SetOptions(o)
}, },
}, },
{ {
Name: "relativenumber", Name: "relativenumber",
ShortForm: "rnu", ShortForm: "rnu",
Type: BoolSetting, Type: BoolSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.RelativeNumber }, Get: func(s action.Settings) any { return s.RelativeNumber },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
w := m.ActiveWindow() s := m.Settings()
o := w.Options s.RelativeNumber = val.(bool)
o.RelativeNumber = val.(bool) m.SetSettings(s)
w.SetOptions(o) },
},
{
Name: "tabstop",
ShortForm: "ts",
Type: IntSetting,
Get: func(s action.Settings) any { return s.TabSize },
Set: func(m action.Model, val any) {
s := m.Settings()
s.TabSize = val.(int)
m.SetSettings(s)
}, },
}, },
{ {
Name: "scrolloff", Name: "scrolloff",
ShortForm: "so", ShortForm: "so",
Type: IntSetting, Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.ScrollOff }, Get: func(s action.Settings) any { return s.ScrollOff },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
w := m.ActiveWindow() s := m.Settings()
o := w.Options s.ScrollOff = val.(int)
o.ScrollOff = val.(int) m.SetSettings(s)
w.SetOptions(o)
},
},
{
Name: "guttersize",
ShortForm: "gu",
Type: IntSetting,
Get: func(m action.Model) any { return m.ActiveWindow().Options.GutterSize },
Set: func(m action.Model, val any) {
w := m.ActiveWindow()
o := w.Options
o.GutterSize = val.(int)
w.SetOptions(o)
}, },
}, },
} }
// lookupSetting: Finds a setting by name, short form, or prefix.
func lookupSetting(name string) *Setting { func lookupSetting(name string) *Setting {
for i := range settingsMap { for i := range settingsMap {
s := &settingsMap[i] s := &settingsMap[i]
@ -368,13 +183,16 @@ func lookupSetting(name string) *Setting {
return nil return nil
} }
// parseSetOption: Parses and applies a single :set option.
func parseSetOption(m action.Model, opt string) error { func parseSetOption(m action.Model, opt string) error {
// Handle toggle: option! // Handle toggle: option!
if name, ok := strings.CutSuffix(opt, "!"); ok { if name, ok := strings.CutSuffix(opt, "!"); ok {
setting := lookupSetting(name) setting := lookupSetting(name)
if setting != nil && setting.Type == BoolSetting { if setting == nil {
currentVal := setting.Get(m).(bool) return nil // Unknown setting
}
if setting.Type == BoolSetting {
// Toggle the boolean
currentVal := setting.Get(m.Settings()).(bool)
setting.Set(m, !currentVal) setting.Set(m, !currentVal)
} }
return nil return nil
@ -383,7 +201,10 @@ func parseSetOption(m action.Model, opt string) error {
// Handle disable: nooption // Handle disable: nooption
if name, ok := strings.CutPrefix(opt, "no"); ok { if name, ok := strings.CutPrefix(opt, "no"); ok {
setting := lookupSetting(name) setting := lookupSetting(name)
if setting != nil && setting.Type == BoolSetting { if setting == nil {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, false) setting.Set(m, false)
} }
return nil return nil
@ -393,30 +214,33 @@ func parseSetOption(m action.Model, opt string) error {
if strings.Contains(opt, "=") { if strings.Contains(opt, "=") {
parts := strings.SplitN(opt, "=", 2) parts := strings.SplitN(opt, "=", 2)
name, value := parts[0], parts[1] name, value := parts[0], parts[1]
setting := lookupSetting(name) setting := lookupSetting(name)
if setting != nil { if setting == nil {
switch setting.Type { return nil
case IntSetting: }
intVal, err := strconv.Atoi(value) switch setting.Type {
if err != nil { case IntSetting:
return err intVal, err := strconv.Atoi(value)
} if err != nil {
setting.Set(m, intVal) return err
case StringSetting:
setting.Set(m, value)
case BoolSetting:
// Handle :set option=true / :set option=false
boolVal := value == "true" || value == "1" || value == "yes"
setting.Set(m, boolVal)
} }
setting.Set(m, intVal)
case StringSetting:
setting.Set(m, value)
case BoolSetting:
// Handle :set option=true / :set option=false
boolVal := value == "true" || value == "1" || value == "yes"
setting.Set(m, boolVal)
} }
return nil return nil
} }
// Handle enable: option (boolean only) // Handle enable: option (boolean only)
setting := lookupSetting(opt) setting := lookupSetting(opt)
if setting != nil && setting.Type == BoolSetting { if setting == nil {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, true) setting.Set(m, true)
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,83 +0,0 @@
package command
import (
"bufio"
"fmt"
"os"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
func writeBuffer(m action.Model, buf *core.Buffer, args []string, force bool) (tea.Cmd, error) {
// Key Steps:
// - Backup Creation (if backup is set): Copy original to .bak file
// - Line Ending Application: Apply fileformat setting (unix=\n, dos=\r\n, mac=\r)
// - Character Encoding: Convert from internal representation to fileencoding
// - Atomic Write: Write to temporary file first (.swp or similar)
// - Atomic Rename: Rename temp file to target filename (atomic operation)
// - Metadata Update: Clear modified flag, update timestamp
// Safety Mechanisms:
// " Vim's write safety
// 1. Check file permissions
// 2. If file exists and 'writebackup' set:
// - Create backup (.bak)
// 3. Write to temporary file (.swp~)
// 4. Verify write succeeded
// 5. Rename temp to target (atomic)
// 6. Remove backup if 'backup' not set
// 7. Update buffer metadata
// TODO: Implement atomic and safe writes
// Check readonly flag ONLY if not forced with !
if !force && buf.ReadOnly {
return nil, fmt.Errorf("cannot write to 'readonly' buffer")
}
if !force && buf.Type == core.ScatchBuffer {
return nil, fmt.Errorf("cannot write to buffer of type 'ScratchBuffer'")
}
// Get the filename; differs by the type
var filename string
if len(args) > 0 {
filename = args[0]
} else {
if buf.Filename == "" {
return nil, fmt.Errorf("cannot write: no file name provided")
}
filename = buf.Filename
}
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
if err != nil {
return nil, err
}
defer file.Close()
// Same write operation regardless of where the file came from
var bytes int
// Using a bufio.Writer because its more efficient
writer := bufio.NewWriter(file)
for _, line := range buf.Lines {
n, err := writer.WriteString(line + "\n")
if err != nil {
return nil, err
}
bytes += n
}
if err := writer.Flush(); err != nil {
return nil, err
}
output := fmt.Sprintf("'%s', %dL %db written", filename, buf.LineCount(), bytes)
m.SetCommandOutput(output)
buf.SetModified(false)
return nil, nil
}

View File

@ -8,31 +8,32 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// Command: Represents a command that can be executed from command mode. // Command represents a command that can be executed from command mode
type Command struct { type Command struct {
Name string // Full name: "quit" Name string // Full name: "quit"
ShortForm string // Minimum abbreviation: "q" ShortForm string // Minimum abbreviation: "q"
Handler func(m action.Model, args []string, force bool) tea.Cmd // Handler function Handler func(m action.Model, args []string) tea.Cmd // Handler function
} }
// Registry: Holds all registered commands. // Registry holds all registered commands
type Registry struct { type Registry struct {
commands []Command commands []Command
} }
// NewRegistry: Creates a new command registry with default commands. // NewRegistry creates a new command registry with default commands
func NewRegistry() *Registry { func NewRegistry() *Registry {
r := &Registry{} r := &Registry{}
r.registerDefaults() r.registerDefaults()
return r return r
} }
// Registry.Register: Adds a command to the registry. // Register adds a command to the registry
func (r *Registry) Register(cmd Command) { func (r *Registry) Register(cmd Command) {
r.commands = append(r.commands, cmd) r.commands = append(r.commands, cmd)
} }
// Registry.Lookup: Finds a command by name or abbreviation with error handling. // Lookup finds a command by name or abbreviation
// Returns the command and any error (unknown or ambiguous)
func (r *Registry) Lookup(input string) (*Command, error) { func (r *Registry) Lookup(input string) (*Command, error) {
if input == "" { if input == "" {
return nil, fmt.Errorf("no command given") return nil, fmt.Errorf("no command given")
@ -74,41 +75,31 @@ func (r *Registry) Lookup(input string) (*Command, error) {
return matches[0], nil return matches[0], nil
} }
// Parse: Splits a command line into command name and arguments. // Parse splits a command line into command name and arguments
func Parse(cmdLine string) (name string, args []string, force bool) { func Parse(cmdLine string) (name string, args []string) {
parts := strings.Fields(cmdLine) parts := strings.Fields(cmdLine)
if len(parts) == 0 { if len(parts) == 0 {
return "", nil, false return "", nil
} }
return parts[0], parts[1:]
name = parts[0]
args = parts[1:]
// Check if command ends with ! (force flag)
if strings.HasSuffix(name, "!") {
name = strings.TrimSuffix(name, "!")
force = true
}
return name, args, force
} }
// Registry.Execute: Parses and executes a command line. // Execute parses and executes a command line
func (r *Registry) Execute(m action.Model, cmdLine string) (tea.Cmd, error) { func (r *Registry) Execute(m action.Model, cmdLine string) (tea.Cmd, error) {
name, args, force := Parse(cmdLine) name, args := Parse(cmdLine)
cmd, err := r.Lookup(name) cmd, err := r.Lookup(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return cmd.Handler(m, args, force), nil return cmd.Handler(m, args), nil
} }
// DefaultRegistry is the global command registry // DefaultRegistry is the global command registry
var DefaultRegistry = NewRegistry() var DefaultRegistry = NewRegistry()
// Registry.registerDefaults: Registers the built-in commands. // registerDefaults registers the built-in commands
func (r *Registry) registerDefaults() { func (r *Registry) registerDefaults() {
// Quit commands // Quit commands
r.Register(Command{ r.Register(Command{
@ -142,12 +133,6 @@ func (r *Registry) registerDefaults() {
Handler: cmdWriteQuit, Handler: cmdWriteQuit,
}) })
r.Register(Command{
Name: "wqall",
ShortForm: "wqa",
Handler: cmdWriteQuitAll,
})
// Set command // Set command
r.Register(Command{ r.Register(Command{
Name: "set", Name: "set",
@ -161,11 +146,4 @@ func (r *Registry) registerDefaults() {
ShortForm: "reg", ShortForm: "reg",
Handler: cmdRegisters, Handler: cmdRegisters,
}) })
// File commands
r.Register(Command{
Name: "edit",
ShortForm: "e",
Handler: cmdEdit,
})
} }

View File

@ -89,26 +89,6 @@ func TestRegistryLookup(t *testing.T) {
} }
}) })
t.Run("e matches edit", func(t *testing.T) {
cmd, err := r.Lookup("e")
if err != nil {
t.Fatalf("Lookup error: %v", err)
}
if cmd.Name != "edit" {
t.Errorf("cmd.Name = %q, want \"edit\"", cmd.Name)
}
})
t.Run("ed matches edit", func(t *testing.T) {
cmd, err := r.Lookup("ed")
if err != nil {
t.Fatalf("Lookup error: %v", err)
}
if cmd.Name != "edit" {
t.Errorf("cmd.Name = %q, want \"edit\"", cmd.Name)
}
})
t.Run("unknown command returns error", func(t *testing.T) { t.Run("unknown command returns error", func(t *testing.T) {
_, err := r.Lookup("xyz") _, err := r.Lookup("xyz")
if err == nil { if err == nil {
@ -126,20 +106,17 @@ func TestRegistryLookup(t *testing.T) {
func TestParse(t *testing.T) { func TestParse(t *testing.T) {
t.Run("command only", func(t *testing.T) { t.Run("command only", func(t *testing.T) {
name, args, force := Parse("quit") name, args := Parse("quit")
if name != "quit" { if name != "quit" {
t.Errorf("name = %q, want \"quit\"", name) t.Errorf("name = %q, want \"quit\"", name)
} }
if len(args) != 0 { if len(args) != 0 {
t.Errorf("len(args) = %d, want 0", len(args)) t.Errorf("len(args) = %d, want 0", len(args))
} }
if force {
t.Error("force should be false")
}
}) })
t.Run("command with one arg", func(t *testing.T) { t.Run("command with one arg", func(t *testing.T) {
name, args, force := Parse("set number") name, args := Parse("set number")
if name != "set" { if name != "set" {
t.Errorf("name = %q, want \"set\"", name) t.Errorf("name = %q, want \"set\"", name)
} }
@ -149,13 +126,10 @@ func TestParse(t *testing.T) {
if args[0] != "number" { if args[0] != "number" {
t.Errorf("args[0] = %q, want \"number\"", args[0]) t.Errorf("args[0] = %q, want \"number\"", args[0])
} }
if force {
t.Error("force should be false")
}
}) })
t.Run("command with multiple args", func(t *testing.T) { t.Run("command with multiple args", func(t *testing.T) {
name, args, force := Parse("set number tabstop=4") name, args := Parse("set number tabstop=4")
if name != "set" { if name != "set" {
t.Errorf("name = %q, want \"set\"", name) t.Errorf("name = %q, want \"set\"", name)
} }
@ -168,74 +142,26 @@ func TestParse(t *testing.T) {
if args[1] != "tabstop=4" { if args[1] != "tabstop=4" {
t.Errorf("args[1] = %q, want \"tabstop=4\"", args[1]) t.Errorf("args[1] = %q, want \"tabstop=4\"", args[1])
} }
if force {
t.Error("force should be false")
}
}) })
t.Run("empty string", func(t *testing.T) { t.Run("empty string", func(t *testing.T) {
name, args, force := Parse("") name, args := Parse("")
if name != "" { if name != "" {
t.Errorf("name = %q, want \"\"", name) t.Errorf("name = %q, want \"\"", name)
} }
if args != nil { if args != nil {
t.Errorf("args = %v, want nil", args) t.Errorf("args = %v, want nil", args)
} }
if force {
t.Error("force should be false")
}
}) })
t.Run("whitespace only", func(t *testing.T) { t.Run("whitespace only", func(t *testing.T) {
name, args, force := Parse(" ") name, args := Parse(" ")
if name != "" { if name != "" {
t.Errorf("name = %q, want \"\"", name) t.Errorf("name = %q, want \"\"", name)
} }
if args != nil { if args != nil {
t.Errorf("args = %v, want nil", args) t.Errorf("args = %v, want nil", args)
} }
if force {
t.Error("force should be false")
}
})
t.Run("command with force flag", func(t *testing.T) {
name, args, force := Parse("quit!")
if name != "quit" {
t.Errorf("name = %q, want \"quit\"", name)
}
if len(args) != 0 {
t.Errorf("len(args) = %d, want 0", len(args))
}
if !force {
t.Error("force should be true")
}
})
t.Run("write command with force", func(t *testing.T) {
name, _, force := Parse("w!")
if name != "w" {
t.Errorf("name = %q, want \"w\"", name)
}
if !force {
t.Error("force should be true")
}
})
t.Run("command with force and args", func(t *testing.T) {
name, args, force := Parse("w! file.txt")
if name != "w" {
t.Errorf("name = %q, want \"w\"", name)
}
if len(args) != 1 {
t.Errorf("len(args) = %d, want 1", len(args))
}
if args[0] != "file.txt" {
t.Errorf("args[0] = %q, want \"file.txt\"", args[0])
}
if !force {
t.Error("force should be true")
}
}) })
} }

View File

@ -1,130 +0,0 @@
package core
type BufferOptions struct {
// tabstop expandtab
}
type BufferType int
const (
ScatchBuffer BufferType = iota
FileBuffer
DirectoryBuffer
)
type Buffer struct {
// Buffer data
Id int
Type BufferType
// File data
Filename string
Filetype string
Lines []string
// Flags (not used yet)
Modified bool
Loaded bool
Listed bool
ReadOnly bool
// Options BufferOptions
// UndoTree TODO: This will be big
}
// ==================================================
// Helper methods
// ==================================================
// Buffer.Line: Get the line at an index. Returns an empty string if the index
// is out of bounds.
func (b *Buffer) Line(idx int) string {
if idx < 0 || idx >= len(b.Lines) {
return ""
}
return b.Lines[idx]
}
// Buffer.SetLine: Set the content of the line at an index. Does nothing if the
// index is out of bounds. This function sets the modified flag.
func (b *Buffer) SetLine(idx int, content string) {
if idx >= 0 && idx < len(b.Lines) {
b.Lines[idx] = content
}
b.Modified = true
}
// Buffer.InsertLine: Insert a line with content at an index. The index is clamped
// to valid bounds (0 to len(Lines)). The new line is inserted before the line at
// the given index. This function sets the modified flag.
func (b *Buffer) InsertLine(idx int, content string) {
if idx < 0 {
idx = 0
}
if idx > len(b.Lines) {
idx = len(b.Lines)
}
b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...)
b.Modified = true
}
// Buffer.DeleteLine: Delete a line at an index. Does nothing if the index is out
// of bounds. This function sets the modified flag.
func (b *Buffer) DeleteLine(idx int) {
if idx >= 0 && idx < len(b.Lines) {
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
}
b.Modified = true
}
// Buffer.LineCount: Get the number of lines in the buffer.
func (b *Buffer) LineCount() int {
return len(b.Lines)
}
// ==================================================
// Setters
// ==================================================
// Buffer.SetFilename: Set the filename associated with this buffer. This is
// typically the path to the file on disk that this buffer represents.
func (b *Buffer) SetFilename(filename string) {
b.Filename = filename
}
// Buffer.SetFiletype: Set the filetype of this buffer. The filetype is used
// for syntax highlighting and other language-specific features.
func (b *Buffer) SetFiletype(filetype string) {
b.Filetype = filetype
}
// Buffer.SetLines: Replace all lines in the buffer with the provided lines.
// This is useful when loading a file or resetting buffer content.
func (b *Buffer) SetLines(lines []string) {
b.Lines = lines
}
// Buffer.SetModified: Set the modified flag for this buffer. A modified buffer
// has unsaved changes that differ from the file on disk.
func (b *Buffer) SetModified(modified bool) {
b.Modified = modified
}
// Buffer.SetLoaded: Set the loaded flag for this buffer. A loaded buffer has
// its content in memory, while an unloaded buffer exists only as metadata.
func (b *Buffer) SetLoaded(loaded bool) {
b.Loaded = loaded
}
// Buffer.SetListed: Set the listed flag for this buffer. A listed buffer appears
// in buffer lists (like :ls), while an unlisted buffer is hidden from normal
// buffer navigation.
func (b *Buffer) SetListed(listed bool) {
b.Listed = listed
}
// Buffer.SetType: Set the buffers type. This type is used to determine handling
// of I/O functions.
func (b *Buffer) SetType(t BufferType) {
b.Type = t
}

View File

@ -1,89 +0,0 @@
package core
// Not great, but maybe the best way
var CurrentBufferId int = 1
type BufferBuilder struct {
buffer Buffer
}
// NewBufferBuilder: Creates a new buffer builder. The buffer builder implements a
// builder pattern to create a buffer with the defined properties and values.
func NewBufferBuilder() *BufferBuilder {
return &BufferBuilder{
buffer: Buffer{
Id: 0, // This is set when built
Type: ScatchBuffer, // Default buffer type
Filename: "",
Filetype: "",
Lines: []string{""},
Modified: false,
Loaded: false,
Listed: false,
ReadOnly: false,
},
}
}
// BufferBuilder.WithFilename: Attaches a file name to the buffer that is being built.
func (b *BufferBuilder) WithFilename(filename string) *BufferBuilder {
b.buffer.Filename = filename
return b
}
// BufferBuilder.WithFiletype: Attaches a file type to the buffer that is being built.
func (b *BufferBuilder) WithFiletype(filetype string) *BufferBuilder {
b.buffer.Filetype = filetype
return b
}
// BufferBuilder.WithLines: Attaches a lines to the buffer that is being built.
func (b *BufferBuilder) WithLines(lines []string) *BufferBuilder {
b.buffer.Lines = lines
return b
}
// BufferBuilder.Modified: Sets the modified flag of the buffer being built. By default,
// buffers are built with the modified flag being false.
func (b *BufferBuilder) Modified() *BufferBuilder {
b.buffer.Modified = true
return b
}
// BufferBuilder.Loaded: Sets the loaded flag of the buffer being built. By default,
// buffers are built with the loaded flag being false.
func (b *BufferBuilder) Loaded() *BufferBuilder {
b.buffer.Loaded = true
return b
}
// BufferBuilder.Listed: Sets the listed flag of the buffer being built. By default,
// buffers are built with the listed flag being false.
func (b *BufferBuilder) Listed() *BufferBuilder {
b.buffer.Listed = true
return b
}
// BufferBuilder.ReadOnly: Sets the readonly flag of the buffer being built. By default,
// buffers are built with the readonly flag being false.
func (b *BufferBuilder) ReadOnly() *BufferBuilder {
b.buffer.ReadOnly = true
return b
}
// BufferBuilder.Listed: Sets the type of the buffer being built. By default, buffers
// are build with the ScatchBuffer type.
func (b *BufferBuilder) WithType(t BufferType) *BufferBuilder {
b.buffer.Type = t
return b
}
// BufferBuilder.Build: Build the final buffer and return it to the caller. Final
// step in the process. This is where the ID is set, so many buffers can be "in-progress"
// but the ID will be set when they are built. Meaning, this is not thread safe.
func (b *BufferBuilder) Build() Buffer {
b.buffer.Id = CurrentBufferId
CurrentBufferId++
return b.buffer
}

View File

@ -1,302 +0,0 @@
package core
import "testing"
// --------------------------------------------------
// Buffer Tests (generated by ClaudeCode)
// --------------------------------------------------
func TestBuffer_InsertLine(t *testing.T) {
t.Run("inserts at beginning", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2"}).
Build()
buf.InsertLine(0, "new line")
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
if buf.Line(0) != "new line" {
t.Errorf("expected 'new line', got '%s'", buf.Line(0))
}
if buf.Line(1) != "line 1" {
t.Errorf("expected 'line 1' at index 1, got '%s'", buf.Line(1))
}
})
t.Run("inserts at end", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2"}).
Build()
buf.InsertLine(2, "new line")
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
if buf.Line(2) != "new line" {
t.Errorf("expected 'new line' at end, got '%s'", buf.Line(2))
}
})
t.Run("inserts in middle", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 3"}).
Build()
buf.InsertLine(1, "line 2")
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
if buf.Line(1) != "line 2" {
t.Errorf("expected 'line 2' at index 1, got '%s'", buf.Line(1))
}
})
t.Run("handles empty buffer", func(t *testing.T) {
buf := NewBufferBuilder().Build()
buf.InsertLine(0, "first line")
if buf.LineCount() != 2 { // Original empty line + new line
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
})
}
func TestBuffer_DeleteLine(t *testing.T) {
t.Run("deletes from beginning", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"DELETE ME", "line 2", "line 3"}).
Build()
buf.DeleteLine(0)
if buf.LineCount() != 2 {
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
if buf.Line(0) != "line 2" {
t.Errorf("expected 'line 2' at index 0, got '%s'", buf.Line(0))
}
})
t.Run("deletes from middle", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "DELETE ME", "line 3"}).
Build()
buf.DeleteLine(1)
if buf.LineCount() != 2 {
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
if buf.Line(1) != "line 3" {
t.Errorf("expected 'line 3' at index 1, got '%s'", buf.Line(1))
}
})
t.Run("deletes from end", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2", "DELETE ME"}).
Build()
buf.DeleteLine(2)
if buf.LineCount() != 2 {
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
if buf.Line(1) != "line 2" {
t.Errorf("expected 'line 2' at index 1, got '%s'", buf.Line(1))
}
})
t.Run("can delete all lines", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"only line"}).
Build()
buf.DeleteLine(0)
// Buffer allows being completely empty (0 lines)
if buf.LineCount() != 0 {
t.Errorf("expected 0 lines after deleting last line, got %d", buf.LineCount())
}
})
}
func TestBuffer_SetLine(t *testing.T) {
t.Run("updates existing line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"old content"}).
Build()
buf.SetLine(0, "new content")
if buf.Line(0) != "new content" {
t.Errorf("expected 'new content', got '%s'", buf.Line(0))
}
})
t.Run("updates middle line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "old", "line 3"}).
Build()
buf.SetLine(1, "new")
if buf.Line(1) != "new" {
t.Errorf("expected 'new', got '%s'", buf.Line(1))
}
// Verify other lines unchanged
if buf.Line(0) != "line 1" {
t.Error("line 0 should be unchanged")
}
if buf.Line(2) != "line 3" {
t.Error("line 2 should be unchanged")
}
})
t.Run("can set to empty string", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"has content"}).
Build()
buf.SetLine(0, "")
if buf.Line(0) != "" {
t.Errorf("expected empty line, got '%s'", buf.Line(0))
}
})
}
func TestBuffer_LineCount(t *testing.T) {
t.Run("empty buffer has one line", func(t *testing.T) {
buf := NewBufferBuilder().Build()
if buf.LineCount() != 1 {
t.Errorf("expected 1 line in empty buffer, got %d", buf.LineCount())
}
})
t.Run("counts multiple lines", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"a", "b", "c", "d", "e"}).
Build()
if buf.LineCount() != 5 {
t.Errorf("expected 5 lines, got %d", buf.LineCount())
}
})
t.Run("counts after insertions", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1"}).
Build()
buf.InsertLine(1, "line 2")
buf.InsertLine(2, "line 3")
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
})
t.Run("counts after deletions", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"a", "b", "c", "d", "e"}).
Build()
buf.DeleteLine(2)
buf.DeleteLine(2)
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines after 2 deletions, got %d", buf.LineCount())
}
})
}
func TestBuffer_Line(t *testing.T) {
t.Run("retrieves correct line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"first", "second", "third"}).
Build()
if buf.Line(0) != "first" {
t.Errorf("expected 'first', got '%s'", buf.Line(0))
}
if buf.Line(1) != "second" {
t.Errorf("expected 'second', got '%s'", buf.Line(1))
}
if buf.Line(2) != "third" {
t.Errorf("expected 'third', got '%s'", buf.Line(2))
}
})
t.Run("handles special characters", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello\tworld", "foo\nbar"}).
Build()
if buf.Line(0) != "hello\tworld" {
t.Errorf("expected tabs preserved, got '%s'", buf.Line(0))
}
})
t.Run("handles unicode", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello 世界", "emoji 🎉"}).
Build()
if buf.Line(0) != "hello 世界" {
t.Errorf("expected unicode preserved, got '%s'", buf.Line(0))
}
if buf.Line(1) != "emoji 🎉" {
t.Errorf("expected emoji preserved, got '%s'", buf.Line(1))
}
})
}
func TestBufferBuilder(t *testing.T) {
t.Run("builds with default values", func(t *testing.T) {
buf := NewBufferBuilder().Build()
if buf.LineCount() != 1 {
t.Errorf("expected 1 empty line, got %d", buf.LineCount())
}
if buf.Filename != "" {
t.Errorf("expected empty filename, got '%s'", buf.Filename)
}
})
t.Run("builds with custom lines", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2"}).
Build()
if buf.LineCount() != 2 {
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
})
t.Run("builds with filename", func(t *testing.T) {
buf := NewBufferBuilder().
WithFilename("test.txt").
Build()
if buf.Filename != "test.txt" {
t.Errorf("expected filename 'test.txt', got '%s'", buf.Filename)
}
})
t.Run("builds with filetype", func(t *testing.T) {
buf := NewBufferBuilder().
WithFiletype("go").
Build()
if buf.Filetype != "go" {
t.Errorf("expected filetype 'go', got '%s'", buf.Filetype)
}
})
}

View File

@ -1,41 +0,0 @@
package core
// Mode constants for editor mode
type Mode int
const (
NormalMode Mode = iota
InsertMode
CommandMode
VisualMode
VisualLineMode
VisualBlockMode
)
// Mode.ToString: Returns a human-readable string representation of the mode
// for display in the status bar.
func (m Mode) ToString() string {
switch m {
case NormalMode:
return "NORMAL"
case InsertMode:
return "INSERT"
case CommandMode:
return "COMMAND"
case VisualMode:
return "VISUAL"
case VisualLineMode:
return "V-LINE"
case VisualBlockMode:
return "V-BLOCK"
default:
return "-----"
}
}
// Mode.IsVisualMode: Returns true if the mode is any visual mode variant.
func (m Mode) IsVisualMode() bool {
return m == VisualMode ||
m == VisualLineMode ||
m == VisualBlockMode
}

View File

@ -1,6 +0,0 @@
package core
// Position represents a location in the buffer
type Position struct {
Line, Col int
}

View File

@ -1,93 +0,0 @@
package core
// RegisterType: Indicates how the register content should be interpreted when
// pasting. Charwise treats content as continuous text, linewise operates on
// complete lines, and blockwise represents rectangular selections.
type RegisterType int
const (
CharwiseRegister RegisterType = iota
LinewiseRegister
BlockwiseRegister
)
// Register: Stores yanked or deleted text with metadata about how it should be
// pasted. The Type determines paste behavior and Content holds the text lines.
type Register struct {
Type RegisterType
Content []string
}
// DefaultRegisters: Creates and initializes the complete set of vim-style
// registers. Returns a map containing special registers (", *, _, etc.),
// named registers (a-z), and numbered registers (0-9).
func DefaultRegisters() map[rune]Register {
reg := make(map[rune]Register)
addSpecialRegisters(reg)
addNamedRegisters(reg)
addNumberedRegisters(reg)
return reg
}
// addNamedRegisters: Initializes the 26 named registers (a-z) used for explicit
// yank/delete operations. Users can target these with commands like "ayy or "ap.
func addNamedRegisters(reg map[rune]Register) {
name := 'a'
for name <= 'z' {
reg[name] = emptyRegister()
name++
}
}
// addNumberedRegisters: Initializes the numbered registers (0-9) which form the
// delete history. Register 0 holds the most recent yank, and 1-9 hold previous
// deletes in chronological order.
func addNumberedRegisters(reg map[rune]Register) {
name := '0'
for name <= '9' {
reg[name] = emptyRegister()
name++
}
}
// addSpecialRegisters: Initializes special-purpose registers with specific
// behaviors. Includes the unnamed register ("), black hole (_), clipboard (*),
// last insert (.), current filename (%), last command (:), and alternate file (#).
func addSpecialRegisters(reg map[rune]Register) {
// Unnamed (default)
reg['"'] = emptyRegister()
// Black hole (readonly)
reg['_'] = emptyRegister()
// System clipboard
reg['*'] = emptyRegister()
// Small delete? Expression?
// Last inserted text (readonly)
reg['.'] = emptyRegister()
// Current file name (readonly)
reg['%'] = emptyRegister()
// Last executed command (readonly)
reg[':'] = emptyRegister()
// Alternate (previous) file (readonly)
reg['#'] = emptyRegister()
}
// emptyRegister: Creates a new register initialized with charwise type and empty
// content. Used as the default state for all registers during initialization.
func emptyRegister() Register {
return Register{
Type: CharwiseRegister,
Content: []string{},
}
}

View File

@ -1,15 +0,0 @@
package core
// EditorSettings: Configuration options for editor display and behavior.
type EditorSettings struct {
TabStop int
// TODO: Colors
}
// NewDefaultSettings: Creates a Settings struct with sensible defaults for
// line numbers, gutter width, tab size, and scroll offset.
func NewDefaultSettings() EditorSettings {
return EditorSettings{
TabStop: 2,
}
}

View File

@ -1,15 +0,0 @@
package core
// MotionType indicates how a motion operates
type MotionType int
const (
CharwiseExclusive MotionType = iota // w, b, h, l, 0, ^ - end position not included
CharwiseInclusive // e, $, f - end position is included
Linewise // j, k, G, gg, {, } - operates on whole lines
)
// IsCharwise returns true if the motion type is character-based (not linewise)
func (mt MotionType) IsCharwise() bool {
return mt == CharwiseExclusive || mt == CharwiseInclusive
}

View File

@ -1,205 +0,0 @@
package core
type WinOptions struct {
Number bool
RelativeNumber bool
GutterSize int
// Wrap bool
ScrollOff int
}
func NewDefaultWinOptions() WinOptions {
return WinOptions{
Number: true,
RelativeNumber: true,
GutterSize: 5,
ScrollOff: 8,
}
}
type Window struct {
Id int
Number int // Ignored for now, will be used when splits come into play
Buffer *Buffer
Cursor Position // DO NOT MODIFY DIRECTLY, USE SETTERS
Anchor Position
ScrollY int
Height int
Width int
// Folds TODO
Options WinOptions
}
// ==================================================
// Helper methods
// ==================================================
// Window.ClampCursor: Clamps the cursor in the all directions to ensure the cursor
// does not go into an invalid position. Such as negative values or past the end of
// the line. In the Y direction it validates that the cursor does not pass the end
// of the content or attempt to be "above" the content (negative value). This function
// is automatically called in any time the cursor changes. It only needs to be called
// when a force clamp is needed.
func (w *Window) ClampCursor() {
// Clamp line to valid range [0, lineCount-1]
maxLine := max(w.Buffer.LineCount()-1, 0)
if w.Cursor.Line < 0 {
w.Cursor.Line = 0
} else if w.Cursor.Line > maxLine {
w.Cursor.Line = maxLine
}
// Handle empty buffer - no lines to clamp column against
if w.Buffer.LineCount() == 0 {
w.Cursor.Line = 0
w.Cursor.Col = 0
return
}
// Clamp column to valid range [0, lineLen]
lineLen := len(w.Buffer.Lines[w.Cursor.Line])
if w.Cursor.Col < 0 {
w.Cursor.Col = 0
} else if lineLen == 0 {
w.Cursor.Col = 0
} else if w.Cursor.Col >= lineLen {
w.Cursor.Col = lineLen // Allow cursor after last char (insert mode)
}
}
// Window.AdjustScroll ensures the cursor stays within the height with scrollOff margins.
// Call this after any cursor movement.
func (w *Window) AdjustScroll() {
if w.Height <= 0 {
return
}
viewPort := w.ViewportHeight()
// Effective scrollOff (can't be more than half the viewport)
off := min(w.Options.ScrollOff, viewPort/2)
// Cursor too close to top — scroll up
if w.Cursor.Line < w.ScrollY+off {
w.ScrollY = w.Cursor.Line - off
}
// Cursor too close to bottom — scroll down
if w.Cursor.Line > w.ScrollY+viewPort-1-off {
w.ScrollY = w.Cursor.Line - viewPort + 1 + off
}
// Clamp scrollY to valid range
maxScroll := max(0, w.Buffer.LineCount()-viewPort)
w.ScrollY = max(0, min(w.ScrollY, maxScroll))
}
// ==================================================
// Getters (for computed values)
// ==================================================
func (w *Window) ViewportHeight() int {
// TODO: This will need more magic when splits come into play
// -1 for command bar
// -1 for status line
return w.Height - 2
}
// ==================================================
// Setters
// ==================================================
// Window.SetNumber: Sets the position-based number of this window. Currently ignored
// until splits are implemented.
func (w *Window) SetNumber(number int) {
w.Number = number
}
// Window.SetBuffer: Sets the buffer that this window should display. This is used when
// switching between buffers or opening a new file in the current window. This function
// does clamp the cursor to the current buffer
func (w *Window) SetBuffer(buffer *Buffer) {
w.Buffer = buffer
w.ClampCursor()
}
// Window.SetCursor: Sets the cursor position in this window to the given position.
func (w *Window) SetCursor(cursor Position) {
w.Cursor = cursor
w.ClampCursor()
}
// Window.SetCursorLine: Sets the line number of the cursor position.
func (w *Window) SetCursorLine(line int) {
w.Cursor.Line = line
w.ClampCursor()
}
// Window.SetCursorCol: Sets the column number of the cursor position.
func (w *Window) SetCursorCol(col int) {
w.Cursor.Col = col
w.ClampCursor()
}
// Window.SetCursorPos: Sets both the line and column of the cursor position. This is
// a convenience method for setting both components at once.
func (w *Window) SetCursorPos(line, col int) {
w.Cursor.Line = line
w.Cursor.Col = col
w.ClampCursor()
}
// Window.SetAnchor: Sets the anchor position in this window. The anchor is used for
// visual mode selections as the starting point of the selection.
func (w *Window) SetAnchor(anchor Position) {
w.Anchor = anchor
}
// Window.SetAnchorLine: Sets the line number of the anchor position.
func (w *Window) SetAnchorLine(line int) {
w.Anchor.Line = line
}
// Window.SetAnchorCol: Sets the column number of the anchor position.
func (w *Window) SetAnchorCol(col int) {
w.Anchor.Col = col
}
// Window.SetAnchorPos: Sets both the line and column of the anchor position. This is
// a convenience method for setting both components at once.
func (w *Window) SetAnchorPos(line, col int) {
w.Anchor.Line = line
w.Anchor.Col = col
}
// Window.SetScrollY: Sets the vertical scroll offset of this window. This determines
// which line appears at the top of the visible viewport.
func (w *Window) SetScrollY(scrollY int) {
w.ScrollY = scrollY
}
// Window.SetHeight: Sets the height of this window in lines.
func (w *Window) SetHeight(height int) {
w.Height = height
}
// Window.SetWidth: Sets the width of this window in columns.
func (w *Window) SetWidth(width int) {
w.Width = width
}
// Window.SetDimensions: Sets both the width and height of this window. This is a
// convenience method for setting both dimensions at once.
func (w *Window) SetDimensions(width, height int) {
w.Width = width
w.Height = height
}
// Window.SetOptions: Sets the options of this window.
func (w *Window) SetOptions(opts WinOptions) {
w.Options = opts
}

View File

@ -1,111 +0,0 @@
package core
// Not great, but maybe the best way
var CurrentWindowId int = 1000
type WindowBuilder struct {
window Window
}
// NewWindowBuilder: Creates a new window builder. The window builder implements a
// builder pattern to create a window with the defined properties and values.
func NewWindowBuilder() *WindowBuilder {
return &WindowBuilder{
window: Window{
Id: 0, // This is set when built
Number: 1, // Ignored for now, will be used for splits
Buffer: nil,
Cursor: Position{Line: 0, Col: 0},
Anchor: Position{Line: 0, Col: 0},
ScrollY: 0,
Height: 0,
Width: 0,
Options: NewDefaultWinOptions(),
},
}
}
// WindowBuilder.WithNumber: Attaches a window number to the window that is being built.
// Window numbers are position-based and change when windows are rearranged. This is
// ignored for now, but will be used when splits are implemented.
func (w *WindowBuilder) WithNumber(number int) *WindowBuilder {
w.window.Number = number
return w
}
// WindowBuilder.WithBuffer: Attaches a buffer to the window that is being built. The
// window will display and edit the content of this buffer.
func (w *WindowBuilder) WithBuffer(buffer *Buffer) *WindowBuilder {
w.window.Buffer = buffer
return w
}
// WindowBuilder.WithCursor: Sets the cursor position in the window that is being built.
func (w *WindowBuilder) WithCursor(cursor Position) *WindowBuilder {
w.window.Cursor = cursor
return w
}
// WindowBuilder.WithCursorPos: Sets the cursor position in the window that is being built.
// This is an alias for WithCursor that accepts line and column separately.
func (w *WindowBuilder) WithCursorPos(line, col int) *WindowBuilder {
w.window.Cursor = Position{Line: line, Col: col}
return w
}
// WindowBuilder.WithAnchor: Sets the anchor position in the window that is being built.
// The anchor is used for visual mode selections.
func (w *WindowBuilder) WithAnchor(anchor Position) *WindowBuilder {
w.window.Anchor = anchor
return w
}
// WindowBuilder.WithAnchorPos: Sets the anchor position in the window that is being built.
// This is an alias for WithAnchor that accepts line and column separately.
func (w *WindowBuilder) WithAnchorPos(line, col int) *WindowBuilder {
w.window.Anchor = Position{Line: line, Col: col}
return w
}
// WindowBuilder.WithScrollY: Sets the vertical scroll offset of the window that is being built.
func (w *WindowBuilder) WithScrollY(scrollY int) *WindowBuilder {
w.window.ScrollY = scrollY
return w
}
// WindowBuilder.WithHeight: Sets the height of the window that is being built.
func (w *WindowBuilder) WithHeight(height int) *WindowBuilder {
w.window.Height = height
return w
}
// WindowBuilder.WithWidth: Sets the width of the window that is being built.
func (w *WindowBuilder) WithWidth(width int) *WindowBuilder {
w.window.Width = width
return w
}
// WindowBuilder.WithDimensions: Sets both width and height of the window that is being built.
// This is a convenience method for setting dimensions in one call.
func (w *WindowBuilder) WithDimensions(width, height int) *WindowBuilder {
w.window.Width = width
w.window.Height = height
return w
}
// WindowBuilder.WithOptions: Applies the options to the window that is being built.
// This is a convenience method for setting all options in one call.
func (w *WindowBuilder) WithOptions(options WinOptions) *WindowBuilder {
w.window.Options = options
return w
}
// WindowBuilder.Build: Build the final window and return it to the caller. Final
// step in the process. This is where the ID is set, so many windows can be "in-progress"
// but the ID will be set when they are built. Meaning, this is not thread safe.
func (w *WindowBuilder) Build() Window {
w.window.Id = CurrentWindowId
CurrentWindowId++
return w.window
}

View File

@ -1,493 +0,0 @@
package core
import "testing"
// --------------------------------------------------
// Window Tests (generated by ClaudeCode)
// --------------------------------------------------
func TestWindow_SetCursorLine(t *testing.T) {
t.Run("clamps cursor below zero", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2", "line 3"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorLine(-5)
if win.Cursor.Line != 0 {
t.Errorf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("clamps cursor past end", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2", "line 3"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorLine(999)
if win.Cursor.Line != 2 { // 3 lines, max index is 2
t.Errorf("expected cursor at line 2, got %d", win.Cursor.Line)
}
})
t.Run("allows valid position", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"line 1", "line 2", "line 3"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorLine(1)
if win.Cursor.Line != 1 {
t.Errorf("expected cursor at line 1, got %d", win.Cursor.Line)
}
})
t.Run("handles empty buffer", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorLine(5)
if win.Cursor.Line != 0 {
t.Errorf("expected cursor at line 0 for empty buffer, got %d", win.Cursor.Line)
}
})
}
func TestWindow_SetCursorCol(t *testing.T) {
t.Run("clamps to line length", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorCol(999)
// "hello" is 5 chars, max col should be 5 (after last char for insert mode)
if win.Cursor.Col > 5 {
t.Errorf("expected cursor col <= 5, got %d", win.Cursor.Col)
}
})
t.Run("clamps below zero", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorCol(-10)
if win.Cursor.Col != 0 {
t.Errorf("expected cursor col 0, got %d", win.Cursor.Col)
}
})
t.Run("handles empty line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{""}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorCol(5)
if win.Cursor.Col != 0 {
t.Errorf("expected cursor at col 0 on empty line, got %d", win.Cursor.Col)
}
})
t.Run("allows cursor at end of line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetCursorCol(5) // After last char
if win.Cursor.Col != 5 {
t.Errorf("expected cursor at col 5, got %d", win.Cursor.Col)
}
})
}
func TestWindow_AdjustScroll(t *testing.T) {
t.Run("scrolls down when cursor goes below viewport", func(t *testing.T) {
// Create buffer with many lines
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
buf := NewBufferBuilder().WithLines(lines).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
Build()
// Start at top
win.SetCursorLine(0)
win.AdjustScroll()
initialScroll := win.ScrollY
// Move cursor way down
win.SetCursorLine(50)
win.AdjustScroll()
// Scroll should have increased
if win.ScrollY <= initialScroll {
t.Errorf("expected scroll to increase, was %d, now %d", initialScroll, win.ScrollY)
}
// Cursor should be visible
viewport := win.ViewportHeight()
if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Line, win.ScrollY, win.ScrollY+viewport)
}
})
t.Run("scrolls up when cursor goes above viewport", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
buf := NewBufferBuilder().WithLines(lines).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
Build()
// Start at bottom
win.SetCursorLine(80)
win.AdjustScroll()
initialScroll := win.ScrollY
// Move cursor to top
win.SetCursorLine(5)
win.AdjustScroll()
// Scroll should have decreased
if win.ScrollY >= initialScroll {
t.Errorf("expected scroll to decrease, was %d, now %d", initialScroll, win.ScrollY)
}
// Cursor should be visible
viewport := win.ViewportHeight()
if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport {
t.Errorf("cursor at %d not visible in scroll range [%d, %d)",
win.Cursor.Line, win.ScrollY, win.ScrollY+viewport)
}
})
t.Run("respects scrolloff margin", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
buf := NewBufferBuilder().WithLines(lines).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
Build()
// Set scrolloff to 5
opts := win.Options
opts.ScrollOff = 5
win.SetOptions(opts)
// Move to line 20 and adjust
win.SetCursorLine(20)
win.AdjustScroll()
viewport := win.ViewportHeight()
distFromTop := win.Cursor.Line - win.ScrollY
distFromBottom := (win.ScrollY + viewport - 1) - win.Cursor.Line
// At least one should respect scrolloff
if distFromTop < opts.ScrollOff && distFromBottom < opts.ScrollOff {
t.Errorf("scrolloff %d not respected: top=%d, bottom=%d",
opts.ScrollOff, distFromTop, distFromBottom)
}
})
t.Run("handles scrolloff larger than half viewport", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
buf := NewBufferBuilder().WithLines(lines).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
Build()
// Set scrolloff larger than half viewport
opts := win.Options
opts.ScrollOff = 999
win.SetOptions(opts)
win.SetCursorLine(50)
win.AdjustScroll()
// Should not panic or error
viewport := win.ViewportHeight()
if win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport {
t.Error("cursor should still be visible with large scrolloff")
}
})
t.Run("handles small viewport", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = "line"
}
buf := NewBufferBuilder().WithLines(lines).Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(5). // Very small
Build()
win.SetCursorLine(50)
win.AdjustScroll()
// Should not panic
viewport := win.ViewportHeight()
if viewport > 0 && (win.Cursor.Line < win.ScrollY || win.Cursor.Line >= win.ScrollY+viewport) {
t.Error("cursor should be visible in small viewport")
}
})
}
func TestWindow_ViewportHeight(t *testing.T) {
t.Run("calculates viewport height correctly", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
Build()
// Height - 2 (status bar + command bar)
expected := 22
if win.ViewportHeight() != expected {
t.Errorf("expected viewport height %d, got %d", expected, win.ViewportHeight())
}
})
t.Run("handles small window", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(3).
Build()
// 3 - 2 = 1
expected := 1
if win.ViewportHeight() != expected {
t.Errorf("expected viewport height %d, got %d", expected, win.ViewportHeight())
}
})
t.Run("handles zero height", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithHeight(0).
Build()
// With height 0, viewport is 0 - 2 (status + command bars) = -2
// This is an edge case that shouldn't occur in practice, but shouldn't panic
result := win.ViewportHeight()
expected := -2
if result != expected {
t.Errorf("expected viewport height %d for zero height window, got %d", expected, result)
}
})
}
func TestWindow_SetOptions(t *testing.T) {
t.Run("updates options", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
newOpts := WinOptions{
Number: false,
RelativeNumber: false,
GutterSize: 10,
ScrollOff: 3,
}
win.SetOptions(newOpts)
if win.Options.Number != false {
t.Error("expected Number to be false")
}
if win.Options.RelativeNumber != false {
t.Error("expected RelativeNumber to be false")
}
if win.Options.GutterSize != 10 {
t.Errorf("expected GutterSize 10, got %d", win.Options.GutterSize)
}
if win.Options.ScrollOff != 3 {
t.Errorf("expected ScrollOff 3, got %d", win.Options.ScrollOff)
}
})
t.Run("can toggle individual options", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
// Get current options
opts := win.Options
originalNumber := opts.Number
// Toggle number
opts.Number = !opts.Number
win.SetOptions(opts)
if win.Options.Number == originalNumber {
t.Error("Number option should have toggled")
}
})
}
func TestWindow_SetAnchor(t *testing.T) {
t.Run("sets anchor line", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"a", "b", "c"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetAnchorLine(2)
if win.Anchor.Line != 2 {
t.Errorf("expected anchor line 2, got %d", win.Anchor.Line)
}
})
t.Run("sets anchor col", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"hello world"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetAnchorCol(5)
if win.Anchor.Col != 5 {
t.Errorf("expected anchor col 5, got %d", win.Anchor.Col)
}
})
}
func TestWindow_SetDimensions(t *testing.T) {
t.Run("updates width and height", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
win.SetDimensions(100, 50)
if win.Width != 100 {
t.Errorf("expected width 100, got %d", win.Width)
}
if win.Height != 50 {
t.Errorf("expected height 50, got %d", win.Height)
}
})
}
func TestWindowBuilder(t *testing.T) {
t.Run("builds with defaults", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
Build()
// Should have default options
if win.Options.Number != true {
t.Error("expected default Number to be true")
}
if win.Options.RelativeNumber != true {
t.Error("expected default RelativeNumber to be true")
}
if win.Options.ScrollOff != 8 {
t.Errorf("expected default ScrollOff 8, got %d", win.Options.ScrollOff)
}
if win.Options.GutterSize != 5 {
t.Errorf("expected default GutterSize 5, got %d", win.Options.GutterSize)
}
})
t.Run("builds with custom cursor position", func(t *testing.T) {
buf := NewBufferBuilder().
WithLines([]string{"a", "b", "c"}).
Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithCursorPos(2, 0).
Build()
if win.Cursor.Line != 2 {
t.Errorf("expected cursor line 2, got %d", win.Cursor.Line)
}
if win.Cursor.Col != 0 {
t.Errorf("expected cursor col 0, got %d", win.Cursor.Col)
}
})
t.Run("builds with custom dimensions", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win := NewWindowBuilder().
WithBuffer(&buf).
WithDimensions(120, 40).
Build()
if win.Width != 120 {
t.Errorf("expected width 120, got %d", win.Width)
}
if win.Height != 40 {
t.Errorf("expected height 40, got %d", win.Height)
}
})
t.Run("assigns unique IDs", func(t *testing.T) {
buf := NewBufferBuilder().Build()
win1 := NewWindowBuilder().WithBuffer(&buf).Build()
win2 := NewWindowBuilder().WithBuffer(&buf).Build()
if win1.Id == win2.Id {
t.Errorf("expected unique IDs, both were %d", win1.Id)
}
})
}

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// TestHelperExamples demonstrates the different ways to use the test helpers // TestHelperExamples demonstrates the different ways to use the test helpers
@ -19,20 +19,18 @@ func TestHelperExamples(t *testing.T) {
WithLines([]string{"hello", "world"}), WithLines([]string{"hello", "world"}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
buf := m.ActiveBuffer() if len(m.lines) != 2 {
if buf.LineCount() != 2 { t.Errorf("expected 2 lines, got %d", len(m.lines))
t.Errorf("expected 2 lines, got %d", buf.LineCount())
} }
}) })
t.Run("custom cursor position", func(t *testing.T) { t.Run("custom cursor position", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithCursorPos(core.Position{Line: 2, Col: 3}), WithCursorPos(action.Position{Line: 2, Col: 3}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
win := m.ActiveWindow() if m.CursorY() != 2 || m.CursorX() != 3 {
if win.Cursor.Line != 2 || win.Cursor.Col != 3 { t.Errorf("expected cursor at (2,3), got (%d,%d)", m.CursorY(), m.CursorX())
t.Errorf("expected cursor at (2,3), got (%d,%d)", win.Cursor.Line, win.Cursor.Col)
} }
}) })
@ -41,16 +39,15 @@ func TestHelperExamples(t *testing.T) {
WithTermSize(120, 40), WithTermSize(120, 40),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
win := m.ActiveWindow() if m.WinW() != 120 || m.WinH() != 40 {
if win.Width != 120 || win.Height != 40 { t.Errorf("expected size 120x40, got %dx%d", m.WinW(), m.WinH())
t.Errorf("expected size 120x40, got %dx%d", win.Width, win.Height)
} }
}) })
t.Run("with register content for paste testing", func(t *testing.T) { t.Run("with register content for paste testing", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithRegister('"', core.CharwiseRegister, []string{"foo"}), WithRegister('"', action.CharwiseRegister, []string{"foo"}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
@ -58,7 +55,7 @@ func TestHelperExamples(t *testing.T) {
if !ok { if !ok {
t.Fatal("expected register to be set") t.Fatal("expected register to be set")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("expected charwise register, got %v", reg.Type) t.Errorf("expected charwise register, got %v", reg.Type)
} }
if len(reg.Content) != 1 || reg.Content[0] != "foo" { if len(reg.Content) != 1 || reg.Content[0] != "foo" {
@ -69,28 +66,25 @@ func TestHelperExamples(t *testing.T) {
t.Run("combine multiple options", func(t *testing.T) { t.Run("combine multiple options", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line one", "line two", "line three"}), WithLines([]string{"line one", "line two", "line three"}),
WithCursorPos(core.Position{Line: 1, Col: 5}), WithCursorPos(action.Position{Line: 1, Col: 5}),
WithTermSize(100, 30), WithTermSize(100, 30),
WithRegister('"', core.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}), WithRegister('"', action.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Verify all options were applied // Verify all options were applied
win := m.ActiveWindow() if len(m.Lines()) != 3 {
buf := m.ActiveBuffer() t.Errorf("expected 3 lines, got %d", len(m.Lines()))
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
} }
if win.Cursor.Line != 1 || win.Cursor.Col != 5 { if m.CursorY() != 1 || m.CursorX() != 5 {
t.Errorf("expected cursor at (1,5), got (%d,%d)", win.Cursor.Line, win.Cursor.Col) t.Errorf("expected cursor at (1,5), got (%d,%d)", m.CursorY(), m.CursorX())
} }
if win.Width != 100 || win.Height != 30 { if m.WinW() != 100 || m.WinH() != 30 {
t.Errorf("expected size 100x30, got %dx%d", win.Width, win.Height) t.Errorf("expected size 100x30, got %dx%d", m.WinW(), m.WinH())
} }
reg, ok := m.GetRegister('"') reg, ok := m.GetRegister('"')
if !ok || reg.Type != core.LinewiseRegister { if !ok || reg.Type != action.LinewiseRegister {
t.Error("register not set correctly") t.Error("register not set correctly")
} }
}) })
@ -99,29 +93,25 @@ func TestHelperExamples(t *testing.T) {
// Old style helpers still work for existing tests // Old style helpers still work for existing tests
tm1 := newTestModelWithLines(t, []string{"a", "b"}) tm1 := newTestModelWithLines(t, []string{"a", "b"})
m1 := getFinalModel(t, tm1) m1 := getFinalModel(t, tm1)
buf1 := m1.ActiveBuffer() if len(m1.Lines()) != 2 {
if buf1.LineCount() != 2 {
t.Error("newTestModelWithLines failed") t.Error("newTestModelWithLines failed")
} }
tm2 := newTestModelWithCursorPos(t, core.Position{Line: 1, Col: 2}) tm2 := newTestModelWithCursorPos(t, action.Position{Line: 1, Col: 2})
m2 := getFinalModel(t, tm2) m2 := getFinalModel(t, tm2)
win2 := m2.ActiveWindow() if m2.CursorY() != 1 {
if win2.Cursor.Line != 1 {
t.Error("newTestModelWithCursorPos failed") t.Error("newTestModelWithCursorPos failed")
} }
tm3 := newTestModelWithLinesAndCursorPos(t, []string{"x"}, core.Position{Line: 0, Col: 0}) tm3 := newTestModelWithLinesAndCursorPos(t, []string{"x"}, action.Position{Line: 0, Col: 0})
m3 := getFinalModel(t, tm3) m3 := getFinalModel(t, tm3)
buf3 := m3.ActiveBuffer() if len(m3.Lines()) != 1 {
if buf3.LineCount() != 1 {
t.Error("newTestModelWithLinesAndCursorPos failed") t.Error("newTestModelWithLinesAndCursorPos failed")
} }
tm4 := newTestModelWithTermSize(t, []string{"y"}, core.Position{Line: 0, Col: 0}, 50, 20) tm4 := newTestModelWithTermSize(t, []string{"y"}, action.Position{Line: 0, Col: 0}, 50, 20)
m4 := getFinalModel(t, tm4) m4 := getFinalModel(t, tm4)
win4 := m4.ActiveWindow() if m4.WinW() != 50 {
if win4.Width != 50 {
t.Error("newTestModelWithTermSize failed") t.Error("newTestModelWithTermSize failed")
} }
}) })

View File

@ -4,14 +4,11 @@ import (
"testing" "testing"
"time" "time"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest" "github.com/charmbracelet/x/exp/teatest"
) )
// NOTE: Lots of this actually sucks ass, but it works for now...
// TODO: Refactor this to implement the builder pattern
// sendKeys sends a sequence of keys to the test model // sendKeys sends a sequence of keys to the test model
func sendKeys(tm *teatest.TestModel, keys ...string) { func sendKeys(tm *teatest.TestModel, keys ...string) {
for _, key := range keys { for _, key := range keys {
@ -38,23 +35,16 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
} }
} }
// sendKeyString is a convenience function for sending many keys.
func sendKeyString(tm *teatest.TestModel, keyString string) {
for _, key := range keyString {
sendKeys(tm, string(key))
}
}
// TestModelOption is a functional option for configuring test models // TestModelOption is a functional option for configuring test models
type TestModelOption func(*testModelConfig) type TestModelOption func(*testModelConfig)
type testModelConfig struct { type testModelConfig struct {
lines []string lines []string
pos core.Position pos action.Position
width int width int
height int height int
regName rune regName rune
regType core.RegisterType regType action.RegisterType
regContent []string regContent []string
} }
@ -66,7 +56,7 @@ func WithLines(lines []string) TestModelOption {
} }
// WithCursorPos sets the initial cursor position // WithCursorPos sets the initial cursor position
func WithCursorPos(pos core.Position) TestModelOption { func WithCursorPos(pos action.Position) TestModelOption {
return func(c *testModelConfig) { return func(c *testModelConfig) {
c.pos = pos c.pos = pos
} }
@ -81,7 +71,7 @@ func WithTermSize(width, height int) TestModelOption {
} }
// WithRegister sets a register's content // WithRegister sets a register's content
func WithRegister(name rune, regType core.RegisterType, content []string) TestModelOption { func WithRegister(name rune, regType action.RegisterType, content []string) TestModelOption {
return func(c *testModelConfig) { return func(c *testModelConfig) {
c.regName = name c.regName = name
c.regType = regType c.regType = regType
@ -94,7 +84,7 @@ func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel {
// Default configuration // Default configuration
cfg := testModelConfig{ cfg := testModelConfig{
lines: []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"}, lines: []string{"line 1", "line 2", "line 3", "line 4", "line 5", "line 6"},
pos: core.Position{Col: 0, Line: 0}, pos: action.Position{Col: 0, Line: 0},
width: 80, width: 80,
height: 24, height: 24,
} }
@ -104,31 +94,12 @@ func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel {
opt(&cfg) opt(&cfg)
} }
buf := core.NewBufferBuilder(). // Create model
WithLines(cfg.lines). m := NewModel(cfg.lines, cfg.pos)
Build()
win := core.NewWindowBuilder(). // Set register if provided
WithBuffer(&buf).
WithCursorPos(cfg.pos.Line, cfg.pos.Col).
WithDimensions(cfg.width, cfg.height).
Build()
// Create model with default registers
m := NewModelBuilder().
AddBuffer(&buf).
AddWindow(&win).
WithActiveWindowId(win.Id).
WithTermSize(cfg.width, cfg.height).
Build()
// Set register if provided (must be done AFTER Build because SetRegister
// requires the register to already exist in the default register map)
if cfg.regContent != nil { if cfg.regContent != nil {
err := m.SetRegister(cfg.regName, cfg.regType, cfg.regContent) m.SetRegister(cfg.regName, cfg.regType, cfg.regContent)
if err != nil {
t.Fatalf("Failed to set register %c: %v", cfg.regName, err)
}
} }
return teatest.NewTestModel(t, m, teatest.WithInitialTermSize(cfg.width, cfg.height)) return teatest.NewTestModel(t, m, teatest.WithInitialTermSize(cfg.width, cfg.height))
@ -139,21 +110,21 @@ func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel {
return newTestModel(t, WithLines(lines)) return newTestModel(t, WithLines(lines))
} }
func newTestModelWithCursorPos(t *testing.T, pos core.Position) *teatest.TestModel { func newTestModelWithCursorPos(t *testing.T, pos action.Position) *teatest.TestModel {
return newTestModel(t, WithCursorPos(pos)) return newTestModel(t, WithCursorPos(pos))
} }
func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.Position) *teatest.TestModel { func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.Position) *teatest.TestModel {
return newTestModel(t, WithLines(lines), WithCursorPos(pos)) return newTestModel(t, WithLines(lines), WithCursorPos(pos))
} }
func newTestModelWithTermSize(t *testing.T, lines []string, pos core.Position, width, height int) *teatest.TestModel { func newTestModelWithTermSize(t *testing.T, lines []string, pos action.Position, width, height int) *teatest.TestModel {
return newTestModel(t, WithLines(lines), WithCursorPos(pos), WithTermSize(width, height)) return newTestModel(t, WithLines(lines), WithCursorPos(pos), WithTermSize(width, height))
} }
// getFinalModel extracts the final model state (sends ctrl+c to quit first) // getFinalModel extracts the final model state (sends ctrl+c to quit first)
func getFinalModel(t *testing.T, tm *teatest.TestModel) *Model { func getFinalModel(t *testing.T, tm *teatest.TestModel) Model {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC})
fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
return fm.(*Model) return fm.(Model)
} }

View File

@ -3,11 +3,13 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// NOTE: AI Generated tests // NOTE: AI Generated tests
// Default settings are: Number=true, RelativeNumber=true, TabSize=2, ScrollOff=8
func TestCommandSetBoolean(t *testing.T) { func TestCommandSetBoolean(t *testing.T) {
t.Run("':set nonumber' disables line numbers", func(t *testing.T) { t.Run("':set nonumber' disables line numbers", func(t *testing.T) {
// Default has Number=true // Default has Number=true
@ -17,7 +19,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "n", "u", "m", "b", "e", "r", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "n", "u", "m", "b", "e", "r", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Options.Number { if m.Settings().Number {
t.Error("expected Number to be false after :set nonumber") t.Error("expected Number to be false after :set nonumber")
} }
}) })
@ -31,7 +33,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.Number { if !m.Settings().Number {
t.Error("expected Number to be true after :set nu") t.Error("expected Number to be true after :set nu")
} }
}) })
@ -44,7 +46,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "m", "b", "e", "r", "!", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "m", "b", "e", "r", "!", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Options.Number { if m.Settings().Number {
t.Error("expected Number to be false after :set number!") t.Error("expected Number to be false after :set number!")
} }
}) })
@ -58,7 +60,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "!", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "!", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.Number { if !m.Settings().Number {
t.Error("expected Number to be true after double toggle") t.Error("expected Number to be true after double toggle")
} }
}) })
@ -71,7 +73,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "r", "n", "u", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "r", "n", "u", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Options.RelativeNumber { if m.Settings().RelativeNumber {
t.Error("expected RelativeNumber to be false after :set nornu") t.Error("expected RelativeNumber to be false after :set nornu")
} }
}) })
@ -85,7 +87,7 @@ func TestCommandSetBoolean(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "r", "n", "u", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "r", "n", "u", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if !m.ActiveWindow().Options.RelativeNumber { if !m.Settings().RelativeNumber {
t.Error("expected RelativeNumber to be true after :set rnu") t.Error("expected RelativeNumber to be true after :set rnu")
} }
}) })
@ -93,15 +95,15 @@ func TestCommandSetBoolean(t *testing.T) {
func TestCommandSetInteger(t *testing.T) { func TestCommandSetInteger(t *testing.T) {
t.Run("':set tabstop=4' sets tab size", func(t *testing.T) { t.Run("':set tabstop=4' sets tab size", func(t *testing.T) {
// Default TabStop=2 // Default TabSize=2
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLines(t, lines) tm := newTestModelWithLines(t, lines)
sendKeys(tm, ":", "s", "e", "t", " ", "t", "a", "b", "s", "t", "o", "p", "=", "4", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "t", "a", "b", "s", "t", "o", "p", "=", "4", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Settings().TabStop != 4 { if m.Settings().TabSize != 4 {
t.Errorf("TabStop = %d, want 4", m.Settings().TabStop) t.Errorf("TabSize = %d, want 4", m.Settings().TabSize)
} }
}) })
@ -112,8 +114,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "t", "s", "=", "8", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "t", "s", "=", "8", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Settings().TabStop != 8 { if m.Settings().TabSize != 8 {
t.Errorf("TabStop = %d, want 8", m.Settings().TabStop) t.Errorf("TabSize = %d, want 8", m.Settings().TabSize)
} }
}) })
@ -125,8 +127,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "s", "c", "r", "o", "l", "l", "o", "f", "f", "=", "5", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "s", "c", "r", "o", "l", "l", "o", "f", "f", "=", "5", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Options.ScrollOff != 5 { if m.Settings().ScrollOff != 5 {
t.Errorf("ScrollOff = %d, want 5", m.ActiveWindow().Options.ScrollOff) t.Errorf("ScrollOff = %d, want 5", m.Settings().ScrollOff)
} }
}) })
@ -137,8 +139,8 @@ func TestCommandSetInteger(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "s", "o", "=", "1", "0", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "s", "o", "=", "1", "0", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Options.ScrollOff != 10 { if m.Settings().ScrollOff != 10 {
t.Errorf("ScrollOff = %d, want 10", m.ActiveWindow().Options.ScrollOff) t.Errorf("ScrollOff = %d, want 10", m.Settings().ScrollOff)
} }
}) })
} }
@ -151,7 +153,7 @@ func TestCommandModeNavigation(t *testing.T) {
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// After esc we should be back in normal mode // After esc we should be back in normal mode
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode after esc", m.Mode()) t.Errorf("Mode() = %v, want NormalMode after esc", m.Mode())
} }
}) })
@ -162,7 +164,7 @@ func TestCommandModeNavigation(t *testing.T) {
sendKeys(tm, ":", "esc") sendKeys(tm, ":", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -173,7 +175,7 @@ func TestCommandModeNavigation(t *testing.T) {
sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter") sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -337,16 +339,3 @@ func TestCommandModeErrors(t *testing.T) {
} }
}) })
} }
func TestCommandEdit(t *testing.T) {
t.Run(":edit with no args fails", func(t *testing.T) {
tm := newTestModel(t)
sendKeyString(tm, ":edit")
sendKeys(tm, "enter")
m := getFinalModel(t, tm)
if m.commandError == nil {
t.Error("expected commandError to be set for edit without args")
}
})
}

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
func TestDeleteChar(t *testing.T) { func TestDeleteChar(t *testing.T) {
@ -13,30 +13,30 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ello" { if m.lines[0] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'ello'", m.lines[0])
} }
}) })
t.Run("test 'x' in middle of line", func(t *testing.T) { t.Run("test 'x' in middle of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helo" { if m.lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
} }
}) })
t.Run("test 'x' at end of line", func(t *testing.T) { t.Run("test 'x' at end of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hell" { if m.lines[0] != "hell" {
t.Errorf("lines[0] = %q, want 'hell'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hell'", m.lines[0])
} }
}) })
@ -46,8 +46,8 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x", "x") sendKeys(tm, "x", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" { if m.lines[0] != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'llo'", m.lines[0])
} }
}) })
} }
@ -59,8 +59,8 @@ func TestDeleteCharWithCount(t *testing.T) {
sendKeys(tm, "3", "x") sendKeys(tm, "3", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" { if m.lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
} }
}) })
@ -70,19 +70,19 @@ func TestDeleteCharWithCount(t *testing.T) {
sendKeys(tm, "1", "0", "x") sendKeys(tm, "1", "0", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want ''", m.lines[0])
} }
}) })
t.Run("test '2x' from middle", func(t *testing.T) { t.Run("test '2x' from middle", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "x") sendKeys(tm, "2", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hlo" { if m.lines[0] != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0])
} }
}) })
} }
@ -94,11 +94,11 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
@ -108,30 +108,30 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'x' at last char deletes it", func(t *testing.T) { t.Run("test 'x' at last char deletes it", func(t *testing.T) {
lines := []string{"ab"} lines := []string{"ab"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "a" { if m.Line(0) != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'a'", m.Line(0))
} }
}) })
t.Run("test 'x' with whitespace", func(t *testing.T) { t.Run("test 'x' with whitespace", func(t *testing.T) {
lines := []string{"a b c"} lines := []string{"a b c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ab c" { if m.Line(0) != "ab c" {
t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'ab c'", m.Line(0))
} }
}) })
@ -141,11 +141,11 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 { if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 2", m.LineCount())
} }
if m.ActiveBuffer().Lines[1] != "world" { if m.Line(1) != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'world'", m.Line(1))
} }
}) })
@ -155,19 +155,19 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x", "x", "x") sendKeys(tm, "x", "x", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" { if m.Line(0) != "lo" {
t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'lo'", m.Line(0))
} }
}) })
t.Run("test 'x' on line with tabs", func(t *testing.T) { t.Run("test 'x' on line with tabs", func(t *testing.T) {
lines := []string{"a\tb"} lines := []string{"a\tb"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ab" { if m.Line(0) != "ab" {
t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'ab'", m.Line(0))
} }
}) })
@ -177,19 +177,19 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "5", "x") sendKeys(tm, "5", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'x' in middle preserves surrounding chars", func(t *testing.T) { t.Run("test 'x' in middle preserves surrounding chars", func(t *testing.T) {
lines := []string{"abcde"} lines := []string{"abcde"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "abde" { if m.Line(0) != "abde" {
t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'abde'", m.Line(0))
} }
}) })
} }
@ -197,12 +197,12 @@ func TestDeleteCharEdgeCases(t *testing.T) {
func TestDeleteToEndOfLine(t *testing.T) { func TestDeleteToEndOfLine(t *testing.T) {
t.Run("test 'D' deletes to end of line", func(t *testing.T) { t.Run("test 'D' deletes to end of line", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.Line(0) != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello'", m.Line(0))
} }
}) })
@ -212,31 +212,31 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'D' at last character", func(t *testing.T) { t.Run("test 'D' at last character", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hell" { if m.Line(0) != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hell'", m.Line(0))
} }
}) })
t.Run("test 'D' cursor position after delete", func(t *testing.T) { t.Run("test 'D' cursor position after delete", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Cursor should move to last character of remaining text // Cursor should move to last character of remaining text
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
@ -246,39 +246,39 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'D' with count deletes following lines", func(t *testing.T) { t.Run("test 'D' with count deletes following lines", func(t *testing.T) {
lines := []string{"hello", "world", "hi", "mom"} lines := []string{"hello", "world", "hi", "mom"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "2", "D") sendKeys(tm, "2", "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %q, want '3'", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %q, want '3'", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "he" { if m.Line(0) != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "hi" { if m.Line(1) != "hi" {
t.Errorf("Line(1) = %q, want 'hi'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'hi'", m.Line(1))
} }
}) })
t.Run("test 'D' with count deletes following lines with overflow", func(t *testing.T) { t.Run("test 'D' with count deletes following lines with overflow", func(t *testing.T) {
lines := []string{"hello", "world", "hi", "mom"} lines := []string{"hello", "world", "hi", "mom"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "8", "D") sendKeys(tm, "8", "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %q, want '1'", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %q, want '1'", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "he" { if m.Line(0) != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
} }
}) })
} }
@ -290,51 +290,51 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'D' at end of file", func(t *testing.T) { t.Run("test 'D' at end of file", func(t *testing.T) {
lines := []string{"line 1", "line 2"} lines := []string{"line 1", "line 2"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 { if m.LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 2", m.LineCount())
} }
if m.ActiveBuffer().Lines[1] != "" { if m.Line(1) != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want ''", m.Line(1))
} }
}) })
t.Run("test 'D' preserves lines above", func(t *testing.T) { t.Run("test 'D' preserves lines above", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"} lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "line 1" { if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
} }
if m.ActiveBuffer().Lines[2] != "line 3" { if m.Line(2) != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
} }
}) })
t.Run("test 'D' cursor clamps to valid position", func(t *testing.T) { t.Run("test 'D' cursor clamps to valid position", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "he" { if m.Line(0) != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
} }
// Cursor should clamp to last char // Cursor should clamp to last char
if m.ActiveWindow().Cursor.Col != 1 { if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 1", m.CursorX())
} }
}) })
@ -344,77 +344,77 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'D' from middle of whitespace-only line", func(t *testing.T) { t.Run("test 'D' from middle of whitespace-only line", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != " " { if m.Line(0) != " " {
t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ' '", m.Line(0))
} }
}) })
t.Run("test 'D' with tabs", func(t *testing.T) { t.Run("test 'D' with tabs", func(t *testing.T) {
lines := []string{"hello\tworld"} lines := []string{"hello\tworld"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.Line(0) != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello'", m.Line(0))
} }
}) })
t.Run("test 'D' on line with only one char remaining after cursor", func(t *testing.T) { t.Run("test 'D' on line with only one char remaining after cursor", func(t *testing.T) {
lines := []string{"ab"} lines := []string{"ab"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "a" { if m.Line(0) != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'a'", m.Line(0))
} }
}) })
t.Run("test 'D' does not affect line below", func(t *testing.T) { t.Run("test 'D' does not affect line below", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "world" { if m.Line(1) != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'world'", m.Line(1))
} }
}) })
t.Run("test 'D' with multiple lines", func(t *testing.T) { t.Run("test 'D' with multiple lines", func(t *testing.T) {
lines := []string{"first line", "second line", "third line"} lines := []string{"first line", "second line", "third line"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "first" { if m.Line(0) != "first" {
t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'first'", m.Line(0))
} }
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
}) })
t.Run("test 'D' preserves cursor Y position", func(t *testing.T) { t.Run("test 'D' preserves cursor Y position", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"} lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// --- Insert Mode Entry Tests --- // --- Insert Mode Entry Tests ---
@ -14,8 +14,8 @@ func TestEnterInsert(t *testing.T) {
sendKeys(tm, "i") sendKeys(tm, "i")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.mode != core.InsertMode { if m.mode != action.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, core.InsertMode) t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
} }
}) })
@ -25,30 +25,30 @@ func TestEnterInsert(t *testing.T) {
sendKeys(tm, "i", "X", "esc") sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" { if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
} }
}) })
t.Run("test 'i' insert in middle", func(t *testing.T) { t.Run("test 'i' insert in middle", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc") sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
}) })
t.Run("test 'i' cursor moves back on esc", func(t *testing.T) { t.Run("test 'i' cursor moves back on esc", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "X", "esc") sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 2 { if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 2", m.cursor.x)
} }
}) })
} }
@ -59,8 +59,8 @@ func TestEnterInsertAfter(t *testing.T) {
sendKeys(tm, "a") sendKeys(tm, "a")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.mode != core.InsertMode { if m.mode != action.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, core.InsertMode) t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
} }
}) })
@ -70,19 +70,19 @@ func TestEnterInsertAfter(t *testing.T) {
sendKeys(tm, "a", "X", "esc") sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hXello" { if m.lines[0] != "hXello" {
t.Errorf("lines[0] = %q, want 'hXello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0])
} }
}) })
t.Run("test 'a' from middle of line", func(t *testing.T) { t.Run("test 'a' from middle of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "a", "X", "esc") sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helXlo" { if m.lines[0] != "helXlo" {
t.Errorf("lines[0] = %q, want 'helXlo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0])
} }
}) })
} }
@ -90,23 +90,23 @@ func TestEnterInsertAfter(t *testing.T) {
func TestEnterInsertLineStart(t *testing.T) { func TestEnterInsertLineStart(t *testing.T) {
t.Run("test 'I' enters insert mode at line start", func(t *testing.T) { t.Run("test 'I' enters insert mode at line start", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "I", "X", "esc") sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" { if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
} }
}) })
t.Run("test 'I' from end of line", func(t *testing.T) { t.Run("test 'I' from end of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "I", "X", "esc") sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" { if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
} }
}) })
} }
@ -118,19 +118,19 @@ func TestEnterInsertLineEnd(t *testing.T) {
sendKeys(tm, "A", "X", "esc") sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" { if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
} }
}) })
t.Run("test 'A' from middle of line", func(t *testing.T) { t.Run("test 'A' from middle of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "A", "X", "esc") sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" { if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
} }
}) })
} }
@ -144,39 +144,39 @@ func TestOpenLineBelow(t *testing.T) {
sendKeys(tm, "o", "n", "e", "w", "esc") sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 3", len(m.lines))
} }
if m.ActiveBuffer().Lines[1] != "new" { if m.lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
} }
}) })
t.Run("test 'o' from middle of file", func(t *testing.T) { t.Run("test 'o' from middle of file", func(t *testing.T) {
lines := []string{"line 1", "line 2", "line 3"} lines := []string{"line 1", "line 2", "line 3"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc") sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 { if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 4", len(m.lines))
} }
if m.ActiveBuffer().Lines[2] != "new" { if m.lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2]) t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
} }
}) })
t.Run("test 'o' at end of file", func(t *testing.T) { t.Run("test 'o' at end of file", func(t *testing.T) {
lines := []string{"line 1", "line 2"} lines := []string{"line 1", "line 2"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "o", "n", "e", "w", "esc") sendKeys(tm, "o", "n", "e", "w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 3", len(m.lines))
} }
if m.ActiveBuffer().Lines[2] != "new" { if m.lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2]) t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
} }
}) })
@ -186,11 +186,11 @@ func TestOpenLineBelow(t *testing.T) {
sendKeys(tm, "o", "esc") sendKeys(tm, "o", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 1", m.cursor.y)
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 0", m.cursor.x)
} }
}) })
} }
@ -202,12 +202,12 @@ func TestOpenLineBelowWithCount(t *testing.T) {
sendKeys(tm, "3", "o", "x", "esc") sendKeys(tm, "3", "o", "x", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 { if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 4", len(m.lines))
} }
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
if m.ActiveBuffer().Lines[i] != "x" { if m.lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i]) t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
} }
} }
}) })
@ -218,14 +218,14 @@ func TestOpenLineBelowWithCount(t *testing.T) {
sendKeys(tm, "2", "o", "a", "b", "esc") sendKeys(tm, "2", "o", "a", "b", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 3", len(m.lines))
} }
if m.ActiveBuffer().Lines[1] != "ab" { if m.lines[1] != "ab" {
t.Errorf("lines[1] = %q, want 'ab'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'ab'", m.lines[1])
} }
if m.ActiveBuffer().Lines[2] != "ab" { if m.lines[2] != "ab" {
t.Errorf("lines[2] = %q, want 'ab'", m.ActiveBuffer().Lines[2]) t.Errorf("lines[2] = %q, want 'ab'", m.lines[2])
} }
}) })
} }
@ -233,15 +233,15 @@ func TestOpenLineBelowWithCount(t *testing.T) {
func TestOpenLineAbove(t *testing.T) { func TestOpenLineAbove(t *testing.T) {
t.Run("test 'O' creates line above", func(t *testing.T) { t.Run("test 'O' creates line above", func(t *testing.T) {
lines := []string{"line 1", "line 2"} lines := []string{"line 1", "line 2"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "O", "n", "e", "w", "esc") sendKeys(tm, "O", "n", "e", "w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 3", len(m.lines))
} }
if m.ActiveBuffer().Lines[1] != "new" { if m.lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
} }
}) })
@ -251,22 +251,22 @@ func TestOpenLineAbove(t *testing.T) {
sendKeys(tm, "O", "n", "e", "w", "esc") sendKeys(tm, "O", "n", "e", "w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if len(m.lines) != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 3", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "new" { if m.lines[0] != "new" {
t.Errorf("lines[0] = %q, want 'new'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'new'", m.lines[0])
} }
}) })
t.Run("test 'O' cursor at start of new line", func(t *testing.T) { t.Run("test 'O' cursor at start of new line", func(t *testing.T) {
lines := []string{"line 1", "line 2"} lines := []string{"line 1", "line 2"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "O", "esc") sendKeys(tm, "O", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 0", m.cursor.x)
} }
}) })
} }
@ -274,16 +274,16 @@ func TestOpenLineAbove(t *testing.T) {
func TestOpenLineAboveWithCount(t *testing.T) { func TestOpenLineAboveWithCount(t *testing.T) {
t.Run("test '3O' creates 3 lines above", func(t *testing.T) { t.Run("test '3O' creates 3 lines above", func(t *testing.T) {
lines := []string{"line 1"} lines := []string{"line 1"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "3", "O", "x", "esc") sendKeys(tm, "3", "O", "x", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 { if len(m.lines) != 4 {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 4", len(m.lines))
} }
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
if m.ActiveBuffer().Lines[i] != "x" { if m.lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i]) t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
} }
} }
}) })
@ -294,35 +294,35 @@ func TestOpenLineAboveWithCount(t *testing.T) {
func TestInsertModeEnter(t *testing.T) { func TestInsertModeEnter(t *testing.T) {
t.Run("test enter splits line", func(t *testing.T) { t.Run("test enter splits line", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc") sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 { if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 2", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
if m.ActiveBuffer().Lines[1] != " world" { if m.lines[1] != " world" {
t.Errorf("lines[1] = %q, want ' world'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want ' world'", m.lines[1])
} }
}) })
t.Run("test enter at end of line", func(t *testing.T) { t.Run("test enter at end of line", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc") sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 { if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 2", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
if m.ActiveBuffer().Lines[1] != "" { if m.lines[1] != "" {
t.Errorf("lines[1] = %q, want ''", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want ''", m.lines[1])
} }
}) })
@ -332,14 +332,14 @@ func TestInsertModeEnter(t *testing.T) {
sendKeys(tm, "i", "enter", "esc") sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 { if len(m.lines) != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 2", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "" { if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want ''", m.lines[0])
} }
if m.ActiveBuffer().Lines[1] != "hello" { if m.lines[1] != "hello" {
t.Errorf("lines[1] = %q, want 'hello'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'hello'", m.lines[1])
} }
}) })
} }
@ -347,26 +347,26 @@ func TestInsertModeEnter(t *testing.T) {
func TestInsertModeBackspace(t *testing.T) { func TestInsertModeBackspace(t *testing.T) {
t.Run("test backspace deletes character", func(t *testing.T) { t.Run("test backspace deletes character", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "backspace", "esc") sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helo" { if m.lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
} }
}) })
t.Run("test backspace at start of line joins lines", func(t *testing.T) { t.Run("test backspace at start of line joins lines", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "backspace", "esc") sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 1", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "helloworld" { if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
} }
}) })
@ -376,19 +376,19 @@ func TestInsertModeBackspace(t *testing.T) {
sendKeys(tm, "i", "backspace", "esc") sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
}) })
t.Run("test multiple backspaces", func(t *testing.T) { t.Run("test multiple backspaces", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc") sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "he" { if m.lines[0] != "he" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
} }
}) })
} }
@ -396,26 +396,26 @@ func TestInsertModeBackspace(t *testing.T) {
func TestInsertModeDelete(t *testing.T) { func TestInsertModeDelete(t *testing.T) {
t.Run("test delete deletes character", func(t *testing.T) { t.Run("test delete deletes character", func(t *testing.T) {
lines := []string{"world"} lines := []string{"world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "delete", "esc") sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "word" { if m.lines[0] != "word" {
t.Errorf("lines[0] = %q, want 'word'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'word'", m.lines[0])
} }
}) })
t.Run("test delete at end of line joins lines", func(t *testing.T) { t.Run("test delete at end of line joins lines", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "delete", "esc") sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 1", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "helloworld" { if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
} }
}) })
@ -425,33 +425,33 @@ func TestInsertModeDelete(t *testing.T) {
sendKeys(tm, "i", "delete", "esc") sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if len(m.lines) != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("len(lines) = %d, want 1", len(m.lines))
} }
if m.ActiveBuffer().Lines[0] != "world" { if m.lines[0] != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'world'", m.lines[0])
} }
}) })
t.Run("test delete at end of last line does nothing", func(t *testing.T) { t.Run("test delete at end of last line does nothing", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "delete", "esc") sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
}) })
t.Run("test multiple delete", func(t *testing.T) { t.Run("test multiple delete", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "i", "delete", "delete", "delete", "esc") sendKeys(tm, "i", "delete", "delete", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" { if m.lines[0] != "ho" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
} }
}) })
@ -460,51 +460,51 @@ func TestInsertModeDelete(t *testing.T) {
func TestInsertModeArrowKeys(t *testing.T) { func TestInsertModeArrowKeys(t *testing.T) {
t.Run("test left arrow moves cursor left", func(t *testing.T) { t.Run("test left arrow moves cursor left", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "left", "X", "esc") sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
}) })
t.Run("test right arrow moves cursor right", func(t *testing.T) { t.Run("test right arrow moves cursor right", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 1, Line: 0})
sendKeys(tm, "i", "right", "X", "esc") sendKeys(tm, "i", "right", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
}) })
t.Run("test up arrow moves cursor up", func(t *testing.T) { t.Run("test up arrow moves cursor up", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1})
sendKeys(tm, "i", "up", "X", "esc") sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
if m.ActiveBuffer().Lines[1] != "world" { if m.lines[1] != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'world'", m.lines[1])
} }
}) })
t.Run("test down arrow moves cursor down", func(t *testing.T) { t.Run("test down arrow moves cursor down", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "down", "X", "esc") sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
if m.ActiveBuffer().Lines[1] != "woXrld" { if m.lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
} }
}) })
@ -514,63 +514,63 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "left", "X", "esc") sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" { if m.lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
} }
}) })
t.Run("test right arrow at end of line does nothing", func(t *testing.T) { t.Run("test right arrow at end of line does nothing", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "a", "right", "X", "esc") sendKeys(tm, "a", "right", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" { if m.lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
} }
}) })
t.Run("test up arrow at first line does nothing", func(t *testing.T) { t.Run("test up arrow at first line does nothing", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "up", "X", "esc") sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
}) })
t.Run("test down arrow at last line does nothing", func(t *testing.T) { t.Run("test down arrow at last line does nothing", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "i", "down", "X", "esc") sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" { if m.lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
} }
}) })
t.Run("test up arrow clamps cursor to shorter line", func(t *testing.T) { t.Run("test up arrow clamps cursor to shorter line", func(t *testing.T) {
lines := []string{"hi", "hello"} lines := []string{"hi", "hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 1})
sendKeys(tm, "i", "up", "X", "esc") sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hiX" { if m.lines[0] != "hiX" {
t.Errorf("lines[0] = %q, want 'hiX'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hiX'", m.lines[0])
} }
}) })
t.Run("test down arrow clamps cursor to shorter line", func(t *testing.T) { t.Run("test down arrow clamps cursor to shorter line", func(t *testing.T) {
lines := []string{"hello", "hi"} lines := []string{"hello", "hi"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "i", "down", "X", "esc") sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "hiX" { if m.lines[1] != "hiX" {
t.Errorf("lines[1] = %q, want 'hiX'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'hiX'", m.lines[1])
} }
}) })
@ -580,8 +580,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "right", "right", "down", "X", "esc") sendKeys(tm, "i", "right", "right", "down", "X", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "woXrld" { if m.lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1]) t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
} }
}) })
} }
@ -589,80 +589,80 @@ func TestInsertModeArrowKeys(t *testing.T) {
func TestInsertModeDeletePreviousWord(t *testing.T) { func TestInsertModeDeletePreviousWord(t *testing.T) {
t.Run("test 'ctrl+w' deletes word", func(t *testing.T) { t.Run("test 'ctrl+w' deletes word", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " { if m.lines[0] != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello '", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 5 { if m.CursorX() != 5 {
t.Errorf("CursorX() = %d, want '5'", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want '5'", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' deletes word with whitespace", func(t *testing.T) { t.Run("test 'ctrl+w' deletes word with whitespace", func(t *testing.T) {
lines := []string{"hello "} lines := []string{"hello "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want ''", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want '0'", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' deletes word until period", func(t *testing.T) { t.Run("test 'ctrl+w' deletes word until period", func(t *testing.T) {
lines := []string{"hello wo..."} lines := []string{"hello wo..."}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello wo" { if m.lines[0] != "hello wo" {
t.Errorf("lines[0] = %q, want 'hello wo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello wo'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 7 { if m.CursorX() != 7 {
t.Errorf("CursorX() = %d, want '7'", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want '7'", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' deletes line when blank", func(t *testing.T) { t.Run("test 'ctrl+w' deletes line when blank", func(t *testing.T) {
lines := []string{"", ""} lines := []string{"", ""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want '1'", m.LineCount())
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want '0'", m.CursorX())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want '0'", m.CursorY())
} }
}) })
t.Run("test 'ctrl+w' deletes all whitespace when line is only whitespace", func(t *testing.T) { t.Run("test 'ctrl+w' deletes all whitespace when line is only whitespace", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want '1'", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %s, want ''", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want '0'", m.CursorX())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want '0'", m.CursorY())
} }
}) })
@ -672,87 +672,87 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "i", "ctrl+w", "esc") sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" { if m.lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' from middle of word", func(t *testing.T) { t.Run("test 'ctrl+w' from middle of word", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "i", "ctrl+w", "esc") sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" { if m.lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' deletes word after punctuation", func(t *testing.T) { t.Run("test 'ctrl+w' deletes word after punctuation", func(t *testing.T) {
lines := []string{"...hello"} lines := []string{"...hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 7, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "..." { if m.lines[0] != "..." {
t.Errorf("lines[0] = %q, want '...'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want '...'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 2 { if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 2", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' with tabs as whitespace", func(t *testing.T) { t.Run("test 'ctrl+w' with tabs as whitespace", func(t *testing.T) {
lines := []string{"hello\tworld"} lines := []string{"hello\tworld"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello\t" { if m.lines[0] != "hello\t" {
t.Errorf("lines[0] = %q, want 'hello\\t'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'hello\\t'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 5 { if m.CursorX() != 5 {
t.Errorf("CursorX() = %d, want 5", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 5", m.CursorX())
} }
}) })
t.Run("test 'ctrl+w' at start of line merges with previous line content", func(t *testing.T) { t.Run("test 'ctrl+w' at start of line merges with previous line content", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "ctrl+w", "esc") sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "helloworld" { if m.lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'ctrl+w' with underscore in word", func(t *testing.T) { t.Run("test 'ctrl+w' with underscore in word", func(t *testing.T) {
lines := []string{"hello_world"} lines := []string{"hello_world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "a", "ctrl+w", "esc") sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" { if m.lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("lines[0] = %q, want ''", m.lines[0])
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
func TestMoveDown(t *testing.T) { func TestMoveDown(t *testing.T) {
@ -12,8 +12,8 @@ func TestMoveDown(t *testing.T) {
sendKeys(tm, "j") sendKeys(tm, "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 1", m.cursor.y)
} }
}) })
@ -22,8 +22,8 @@ func TestMoveDown(t *testing.T) {
sendKeys(tm, "j", "j", "j", "j") sendKeys(tm, "j", "j", "j", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 4 { if m.cursor.y != 4 {
t.Errorf("cursor.y = %d, want 4", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 4", m.cursor.y)
} }
}) })
@ -32,8 +32,8 @@ func TestMoveDown(t *testing.T) {
sendKeys(tm, "j", "j", "j", "j", "j", "j", "j", "j", "j") sendKeys(tm, "j", "j", "j", "j", "j", "j", "j", "j", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 5", m.cursor.y)
} }
}) })
} }
@ -44,8 +44,8 @@ func TestMoveDownWithCount(t *testing.T) {
sendKeys(tm, "3", "j") sendKeys(tm, "3", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 3 { if m.cursor.y != 3 {
t.Errorf("cursor.y = %d, want 3", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 3", m.cursor.y)
} }
}) })
@ -54,8 +54,8 @@ func TestMoveDownWithCount(t *testing.T) {
sendKeys(tm, "1", "0", "j") sendKeys(tm, "1", "0", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.cursor.y != 5 {
t.Errorf("cursor.y = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 5", m.cursor.y)
} }
}) })
} }
@ -64,45 +64,45 @@ func TestMoveDownWithOverflow(t *testing.T) {
lines := []string{"long line", "small"} lines := []string{"long line", "small"}
t.Run("test 'j' with overflow", func(t *testing.T) { t.Run("test 'j' with overflow", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 8, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 8, Line: 0})
sendKeys(tm, "j") sendKeys(tm, "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[1]) want := len(lines[1])
if m.ActiveWindow().Cursor.Col != want { if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
} }
}) })
t.Run("test 'j' without overflow", func(t *testing.T) { t.Run("test 'j' without overflow", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "j") sendKeys(tm, "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 3 { if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 3", m.cursor.x)
} }
}) })
} }
func TestMoveUp(t *testing.T) { func TestMoveUp(t *testing.T) {
t.Run("test 'k'", func(t *testing.T) { t.Run("test 'k'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "k") sendKeys(tm, "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.cursor.y != 1 {
t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 1", m.cursor.y)
} }
}) })
t.Run("test 'kkkk'", func(t *testing.T) { t.Run("test 'kkkk'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 4}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 4})
sendKeys(tm, "k", "k", "k", "k") sendKeys(tm, "k", "k", "k", "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 0", m.cursor.y)
} }
}) })
@ -111,30 +111,30 @@ func TestMoveUp(t *testing.T) {
sendKeys(tm, "k", "k", "k") sendKeys(tm, "k", "k", "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 0", m.cursor.y)
} }
}) })
} }
func TestMoveUpWithCount(t *testing.T) { func TestMoveUpWithCount(t *testing.T) {
t.Run("test '3k'", func(t *testing.T) { t.Run("test '3k'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "3", "k") sendKeys(tm, "3", "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 2 { if m.cursor.y != 2 {
t.Errorf("cursor.y = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 2", m.cursor.y)
} }
}) })
t.Run("test '10k' with overflow", func(t *testing.T) { t.Run("test '10k' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 3}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3})
sendKeys(tm, "1", "0", "k") sendKeys(tm, "1", "0", "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.cursor.y != 0 {
t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("cursor.y = %d, want 0", m.cursor.y)
} }
}) })
} }
@ -143,23 +143,23 @@ func TestMoveUpWithOverflow(t *testing.T) {
lines := []string{"small", "long line"} lines := []string{"small", "long line"}
t.Run("test 'k' with overflow", func(t *testing.T) { t.Run("test 'k' with overflow", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "k") sendKeys(tm, "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
} }
}) })
t.Run("test 'k' without overflow", func(t *testing.T) { t.Run("test 'k' without overflow", func(t *testing.T) {
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 1})
sendKeys(tm, "k") sendKeys(tm, "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 3 { if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 3", m.cursor.x)
} }
}) })
} }
@ -170,8 +170,8 @@ func TestMoveRight(t *testing.T) {
sendKeys(tm, "l") sendKeys(tm, "l")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 1 { if m.cursor.x != 1 {
t.Errorf("cursor.x = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 1", m.cursor.x)
} }
}) })
@ -180,8 +180,8 @@ func TestMoveRight(t *testing.T) {
sendKeys(tm, "l", "l", "l", "l") sendKeys(tm, "l", "l", "l", "l")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.cursor.x != 4 {
t.Errorf("cursor.x = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 4", m.cursor.x)
} }
}) })
@ -192,8 +192,8 @@ func TestMoveRight(t *testing.T) {
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
} }
}) })
} }
@ -204,8 +204,8 @@ func TestMoveRightWithCount(t *testing.T) {
sendKeys(tm, "3", "l") sendKeys(tm, "3", "l")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 3 { if m.cursor.x != 3 {
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 3", m.cursor.x)
} }
}) })
@ -216,30 +216,30 @@ func TestMoveRightWithCount(t *testing.T) {
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.cursor.x != want {
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
} }
}) })
} }
func TestMoveLeft(t *testing.T) { func TestMoveLeft(t *testing.T) {
t.Run("test 'h'", func(t *testing.T) { t.Run("test 'h'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "h") sendKeys(tm, "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 2 { if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 2", m.cursor.x)
} }
}) })
t.Run("test 'hhhh'", func(t *testing.T) { t.Run("test 'hhhh'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 4, Line: 0}) tm := newTestModelWithCursorPos(t, action.Position{Col: 4, Line: 0})
sendKeys(tm, "h", "h", "h", "h") sendKeys(tm, "h", "h", "h", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 0", m.cursor.x)
} }
}) })
@ -248,30 +248,30 @@ func TestMoveLeft(t *testing.T) {
sendKeys(tm, "h", "h", "h") sendKeys(tm, "h", "h", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 0", m.cursor.x)
} }
}) })
} }
func TestMoveLeftWithCount(t *testing.T) { func TestMoveLeftWithCount(t *testing.T) {
t.Run("test '3h'", func(t *testing.T) { t.Run("test '3h'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 5, Line: 0}) tm := newTestModelWithCursorPos(t, action.Position{Col: 5, Line: 0})
sendKeys(tm, "3", "h") sendKeys(tm, "3", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 2 { if m.cursor.x != 2 {
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 2", m.cursor.x)
} }
}) })
t.Run("test '10h' with overflow", func(t *testing.T) { t.Run("test '10h' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "1", "0", "h") sendKeys(tm, "1", "0", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.cursor.x != 0 {
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("cursor.x = %d, want 0", m.cursor.x)
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// --- G and gg Tests --- // --- G and gg Tests ---
@ -14,43 +14,43 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 5", m.CursorY())
} }
}) })
t.Run("test 'G' from middle", func(t *testing.T) { t.Run("test 'G' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 5", m.CursorY())
} }
}) })
t.Run("test 'G' already at bottom", func(t *testing.T) { t.Run("test 'G' already at bottom", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 5", m.CursorY())
} }
}) })
t.Run("test 'G' clamps CursorX()", func(t *testing.T) { t.Run("test 'G' clamps CursorX()", func(t *testing.T) {
lines := []string{"long line here", "short"} lines := []string{"long line here", "short"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
want := len(lines[1]) want := len(lines[1])
if m.ActiveWindow().Cursor.Col != want { if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
} }
}) })
@ -60,30 +60,30 @@ func TestMoveToBottom(t *testing.T) {
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
} }
func TestMoveToTop(t *testing.T) { func TestMoveToTop(t *testing.T) {
t.Run("test 'gg' from bottom", func(t *testing.T) { t.Run("test 'gg' from bottom", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 5})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'gg' from middle", func(t *testing.T) { t.Run("test 'gg' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 3}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
@ -92,23 +92,23 @@ func TestMoveToTop(t *testing.T) {
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'gg' clamps CursorX()", func(t *testing.T) { t.Run("test 'gg' clamps CursorX()", func(t *testing.T) {
lines := []string{"short", "long line here"} lines := []string{"short", "long line here"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
} }
}) })
} }
@ -117,23 +117,23 @@ func TestMoveToTop(t *testing.T) {
func TestMoveToLineStart(t *testing.T) { func TestMoveToLineStart(t *testing.T) {
t.Run("test '0' from middle of line", func(t *testing.T) { t.Run("test '0' from middle of line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '0' from end of line", func(t *testing.T) { t.Run("test '0' from end of line", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: len(lines[0]), Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
@ -142,8 +142,8 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
@ -153,18 +153,18 @@ func TestMoveToLineStart(t *testing.T) {
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '0' preserves line", func(t *testing.T) { t.Run("test '0' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 2}) tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 2})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 2 { if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 2", m.CursorY())
} }
}) })
} }
@ -177,32 +177,32 @@ func TestMoveToLineEnd(t *testing.T) {
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
} }
}) })
t.Run("test '$' from middle of line", func(t *testing.T) { t.Run("test '$' from middle of line", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
} }
}) })
t.Run("test '$' already at end", func(t *testing.T) { t.Run("test '$' already at end", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: len(lines[0]), Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
want := len(lines[0]) want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want { if m.CursorX() != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want) t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
} }
}) })
@ -212,18 +212,18 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '$' preserves line", func(t *testing.T) { t.Run("test '$' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2})
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 2 { if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 2", m.CursorY())
} }
}) })
} }
@ -231,78 +231,78 @@ func TestMoveToLineEnd(t *testing.T) {
func TestMoveToLineContentStart(t *testing.T) { func TestMoveToLineContentStart(t *testing.T) {
t.Run("test '_' from middle of line with no leading whitespace", func(t *testing.T) { t.Run("test '_' from middle of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '_' from middle of line with leading whitespace", func(t *testing.T) { t.Run("test '_' from middle of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"} lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '_' from start of line with leading whitespace", func(t *testing.T) { t.Run("test '_' from start of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"} lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '_' from start of line with no leading whitespace", func(t *testing.T) { t.Run("test '_' from start of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '_' from middle of line with only whitespace", func(t *testing.T) { t.Run("test '_' from middle of line with only whitespace", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '_' from end of line with only whitespace", func(t *testing.T) { t.Run("test '_' from end of line with only whitespace", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '_' on empty line", func(t *testing.T) { t.Run("test '_' on empty line", func(t *testing.T) {
lines := []string{""} lines := []string{""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
} }
@ -310,78 +310,78 @@ func TestMoveToLineContentStart(t *testing.T) {
func TestMoveToLineContentStartAlias(t *testing.T) { func TestMoveToLineContentStartAlias(t *testing.T) {
t.Run("test '^' from middle of line with no leading whitespace", func(t *testing.T) { t.Run("test '^' from middle of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 6, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '^' from middle of line with leading whitespace", func(t *testing.T) { t.Run("test '^' from middle of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"} lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '^' from start of line with leading whitespace", func(t *testing.T) { t.Run("test '^' from start of line with leading whitespace", func(t *testing.T) {
lines := []string{" hello world"} lines := []string{" hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '^' from start of line with no leading whitespace", func(t *testing.T) { t.Run("test '^' from start of line with no leading whitespace", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '^' from middle of line with only whitespace", func(t *testing.T) { t.Run("test '^' from middle of line with only whitespace", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '^' from end of line with only whitespace", func(t *testing.T) { t.Run("test '^' from end of line with only whitespace", func(t *testing.T) {
lines := []string{" "} lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '^' on empty line", func(t *testing.T) { t.Run("test '^' on empty line", func(t *testing.T) {
lines := []string{""} lines := []string{""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
} }
@ -395,70 +395,70 @@ func TestMoveToLineContentStartAlias(t *testing.T) {
func TestMoveToColumn(t *testing.T) { func TestMoveToColumn(t *testing.T) {
t.Run("test '|' alone goes to column 1 (index 0)", func(t *testing.T) { t.Run("test '|' alone goes to column 1 (index 0)", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// | with no count = 1| = column 1 = index 0 // | with no count = 1| = column 1 = index 0
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '1|' goes to column 1 (index 0)", func(t *testing.T) { t.Run("test '1|' goes to column 1 (index 0)", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "1", "|") sendKeys(tm, "1", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '5|' goes to column 5 (index 4)", func(t *testing.T) { t.Run("test '5|' goes to column 5 (index 4)", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 5 = index 4 (the 'o' in hello) // Column 5 = index 4 (the 'o' in hello)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '10|' goes to column 10 (index 9)", func(t *testing.T) { t.Run("test '10|' goes to column 10 (index 9)", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "1", "0", "|") sendKeys(tm, "1", "0", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 10 = index 9 (the 'l' in world) // Column 10 = index 9 (the 'l' in world)
if m.ActiveWindow().Cursor.Col != 9 { if m.CursorX() != 9 {
t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 9", m.CursorX())
} }
}) })
t.Run("test '|' already at column 1", func(t *testing.T) { t.Run("test '|' already at column 1", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '5|' already at column 5", func(t *testing.T) { t.Run("test '5|' already at column 5", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
} }
@ -466,71 +466,71 @@ func TestMoveToColumn(t *testing.T) {
func TestMoveToColumnClamp(t *testing.T) { func TestMoveToColumnClamp(t *testing.T) {
t.Run("test '20|' clamps to end of short line", func(t *testing.T) { t.Run("test '20|' clamps to end of short line", func(t *testing.T) {
lines := []string{"hello"} // 5 chars, max index 4 lines := []string{"hello"} // 5 chars, max index 4
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "2", "0", "|") sendKeys(tm, "2", "0", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 20 exceeds line length, should clamp to last char (index 4) // Column 20 exceeds line length, should clamp to last char (index 4)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '100|' clamps to end of line", func(t *testing.T) { t.Run("test '100|' clamps to end of line", func(t *testing.T) {
lines := []string{"hello world"} // 11 chars, max index 10 lines := []string{"hello world"} // 11 chars, max index 10
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "1", "0", "0", "|") sendKeys(tm, "1", "0", "0", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Should clamp to last char (index 10) // Should clamp to last char (index 10)
if m.ActiveWindow().Cursor.Col != 10 { if m.CursorX() != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 10", m.CursorX())
} }
}) })
t.Run("test '6|' clamps on 5-char line", func(t *testing.T) { t.Run("test '6|' clamps on 5-char line", func(t *testing.T) {
lines := []string{"hello"} // 5 chars lines := []string{"hello"} // 5 chars
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "6", "|") sendKeys(tm, "6", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 6 = index 5, but line only has 5 chars (max index 4) // Column 6 = index 5, but line only has 5 chars (max index 4)
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '|' on empty line stays at 0", func(t *testing.T) { t.Run("test '|' on empty line stays at 0", func(t *testing.T) {
lines := []string{""} lines := []string{""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '5|' on empty line stays at 0", func(t *testing.T) { t.Run("test '5|' on empty line stays at 0", func(t *testing.T) {
lines := []string{""} lines := []string{""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '3|' on 2-char line clamps", func(t *testing.T) { t.Run("test '3|' on 2-char line clamps", func(t *testing.T) {
lines := []string{"ab"} lines := []string{"ab"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "3", "|") sendKeys(tm, "3", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 3 = index 2, but line only has 2 chars (max index 1) // Column 3 = index 2, but line only has 2 chars (max index 1)
if m.ActiveWindow().Cursor.Col != 1 { if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 1", m.CursorX())
} }
}) })
} }
@ -538,37 +538,37 @@ func TestMoveToColumnClamp(t *testing.T) {
func TestMoveToColumnPreservesLine(t *testing.T) { func TestMoveToColumnPreservesLine(t *testing.T) {
t.Run("test '|' preserves Y position", func(t *testing.T) { t.Run("test '|' preserves Y position", func(t *testing.T) {
lines := []string{"line one", "line two", "line three"} lines := []string{"line one", "line two", "line three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 1})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
}) })
t.Run("test '5|' preserves Y position", func(t *testing.T) { t.Run("test '5|' preserves Y position", func(t *testing.T) {
lines := []string{"line one", "line two", "line three"} lines := []string{"line one", "line two", "line three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 2 { if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 2", m.CursorY())
} }
}) })
t.Run("test '|' on different lines", func(t *testing.T) { t.Run("test '|' on different lines", func(t *testing.T) {
lines := []string{"short", "longer line here"} lines := []string{"short", "longer line here"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 10, Line: 1})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
} }
@ -576,49 +576,49 @@ func TestMoveToColumnPreservesLine(t *testing.T) {
func TestMoveToColumnWithWhitespace(t *testing.T) { func TestMoveToColumnWithWhitespace(t *testing.T) {
t.Run("test '5|' with leading whitespace", func(t *testing.T) { t.Run("test '5|' with leading whitespace", func(t *testing.T) {
lines := []string{" hello"} lines := []string{" hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 5 = index 4 = 'h' in " hello" // Column 5 = index 4 = 'h' in " hello"
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test '3|' lands on whitespace", func(t *testing.T) { t.Run("test '3|' lands on whitespace", func(t *testing.T) {
lines := []string{" hello"} lines := []string{" hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "3", "|") sendKeys(tm, "3", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 3 = index 2 = third space // Column 3 = index 2 = third space
if m.ActiveWindow().Cursor.Col != 2 { if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 2", m.CursorX())
} }
}) })
t.Run("test '|' with tabs", func(t *testing.T) { t.Run("test '|' with tabs", func(t *testing.T) {
lines := []string{"\thello"} lines := []string{"\thello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// | goes to column 1 = index 0 = the tab // | goes to column 1 = index 0 = the tab
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test '2|' with tabs", func(t *testing.T) { t.Run("test '2|' with tabs", func(t *testing.T) {
lines := []string{"\thello"} lines := []string{"\thello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "2", "|") sendKeys(tm, "2", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Column 2 = index 1 = 'h' in "\thello" // Column 2 = index 1 = 'h' in "\thello"
if m.ActiveWindow().Cursor.Col != 1 { if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 1", m.CursorX())
} }
}) })
} }
@ -626,34 +626,34 @@ func TestMoveToColumnWithWhitespace(t *testing.T) {
func TestMoveToColumnWithOperator(t *testing.T) { func TestMoveToColumnWithOperator(t *testing.T) {
t.Run("test 'd|' deletes to column 1", func(t *testing.T) { t.Run("test 'd|' deletes to column 1", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "d", "|") sendKeys(tm, "d", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes from column 1 to current position (exclusive), so "hello" deleted // Deletes from column 1 to current position (exclusive), so "hello" deleted
// Result depends on inclusive/exclusive behavior // Result depends on inclusive/exclusive behavior
// In Vim: d| from col 5 deletes chars 0-4, leaving " world" // In Vim: d| from col 5 deletes chars 0-4, leaving " world"
if m.ActiveBuffer().Lines[0] != " world" { if m.Line(0) != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
} }
}) })
t.Run("test 'd5|' deletes to column 5", func(t *testing.T) { t.Run("test 'd5|' deletes to column 5", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "5", "|") sendKeys(tm, "d", "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes from cursor (0) to column 5 (index 4), so "hell" deleted // Deletes from cursor (0) to column 5 (index 4), so "hell" deleted
// Result: "o world" // Result: "o world"
if m.ActiveBuffer().Lines[0] != "o world" { if m.Line(0) != "o world" {
t.Errorf("Line(0) = %q, want 'o world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'o world'", m.Line(0))
} }
}) })
t.Run("test 'y5|' yanks to column 5", func(t *testing.T) { t.Run("test 'y5|' yanks to column 5", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "y", "5", "|") sendKeys(tm, "y", "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
@ -669,7 +669,7 @@ func TestMoveToColumnWithOperator(t *testing.T) {
t.Run("test 'y|' yanks to column 1 (nothing if at start)", func(t *testing.T) { t.Run("test 'y|' yanks to column 1 (nothing if at start)", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "y", "|") sendKeys(tm, "y", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
@ -687,41 +687,41 @@ func TestMoveToColumnWithOperator(t *testing.T) {
func TestMoveToColumnInVisualMode(t *testing.T) { func TestMoveToColumnInVisualMode(t *testing.T) {
t.Run("test 'v5|' selects to column 5", func(t *testing.T) { t.Run("test 'v5|' selects to column 5", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "v", "5", "|") sendKeys(tm, "v", "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 { if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
} }
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test 'v|' selects backward to column 1", func(t *testing.T) { t.Run("test 'v|' selects backward to column 1", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 5, Line: 0})
sendKeys(tm, "v", "|") sendKeys(tm, "v", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 5 { if m.AnchorX() != 5 {
t.Errorf("AnchorX() = %d, want 5", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 5", m.AnchorX())
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'v5|d' deletes selection to column 5", func(t *testing.T) { t.Run("test 'v5|d' deletes selection to column 5", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
sendKeys(tm, "v", "5", "|", "d") sendKeys(tm, "v", "5", "|", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Visual selection from 0 to 4 inclusive, delete "hello" // Visual selection from 0 to 4 inclusive, delete "hello"
if m.ActiveBuffer().Lines[0] != " world" { if m.Line(0) != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
} }
}) })
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// NOTE: AI Generated tests // NOTE: AI Generated tests
@ -22,12 +22,12 @@ func TestScrollBasic(t *testing.T) {
t.Run("small file does not scroll", func(t *testing.T) { t.Run("small file does not scroll", func(t *testing.T) {
// 10 lines, viewport 24 -> no scrolling needed // 10 lines, viewport 24 -> no scrolling needed
lines := generateLines(10) lines := generateLines(10)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 24) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 24)
sendKeys(tm, "G") // go to bottom sendKeys(tm, "G") // go to bottom
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
}) })
@ -37,7 +37,7 @@ func TestScrollBasic(t *testing.T) {
// Safe zone: lines 8 to 10 (19-1-8=10) // Safe zone: lines 8 to 10 (19-1-8=10)
// Moving to line 11+ should trigger scroll // Moving to line 11+ should trigger scroll
lines := generateLines(50) lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
// Move down 15 times to get to line 15 // Move down 15 times to get to line 15
for range 15 { for range 15 {
@ -45,21 +45,21 @@ func TestScrollBasic(t *testing.T) {
} }
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 15 { if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 15", m.CursorY())
} }
// With scrollOff=8, viewport=19, cursor at 15 means: // With scrollOff=8, viewport=19, cursor at 15 means:
// cursor should be at position 10 from top (19-1-8=10) // cursor should be at position 10 from top (19-1-8=10)
// so scrollY = 15 - 10 = 5 // so scrollY = 15 - 10 = 5
if m.ActiveWindow().ScrollY < 1 { if m.ScrollY() < 1 {
t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ScrollY())
} }
}) })
t.Run("scrolls up when cursor moves past top margin", func(t *testing.T) { t.Run("scrolls up when cursor moves past top margin", func(t *testing.T) {
// Start at line 20, move up // Start at line 20, move up
lines := generateLines(50) lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 20}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 20}, 80, 20)
// First, let the model adjust (it will scroll to show cursor) // First, let the model adjust (it will scroll to show cursor)
// Then move up 15 times // Then move up 15 times
@ -68,43 +68,43 @@ func TestScrollBasic(t *testing.T) {
} }
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 5 { if m.CursorY() != 5 {
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 5", m.CursorY())
} }
// Cursor at line 5 with scrollOff=8 means scrollY should be 0 // Cursor at line 5 with scrollOff=8 means scrollY should be 0
// (can't scroll negative, and cursor is within safe zone from top) // (can't scroll negative, and cursor is within safe zone from top)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
}) })
t.Run("G jumps to bottom and scrolls", func(t *testing.T) { t.Run("G jumps to bottom and scrolls", func(t *testing.T) {
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 99 { if m.CursorY() != 99 {
t.Errorf("CursorY() = %d, want 99", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 99", m.CursorY())
} }
// With 100 lines and viewport 18 (height - 2 for status + command bar), // With 100 lines and viewport 18 (height - 2 for status + command bar),
// max scrollY = 100 - 18 = 82 // max scrollY = 100 - 18 = 82
if m.ActiveWindow().ScrollY != 82 { if m.ScrollY() != 82 {
t.Errorf("ScrollY() = %d, want 82", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 82", m.ScrollY())
} }
}) })
t.Run("gg jumps to top and scrolls", func(t *testing.T) { t.Run("gg jumps to top and scrolls", func(t *testing.T) {
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 20)
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
}) })
} }
@ -112,7 +112,7 @@ func TestScrollBasic(t *testing.T) {
func TestScrollEdgeCases(t *testing.T) { func TestScrollEdgeCases(t *testing.T) {
t.Run("scrollY never goes negative", func(t *testing.T) { t.Run("scrollY never goes negative", func(t *testing.T) {
lines := generateLines(50) lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
// Try to move up from top // Try to move up from top
for range 5 { for range 5 {
@ -120,27 +120,27 @@ func TestScrollEdgeCases(t *testing.T) {
} }
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY < 0 { if m.ScrollY() < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
} }
}) })
t.Run("scrollY clamped to max scroll", func(t *testing.T) { t.Run("scrollY clamped to max scroll", func(t *testing.T) {
lines := generateLines(30) lines := generateLines(30)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12 // 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12
maxScroll := 30 - 18 maxScroll := 30 - 18
if m.ActiveWindow().ScrollY > maxScroll { if m.ScrollY() > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll) t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
} }
}) })
t.Run("cursor stays visible after delete at bottom", func(t *testing.T) { t.Run("cursor stays visible after delete at bottom", func(t *testing.T) {
lines := generateLines(30) lines := generateLines(30)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 29}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 29}, 80, 20)
// Delete some lines at bottom // Delete some lines at bottom
sendKeys(tm, "d", "d", "d", "d") sendKeys(tm, "d", "d", "d", "d")
@ -148,9 +148,9 @@ func TestScrollEdgeCases(t *testing.T) {
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Cursor should still be visible // Cursor should still be visible
viewportHeight := 19 viewportHeight := 19
if m.ActiveWindow().Cursor.Line < m.ActiveWindow().ScrollY || m.ActiveWindow().Cursor.Line >= m.ActiveWindow().ScrollY+viewportHeight { if m.CursorY() < m.ScrollY() || m.CursorY() >= m.ScrollY()+viewportHeight {
t.Errorf("Cursor at %d not visible in viewport [%d, %d)", t.Errorf("Cursor at %d not visible in viewport [%d, %d)",
m.ActiveWindow().Cursor.Line, m.ActiveWindow().ScrollY, m.ActiveWindow().ScrollY+viewportHeight) m.CursorY(), m.ScrollY(), m.ScrollY()+viewportHeight)
} }
}) })
} }
@ -162,26 +162,26 @@ func TestHalfPageScrollDown(t *testing.T) {
// cursor at line 15 (relY=15, in safe zone), scrollY starts at 0 // cursor at line 15 (relY=15, in safe zone), scrollY starts at 0
// After ctrl+d: newScrollY=14, newCursorY=14+15=29 // After ctrl+d: newScrollY=14, newCursorY=14+15=29
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 14 { if m.ScrollY() != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 29 { if m.CursorY() != 29 {
t.Errorf("CursorY() = %d, want 29", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 29", m.CursorY())
} }
}) })
t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) { t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) {
// relY=15 before and after // relY=15 before and after
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY relY := m.CursorY() - m.ScrollY()
if relY != 15 { if relY != 15 {
t.Errorf("relative position = %d, want 15", relY) t.Errorf("relative position = %d, want 15", relY)
} }
@ -191,15 +191,15 @@ func TestHalfPageScrollDown(t *testing.T) {
// cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff // cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff
// After ctrl+d: newScrollY=14, newCursorY=14+8=22 // After ctrl+d: newScrollY=14, newCursorY=14+8=22
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 14 { if m.ScrollY() != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 22 { if m.CursorY() != 22 {
t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 22", m.CursorY())
} }
}) })
@ -208,16 +208,16 @@ func TestHalfPageScrollDown(t *testing.T) {
// AdjustScroll puts cursor 35 at scrollY=12 (clamped), relY=23 // AdjustScroll puts cursor 35 at scrollY=12 (clamped), relY=23
// After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31 // After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31
lines := generateLines(40) lines := generateLines(40)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 35}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 35}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
maxScroll := 40 - 28 maxScroll := 40 - 28
if m.ActiveWindow().ScrollY > maxScroll { if m.ScrollY() > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll) t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
} }
if m.ActiveWindow().Cursor.Line != 31 { if m.CursorY() != 31 {
t.Errorf("CursorY() = %d, want 31", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 31", m.CursorY())
} }
}) })
@ -225,15 +225,15 @@ func TestHalfPageScrollDown(t *testing.T) {
// 20 lines < viewport 28: maxScroll=0, scrollY stays 0 // 20 lines < viewport 28: maxScroll=0, scrollY stays 0
// relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8 // relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8
lines := generateLines(20) lines := generateLines(20)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 8 { if m.CursorY() != 8 {
t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 8", m.CursorY())
} }
}) })
@ -249,15 +249,15 @@ func TestHalfPageScrollDown(t *testing.T) {
for i := 15; i < 100; i++ { for i := 15; i < 100; i++ {
lines[i] = "hi" lines[i] = "hi"
} }
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 10, Line: 5}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 10, Line: 5}, 80, 30)
sendKeys(tm, "ctrl+d") sendKeys(tm, "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 22 { if m.CursorY() != 22 {
t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 22", m.CursorY())
} }
if m.ActiveWindow().Cursor.Col > len(m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line]) { if m.CursorX() > len(m.Line(m.CursorY())) {
t.Errorf("CursorX() = %d exceeds line length %d", m.ActiveWindow().Cursor.Col, len(m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line])) t.Errorf("CursorX() = %d exceeds line length %d", m.CursorX(), len(m.Line(m.CursorY())))
} }
}) })
@ -266,15 +266,15 @@ func TestHalfPageScrollDown(t *testing.T) {
// ctrl+d #1: scrollY=14, cursorY=29, relY=15 // ctrl+d #1: scrollY=14, cursorY=29, relY=15
// ctrl+d #2: scrollY=28, cursorY=43, relY=15 // ctrl+d #2: scrollY=28, cursorY=43, relY=15
lines := generateLines(200) lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+d") sendKeys(tm, "ctrl+d", "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 28 { if m.ScrollY() != 28 {
t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 28", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 43 { if m.CursorY() != 43 {
t.Errorf("CursorY() = %d, want 43", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 43", m.CursorY())
} }
}) })
} }
@ -284,26 +284,26 @@ func TestHalfPageScrollUp(t *testing.T) {
// cursor at line 50: AdjustScroll -> scrollY=31, relY=19 // cursor at line 50: AdjustScroll -> scrollY=31, relY=19
// After ctrl+u: newScrollY=17, newCursorY=17+19=36 // After ctrl+u: newScrollY=17, newCursorY=17+19=36
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u") sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 17 { if m.ScrollY() != 17 {
t.Errorf("ScrollY() = %d, want 17", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 17", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 36 { if m.CursorY() != 36 {
t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 36", m.CursorY())
} }
}) })
t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) { t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) {
// relY=19 before and after // relY=19 before and after
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u") sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY relY := m.CursorY() - m.ScrollY()
if relY != 19 { if relY != 19 {
t.Errorf("relative position = %d, want 19", relY) t.Errorf("relative position = %d, want 19", relY)
} }
@ -313,18 +313,18 @@ func TestHalfPageScrollUp(t *testing.T) {
// cursor at line 10, scrollY=0, relY=10 (in safe zone) // cursor at line 10, scrollY=0, relY=10 (in safe zone)
// ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10 // ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 10}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 10}, 80, 30)
sendKeys(tm, "ctrl+u") sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY < 0 { if m.ScrollY() < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
} }
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 10 { if m.CursorY() != 10 {
t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 10", m.CursorY())
} }
}) })
@ -332,15 +332,15 @@ func TestHalfPageScrollUp(t *testing.T) {
// cursor at line 5, scrollY=0, relY=5 < scrollOff=8 // cursor at line 5, scrollY=0, relY=5 < scrollOff=8
// ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8 // ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8
lines := generateLines(100) lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 5}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 5}, 80, 30)
sendKeys(tm, "ctrl+u") sendKeys(tm, "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 8 { if m.CursorY() != 8 {
t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 8", m.CursorY())
} }
}) })
@ -349,15 +349,15 @@ func TestHalfPageScrollUp(t *testing.T) {
// ctrl+u #1: newScrollY=47, cursorY=66 // ctrl+u #1: newScrollY=47, cursorY=66
// ctrl+u #2: newScrollY=33, cursorY=52 // ctrl+u #2: newScrollY=33, cursorY=52
lines := generateLines(200) lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 80}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 80}, 80, 30)
sendKeys(tm, "ctrl+u", "ctrl+u") sendKeys(tm, "ctrl+u", "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 33 { if m.ScrollY() != 33 {
t.Errorf("ScrollY() = %d, want 33", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 33", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 52 { if m.CursorY() != 52 {
t.Errorf("CursorY() = %d, want 52", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 52", m.CursorY())
} }
}) })
} }
@ -368,15 +368,15 @@ func TestHalfPageScrollRoundTrip(t *testing.T) {
// ctrl+d: scrollY=14, cursorY=29, relY=15 // ctrl+d: scrollY=14, cursorY=29, relY=15
// ctrl+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15 // ctrl+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15
lines := generateLines(200) lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+u") sendKeys(tm, "ctrl+d", "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 15 { if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 15", m.CursorY())
} }
}) })
@ -385,29 +385,29 @@ func TestHalfPageScrollRoundTrip(t *testing.T) {
// ctrl+u: scrollY=17, cursorY=36, relY=19 // ctrl+u: scrollY=17, cursorY=36, relY=19
// ctrl+d: scrollY=31, cursorY=50, relY=19 // ctrl+d: scrollY=31, cursorY=50, relY=19
lines := generateLines(200) lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+u", "ctrl+d") sendKeys(tm, "ctrl+u", "ctrl+d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 31 { if m.ScrollY() != 31 {
t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 31", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 50 { if m.CursorY() != 50 {
t.Errorf("CursorY() = %d, want 50", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 50", m.CursorY())
} }
}) })
t.Run("alternating ctrl+d and ctrl+u maintains scroll stability", func(t *testing.T) { t.Run("alternating ctrl+d and ctrl+u maintains scroll stability", func(t *testing.T) {
lines := generateLines(200) lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+d", "ctrl+u", "ctrl+d", "ctrl+u") sendKeys(tm, "ctrl+d", "ctrl+u", "ctrl+d", "ctrl+u")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 { if m.ScrollY() != 0 {
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ScrollY())
} }
if m.ActiveWindow().Cursor.Line != 15 { if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.CursorY())
} }
}) })
} }
@ -415,29 +415,29 @@ func TestHalfPageScrollRoundTrip(t *testing.T) {
func TestScrollWithCount(t *testing.T) { func TestScrollWithCount(t *testing.T) {
t.Run("5j scrolls appropriately", func(t *testing.T) { t.Run("5j scrolls appropriately", func(t *testing.T) {
lines := generateLines(50) lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 5}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 5}, 80, 20)
sendKeys(tm, "1", "0", "j") // move down 10 lines sendKeys(tm, "1", "0", "j") // move down 10 lines
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 15 { if m.CursorY() != 15 {
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 15", m.CursorY())
} }
// Should have scrolled since we moved past the safe zone // Should have scrolled since we moved past the safe zone
if m.ActiveWindow().ScrollY == 0 { if m.ScrollY() == 0 {
t.Errorf("ScrollY() = %d, want > 0", m.ActiveWindow().ScrollY) t.Errorf("ScrollY() = %d, want > 0", m.ScrollY())
} }
}) })
t.Run("5k scrolls appropriately", func(t *testing.T) { t.Run("5k scrolls appropriately", func(t *testing.T) {
lines := generateLines(50) lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 25}, 80, 20) tm := newTestModelWithTermSize(t, lines, action.Position{Col: 0, Line: 25}, 80, 20)
sendKeys(tm, "1", "5", "k") // move up 15 lines sendKeys(tm, "1", "5", "k") // move up 15 lines
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 10 { if m.CursorY() != 10 {
t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 10", m.CursorY())
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// NOTE: Lots of AI tests here // NOTE: Lots of AI tests here
@ -13,18 +13,18 @@ import (
func TestVisualModeSelectionState(t *testing.T) { func TestVisualModeSelectionState(t *testing.T) {
t.Run("test 'v' enters visual mode and sets anchor at cursor", func(t *testing.T) { t.Run("test 'v' enters visual mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v") sendKeys(tm, "v")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode { if m.Mode() != action.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode()) t.Errorf("Mode() = %v, want VisualMode", m.Mode())
} }
if m.ActiveWindow().Anchor.Col != 3 { if m.AnchorX() != 3 {
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
} }
if m.ActiveWindow().Anchor.Line != 0 { if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
} }
}) })
@ -34,73 +34,73 @@ func TestVisualModeSelectionState(t *testing.T) {
sendKeys(tm, "v", "l") sendKeys(tm, "v", "l")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 { if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
} }
if m.ActiveWindow().Cursor.Col != 1 { if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 1", m.CursorX())
} }
}) })
t.Run("test 'vh' creates backward selection", func(t *testing.T) { t.Run("test 'vh' creates backward selection", func(t *testing.T) {
lines := []string{"hello world"} lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "h") sendKeys(tm, "v", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 3 { if m.AnchorX() != 3 {
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
} }
if m.ActiveWindow().Cursor.Col != 2 { if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 2", m.CursorX())
} }
}) })
t.Run("test 'vj' extends selection down", func(t *testing.T) { t.Run("test 'vj' extends selection down", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "j") sendKeys(tm, "v", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 2 { if m.AnchorX() != 2 {
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
} }
if m.ActiveWindow().Anchor.Line != 0 { if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
} }
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
}) })
t.Run("test 'V' enters visual line mode and sets anchor at cursor", func(t *testing.T) { t.Run("test 'V' enters visual line mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 1})
sendKeys(tm, "V") sendKeys(tm, "V")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode { if m.Mode() != action.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
} }
if m.ActiveWindow().Anchor.Line != 1 { if m.AnchorY() != 1 {
t.Errorf("AnchorY() = %d, want 1", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
} }
}) })
t.Run("test 'ctrl+v' enters visual block mode and sets anchor at cursor", func(t *testing.T) { t.Run("test 'ctrl+v' enters visual block mode and sets anchor at cursor", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 1})
sendKeys(tm, "ctrl+v") sendKeys(tm, "ctrl+v")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.VisualBlockMode { if m.Mode() != action.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
} }
if m.ActiveWindow().Anchor.Col != 2 { if m.AnchorX() != 2 {
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
} }
if m.ActiveWindow().Anchor.Line != 1 { if m.AnchorY() != 1 {
t.Errorf("AnchorY() = %d, want 1", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
} }
}) })
@ -110,7 +110,7 @@ func TestVisualModeSelectionState(t *testing.T) {
sendKeys(tm, "v", "l", "l", "esc") sendKeys(tm, "v", "l", "l", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -125,11 +125,11 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "v", "d") sendKeys(tm, "v", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ello" { if m.Line(0) != "ello" {
t.Errorf("Line(0) = %q, want \"ello\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"ello\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
@ -139,47 +139,47 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "v", "l", "l", "l", "d") sendKeys(tm, "v", "l", "l", "l", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "o world" { if m.Line(0) != "o world" {
t.Errorf("Line(0) = %q, want \"o world\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'v' backward selection 'hh d' deletes correct range", func(t *testing.T) { t.Run("test 'v' backward selection 'hh d' deletes correct range", func(t *testing.T) {
lines := []string{"hello"} lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "h", "h", "d") sendKeys(tm, "v", "h", "h", "d")
// anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho" // anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho"
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" { if m.Line(0) != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 1 { if m.CursorX() != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 1", m.CursorX())
} }
}) })
t.Run("test 'vj d' deletes char selection across two lines", func(t *testing.T) { t.Run("test 'vj d' deletes char selection across two lines", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "j", "d") sendKeys(tm, "v", "j", "d")
// start=(2,0), end=(2,1) → prefix="he", suffix="ld" → "held" // start=(2,0), end=(2,1) → prefix="he", suffix="ld" → "held"
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "held" { if m.Line(0) != "held" {
t.Errorf("Line(0) = %q, want \"held\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"held\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Col != 2 { if m.CursorX() != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 2", m.CursorX())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
@ -189,14 +189,14 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "V", "d") sendKeys(tm, "V", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "world" { if m.Line(0) != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"world\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
@ -206,29 +206,29 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "V", "j", "d") sendKeys(tm, "V", "j", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "testing" { if m.Line(0) != "testing" {
t.Errorf("Line(0) = %q, want \"testing\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"testing\"", m.Line(0))
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'Vkd' deletes two lines with backward selection", func(t *testing.T) { t.Run("test 'Vkd' deletes two lines with backward selection", func(t *testing.T) {
lines := []string{"hello", "world", "testing"} lines := []string{"hello", "world", "testing"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 2})
sendKeys(tm, "V", "k", "d") sendKeys(tm, "V", "k", "d")
// anchor=line2, cursor=line1 → normalized start=line1, end=line2 → delete both // anchor=line2, cursor=line1 → normalized start=line1, end=line2 → delete both
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "hello" { if m.Line(0) != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"hello\"", m.Line(0))
} }
}) })
@ -241,34 +241,34 @@ func TestVisualModeDelete(t *testing.T) {
// "hello"[:0]+"hello"[2:] = "llo" // "hello"[:0]+"hello"[2:] = "llo"
// "world"[:0]+"world"[2:] = "rld" // "world"[:0]+"world"[2:] = "rld"
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" { if m.Line(0) != "llo" {
t.Errorf("Line(0) = %q, want \"llo\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"llo\"", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "rld" { if m.Line(1) != "rld" {
t.Errorf("Line(1) = %q, want \"rld\"", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want \"rld\"", m.Line(1))
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'ctrl+v' backward col selection deletes correct block", func(t *testing.T) { t.Run("test 'ctrl+v' backward col selection deletes correct block", func(t *testing.T) {
lines := []string{"hello", "world"} lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 3, Line: 0})
sendKeys(tm, "ctrl+v", "h", "h", "j", "d") sendKeys(tm, "ctrl+v", "h", "h", "j", "d")
// anchor=(3,0), cursor=(1,1) → cols min(3,1)=1 to max(3,1)=3, lines 0-1 // anchor=(3,0), cursor=(1,1) → cols min(3,1)=1 to max(3,1)=3, lines 0-1
// "hello"[:1]+"hello"[4:] = "h"+"o" = "ho" // "hello"[:1]+"hello"[4:] = "h"+"o" = "ho"
// "world"[:1]+"world"[4:] = "w"+"d" = "wd" // "world"[:1]+"world"[4:] = "w"+"d" = "wd"
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" { if m.Line(0) != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "wd" { if m.Line(1) != "wd" {
t.Errorf("Line(1) = %q, want \"wd\"", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want \"wd\"", m.Line(1))
} }
}) })
} }
@ -279,107 +279,107 @@ func TestVisualModeWordMotions(t *testing.T) {
t.Run("test 'vw' selects to next word", func(t *testing.T) { t.Run("test 'vw' selects to next word", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "w") sendKeys(tm, "v", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 { if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
} }
// w moves to start of "world" at col 6 // w moves to start of "world" at col 6
if m.ActiveWindow().Cursor.Col != 6 { if m.CursorX() != 6 {
t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 6", m.CursorX())
} }
}) })
t.Run("test 'vwd' deletes word plus space", func(t *testing.T) { t.Run("test 'vwd' deletes word plus space", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "w", "d") sendKeys(tm, "v", "w", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes from 0 to 6 inclusive = "hello w", leaves "orld" // Deletes from 0 to 6 inclusive = "hello w", leaves "orld"
if m.ActiveBuffer().Lines[0] != "orld" { if m.Line(0) != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
} }
}) })
t.Run("test 've' selects to end of word", func(t *testing.T) { t.Run("test 've' selects to end of word", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "e") sendKeys(tm, "v", "e")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 { if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
} }
// e moves to end of "hello" at col 4 // e moves to end of "hello" at col 4
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test 'ved' deletes word", func(t *testing.T) { t.Run("test 'ved' deletes word", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "e", "d") sendKeys(tm, "v", "e", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes "hello" // Deletes "hello"
if m.ActiveBuffer().Lines[0] != " world" { if m.Line(0) != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
} }
}) })
t.Run("test 'vb' selects backward word", func(t *testing.T) { t.Run("test 'vb' selects backward word", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "v", "b") sendKeys(tm, "v", "b")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 6 { if m.AnchorX() != 6 {
t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 6", m.AnchorX())
} }
// b moves to start of "hello" at col 0 // b moves to start of "hello" at col 0
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'vbd' deletes backward to word start", func(t *testing.T) { t.Run("test 'vbd' deletes backward to word start", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "v", "b", "d") sendKeys(tm, "v", "b", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes from "h" (0) to "w" (6) inclusive // Deletes from "h" (0) to "w" (6) inclusive
if m.ActiveBuffer().Lines[0] != "orld" { if m.Line(0) != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
} }
}) })
t.Run("test 'v2w' selects two words", func(t *testing.T) { t.Run("test 'v2w' selects two words", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"one two three"}), WithLines([]string{"one two three"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "2", "w") sendKeys(tm, "v", "2", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// 2w moves past "one " and "two " to start of "three" at col 8 // 2w moves past "one " and "two " to start of "three" at col 8
if m.ActiveWindow().Cursor.Col != 8 { if m.CursorX() != 8 {
t.Errorf("CursorX() = %d, want 8", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 8", m.CursorX())
} }
}) })
} }
@ -390,145 +390,145 @@ func TestVisualModeJumpMotions(t *testing.T) {
t.Run("test 'v$' selects to end of line", func(t *testing.T) { t.Run("test 'v$' selects to end of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "$") sendKeys(tm, "v", "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 { if m.AnchorX() != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
} }
// $ moves past end of line // $ moves past end of line
if m.ActiveWindow().Cursor.Col != 11 { if m.CursorX() != 11 {
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 11", m.CursorX())
} }
}) })
t.Run("test 'v$d' deletes to end of line", func(t *testing.T) { t.Run("test 'v$d' deletes to end of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "v", "$", "d") sendKeys(tm, "v", "$", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " { if m.Line(0) != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello '", m.Line(0))
} }
}) })
t.Run("test 'v0' selects to beginning of line", func(t *testing.T) { t.Run("test 'v0' selects to beginning of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), WithCursorPos(action.Position{Line: 0, Col: 6}),
) )
sendKeys(tm, "v", "0") sendKeys(tm, "v", "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 6 { if m.AnchorX() != 6 {
t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 6", m.AnchorX())
} }
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("test 'v0d' deletes to beginning of line", func(t *testing.T) { t.Run("test 'v0d' deletes to beginning of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "v", "0", "d") sendKeys(tm, "v", "0", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// Deletes from 'h' (0) to 'w' (6) inclusive // Deletes from 'h' (0) to 'w' (6) inclusive
if m.ActiveBuffer().Lines[0] != "orld" { if m.Line(0) != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
} }
}) })
t.Run("test 'v_' selects to first non-whitespace", func(t *testing.T) { t.Run("test 'v_' selects to first non-whitespace", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{" hello world"}), WithLines([]string{" hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 10}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 10}), // on 'w'
) )
sendKeys(tm, "v", "_") sendKeys(tm, "v", "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 10 { if m.AnchorX() != 10 {
t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col) t.Errorf("AnchorX() = %d, want 10", m.AnchorX())
} }
// _ moves to first non-ws at col 4 // _ moves to first non-ws at col 4
if m.ActiveWindow().Cursor.Col != 4 { if m.CursorX() != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 4", m.CursorX())
} }
}) })
t.Run("test 'vG' selects to bottom of file", func(t *testing.T) { t.Run("test 'vG' selects to bottom of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "G") sendKeys(tm, "v", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Line != 0 { if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
} }
if m.ActiveWindow().Cursor.Line != 2 { if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 2", m.CursorY())
} }
}) })
t.Run("test 'vGd' deletes to bottom of file", func(t *testing.T) { t.Run("test 'vGd' deletes to bottom of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 3}), // on 'e' of "line" WithCursorPos(action.Position{Line: 0, Col: 3}), // on 'e' of "line"
) )
sendKeys(tm, "v", "G", "d") sendKeys(tm, "v", "G", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// G goes to last line at same col, deletes from (0,3) to (2,3) // G goes to last line at same col, deletes from (0,3) to (2,3)
// Keeps "lin" from first line + "e 3" from last line = "lin 3" // Keeps "lin" from first line + "e 3" from last line = "lin 3"
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "lin 3" { if m.Line(0) != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0))
} }
}) })
t.Run("test 'vgg' selects to top of file", func(t *testing.T) { t.Run("test 'vgg' selects to top of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "v", "g", "g") sendKeys(tm, "v", "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Line != 2 { if m.AnchorY() != 2 {
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 2", m.AnchorY())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("test 'vggd' deletes to top of file", func(t *testing.T) { t.Run("test 'vggd' deletes to top of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 2, Col: 3}), WithCursorPos(action.Position{Line: 2, Col: 3}),
) )
sendKeys(tm, "v", "g", "g", "d") sendKeys(tm, "v", "g", "g", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// gg goes to first line at same col, deletes selection // gg goes to first line at same col, deletes selection
// Keeps "lin" from first line + " 3" from last line = "lin 3" // Keeps "lin" from first line + " 3" from last line = "lin 3"
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "lin 3" { if m.Line(0) != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0))
} }
}) })
} }
@ -539,49 +539,49 @@ func TestVisualLineModeJumpMotions(t *testing.T) {
t.Run("test 'VG' selects all lines to bottom", func(t *testing.T) { t.Run("test 'VG' selects all lines to bottom", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "G") sendKeys(tm, "V", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Line != 0 { if m.AnchorY() != 0 {
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
} }
if m.ActiveWindow().Cursor.Line != 2 { if m.CursorY() != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 2", m.CursorY())
} }
}) })
t.Run("test 'VGd' deletes all lines", func(t *testing.T) { t.Run("test 'VGd' deletes all lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "G", "d") sendKeys(tm, "V", "G", "d")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
// All lines deleted, should have empty buffer // All lines deleted, should have empty buffer
if m.ActiveBuffer().LineCount() != 1 { if m.LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 1", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "" { if m.Line(0) != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want ''", m.Line(0))
} }
}) })
t.Run("test 'Vgg' selects lines to top", func(t *testing.T) { t.Run("test 'Vgg' selects lines to top", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "V", "g", "g") sendKeys(tm, "V", "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Line != 2 { if m.AnchorY() != 2 {
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line) t.Errorf("AnchorY() = %d, want 2", m.AnchorY())
} }
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
} }

View File

@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
) )
// ============================================================================= // =============================================================================
@ -15,7 +15,7 @@ func TestYankLineBasic(t *testing.T) {
t.Run("yy yanks current line to register", func(t *testing.T) { t.Run("yy yanks current line to register", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -24,7 +24,7 @@ func TestYankLineBasic(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 1 { if len(reg.Content) != 1 {
@ -38,45 +38,45 @@ func TestYankLineBasic(t *testing.T) {
t.Run("yy does not modify buffer", func(t *testing.T) { t.Run("yy does not modify buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "line 1" { if m.Line(0) != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "line 2" { if m.Line(1) != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1))
} }
if m.ActiveBuffer().Lines[2] != "line 3" { if m.Line(2) != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
} }
}) })
t.Run("yy does not move cursor", func(t *testing.T) { t.Run("yy does not move cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 3}), WithCursorPos(action.Position{Line: 1, Col: 3}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 { if m.CursorY() != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 1", m.CursorY())
} }
if m.ActiveWindow().Cursor.Col != 3 { if m.CursorX() != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 3", m.CursorX())
} }
}) })
t.Run("yy from middle of file", func(t *testing.T) { t.Run("yy from middle of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"first", "second", "third", "fourth"}), WithLines([]string{"first", "second", "third", "fourth"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -90,7 +90,7 @@ func TestYankLineBasic(t *testing.T) {
t.Run("yy at last line", func(t *testing.T) { t.Run("yy at last line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "last line"}), WithLines([]string{"line 1", "line 2", "last line"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -106,7 +106,7 @@ func TestYankLineWithCount(t *testing.T) {
t.Run("2yy yanks two lines", func(t *testing.T) { t.Run("2yy yanks two lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), WithLines([]string{"line 1", "line 2", "line 3", "line 4"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "2", "y", "y") sendKeys(tm, "2", "y", "y")
@ -126,7 +126,7 @@ func TestYankLineWithCount(t *testing.T) {
t.Run("3yy yanks three lines", func(t *testing.T) { t.Run("3yy yanks three lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"a", "b", "c", "d", "e"}), WithLines([]string{"a", "b", "c", "d", "e"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "3", "y", "y") sendKeys(tm, "3", "y", "y")
@ -149,7 +149,7 @@ func TestYankLineWithCount(t *testing.T) {
t.Run("yy with count overflow clamps to available lines", func(t *testing.T) { t.Run("yy with count overflow clamps to available lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "1", "0", "y", "y") // 10yy but only 2 lines available sendKeys(tm, "1", "0", "y", "y") // 10yy but only 2 lines available
@ -169,13 +169,13 @@ func TestYankLineWithCount(t *testing.T) {
t.Run("yy with count does not modify buffer", func(t *testing.T) { t.Run("yy with count does not modify buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "3", "y", "y") sendKeys(tm, "3", "y", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
}) })
} }
@ -184,7 +184,7 @@ func TestYankLineEdgeCases(t *testing.T) {
t.Run("yy on empty line", func(t *testing.T) { t.Run("yy on empty line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "", "line 3"}), WithLines([]string{"line 1", "", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -201,7 +201,7 @@ func TestYankLineEdgeCases(t *testing.T) {
t.Run("yy on single line buffer", func(t *testing.T) { t.Run("yy on single line buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"only line"}), WithLines([]string{"only line"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -215,7 +215,7 @@ func TestYankLineEdgeCases(t *testing.T) {
t.Run("yy preserves whitespace", func(t *testing.T) { t.Run("yy preserves whitespace", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{" indented", "\ttabbed", " spaces "}), WithLines([]string{" indented", "\ttabbed", " spaces "}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "3", "y", "y") sendKeys(tm, "3", "y", "y")
@ -241,7 +241,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("yj yanks current line and line below", func(t *testing.T) { t.Run("yj yanks current line and line below", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "j") sendKeys(tm, "y", "j")
@ -250,7 +250,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 2 { if len(reg.Content) != 2 {
@ -267,7 +267,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("yk yanks current line and line above", func(t *testing.T) { t.Run("yk yanks current line and line above", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "k") sendKeys(tm, "y", "k")
@ -276,7 +276,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 2 { if len(reg.Content) != 2 {
@ -293,7 +293,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("yG yanks from cursor to end of file", func(t *testing.T) { t.Run("yG yanks from cursor to end of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), WithLines([]string{"line 1", "line 2", "line 3", "line 4"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "G") sendKeys(tm, "y", "G")
@ -302,7 +302,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 3 { if len(reg.Content) != 3 {
@ -319,7 +319,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("ygg yanks from cursor to beginning of file", func(t *testing.T) { t.Run("ygg yanks from cursor to beginning of file", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), WithLines([]string{"line 1", "line 2", "line 3", "line 4"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "y", "g", "g") sendKeys(tm, "y", "g", "g")
@ -328,7 +328,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 3 { if len(reg.Content) != 3 {
@ -345,7 +345,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("y2j yanks current and next two lines", func(t *testing.T) { t.Run("y2j yanks current and next two lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"a", "b", "c", "d", "e"}), WithLines([]string{"a", "b", "c", "d", "e"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "2", "j") sendKeys(tm, "y", "2", "j")
@ -371,26 +371,26 @@ func TestYankWithLinewiseMotions(t *testing.T) {
t.Run("yj does not move cursor", func(t *testing.T) { t.Run("yj does not move cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 3}), WithCursorPos(action.Position{Line: 0, Col: 3}),
) )
sendKeys(tm, "y", "j") sendKeys(tm, "y", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 0 { if m.CursorY() != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) t.Errorf("CursorY() = %d, want 0", m.CursorY())
} }
}) })
t.Run("yG does not modify buffer", func(t *testing.T) { t.Run("yG does not modify buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "G") sendKeys(tm, "y", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
}) })
} }
@ -403,7 +403,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("yw yanks word under cursor", func(t *testing.T) { t.Run("yw yanks word under cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
@ -412,7 +412,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
// yw includes trailing space // yw includes trailing space
@ -427,7 +427,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("ye yanks to end of word (exclusive)", func(t *testing.T) { t.Run("ye yanks to end of word (exclusive)", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "e") sendKeys(tm, "y", "e")
@ -436,7 +436,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
// ye is inclusive of last char // ye is inclusive of last char
@ -451,7 +451,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("yb yanks backward word", func(t *testing.T) { t.Run("yb yanks backward word", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "y", "b") sendKeys(tm, "y", "b")
@ -460,7 +460,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
// yb from 'w' back to start of 'hello' // yb from 'w' back to start of 'hello'
@ -475,7 +475,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("y$ yanks to end of line", func(t *testing.T) { t.Run("y$ yanks to end of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "y", "$") sendKeys(tm, "y", "$")
@ -484,7 +484,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
if len(reg.Content) != 1 { if len(reg.Content) != 1 {
@ -498,7 +498,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("y0 yanks to beginning of line", func(t *testing.T) { t.Run("y0 yanks to beginning of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
sendKeys(tm, "y", "0") sendKeys(tm, "y", "0")
@ -507,7 +507,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
if len(reg.Content) != 1 { if len(reg.Content) != 1 {
@ -521,7 +521,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("y_ yanks to first non-whitespace", func(t *testing.T) { t.Run("y_ yanks to first non-whitespace", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{" hello world"}), WithLines([]string{" hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 10}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 10}), // on 'w'
) )
sendKeys(tm, "y", "_") sendKeys(tm, "y", "_")
@ -530,7 +530,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
// From 'w' back to 'h' (first non-ws) // From 'w' back to 'h' (first non-ws)
@ -545,7 +545,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("y2w yanks two words", func(t *testing.T) { t.Run("y2w yanks two words", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"one two three four"}), WithLines([]string{"one two three four"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "2", "w") sendKeys(tm, "y", "2", "w")
@ -565,26 +565,26 @@ func TestYankWithCharwiseMotions(t *testing.T) {
t.Run("yw does not move cursor", func(t *testing.T) { t.Run("yw does not move cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 { if m.CursorX() != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col) t.Errorf("CursorX() = %d, want 0", m.CursorX())
} }
}) })
t.Run("yw does not modify buffer", func(t *testing.T) { t.Run("yw does not modify buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" { if m.Line(0) != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0))
} }
}) })
} }
@ -597,7 +597,7 @@ func TestYankVisualCharwise(t *testing.T) {
t.Run("v selection then y yanks selected text", func(t *testing.T) { t.Run("v selection then y yanks selected text", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "l", "l", "l", "l", "y") // select "hello" sendKeys(tm, "v", "l", "l", "l", "l", "y") // select "hello"
@ -606,7 +606,7 @@ func TestYankVisualCharwise(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
if len(reg.Content) != 1 { if len(reg.Content) != 1 {
@ -620,7 +620,7 @@ func TestYankVisualCharwise(t *testing.T) {
t.Run("v selection across lines yanks with newlines", func(t *testing.T) { t.Run("v selection across lines yanks with newlines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2"}), WithLines([]string{"line 1", "line 2"}),
WithCursorPos(core.Position{Line: 0, Col: 3}), WithCursorPos(action.Position{Line: 0, Col: 3}),
) )
sendKeys(tm, "v", "j", "l", "l", "y") // select "e 1\nlin" sendKeys(tm, "v", "j", "l", "l", "y") // select "e 1\nlin"
@ -629,7 +629,7 @@ func TestYankVisualCharwise(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.CharwiseRegister { if reg.Type != action.CharwiseRegister {
t.Errorf("register type = %v, want CharwiseRegister", reg.Type) t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
} }
// Multi-line charwise yank // Multi-line charwise yank
@ -641,12 +641,12 @@ func TestYankVisualCharwise(t *testing.T) {
t.Run("visual yank exits visual mode", func(t *testing.T) { t.Run("visual yank exits visual mode", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "l", "l", "y") sendKeys(tm, "v", "l", "l", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -654,13 +654,13 @@ func TestYankVisualCharwise(t *testing.T) {
t.Run("visual yank does not modify buffer", func(t *testing.T) { t.Run("visual yank does not modify buffer", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "l", "l", "l", "l", "y") sendKeys(tm, "v", "l", "l", "l", "l", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" { if m.Line(0) != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0))
} }
}) })
} }
@ -669,7 +669,7 @@ func TestYankVisualLinewise(t *testing.T) {
t.Run("V selection then y yanks entire lines", func(t *testing.T) { t.Run("V selection then y yanks entire lines", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "j", "y") // select lines 1 and 2 sendKeys(tm, "V", "j", "y") // select lines 1 and 2
@ -678,7 +678,7 @@ func TestYankVisualLinewise(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 2 { if len(reg.Content) != 2 {
@ -695,7 +695,7 @@ func TestYankVisualLinewise(t *testing.T) {
t.Run("V on single line then y yanks that line", func(t *testing.T) { t.Run("V on single line then y yanks that line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2"}), WithLines([]string{"line 1", "line 2"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "y") sendKeys(tm, "V", "y")
@ -704,7 +704,7 @@ func TestYankVisualLinewise(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.LinewiseRegister { if reg.Type != action.LinewiseRegister {
t.Errorf("register type = %v, want LinewiseRegister", reg.Type) t.Errorf("register type = %v, want LinewiseRegister", reg.Type)
} }
if len(reg.Content) != 1 { if len(reg.Content) != 1 {
@ -718,7 +718,7 @@ func TestYankVisualLinewise(t *testing.T) {
t.Run("V selection upward yanks in correct order", func(t *testing.T) { t.Run("V selection upward yanks in correct order", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
sendKeys(tm, "V", "k", "y") // select from line 3 upward to line 2 sendKeys(tm, "V", "k", "y") // select from line 3 upward to line 2
@ -742,12 +742,12 @@ func TestYankVisualLinewise(t *testing.T) {
t.Run("visual line yank exits visual mode", func(t *testing.T) { t.Run("visual line yank exits visual mode", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2"}), WithLines([]string{"line 1", "line 2"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "y") sendKeys(tm, "V", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -757,7 +757,7 @@ func TestYankVisualBlock(t *testing.T) {
t.Run("ctrl+v selection then y yanks block", func(t *testing.T) { t.Run("ctrl+v selection then y yanks block", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"abcdef", "ghijkl", "mnopqr"}), WithLines([]string{"abcdef", "ghijkl", "mnopqr"}),
WithCursorPos(core.Position{Line: 0, Col: 1}), WithCursorPos(action.Position{Line: 0, Col: 1}),
) )
sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "y") // select 3x3 block starting at col 1 sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "y") // select 3x3 block starting at col 1
@ -766,7 +766,7 @@ func TestYankVisualBlock(t *testing.T) {
if !ok { if !ok {
t.Fatal("unnamed register not found") t.Fatal("unnamed register not found")
} }
if reg.Type != core.BlockwiseRegister { if reg.Type != action.BlockwiseRegister {
t.Errorf("register type = %v, want BlockwiseRegister", reg.Type) t.Errorf("register type = %v, want BlockwiseRegister", reg.Type)
} }
// Block should contain "bcd", "hij", "nop" // Block should contain "bcd", "hij", "nop"
@ -787,12 +787,12 @@ func TestYankVisualBlock(t *testing.T) {
t.Run("visual block yank exits visual mode", func(t *testing.T) { t.Run("visual block yank exits visual mode", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"abcd", "efgh"}), WithLines([]string{"abcd", "efgh"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "ctrl+v", "j", "l", "y") sendKeys(tm, "ctrl+v", "j", "l", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != core.NormalMode { if m.Mode() != action.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -800,7 +800,7 @@ func TestYankVisualBlock(t *testing.T) {
t.Run("visual block yank with uneven line lengths", func(t *testing.T) { t.Run("visual block yank with uneven line lengths", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"abcdefgh", "ij", "klmnop"}), WithLines([]string{"abcdefgh", "ij", "klmnop"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "l", "y") // 4-wide block sendKeys(tm, "ctrl+v", "j", "j", "l", "l", "l", "y") // 4-wide block
@ -824,7 +824,7 @@ func TestYankRegisterBehavior(t *testing.T) {
t.Run("yy updates register 0 and unnamed register", func(t *testing.T) { t.Run("yy updates register 0 and unnamed register", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2"}), WithLines([]string{"line 1", "line 2"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -858,7 +858,7 @@ func TestYankRegisterBehavior(t *testing.T) {
t.Run("multiple yanks shift numbered registers", func(t *testing.T) { t.Run("multiple yanks shift numbered registers", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"first", "second", "third"}), WithLines([]string{"first", "second", "third"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") // yank "first" sendKeys(tm, "y", "y") // yank "first"
sendKeys(tm, "j") sendKeys(tm, "j")
@ -894,18 +894,18 @@ func TestYankRegisterBehavior(t *testing.T) {
t.Run("yank then paste uses correct content", func(t *testing.T) { t.Run("yank then paste uses correct content", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"original", "to copy"}), WithLines([]string{"original", "to copy"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y") // yank "to copy" sendKeys(tm, "y", "y") // yank "to copy"
sendKeys(tm, "k") // move up sendKeys(tm, "k") // move up
sendKeys(tm, "p") // paste sendKeys(tm, "p") // paste
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
if m.ActiveBuffer().Lines[1] != "to copy" { if m.Line(1) != "to copy" {
t.Errorf("Line(1) = %q, want 'to copy'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'to copy'", m.Line(1))
} }
}) })
} }
@ -918,7 +918,7 @@ func TestYankEdgeCases(t *testing.T) {
t.Run("yy on whitespace-only line", func(t *testing.T) { t.Run("yy on whitespace-only line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", " ", "line 3"}), WithLines([]string{"line 1", " ", "line 3"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -938,7 +938,7 @@ func TestYankEdgeCases(t *testing.T) {
t.Run("yw at end of line", func(t *testing.T) { t.Run("yw at end of line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello"}), WithLines([]string{"hello"}),
WithCursorPos(core.Position{Line: 0, Col: 4}), // on 'o' WithCursorPos(action.Position{Line: 0, Col: 4}), // on 'o'
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
@ -959,7 +959,7 @@ func TestYankEdgeCases(t *testing.T) {
t.Run("y$ at beginning of line yanks entire line content", func(t *testing.T) { t.Run("y$ at beginning of line yanks entire line content", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "$") sendKeys(tm, "y", "$")
@ -979,7 +979,7 @@ func TestYankEdgeCases(t *testing.T) {
t.Run("y0 at beginning of line yanks nothing", func(t *testing.T) { t.Run("y0 at beginning of line yanks nothing", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello"}), WithLines([]string{"hello"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "0") sendKeys(tm, "y", "0")
@ -1001,7 +1001,7 @@ func TestYankEdgeCases(t *testing.T) {
longLine := strings.Repeat("a", 1000) longLine := strings.Repeat("a", 1000)
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{longLine}), WithLines([]string{longLine}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -1021,7 +1021,7 @@ func TestYankEdgeCases(t *testing.T) {
t.Run("yy with special characters", func(t *testing.T) { t.Run("yy with special characters", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello\tworld", "foo\nbar"}), // tab and embedded newline WithLines([]string{"hello\tworld", "foo\nbar"}), // tab and embedded newline
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
@ -1047,178 +1047,178 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
t.Run("visual charwise yank then paste single line", func(t *testing.T) { t.Run("visual charwise yank then paste single line", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// Select "hello", yank it, move to end, paste // Select "hello", yank it, move to end, paste
sendKeys(tm, "v", "l", "l", "l", "l", "y", "$", "p") sendKeys(tm, "v", "l", "l", "l", "l", "y", "$", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello" { if m.Line(0) != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0))
} }
}) })
t.Run("visual charwise yank then paste before", func(t *testing.T) { t.Run("visual charwise yank then paste before", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(action.Position{Line: 0, Col: 6}), // on 'w'
) )
// Select "world", yank it, go to start, paste before // Select "world", yank it, go to start, paste before
sendKeys(tm, "v", "$", "y", "0", "P") sendKeys(tm, "v", "$", "y", "0", "P")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "worldhello world" { if m.Line(0) != "worldhello world" {
t.Errorf("Line(0) = %q, want 'worldhello world'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'worldhello world'", m.Line(0))
} }
}) })
t.Run("visual line yank then paste", func(t *testing.T) { t.Run("visual line yank then paste", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// V yank line 1, go to line 2, paste // V yank line 1, go to line 2, paste
sendKeys(tm, "V", "y", "j", "p") sendKeys(tm, "V", "y", "j", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 { if m.LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 4", m.LineCount())
} }
if m.ActiveBuffer().Lines[2] != "line 1" { if m.Line(2) != "line 1" {
t.Errorf("Line(2) = %q, want 'line 1'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'line 1'", m.Line(2))
} }
}) })
t.Run("visual line yank multiple lines then paste", func(t *testing.T) { t.Run("visual line yank multiple lines then paste", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3", "line 4"}), WithLines([]string{"line 1", "line 2", "line 3", "line 4"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// V select lines 1-2, yank, go to end, paste // V select lines 1-2, yank, go to end, paste
sendKeys(tm, "V", "j", "y", "G", "p") sendKeys(tm, "V", "j", "y", "G", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 6 { if m.LineCount() != 6 {
t.Errorf("LineCount() = %d, want 6", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 6", m.LineCount())
} }
if m.ActiveBuffer().Lines[4] != "line 1" { if m.Line(4) != "line 1" {
t.Errorf("Line(4) = %q, want 'line 1'", m.ActiveBuffer().Lines[4]) t.Errorf("Line(4) = %q, want 'line 1'", m.Line(4))
} }
if m.ActiveBuffer().Lines[5] != "line 2" { if m.Line(5) != "line 2" {
t.Errorf("Line(5) = %q, want 'line 2'", m.ActiveBuffer().Lines[5]) t.Errorf("Line(5) = %q, want 'line 2'", m.Line(5))
} }
}) })
t.Run("visual line yank then paste before", func(t *testing.T) { t.Run("visual line yank then paste before", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 2, Col: 0}), WithCursorPos(action.Position{Line: 2, Col: 0}),
) )
// V yank line 3, go to line 1, paste before // V yank line 3, go to line 1, paste before
sendKeys(tm, "V", "y", "g", "g", "P") sendKeys(tm, "V", "y", "g", "g", "P")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 { if m.LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 4", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "line 3" { if m.Line(0) != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "line 1" { if m.Line(1) != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
} }
}) })
t.Run("yy then p duplicates line below", func(t *testing.T) { t.Run("yy then p duplicates line below", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"original", "other"}), WithLines([]string{"original", "other"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "y", "p") sendKeys(tm, "y", "y", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "original" { if m.Line(0) != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'original'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "original" { if m.Line(1) != "original" {
t.Errorf("Line(1) = %q, want 'original'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'original'", m.Line(1))
} }
if m.ActiveBuffer().Lines[2] != "other" { if m.Line(2) != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'other'", m.Line(2))
} }
}) })
t.Run("yy then P duplicates line above", func(t *testing.T) { t.Run("yy then P duplicates line above", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"original", "other"}), WithLines([]string{"original", "other"}),
WithCursorPos(core.Position{Line: 1, Col: 0}), WithCursorPos(action.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y", "P") sendKeys(tm, "y", "y", "P")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "original" { if m.Line(0) != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'original'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "other" { if m.Line(1) != "other" {
t.Errorf("Line(1) = %q, want 'other'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'other'", m.Line(1))
} }
if m.ActiveBuffer().Lines[2] != "other" { if m.Line(2) != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'other'", m.Line(2))
} }
}) })
t.Run("yw then p pastes word after cursor", func(t *testing.T) { t.Run("yw then p pastes word after cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// yw yanks "hello ", move to end of world, paste // yw yanks "hello ", move to end of world, paste
sendKeys(tm, "y", "w", "$", "p") sendKeys(tm, "y", "w", "$", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello " { if m.Line(0) != "hello worldhello " {
t.Errorf("Line(0) = %q, want 'hello worldhello '", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello worldhello '", m.Line(0))
} }
}) })
t.Run("ye then p pastes word after cursor", func(t *testing.T) { t.Run("ye then p pastes word after cursor", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello world"}), WithLines([]string{"hello world"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// ye yanks "hello" (inclusive), move to end of line, paste // ye yanks "hello" (inclusive), move to end of line, paste
sendKeys(tm, "y", "e", "$", "p") sendKeys(tm, "y", "e", "$", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello" { if m.Line(0) != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0))
} }
}) })
t.Run("visual select partial word yank then paste", func(t *testing.T) { t.Run("visual select partial word yank then paste", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"abcdefgh"}), WithLines([]string{"abcdefgh"}),
WithCursorPos(core.Position{Line: 0, Col: 2}), // on 'c' WithCursorPos(action.Position{Line: 0, Col: 2}), // on 'c'
) )
// Select "cde", yank, go to end, paste // Select "cde", yank, go to end, paste
sendKeys(tm, "v", "l", "l", "y", "$", "p") sendKeys(tm, "v", "l", "l", "y", "$", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "abcdefghcde" { if m.Line(0) != "abcdefghcde" {
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.Line(0))
} }
}) })
t.Run("visual yank empty selection does nothing", func(t *testing.T) { t.Run("visual yank empty selection does nothing", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"hello"}), WithLines([]string{"hello"}),
WithCursorPos(core.Position{Line: 0, Col: 2}), WithCursorPos(action.Position{Line: 0, Col: 2}),
) )
// Enter visual mode then immediately yank (single char) // Enter visual mode then immediately yank (single char)
sendKeys(tm, "v", "y") sendKeys(tm, "v", "y")
@ -1237,50 +1237,50 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
t.Run("dd then p moves deleted line down", func(t *testing.T) { t.Run("dd then p moves deleted line down", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// dd deletes line 1, p pastes it below cursor (now on line 2) // dd deletes line 1, p pastes it below cursor (now on line 2)
sendKeys(tm, "d", "d", "p") sendKeys(tm, "d", "d", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 { if m.LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 3", m.LineCount())
} }
if m.ActiveBuffer().Lines[0] != "line 2" { if m.Line(0) != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0]) t.Errorf("Line(0) = %q, want 'line 2'", m.Line(0))
} }
if m.ActiveBuffer().Lines[1] != "line 1" { if m.Line(1) != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
} }
if m.ActiveBuffer().Lines[2] != "line 3" { if m.Line(2) != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
} }
}) })
t.Run("2yy then 2p pastes twice", func(t *testing.T) { t.Run("2yy then 2p pastes twice", func(t *testing.T) {
tm := newTestModel(t, tm := newTestModel(t,
WithLines([]string{"line 1", "line 2", "line 3"}), WithLines([]string{"line 1", "line 2", "line 3"}),
WithCursorPos(core.Position{Line: 0, Col: 0}), WithCursorPos(action.Position{Line: 0, Col: 0}),
) )
// 2yy yanks lines 1-2, 2p pastes them twice after current line // 2yy yanks lines 1-2, 2p pastes them twice after current line
sendKeys(tm, "2", "y", "y", "2", "p") sendKeys(tm, "2", "y", "y", "2", "p")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 7 { if m.LineCount() != 7 {
t.Errorf("LineCount() = %d, want 7", m.ActiveBuffer().LineCount()) t.Errorf("LineCount() = %d, want 7", m.LineCount())
} }
// Original + 2 copies of 2 lines = 3 + 4 = 7 // Original + 2 copies of 2 lines = 3 + 4 = 7
if m.ActiveBuffer().Lines[1] != "line 1" { if m.Line(1) != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1]) t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
} }
if m.ActiveBuffer().Lines[2] != "line 2" { if m.Line(2) != "line 2" {
t.Errorf("Line(2) = %q, want 'line 2'", m.ActiveBuffer().Lines[2]) t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2))
} }
if m.ActiveBuffer().Lines[3] != "line 1" { if m.Line(3) != "line 1" {
t.Errorf("Line(3) = %q, want 'line 1'", m.ActiveBuffer().Lines[3]) t.Errorf("Line(3) = %q, want 'line 1'", m.Line(3))
} }
if m.ActiveBuffer().Lines[4] != "line 2" { if m.Line(4) != "line 2" {
t.Errorf("Line(4) = %q, want 'line 2'", m.ActiveBuffer().Lines[4]) t.Errorf("Line(4) = %q, want 'line 2'", m.Line(4))
} }
}) })
} }

View File

@ -5,93 +5,136 @@ import (
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input" "git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type cursor struct {
x int
y int
}
type Model struct { type Model struct {
// Buffers lines []string
buffers []*core.Buffer cursor cursor
//next buffer id? anchor cursor // starting point for visual modes
scrollY int
mode action.Mode
win_h int
win_w int
input *input.Handler
// Windows // Insert repetition
windows []*core.Window
activeWindowId int
// Editor wide state
mode core.Mode
// Terminal dimensions
termWidth int
termHeight int
// Input and key handling
input *input.Handler
// Insert mode state & repetition (applied to active window)
insertCount int insertCount int
insertKeys []string insertKeys []string
insertAction action.Action insertAction action.Action
// Command line state // Command mode
command string command string
commandCursor int commandCursor int
commandError error commandError error
commandOutput string commandOutput string
// Global settings // Settings
settings core.EditorSettings settings action.Settings
// Registers // Registers
registers map[rune]core.Register // name -> register registers map[rune]action.Register // name -> register
}
// Visual styles
styles style.Styles func NewModel(lines []string, pos action.Position) Model {
return Model{
lines: lines,
cursor: cursor{
x: pos.Col,
y: pos.Line,
},
scrollY: 0,
mode: action.NormalMode,
command: "",
input: input.NewHandler(),
settings: action.NewDefaultSettings(),
registers: action.DefaultRegisters(),
}
} }
// Model.Init: Initialize the model and start any commands that may need to run. Required
// for the bubbletea architecture.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil return nil
} }
// Implement action.Model interface // Implement action.Model interface
// ================================================== func (m *Model) Lines() []string {
// Core Data Access return m.lines
// ==================================================
func (m *Model) Windows() []*core.Window {
return m.windows
} }
func (m *Model) ActiveWindow() *core.Window { func (m *Model) Line(idx int) string {
winId := m.activeWindowId if idx < 0 || idx >= len(m.lines) {
for i := range m.Windows() { return ""
if m.windows[i].Id == winId {
return m.windows[i]
}
} }
panic("Could not find window") return m.lines[idx]
} }
func (m *Model) Buffers() []*core.Buffer { func (m *Model) SetLine(idx int, content string) {
return m.buffers if idx >= 0 && idx < len(m.lines) {
m.lines[idx] = content
}
} }
func (m *Model) SetBuffers(bufs []*core.Buffer) { func (m *Model) InsertLine(idx int, content string) {
m.buffers = bufs 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) ActiveBuffer() *core.Buffer { func (m *Model) DeleteLine(idx int) {
win := m.ActiveWindow() if idx >= 0 && idx < len(m.lines) {
return win.Buffer m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
}
} }
// ================================================== func (m *Model) LineCount() int {
// Insert Mode Methods 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
}
// Anchor methods
func (m *Model) AnchorX() int {
return m.anchor.x
}
func (m *Model) AnchorY() int {
return m.anchor.y
}
func (m *Model) SetAnchorX(x int) {
m.anchor.x = x
}
func (m *Model) SetAnchorY(y int) {
m.anchor.y = y
}
// Insert methods
func (m *Model) InsertKeys() []string { func (m *Model) InsertKeys() []string {
return m.insertKeys return m.insertKeys
} }
@ -100,143 +143,7 @@ func (m *Model) SetInsertKeys(keys []string) {
m.insertKeys = keys m.insertKeys = keys
} }
func (m *Model) SetInsertRecording(count int, act action.Action) { // Command mode
m.insertCount = count
m.insertKeys = []string{}
m.insertAction = act
}
func (m *Model) ExitInsertMode() {
win := m.ActiveWindow()
if m.insertCount > 1 {
m.replayInsert()
}
if win.Cursor.Col > 0 {
win.Cursor.Col--
}
m.mode = core.NormalMode
m.insertCount = 0
m.insertKeys = nil
}
func (m *Model) replayInsert() {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// 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 := win.Cursor.Line
buf.InsertLine(pos+1, "")
win.SetCursorLine(pos + 1)
case action.OpenLineAbove:
pos := win.Cursor.Line
buf.InsertLine(pos, "")
// 'i' and 'a' don't need setup - just replay keys
}
// Replay each recorded keystroke
for _, key := range m.insertKeys {
m.processInsertKey(key)
}
}
}
// TODO: Fix this shitty shit shit shit
func (m *Model) processInsertKey(key string) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
col := win.Cursor.Col
line := win.Cursor.Line
l := buf.Line(line)
switch key {
case "enter":
if col == len(l) {
buf.InsertLine(line+1, "")
} else {
buf.SetLine(line, l[:col])
buf.InsertLine(line+1, l[col:])
}
win.SetCursorLine(line + 1)
win.SetCursorCol(0)
case "backspace":
if col > 0 {
buf.SetLine(line, l[:col-1]+l[col:])
win.SetCursorCol(col - 1)
} else if line > 0 {
prevLine := buf.Line(line - 1)
newCol := len(prevLine)
buf.SetLine(line-1, prevLine+l)
buf.DeleteLine(line)
win.SetCursorLine(line - 1)
win.SetCursorCol(newCol)
}
case "delete":
if col == len(l) && line < buf.LineCount()-1 {
nextLine := buf.Line(line + 1)
buf.SetLine(line, l+nextLine)
buf.DeleteLine(line + 1)
} else if col < len(l) {
buf.SetLine(line, l[:col]+l[col+1:])
}
case "tab":
tabs := strings.Repeat(" ", m.Settings().TabStop)
if col < len(l) {
buf.SetLine(line, l[:col]+tabs+l[col:])
} else {
buf.SetLine(line, l+tabs)
}
win.SetCursorCol(col + len(tabs))
case "up":
if line > 0 {
win.SetCursorLine(line - 1)
}
case "down":
if line+1 < buf.LineCount() {
win.SetCursorLine(line + 1)
}
case "left":
if col > 0 {
win.SetCursorCol(col - 1)
} else if line > 0 {
prevLine := buf.Line(line - 1)
win.SetCursorCol(len(prevLine))
win.SetCursorLine(line - 1)
}
case "right":
if col < len(l) {
win.SetCursorCol(col + 1)
} else if line+1 < buf.LineCount() {
win.SetCursorCol(0)
win.SetCursorLine(line + 1)
}
default:
if col < len(l) {
buf.SetLine(line, l[:col]+key+l[col:])
} else {
buf.SetLine(line, l+key)
}
win.SetCursorCol(col + len(key))
}
}
// ==================================================
// Command Mode State
// ==================================================
func (m *Model) Command() string { func (m *Model) Command() string {
return m.command return m.command
} }
@ -275,60 +182,38 @@ func (m *Model) SetCommandOutput(out string) {
m.commandOutput = out m.commandOutput = out
} }
// ================================================== // Settings
// Editor-wide State func (m *Model) Settings() action.Settings {
// ==================================================
func (m *Model) Mode() core.Mode {
return m.mode
}
func (m *Model) SetMode(mode core.Mode) {
m.mode = mode
}
func (m *Model) Settings() core.EditorSettings {
return m.settings return m.settings
} }
func (m *Model) SetSettings(s core.EditorSettings) { func (m *Model) SetSettings(s action.Settings) {
m.settings = s m.settings = s
} }
// Model.Styles: Returns the visual styles used for rendering.
func (m *Model) Styles() style.Styles {
return m.styles
}
// Model.SetStyles: Sets the visual styles used for rendering.
func (m *Model) SetStyles(s style.Styles) {
m.styles = s
}
// ==================================================
// Registers // Registers
// ================================================== func (m *Model) Registers() map[rune]action.Register {
func (m *Model) Registers() map[rune]core.Register {
return m.registers return m.registers
} }
func (m *Model) GetRegister(name rune) (core.Register, bool) { func (m *Model) GetRegister(name rune) (action.Register, bool) {
reg, found := m.registers[name] reg, found := m.registers[name]
return reg, found return reg, found
} }
func (m *Model) SetRegister(name rune, t core.RegisterType, cnt []string) error { func (m *Model) SetRegister(name rune, t action.RegisterType, cnt []string) error {
if _, found := m.GetRegister(name); !found { if _, found := m.GetRegister(name); !found {
return fmt.Errorf("Register '%c' does not exist.", name) return fmt.Errorf("Register '%c' does not exist.", name)
} }
// TODO: This might be slow, pointers maybe? // TODO: This might be slow, pointers maybe?
reg := core.Register{Type: t, Content: cnt} reg := action.Register{Type: t, Content: cnt}
m.registers[name] = reg m.registers[name] = reg
return nil return nil
} }
func (m *Model) UpdateDefaultRegister(t core.RegisterType, cnt []string) { func (m *Model) UpdateDefaultRegister(t action.RegisterType, cnt []string) {
// Shift numbered registers: 0 -> 1 -> 2 -> ... -> 9 -> _ (discarded) // Shift numbered registers: 0 -> 1 -> 2 -> ... -> 9 -> _ (discarded)
for i := rune('9'); i > '0'; i-- { for i := rune('9'); i > '0'; i-- {
m.registers[i] = m.registers[i-1] m.registers[i] = m.registers[i-1]
@ -338,3 +223,199 @@ func (m *Model) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.SetRegister('0', t, cnt) m.SetRegister('0', t, cnt)
m.SetRegister('"', t, cnt) m.SetRegister('"', t, cnt)
} }
// Window
func (m *Model) ScrollY() int {
return m.scrollY
}
func (m *Model) SetScrollY(y int) {
m.scrollY = y
}
func (m *Model) WinH() int {
return m.win_h
}
func (m *Model) WinW() int {
return m.win_w
}
func (m *Model) ViewPortH() int {
return m.win_h - 2 // -2 for status bar and commmand bar
}
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
}
}
// AdjustScroll ensures the cursor stays within the viewport with scrollOff margins.
// Call this after any cursor movement.
func (m *Model) AdjustScroll() {
viewportHeight := m.ViewPortH()
if viewportHeight <= 0 {
return
}
// Effective scrollOff (can't be more than half the viewport)
off := min(m.Settings().ScrollOff, viewportHeight/2)
// Cursor too close to top — scroll up
if m.CursorY() < m.ScrollY()+off {
m.SetScrollY(m.CursorY() - off)
}
// Cursor too close to bottom — scroll down
if m.CursorY() > m.ScrollY()+viewportHeight-1-off {
m.SetScrollY(m.CursorY() - viewportHeight + 1 + off)
}
// Clamp scrollY to valid range
maxScroll := max(0, m.LineCount()-viewportHeight)
m.SetScrollY(max(0, min(m.ScrollY(), maxScroll)))
}
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) ExitInsertMode() {
if m.insertCount > 1 {
m.replayInsert()
}
if m.cursor.x > 0 {
m.cursor.x--
}
m.mode = action.NormalMode
m.insertCount = 0
m.insertKeys = nil
}
func (m *Model) processInsertKey(key string) {
x := m.CursorX()
y := m.CursorY()
l := m.Line(y)
switch key {
case "enter":
if x == len(l) {
m.InsertLine(y+1, "")
} 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)
}
case "delete":
if x == len(l) && y < m.LineCount()-1 {
nextLine := m.Line(y + 1)
m.SetLine(y, l+nextLine)
m.DeleteLine(y + 1)
} else if x < len(l) {
m.SetLine(y, l[:x]+l[x+1:])
}
case "tab":
tabs := strings.Repeat(" ", m.Settings().TabSize)
if x < len(l) {
m.SetLine(y, l[:x]+tabs+l[x:])
} else {
m.SetLine(y, l+tabs)
}
m.SetCursorX(x + len(tabs))
case "up":
if y > 0 {
m.SetCursorY(y - 1)
m.ClampCursorX()
}
case "down":
if y+1 < m.LineCount() {
m.SetCursorY(y + 1)
m.ClampCursorX()
}
case "left":
if x > 0 {
m.SetCursorX(x - 1)
} else if y > 0 {
prevLine := m.Line(y - 1)
m.SetCursorX(len(prevLine))
m.SetCursorY(y - 1)
}
case "right":
if x < len(l) {
m.SetCursorX(x + 1)
} else if y+1 < m.LineCount() {
m.SetCursorX(0)
m.SetCursorY(y + 1)
}
default:
if x < len(l) {
m.SetLine(y, l[:x]+key+l[x:])
} else {
m.SetLine(y, l+key)
}
m.SetCursorX(x + len(key))
}
}

View File

@ -1,140 +0,0 @@
package editor
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
type ModelBuilder struct {
model Model
}
func NewModelBuilder() *ModelBuilder {
return &ModelBuilder{
model: Model{
buffers: []*core.Buffer{},
windows: []*core.Window{},
activeWindowId: -1,
mode: core.NormalMode,
termWidth: 0,
termHeight: 0,
input: input.NewHandler(),
insertCount: 0,
insertKeys: []string{},
insertAction: nil,
command: "",
commandCursor: 0,
commandError: nil,
commandOutput: "",
settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(),
styles: style.DefaultStyles(),
},
}
}
// ModelBuilder.WithBuffers: Set the buffers for the model. Buffers represent
// the in-memory text content of files being edited.
func (mb *ModelBuilder) WithBuffers(buffers []*core.Buffer) *ModelBuilder {
mb.model.buffers = buffers
return mb
}
// ModelBuilder.AddBuffer: Add a single buffer to the model's buffer list.
func (mb *ModelBuilder) AddBuffer(buffer *core.Buffer) *ModelBuilder {
mb.model.buffers = append(mb.model.buffers, buffer)
return mb
}
// ModelBuilder.WithWindows: Set the windows for the model. Windows are viewports
// that display buffer content with their own cursor position and scroll state.
func (mb *ModelBuilder) WithWindows(windows []*core.Window) *ModelBuilder {
mb.model.windows = windows
return mb
}
// ModelBuilder.AddWindow: Add a single window to the model's window list.
func (mb *ModelBuilder) AddWindow(window *core.Window) *ModelBuilder {
mb.model.windows = append(mb.model.windows, window)
return mb
}
// ModelBuilder.WithActiveWindowId: Set the ID of the currently active window.
// This determines which window receives input and displays the cursor.
func (mb *ModelBuilder) WithActiveWindowId(id int) *ModelBuilder {
mb.model.activeWindowId = id
return mb
}
// ModelBuilder.WithMode: Set the editor mode (Normal, Insert, Visual, etc).
func (mb *ModelBuilder) WithMode(mode core.Mode) *ModelBuilder {
mb.model.mode = mode
return mb
}
// ModelBuilder.WithTermSize: Set the terminal dimensions in columns and rows.
func (mb *ModelBuilder) WithTermSize(width, height int) *ModelBuilder {
mb.model.termWidth = width
mb.model.termHeight = height
return mb
}
// ModelBuilder.WithTermWidth: Set the terminal width in columns.
func (mb *ModelBuilder) WithTermWidth(width int) *ModelBuilder {
mb.model.termWidth = width
return mb
}
// ModelBuilder.WithTermHeight: Set the terminal height in rows.
func (mb *ModelBuilder) WithTermHeight(height int) *ModelBuilder {
mb.model.termHeight = height
return mb
}
// ModelBuilder.WithSettings: Set the editor settings (tabstop, scrolloff, etc).
func (mb *ModelBuilder) WithSettings(settings core.EditorSettings) *ModelBuilder {
mb.model.settings = settings
return mb
}
// ModelBuilder.WithRegisters: Set the register map for yank/delete/paste operations.
func (mb *ModelBuilder) WithRegisters(registers map[rune]core.Register) *ModelBuilder {
mb.model.registers = registers
return mb
}
// ModelBuilder.WithCommand: Set the command line text.
func (mb *ModelBuilder) WithCommand(command string) *ModelBuilder {
mb.model.command = command
return mb
}
// ModelBuilder.WithCommandCursor: Set the cursor position in the command line.
func (mb *ModelBuilder) WithCommandCursor(cursor int) *ModelBuilder {
mb.model.commandCursor = cursor
return mb
}
// ModelBuilder.WithCommandError: Set the command line error state.
func (mb *ModelBuilder) WithCommandError(err error) *ModelBuilder {
mb.model.commandError = err
return mb
}
// ModelBuilder.WithCommandOutput: Set the command line output text.
func (mb *ModelBuilder) WithCommandOutput(output string) *ModelBuilder {
mb.model.commandOutput = output
return mb
}
// ModelBuilder.WithStyles: Set the visual styling for the editor.
func (mb *ModelBuilder) WithStyles(styles style.Styles) *ModelBuilder {
mb.model.styles = styles
return mb
}
// ModelBuilder.Build: Build and return the configured Model instance.
func (mb *ModelBuilder) Build() *Model {
return &mb.model
}

52
internal/editor/style.go Normal file
View File

@ -0,0 +1,52 @@
package editor
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"github.com/charmbracelet/lipgloss"
)
func (m Model) cursorStyle() lipgloss.Style {
switch m.mode {
case action.NormalMode,
action.VisualMode,
action.VisualBlockMode,
action.VisualLineMode:
// Block cursor for normal mode
return lipgloss.NewStyle().Reverse(true)
case action.InsertMode:
// Bar/underline for insert mode
return lipgloss.NewStyle().Underline(true)
case action.CommandMode:
return lipgloss.NewStyle().Reverse(true)
default:
return lipgloss.NewStyle().Reverse(true)
}
}
// DEBUGGING STYLE
func (m Model) visualAnchorStyle() lipgloss.Style {
bg := lipgloss.Color("#a89020")
return lipgloss.NewStyle().Background(bg)
}
func (m Model) gutterStyle(currentLine bool) lipgloss.Style {
bg := lipgloss.Color("236")
fg := lipgloss.Color("243")
if currentLine {
fg = lipgloss.Color("#d69d00")
}
return lipgloss.NewStyle().
Width(m.Settings().GutterSize).
Background(bg).
Foreground(fg)
}
func (m Model) visualHighlightStyle() lipgloss.Style {
bg := lipgloss.Color("#7a6a00")
return lipgloss.NewStyle().Background(bg)
}
func (m Model) commandErrorStyle() lipgloss.Style {
fg := lipgloss.Color("#e3203a")
return lipgloss.NewStyle().Foreground(fg)
}

View File

@ -4,49 +4,14 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// Model.Update: Handles BubbleTea messages including window resizes and key func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// presses. Routes input to the handler and adjusts scroll after updates.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.termHeight = msg.Height m.win_h = msg.Height
m.termWidth = msg.Width m.win_w = msg.Width
// TODO: Implement a layout method that handles this
//
// func (m *Model) layoutWindows() {
// if len(m.windows) == 0 {
// return
// }
//
// if len(m.windows) == 1 {
// // Single window - full screen
// m.windows[0].Width = m.termWidth
// m.windows[0].Height = m.termHeight
// return
// }
//
// // Multiple windows - distribute space
// // This is where you'd implement split layout logic
// // For example, horizontal split:
// halfHeight := m.termHeight / 2
// for i, win := range m.windows {
// win.Width = m.termWidth
// if i < len(m.windows)-1 {
// win.Height = halfHeight
// } else {
// // Last window gets remainder
// win.Height = m.termHeight - (halfHeight * (len(m.windows) - 1))
// }
// }
// }
for i := range m.windows {
m.windows[i].Height = msg.Height
m.windows[i].Width = msg.Width
}
case tea.KeyMsg: case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests. // TODO: This needs to be removed, but for now its required for the tests.
@ -54,12 +19,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC { if msg.Type == tea.KeyCtrlC {
return m, tea.Quit return m, tea.Quit
} }
cmd = m.input.Handle(m, msg.String()) cmd = m.input.Handle(&m, msg.String())
} }
// Keep cursor in view after any update // Keep cursor in view after any update
win := m.ActiveWindow() m.AdjustScroll()
win.AdjustScroll()
return m, cmd return m, cmd
} }

View File

@ -4,290 +4,22 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/style"
) )
// Model.View: Renders the complete editor view including buffer content, line func posInsideSelection(m Model, col, line int) bool {
// numbers, status bar, and command line. switch m.Mode() {
func (m Model) View() string { case action.VisualLineMode:
win := m.ActiveWindow() startY := min(m.AnchorY(), m.CursorY())
endY := max(m.AnchorY(), m.CursorY())
// NOTES:
// One single command line across entire viewport
// Each window has its own line numbers and gutter
// Each window has its own status bar and mode
styles := m.Styles()
options := win.Options
// Draw window
view := viewWindow(win, styles, options, m.Mode())
// Command bar is seperate
cmdBar := drawCommandBar(m)
return view + cmdBar
}
// viewWindow: Renders a single window's content including line numbers and buffer text.
// Each window has its own line numbers, gutter, and viewport dimensions.
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode) string {
buf := w.Buffer
var view strings.Builder
// Compute window size (y)
start := w.ScrollY
end := w.ScrollY + w.ViewportHeight()
// Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() {
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum)
view.WriteString(line)
}
view.WriteRune('\n')
}
// Draw status line
statusBar := drawStatusBar(w, mode)
view.WriteString(statusBar + "\n")
return view.String()
}
// drawLine: Renders a single line with syntax highlighting, cursor, and visual selection.
// Handles gutter, cursor rendering, and visual mode highlighting.
func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int) string {
runes := []rune(line)
curStyle := styles.CursorStyle(mode)
visStyle := styles.VisualHighlight
var view strings.Builder
// Draw gutter first
gutter := drawGutter(w, styles, options, lineNumber)
view.WriteString(gutter)
// Now draw the line content
for col := 0; col <= len(runes); col++ {
// Current char is cursor
if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
if col < len(runes) {
view.WriteString(curStyle.Render(string(runes[col])))
} else {
view.WriteString(curStyle.Render(" "))
}
// Not cursor, but not end
} else if col < len(runes) {
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(string(runes[col])))
} else {
view.WriteRune(runes[col])
}
// Allow highlight on blank lines or chars
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(" "))
}
}
return view.String()
}
// drawGutter: Renders the line number gutter with support for both absolute and
// relative line numbers, highlighting the current line differently.
func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, curLine int) string {
if !(options.Number || options.RelativeNumber) {
return ""
}
// Required vars
var (
view strings.Builder
gutSize int = options.GutterSize - 1 // -1 is for padding
currentLine bool = curLine == w.Cursor.Line
lineNumber int
gutter string
gutterStyle = styles.Gutter
gutterStyleCur = styles.GutterCurrentLine
)
// If we have relative setting, set the numbers relatively
if options.RelativeNumber {
if curLine > w.Cursor.Line {
lineNumber = curLine - w.Cursor.Line
}
if curLine < w.Cursor.Line {
lineNumber = w.Cursor.Line - curLine
}
}
// If we have number setting AND not relative setting OR we are on current line, use current line number
if (options.Number && !options.RelativeNumber) || (options.Number && currentLine) {
lineNumber = curLine + 1
}
// Draw the gutter
gutter = fmt.Sprintf("%*d ", gutSize, lineNumber)
if currentLine {
view.WriteString(gutterStyleCur.Render(gutter))
} else {
view.WriteString(gutterStyle.Render(gutter))
}
return view.String()
// if m.Settings().Number || m.Settings().RelativeNumber {
// var (
// gutter string
// currentLine bool = false
// lineNumber int
// )
//
// if m.Settings().RelativeNumber {
// // Relative line numbers: show distance from cursor, current line shows absolute
// if i > win.Cursor.Line {
// lineNumber = i - win.Cursor.Line
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else if i < win.Cursor.Line {
// lineNumber = win.Cursor.Line - i
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else {
// // Current line: show absolute number if Number is also set, otherwise show 0
// currentLine = true
// if m.Settings().Number {
// lineNumber = i + 1
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// } else {
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, 0)
// }
// }
// } else if m.Settings().Number {
// // Absolute line numbers only
// lineNumber = i + 1
// currentLine = (i == win.Cursor.Line)
// gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
// }
// if currentLine {
// view.WriteString(m.Styles().GutterCurrentLine.Render(gutter))
// } else {
// view.WriteString(m.Styles().Gutter.Render(gutter))
// }
// }
}
// drawStatusBar: Renders the status bar with mode and cursor position,
// padding the middle with spaces to fill the terminal width.
func drawStatusBar(w *core.Window, mode core.Mode) string {
left := leftBar(w, mode)
right := rightBar(w, mode)
diff := w.Width - (len(left) + len(right))
// This happens when the terminal spawns
if diff <= 0 {
return ""
}
middle := strings.Repeat(" ", diff)
return left + middle + right
}
// leftBar: Returns the left side of the status bar showing the current mode.
func leftBar(w *core.Window, mode core.Mode) string {
buf := w.Buffer
var flags []string
if buf.Modified {
flags = append(flags, "!")
}
if buf.ReadOnly {
flags = append(flags, "x")
}
var flagStr string
if len(flags) > 0 {
flagStr = "(" + strings.Join(flags, "") + ")"
}
return fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr)
}
// rightBar: Returns the right side of the status bar showing cursor position
// and selection count in visual mode.
func rightBar(w *core.Window, mode core.Mode) (bar string) {
if mode.IsVisualMode() {
lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1
bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount)
} else {
bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1)
}
buf := w.Buffer
bar = fmt.Sprintf("%s %s", buf.Filetype, bar)
return
}
// drawCommandBar: Renders the command line showing command input, errors, or
// output depending on the current mode and state.
func drawCommandBar(m Model) string {
// Compute left bar (command side)
var leftBar string
if m.Mode() == core.CommandMode {
leftBar = ":"
cmd := m.Command()
cur := m.CommandCursor()
for i := 0; i < len(cmd); i++ {
if i == cur {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(string(cmd[i]))
} else {
leftBar += string(cmd[i])
}
}
// Cursor at end of command
if cur >= len(cmd) {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(" ")
}
// bar = fmt.Sprintf("%s %d", bar, cur)
} else if m.CommandError() != nil {
leftBar = m.Styles().CommandError.Render(m.CommandError().Error())
} else if strings.TrimSpace(m.CommandOutput()) != "" {
leftBar = m.CommandOutput()
} else if strings.TrimSpace(m.Command()) != "" {
leftBar = fmt.Sprintf(":%s", m.Command())
}
// Compute right bar
var rightBar string
if len(m.input.Pending()) > 0 {
width := 10 // Size of the block to display
rightBar = fmt.Sprintf("%-*s", width, m.input.Pending())
}
dif := m.termWidth - (len(leftBar) + len(rightBar))
bar := leftBar + strings.Repeat(" ", dif) + rightBar
return bar
}
// posInsideSelection: Returns true if the given position is inside the current
// visual selection, handling all three visual modes differently.
func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
switch mode {
case core.VisualLineMode:
startY := min(w.Anchor.Line, w.Cursor.Line)
endY := max(w.Anchor.Line, w.Cursor.Line)
return line >= startY && line <= endY return line >= startY && line <= endY
case core.VisualMode: case action.VisualMode:
ax := w.Anchor.Col ax := m.AnchorX()
ay := w.Anchor.Line ay := m.AnchorY()
cx := w.Cursor.Col cx := m.CursorX()
cy := w.Cursor.Line cy := m.CursorY()
// Normalize so start is always before end in document order // Normalize so start is always before end in document order
var startX, startY, endX, endY int var startX, startY, endX, endY int
@ -304,11 +36,11 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
beforeEnd := line < endY || (line == endY && col <= endX) beforeEnd := line < endY || (line == endY && col <= endX)
return afterStart && beforeEnd return afterStart && beforeEnd
case core.VisualBlockMode: case action.VisualBlockMode:
startX := min(w.Anchor.Col, w.Cursor.Col) startX := min(m.AnchorX(), m.CursorX())
startY := min(w.Anchor.Line, w.Cursor.Line) startY := min(m.AnchorY(), m.CursorY())
endX := max(w.Anchor.Col, w.Cursor.Col) endX := max(m.AnchorX(), m.CursorX())
endY := max(w.Anchor.Line, w.Cursor.Line) endY := max(m.AnchorY(), m.CursorY())
return col >= startX && col <= endX && return col >= startX && col <= endX &&
line >= startY && line <= endY line >= startY && line <= endY
@ -317,3 +49,152 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
return false return false
} }
} }
func posIsAnchor(m Model, col, line int) bool {
ax := m.AnchorX()
ay := m.AnchorY()
return col == ax && line == ay
}
func (m Model) View() string {
var view strings.Builder
viewportHeight := m.ViewPortH()
start := m.ScrollY()
end := m.ScrollY() + viewportHeight
for i := start; i < end; i++ {
if i < m.LineCount() {
if m.Settings().Number || m.Settings().RelativeNumber {
var (
gutter string
currentLine bool = false
lineNumber int
)
if m.Settings().RelativeNumber {
// Relative line numbers: show distance from cursor, current line shows absolute
if i > m.CursorY() {
lineNumber = i - m.CursorY()
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else if i < m.CursorY() {
lineNumber = m.CursorY() - i
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else {
// Current line: show absolute number if Number is also set, otherwise show 0
currentLine = true
if m.Settings().Number {
lineNumber = i + 1
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
} else {
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, 0)
}
}
} else if m.Settings().Number {
// Absolute line numbers only
lineNumber = i + 1
currentLine = (i == m.CursorY())
gutter = fmt.Sprintf("%*d ", m.Settings().GutterSize-1, lineNumber)
}
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
}
runes := []rune(m.Line(i))
for x := 0; x <= len(runes); x++ {
if m.CursorY() == i && m.CursorX() == x {
if x < len(runes) {
view.WriteString(m.cursorStyle().Render(string(runes[x])))
} else {
view.WriteString(m.cursorStyle().Render(" "))
}
} else if x < len(runes) {
if m.Mode().IsVisualMode() && posIsAnchor(m, x, i) {
view.WriteString(m.visualAnchorStyle().Render(string(runes[x])))
} else if m.Mode().IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.visualHighlightStyle().Render(string(runes[x])))
} else {
view.WriteRune(runes[x])
}
// To highlight blank lines when in visual mode
} else if m.Mode().IsVisualMode() && posInsideSelection(m, x, i) {
view.WriteString(m.visualHighlightStyle().Render(" "))
}
}
} else {
// Empty lines beyond file content
if m.Settings().Number || m.Settings().RelativeNumber {
format := fmt.Sprintf("%%-%ds ", m.Settings().GutterSize-1)
fmt.Fprintf(&view, format, "~")
} else {
view.WriteString("~")
}
}
view.WriteString("\n")
}
view.WriteString(drawStatusBar(m))
view.WriteString("\n")
view.WriteString(drawCommandBar(m))
return view.String()
}
func drawStatusBar(m Model) string {
left := leftBar(m)
right := rightBar(m)
diff := m.win_w - (len(left) + len(right))
// This happens when the terminal spawns
if diff <= 0 {
return ""
}
middle := strings.Repeat(" ", diff)
return left + middle + right
}
func leftBar(m Model) string {
return fmt.Sprintf(" %s", m.Mode().ToString())
}
func rightBar(m Model) (bar string) {
if m.Mode().IsVisualMode() {
lineCount := max(m.AnchorY(), m.CursorY()) - min(m.AnchorY(), m.CursorY()) + 1
bar = fmt.Sprintf("%d:%d <%d>", m.CursorY(), m.CursorX(), lineCount)
} else {
bar = fmt.Sprintf("%d:%d ", m.CursorY(), m.CursorX())
}
return
}
func drawCommandBar(m Model) (bar string) {
if m.Mode() == action.CommandMode {
bar = ":"
cmd := m.Command()
cur := m.CommandCursor()
for i := 0; i < len(cmd); i++ {
if i == cur {
bar += m.cursorStyle().Render(string(cmd[i]))
} else {
bar += string(cmd[i])
}
}
// Cursor at end of command
if cur >= len(cmd) {
bar += m.cursorStyle().Render(" ")
}
// bar = fmt.Sprintf("%s %d", bar, cur)
} else if m.CommandError() != nil {
bar = m.commandErrorStyle().Render(m.CommandError().Error())
} else if strings.TrimSpace(m.CommandOutput()) != "" {
bar = m.CommandOutput()
} else if strings.TrimSpace(m.Command()) != "" {
bar = fmt.Sprintf(":%s", m.Command())
}
return bar
}

View File

@ -2,11 +2,9 @@ package input
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// InputState: Represents the current state of the input handler state machine.
type InputState int type InputState int
const ( const (
@ -16,8 +14,11 @@ const (
StateMotionCount StateMotionCount
) )
// Handler: Manages input processing with a state machine for vim-style commands. // PositionGetter is used to get cursor position for operator ranges
// Handles counts, operators, motions, and multi-key sequences. type PositionGetter interface {
GetCursorPosition() action.Position
}
type Handler struct { type Handler struct {
state InputState state InputState
count1 int count1 int
@ -36,7 +37,6 @@ type Handler struct {
currentKeymap *Keymap currentKeymap *Keymap
} }
// NewHandler: Creates a new input handler with initialized keymaps for all modes.
func NewHandler() *Handler { func NewHandler() *Handler {
return &Handler{ return &Handler{
// keymap: NewNormalKeymap(), // keymap: NewNormalKeymap(),
@ -48,25 +48,23 @@ func NewHandler() *Handler {
} }
} }
// Handler.Handle: Main entry point for processing a keypress. Routes to appropriate
// handler based on current mode and state.
func (h *Handler) Handle(m action.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()
if m.Mode() == core.InsertMode { if m.Mode() == action.InsertMode {
m.ExitInsertMode() m.ExitInsertMode()
} else { } else {
m.SetMode(core.NormalMode) m.SetMode(action.NormalMode)
} }
return nil return nil
} }
// Insert/command mode bypasses the normal state machine entirely // Insert/command mode bypasses the normal state machine entirely
switch m.Mode() { switch m.Mode() {
case core.InsertMode: case action.InsertMode:
return h.handleInsertKey(m, key) return h.handleInsertKey(m, key)
case core.CommandMode: case action.CommandMode:
return h.handleCommandKey(m, key) return h.handleCommandKey(m, key)
} }
@ -80,11 +78,11 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
// Set working keymap // Set working keymap
switch m.Mode() { switch m.Mode() {
case core.NormalMode: case action.NormalMode:
h.currentKeymap = h.normalKeymap h.currentKeymap = h.normalKeymap
case core.VisualMode, case action.VisualMode,
core.VisualLineMode, action.VisualLineMode,
core.VisualBlockMode: action.VisualBlockMode:
h.currentKeymap = h.visualKeymap h.currentKeymap = h.visualKeymap
} }
@ -118,7 +116,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
return nil return nil
} }
// Handler.dispatch: Routes to the appropriate handler based on current state. // dispatch routes to the right handler based on current state
func (h *Handler) dispatch(m action.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:
@ -130,8 +128,6 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string)
return nil return nil
} }
// Handler.handleInitial: Handles input when no operator is pending. Executes
// motions, actions, or stores operators waiting for a motion.
func (h *Handler) handleInitial(m action.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()
@ -151,14 +147,14 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if m.Mode().IsVisualMode() { if m.Mode().IsVisualMode() {
start, end := normalizeVisualSelection(m) start, end := normalizeVisualSelection(m)
// Visual line mode is linewise, others are charwise inclusive // Visual line mode is linewise, others are charwise inclusive
mtype := core.CharwiseInclusive mtype := action.CharwiseInclusive
if m.Mode() == core.VisualLineMode { if m.Mode() == action.VisualLineMode {
mtype = core.Linewise mtype = action.Linewise
} }
cmd := op.Operate(m, start, end, mtype) cmd := op.Operate(m, start, end, mtype)
// Only reset to normal mode if operator didn't enter insert mode // Only reset to normal mode if operator didn't enter insert mode
if m.Mode() != core.InsertMode { if m.Mode() != action.InsertMode {
m.SetMode(core.NormalMode) m.SetMode(action.NormalMode)
} }
h.Reset() h.Reset()
return cmd return cmd
@ -183,11 +179,8 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
return nil return nil
} }
// Handler.handleAfterOperator: Handles input when an operator is pending.
// Processes double-press operators (dd, yy) or applies operator to motion range.
func (h *Handler) handleAfterOperator(m action.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()
win := m.ActiveWindow()
// dd, yy, cc - same operator key pressed twice // dd, yy, cc - same operator key pressed twice
if kind == "operator" && key == h.operatorKey { if kind == "operator" && key == h.operatorKey {
@ -208,9 +201,10 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
mot = r.WithCount(count).(action.Motion) mot = r.WithCount(count).(action.Motion)
} }
// Get range and motion type // Get range and motion type
start := win.Cursor pg := m.(PositionGetter)
start := pg.GetCursorPosition()
mot.Execute(m) mot.Execute(m)
end := win.Cursor end := pg.GetCursorPosition()
cmd := h.operator.Operate(m, start, end, mot.Type()) cmd := h.operator.Operate(m, start, end, mot.Type())
h.Reset() h.Reset()
return cmd return cmd
@ -220,8 +214,6 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
return nil return nil
} }
// Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns
// true if successful, false if the key is not a digit or is an invalid count.
func (h *Handler) 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
@ -247,7 +239,6 @@ func (h *Handler) tryAccumulateCount(key string) bool {
return true return true
} }
// Handler.currentCount: Returns the count currently being accumulated based on state.
func (h *Handler) 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
@ -255,8 +246,6 @@ func (h *Handler) currentCount() int {
return h.count1 return h.count1
} }
// Handler.effectiveCount: Calculates the final count by multiplying count1 and
// count2, treating 0 as 1 for both.
func (h *Handler) 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 {
@ -268,7 +257,6 @@ func (h *Handler) effectiveCount() int {
return c1 * c2 return c1 * c2
} }
// Handler.Reset: Clears all handler state including counts, operators, and buffers.
func (h *Handler) Reset() { func (h *Handler) Reset() {
h.state = StateReady h.state = StateReady
h.count1 = 0 h.count1 = 0
@ -279,13 +267,10 @@ func (h *Handler) Reset() {
h.pending = "" h.pending = ""
} }
// Handler.Pending: Returns the accumulated input buffer for display.
func (h *Handler) Pending() string { func (h *Handler) Pending() string {
return h.buffer return h.buffer
} }
// Handler.handleInsertKey: Processes a keypress in insert mode, recording it
// for count replay and executing it as an action or character insertion.
func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd { func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
// Record the key for count replay (e.g. 5i...) // Record the key for count replay (e.g. 5i...)
m.SetInsertKeys(append(m.InsertKeys(), key)) m.SetInsertKeys(append(m.InsertKeys(), key))
@ -303,8 +288,6 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
return action.InsertChar{Char: key}.Execute(m) return action.InsertChar{Char: key}.Execute(m)
} }
// Handler.handleCommandKey: Processes a keypress in command mode, executing
// it as an action or inserting it into the command line.
func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd { func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
kind, binding := h.commandKeymap.Lookup(key) kind, binding := h.commandKeymap.Lookup(key)
switch kind { switch kind {
@ -318,12 +301,9 @@ func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
return action.InsertCommandChar{Char: key}.Execute(m) return action.InsertCommandChar{Char: key}.Execute(m)
} }
// normalizeVisualSelection: Returns the visual selection with start before end, func normalizeVisualSelection(m action.Model) (action.Position, action.Position) {
// regardless of which direction the selection was made. a := action.Position{Line: m.AnchorY(), Col: m.AnchorX()}
func normalizeVisualSelection(m action.Model) (core.Position, core.Position) { c := action.Position{Line: m.CursorY(), Col: m.CursorX()}
win := m.ActiveWindow()
a := core.Position{Line: win.Anchor.Line, Col: win.Anchor.Col}
c := core.Position{Line: win.Cursor.Line, Col: win.Cursor.Col}
if a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col) { if a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col) {
return a, c return a, c
} }

View File

@ -7,14 +7,12 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/operator" "git.gophernest.net/azpect/TextEditor/internal/operator"
) )
// Keymap: Maps key sequences to motions, operators, and actions.
type Keymap struct { type Keymap struct {
motions map[string]action.Motion motions map[string]action.Motion
operators map[string]action.Operator operators map[string]action.Operator
actions map[string]action.Action // standalone actions: i.e., 'i', 'a' actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
} }
// NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings.
func NewNormalKeymap() *Keymap { func NewNormalKeymap() *Keymap {
return &Keymap{ return &Keymap{
motions: map[string]action.Motion{ motions: map[string]action.Motion{
@ -66,7 +64,6 @@ func NewNormalKeymap() *Keymap {
} }
} }
// NewVisualKeymap: Creates a keymap for visual modes (character, line, block).
func NewVisualKeymap() *Keymap { func NewVisualKeymap() *Keymap {
return &Keymap{ return &Keymap{
motions: map[string]action.Motion{ motions: map[string]action.Motion{
@ -100,7 +97,6 @@ func NewVisualKeymap() *Keymap {
} }
} }
// NewInsertKeymap: Creates a keymap for insert mode with editing actions.
func NewInsertKeymap() *Keymap { func NewInsertKeymap() *Keymap {
return &Keymap{ return &Keymap{
motions: map[string]action.Motion{ motions: map[string]action.Motion{
@ -121,7 +117,6 @@ func NewInsertKeymap() *Keymap {
} }
// NewCommandKeymap: Creates a keymap for command mode with command line editing.
func NewCommandKeymap() *Keymap { func NewCommandKeymap() *Keymap {
return &Keymap{ return &Keymap{
motions: map[string]action.Motion{ motions: map[string]action.Motion{
@ -140,7 +135,7 @@ func NewCommandKeymap() *Keymap {
} }
// Keymap.Lookup: Returns the type and value of a key binding (motion, operator, or action). // Lookup returns what type of binding a key is
func (km *Keymap) Lookup(key string) (kind string, value any) { func (km *Keymap) Lookup(key string) (kind string, value any) {
if m, ok := km.motions[key]; ok { if m, ok := km.motions[key]; ok {
return "motion", m return "motion", m
@ -154,7 +149,7 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
return "", nil return "", nil
} }
// Keymap.HasPrefix: Returns true if any binding starts with the given prefix. // HasPrefix returns true if any binding starts with this prefix
func (km *Keymap) HasPrefix(prefix string) bool { func (km *Keymap) HasPrefix(prefix string) bool {
for key := range km.motions { for key := range km.motions {
if len(key) > len(prefix) && key[:len(prefix)] == prefix { if len(key) > len(prefix) && key[:len(prefix)] == prefix {

View File

@ -1,9 +1,8 @@
package motion package motion
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/action"
) )
// MoveDown implements Motion (j) - linewise // MoveDown implements Motion (j) - linewise
@ -11,18 +10,15 @@ type MoveDown struct {
Count int Count int
} }
// MoveDown.Execute: Moves the cursor down by Count lines without going past
// the last line of the buffer.
func (a MoveDown) Execute(m action.Model) tea.Cmd { func (a MoveDown) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() for i := 0; i < a.Count && m.CursorY() < m.LineCount()-1; i++ {
buf := m.ActiveBuffer() m.SetCursorY(m.CursorY() + 1)
for i := 0; i < a.Count && win.Cursor.Line < buf.LineCount()-1; i++ {
win.SetCursorLine(win.Cursor.Line + 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveDown) Type() core.MotionType { return core.Linewise } func (a MoveDown) Type() action.MotionType { return action.Linewise }
func (a MoveDown) WithCount(n int) action.Action { func (a MoveDown) WithCount(n int) action.Action {
return MoveDown{Count: n} return MoveDown{Count: n}
@ -33,17 +29,15 @@ type MoveUp struct {
Count int Count int
} }
// MoveUp.Execute: Moves the cursor up by Count lines without going above
// line 0.
func (a MoveUp) Execute(m action.Model) tea.Cmd { func (a MoveUp) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() for i := 0; i < a.Count && m.CursorY() > 0; i++ {
for i := 0; i < a.Count && win.Cursor.Line > 0; i++ { m.SetCursorY(m.CursorY() - 1)
win.SetCursorLine(win.Cursor.Line - 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveUp) Type() core.MotionType { return core.Linewise } func (a MoveUp) Type() action.MotionType { return action.Linewise }
func (a MoveUp) WithCount(n int) action.Action { func (a MoveUp) WithCount(n int) action.Action {
return MoveUp{Count: n} return MoveUp{Count: n}
@ -54,17 +48,15 @@ type MoveLeft struct {
Count int Count int
} }
// MoveLeft.Execute: Moves the cursor left by Count columns without going
// past column 0.
func (a MoveLeft) Execute(m action.Model) tea.Cmd { func (a MoveLeft) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() for i := 0; i < a.Count && m.CursorX() > 0; i++ {
for i := 0; i < a.Count && win.Cursor.Col > 0; i++ { m.SetCursorX(m.CursorX() - 1)
win.SetCursorCol(win.Cursor.Col - 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveLeft) Type() core.MotionType { return core.CharwiseExclusive } func (a MoveLeft) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveLeft) WithCount(n int) action.Action { func (a MoveLeft) WithCount(n int) action.Action {
return MoveLeft{Count: n} return MoveLeft{Count: n}
@ -75,19 +67,16 @@ type MoveRight struct {
Count int Count int
} }
// MoveRight.Execute: Moves the cursor right by Count columns without going
// past the end of the line.
func (a MoveRight) Execute(m action.Model) tea.Cmd { func (a MoveRight) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() lineLen := len(m.Line(m.CursorY()))
buf := m.ActiveBuffer() for i := 0; i < a.Count && m.CursorX() <= lineLen; i++ {
lineLen := len(buf.Lines[win.Cursor.Line]) m.SetCursorX(m.CursorX() + 1)
for i := 0; i < a.Count && win.Cursor.Col <= lineLen; i++ {
win.SetCursorCol(win.Cursor.Col + 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveRight) Type() core.MotionType { return core.CharwiseExclusive } func (a MoveRight) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveRight) WithCount(n int) action.Action { func (a MoveRight) WithCount(n int) action.Action {
return MoveRight{Count: n} return MoveRight{Count: n}

View File

@ -2,32 +2,25 @@ package motion
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// MoveCommandLeft implements Motion - moves cursor left in command line.
type MoveCommandLeft struct{} type MoveCommandLeft struct{}
// MoveCommandLeft.Execute: Moves the command line cursor one position to the left.
func (a MoveCommandLeft) Execute(m action.Model) tea.Cmd { func (a MoveCommandLeft) Execute(m action.Model) tea.Cmd {
// The set function handles bounds // The set function handles bounds
m.SetCommandCursor(m.CommandCursor() - 1) m.SetCommandCursor(m.CommandCursor() - 1)
return nil return nil
} }
// MoveCommandLeft.Type: Returns CharwiseExclusive for command line motion. func (a MoveCommandLeft) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveCommandLeft) Type() core.MotionType { return core.CharwiseExclusive }
// MoveCommandRight implements Motion - moves cursor right in command line.
type MoveCommandRight struct{} type MoveCommandRight struct{}
// MoveCommandRight.Execute: Moves the command line cursor one position to the right.
func (a MoveCommandRight) Execute(m action.Model) tea.Cmd { func (a MoveCommandRight) Execute(m action.Model) tea.Cmd {
// The set function handles bounds // The set function handles bounds
m.SetCommandCursor(m.CommandCursor() + 1) m.SetCommandCursor(m.CommandCursor() + 1)
return nil return nil
} }
// MoveCommandRight.Type: Returns CharwiseExclusive for command line motion. func (a MoveCommandRight) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveCommandRight) Type() core.MotionType { return core.CharwiseExclusive }

View File

@ -2,70 +2,58 @@ package motion
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// MoveToTop implements Motion (gg) - linewise // MoveToTop implements Motion (gg) - linewise
type MoveToTop struct{} type MoveToTop struct{}
// MoveToTop.Execute: Moves the cursor to the first line of the buffer.
func (a MoveToTop) Execute(m action.Model) tea.Cmd { func (a MoveToTop) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorY(0)
win.SetCursorLine(0) m.ClampCursorX()
return nil return nil
} }
func (a MoveToTop) Type() core.MotionType { return core.Linewise } func (a MoveToTop) Type() action.MotionType { return action.Linewise }
// MoveToBottom implements Motion (G) - linewise // MoveToBottom implements Motion (G) - linewise
type MoveToBottom struct{} type MoveToBottom struct{}
// MoveToBottom.Execute: Moves the cursor to the last line of the buffer.
func (a MoveToBottom) Execute(m action.Model) tea.Cmd { func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorY(m.LineCount() - 1)
buf := m.ActiveBuffer() m.ClampCursorX()
win.SetCursorLine(buf.LineCount() - 1)
return nil return nil
} }
func (a MoveToBottom) Type() core.MotionType { return core.Linewise } func (a MoveToBottom) Type() action.MotionType { return action.Linewise }
// MoveToLineStart implements Motion (0) - charwise // MoveToLineStart implements Motion (0) - charwise
type MoveToLineStart struct{} type MoveToLineStart struct{}
// MoveToLineStart.Execute: Moves the cursor to the beginning of the current line.
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd { func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorX(0)
win.SetCursorCol(0) m.ClampCursorX()
return nil return nil
} }
func (a MoveToLineStart) Type() core.MotionType { return core.CharwiseExclusive } func (a MoveToLineStart) Type() action.MotionType { return action.CharwiseExclusive }
// MoveToLineEnd implements Motion ($) - charwise // MoveToLineEnd implements Motion ($) - charwise
type MoveToLineEnd struct{} type MoveToLineEnd struct{}
// MoveToLineEnd.Execute: Moves the cursor to the end of the current line.
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd { func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() m.SetCursorX(len(m.Line(m.CursorY())))
buf := m.ActiveBuffer() m.ClampCursorX()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
return nil return nil
} }
func (a MoveToLineEnd) Type() core.MotionType { return core.CharwiseInclusive } func (a MoveToLineEnd) Type() action.MotionType { return action.CharwiseInclusive }
// MoveToLineContentStart implements Motion (_) - charwise // MoveToLineContentStart implements Motion (_) - charwise
type MoveToLineContentStart struct{} type MoveToLineContentStart struct{}
// MoveToLineContentStart.Execute: Moves the cursor to the first non-whitespace
// character on the current line.
func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd { func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() line := m.Line(m.CursorY())
buf := m.ActiveBuffer()
line := buf.Lines[win.Cursor.Line]
x := 0 x := 0
for x < len(line) { for x < len(line) {
ch := line[x] ch := line[x]
@ -80,30 +68,27 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
x-- x--
} }
win.SetCursorCol(x) m.SetCursorX(x)
return nil return nil
} }
func (a MoveToLineContentStart) Type() core.MotionType { return core.CharwiseExclusive } func (a MoveToLineContentStart) Type() action.MotionType { return action.CharwiseExclusive }
// MoveToColumn implements Motion (|) - charwise // MoveToColumn implements Motion (|) - charwise
type MoveToColumn struct { type MoveToColumn struct {
Count int Count int
} }
// MoveToColumn.Execute: Moves the cursor to the column specified by Count.
func (a MoveToColumn) Execute(m action.Model) tea.Cmd { func (a MoveToColumn) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() line := m.Line(m.CursorY())
buf := m.ActiveBuffer()
line := buf.Lines[win.Cursor.Line]
col := min(a.Count-1, len(line)-1) col := min(a.Count-1, len(line)-1)
win.SetCursorCol(col) m.SetCursorX(col)
m.ClampCursorX()
return nil return nil
} }
func (a MoveToColumn) Type() core.MotionType { return core.CharwiseExclusive } func (a MoveToColumn) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveToColumn) WithCount(n int) action.Action { func (a MoveToColumn) WithCount(n int) action.Action {
return MoveToColumn{Count: n} return MoveToColumn{Count: n}
@ -114,28 +99,23 @@ func (a MoveToColumn) WithCount(n int) action.Action {
// ScrollDownHalfPage implements Motion (ctrl+d) - linewise // ScrollDownHalfPage implements Motion (ctrl+d) - linewise
type ScrollDownHalfPage struct{} type ScrollDownHalfPage struct{}
// ScrollDownHalfPage.Execute: Scrolls down half a page while maintaining the
// cursor's relative position in the viewport.
func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd { func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() viewportHeight := m.ViewPortH()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
if viewportHeight <= 0 { if viewportHeight <= 0 {
return nil return nil
} }
scroll := viewportHeight / 2 scroll := viewportHeight / 2
scrollOff := win.Options.ScrollOff scrollOff := m.Settings().ScrollOff
// Current relative position in viewport // Current relative position in viewport
relY := win.Cursor.Line - win.ScrollY relY := m.CursorY() - m.ScrollY()
// Scroll down, clamped to valid range // Scroll down, clamped to valid range
newScrollY := win.ScrollY + scroll newScrollY := m.ScrollY() + scroll
maxScroll := max(0, buf.LineCount()-viewportHeight) maxScroll := max(0, m.LineCount()-viewportHeight)
newScrollY = min(newScrollY, maxScroll) newScrollY = min(newScrollY, maxScroll)
win.SetScrollY(newScrollY) m.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff // Maintain relative position, respecting scrollOff
if relY < scrollOff { if relY < scrollOff {
@ -146,37 +126,33 @@ func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
} }
newCursorY := newScrollY + relY newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, buf.LineCount()-1)) newCursorY = max(0, min(newCursorY, m.LineCount()-1))
win.SetCursorLine(newCursorY) m.SetCursorY(newCursorY)
m.ClampCursorX()
return nil return nil
} }
func (a ScrollDownHalfPage) Type() core.MotionType { return core.Linewise } func (a ScrollDownHalfPage) Type() action.MotionType { return action.Linewise }
// ScrollUpHalfPage implements Motion (ctrl+u) - linewise // ScrollUpHalfPage implements Motion (ctrl+u) - linewise
type ScrollUpHalfPage struct{} type ScrollUpHalfPage struct{}
// ScrollUpHalfPage.Execute: Scrolls up half a page while maintaining the
// cursor's relative position in the viewport.
func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd { func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() viewportHeight := m.ViewPortH()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
if viewportHeight <= 0 { if viewportHeight <= 0 {
return nil return nil
} }
scroll := viewportHeight / 2 scroll := viewportHeight / 2
scrollOff := win.Options.ScrollOff scrollOff := m.Settings().ScrollOff
// Current relative position in viewport // Current relative position in viewport
relY := win.Cursor.Line - win.ScrollY relY := m.CursorY() - m.ScrollY()
// Scroll up, clamped to valid range // Scroll up, clamped to valid range
newScrollY := win.ScrollY - scroll newScrollY := m.ScrollY() - scroll
newScrollY = max(0, newScrollY) newScrollY = max(0, newScrollY)
win.SetScrollY(newScrollY) m.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff // Maintain relative position, respecting scrollOff
if relY < scrollOff { if relY < scrollOff {
@ -187,10 +163,11 @@ func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
} }
newCursorY := newScrollY + relY newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, buf.LineCount()-1)) newCursorY = max(0, min(newCursorY, m.LineCount()-1))
win.SetCursorLine(newCursorY) m.SetCursorY(newCursorY)
m.ClampCursorX()
return nil return nil
} }
func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise } func (a ScrollUpHalfPage) Type() action.MotionType { return action.Linewise }

View File

@ -2,12 +2,9 @@ package motion
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// isWordChar: Returns true if the character is a word character (alphanumeric
// or underscore).
func isWordChar(c byte) bool { func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') || return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || (c >= 'A' && c <= 'Z') ||
@ -15,16 +12,12 @@ func isWordChar(c byte) bool {
c == '_' c == '_'
} }
// isWordPunctuation: Returns true if the character is punctuation (not whitespace
// and not a word character).
func isWordPunctuation(c byte) bool { func isWordPunctuation(c byte) bool {
return c != ' ' && c != '\t' && !isWordChar(c) return c != ' ' && c != '\t' && !isWordChar(c)
} }
// nextWordStart: Finds the start of the next word from position (x,y), handling func nextWordStart(m action.Model, x, y int) (int, int) {
// word boundaries and line crossing. line := m.Line(y)
func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
// Skip current class // Skip current class
if x < len(line) { if x < len(line) {
@ -53,13 +46,13 @@ func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
} }
// If next line is the end of the file, exit now // If next line is the end of the file, exit now
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = buf.Lines[y] line = m.Line(y)
x = 0 x = 0
// If the first char of the new line is no whitespace, stay here! // If the first char of the new line is no whitespace, stay here!
@ -71,10 +64,8 @@ func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
return x, y return x, y
} }
// nextWORDStart: Finds the start of the next WORD from position (x,y), treating func nextWORDStart(m action.Model, x, y int) (int, int) {
// all non-whitespace as a single class. line := m.Line(y)
func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
// Skip current WORD (all non-whitespace is one class for W) // Skip current WORD (all non-whitespace is one class for W)
for x < len(line) && line[x] != ' ' && line[x] != '\t' { for x < len(line) && line[x] != ' ' && line[x] != '\t' {
@ -94,13 +85,13 @@ func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
} }
// If next line is the end of the file, exit now // If next line is the end of the file, exit now
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = buf.Lines[y] line = m.Line(y)
x = 0 x = 0
// If the first char of the new line is no whitespace, stay here! // If the first char of the new line is no whitespace, stay here!
@ -112,23 +103,21 @@ func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
return x, y return x, y
} }
// nextWordEnd: Finds the end of the next word from position (x,y), respecting func nextWordEnd(m action.Model, x, y int) (int, int) {
// word character classes. line := m.Line(y)
func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
// Advance once to avoid being stuck on the current end // Advance once to avoid being stuck on the current end
x++ x++
if x >= len(line) { if x >= len(line) {
// At last line of file, pin cursor to end of file // At last line of file, pin cursor to end of file
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return len(line) - 1, y return len(line) - 1, y
} }
// Otherwise, move to next line // Otherwise, move to next line
y++ y++
x = 0 x = 0
line = buf.Lines[y] line = m.Line(y)
} }
// Skip whitespace and cross lines if needed // Skip whitespace and cross lines if needed
@ -144,13 +133,13 @@ func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
} }
// If next line is the end of the file, exit now // If next line is the end of the file, exit now
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = buf.Lines[y] line = m.Line(y)
x = 0 x = 0
} }
@ -171,23 +160,21 @@ func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
return x, y return x, y
} }
// nextWORDEnd: Finds the end of the next WORD from position (x,y), treating func nextWORDEnd(m action.Model, x, y int) (int, int) {
// all non-whitespace as a single class. line := m.Line(y)
func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
// Advance once to avoid being stuck on the current end // Advance once to avoid being stuck on the current end
x++ x++
if x >= len(line) { if x >= len(line) {
// At last line of file, pin cursor to end of file // At last line of file, pin cursor to end of file
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return len(line) - 1, y return len(line) - 1, y
} }
// Otherwise, move to next line // Otherwise, move to next line
y++ y++
x = 0 x = 0
line = buf.Lines[y] line = m.Line(y)
} }
// Skip whitespace and cross lines if needed // Skip whitespace and cross lines if needed
@ -203,13 +190,13 @@ func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
} }
// If next line is the end of the file, exit now // If next line is the end of the file, exit now
if y+1 >= buf.LineCount() { if y+1 >= m.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = buf.Lines[y] line = m.Line(y)
x = 0 x = 0
} }
@ -221,10 +208,8 @@ func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
return x, y return x, y
} }
// prevWordStart: Finds the start of the previous word from position (x,y), func prevWordStart(m action.Model, x, y int) (int, int) {
// moving backward through character classes. line := m.Line(y)
func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
// Back one to avoid being stuck on the current start // Back one to avoid being stuck on the current start
x-- x--
@ -233,7 +218,7 @@ func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
return 0, 0 // beginning of file, stay put return 0, 0 // beginning of file, stay put
} }
y-- y--
line = buf.Lines[y] line = m.Line(y)
x = len(line) - 1 x = len(line) - 1
if x < 0 { if x < 0 {
return 0, y // landed on an empty line return 0, y // landed on an empty line
@ -252,7 +237,7 @@ func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
return 0, 0 return 0, 0
} }
y-- y--
line = buf.Lines[y] line = m.Line(y)
x = len(line) - 1 x = len(line) - 1
if len(line) == 0 { if len(line) == 0 {
return 0, y // empty line acts as a word boundary return 0, y // empty line acts as a word boundary
@ -278,25 +263,19 @@ type MoveForwardWord struct {
Count int Count int
} }
// MoveForwardWord.Execute: Moves the cursor forward by Count words (w motion).
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd { func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() x := m.CursorX()
buf := m.ActiveBuffer() y := m.CursorY()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ { for i := 0; i < a.Count; i++ {
x, y = nextWordStart(buf, x, y) x, y = nextWordStart(m, x, y)
} }
win.SetCursorCol(x) m.SetCursorX(x)
win.SetCursorLine(y) m.SetCursorY(y)
return nil return nil
} }
// MoveForwardWord.Type: Returns CharwiseExclusive for word motion. func (a MoveForwardWord) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveForwardWord) Type() core.MotionType { return core.CharwiseExclusive }
// MoveForwardWord.WithCount: Returns a new MoveForwardWord with the given count.
func (a MoveForwardWord) WithCount(n int) action.Action { func (a MoveForwardWord) WithCount(n int) action.Action {
return MoveForwardWord{Count: n} return MoveForwardWord{Count: n}
} }
@ -306,25 +285,19 @@ type MoveForwardWORD struct {
Count int Count int
} }
// MoveForwardWORD.Execute: Moves the cursor forward by Count WORDs (W motion).
func (a MoveForwardWORD) Execute(m action.Model) tea.Cmd { func (a MoveForwardWORD) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() x := m.CursorX()
buf := m.ActiveBuffer() y := m.CursorY()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ { for i := 0; i < a.Count; i++ {
x, y = nextWORDStart(buf, x, y) x, y = nextWORDStart(m, x, y)
} }
win.SetCursorCol(x) m.SetCursorX(x)
win.SetCursorLine(y) m.SetCursorY(y)
return nil return nil
} }
// MoveForwardWORD.Type: Returns CharwiseExclusive for WORD motion. func (a MoveForwardWORD) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveForwardWORD) Type() core.MotionType { return core.CharwiseExclusive }
// MoveForwardWORD.WithCount: Returns a new MoveForwardWORD with the given count.
func (a MoveForwardWORD) WithCount(n int) action.Action { func (a MoveForwardWORD) WithCount(n int) action.Action {
return MoveForwardWORD{Count: n} return MoveForwardWORD{Count: n}
} }
@ -334,25 +307,19 @@ type MoveForwardWordEnd struct {
Count int Count int
} }
// MoveForwardWordEnd.Execute: Moves the cursor to the end of the Count-th word (e motion).
func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd { func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() x := m.CursorX()
buf := m.ActiveBuffer() y := m.CursorY()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ { for i := 0; i < a.Count; i++ {
x, y = nextWordEnd(buf, x, y) x, y = nextWordEnd(m, x, y)
} }
win.SetCursorCol(x) m.SetCursorX(x)
win.SetCursorLine(y) m.SetCursorY(y)
return nil return nil
} }
// MoveForwardWordEnd.Type: Returns CharwiseInclusive for word-end motion. func (a MoveForwardWordEnd) Type() action.MotionType { return action.CharwiseInclusive }
func (a MoveForwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveForwardWordEnd.WithCount: Returns a new MoveForwardWordEnd with the given count.
func (a MoveForwardWordEnd) WithCount(n int) action.Action { func (a MoveForwardWordEnd) WithCount(n int) action.Action {
return MoveForwardWordEnd{Count: n} return MoveForwardWordEnd{Count: n}
} }
@ -362,25 +329,19 @@ type MoveForwardWORDEnd struct {
Count int Count int
} }
// MoveForwardWORDEnd.Execute: Moves the cursor to the end of the Count-th WORD (E motion).
func (a MoveForwardWORDEnd) Execute(m action.Model) tea.Cmd { func (a MoveForwardWORDEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() x := m.CursorX()
buf := m.ActiveBuffer() y := m.CursorY()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ { for i := 0; i < a.Count; i++ {
x, y = nextWORDEnd(buf, x, y) x, y = nextWORDEnd(m, x, y)
} }
win.SetCursorCol(x) m.SetCursorX(x)
win.SetCursorLine(y) m.SetCursorY(y)
return nil return nil
} }
// MoveForwardWORDEnd.Type: Returns CharwiseInclusive for WORD-end motion. func (a MoveForwardWORDEnd) Type() action.MotionType { return action.CharwiseInclusive }
func (a MoveForwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveForwardWORDEnd.WithCount: Returns a new MoveForwardWORDEnd with the given count.
func (a MoveForwardWORDEnd) WithCount(n int) action.Action { func (a MoveForwardWORDEnd) WithCount(n int) action.Action {
return MoveForwardWORDEnd{Count: n} return MoveForwardWORDEnd{Count: n}
} }
@ -390,25 +351,19 @@ type MoveBackwardWord struct {
Count int Count int
} }
// MoveBackwardWord.Execute: Moves the cursor backward by Count words (b motion).
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd { func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow() x := m.CursorX()
buf := m.ActiveBuffer() y := m.CursorY()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ { for i := 0; i < a.Count; i++ {
x, y = prevWordStart(buf, x, y) x, y = prevWordStart(m, x, y)
} }
win.SetCursorCol(x) m.SetCursorX(x)
win.SetCursorLine(y) m.SetCursorY(y)
return nil return nil
} }
// MoveBackwardWord.Type: Returns CharwiseExclusive for backward word motion. func (a MoveBackwardWord) Type() action.MotionType { return action.CharwiseExclusive }
func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive }
// MoveBackwardWord.WithCount: Returns a new MoveBackwardWord with the given count.
func (a MoveBackwardWord) WithCount(n int) action.Action { func (a MoveBackwardWord) WithCount(n int) action.Action {
return MoveBackwardWord{Count: n} return MoveBackwardWord{Count: n}
} }

View File

@ -2,37 +2,34 @@ package operator
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// ChangeOperator implements Operator (c) - changes (deletes and enters insert mode) text. // Implements Operator (c)
type ChangeOperator struct{} type ChangeOperator struct{}
// ChangeOperator.Operate: Changes text based on the current mode and motion type. func (o ChangeOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd {
func (o ChangeOperator) Operate(m action.Model, start, end core.Position, mtype core.MotionType) tea.Cmd {
switch m.Mode() { switch m.Mode() {
case core.VisualMode: case action.VisualMode:
changeCharSelection(m, start, end) changeCharSelection(m, start, end)
case core.VisualLineMode: case action.VisualLineMode:
changeLineSelection(m, start, end) changeLineSelection(m, start, end)
case core.VisualBlockMode: case action.VisualBlockMode:
changeBlockSelection(m, start, end) changeBlockSelection(m, start, end)
case core.NormalMode: case action.NormalMode:
changeNormalMode(m, start, end, mtype) changeNormalMode(m, start, end, mtype)
} }
return nil return nil
} }
// changeNormalMode: Changes text in normal mode based on motion type. func changeNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) {
func changeNormalMode(m action.Model, start, end core.Position, mtype core.MotionType) {
// Normalize so start is always before or equal to end // Normalize so start is always before or equal to end
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
start, end = end, start start, end = end, start
} }
// Linewise motions (j, k, G, gg) always operate on whole lines // Linewise motions (j, k, G, gg) always operate on whole lines
if mtype == core.Linewise { if mtype == action.Linewise {
changeLineSelection(m, start, end) changeLineSelection(m, start, end)
return return
} }
@ -40,18 +37,18 @@ func changeNormalMode(m action.Model, start, end core.Position, mtype core.Motio
// Charwise motions on same line // Charwise motions on same line
if start.Line == end.Line { if start.Line == end.Line {
// No movement = nothing to change // No movement = nothing to change
if start.Col == end.Col && mtype == core.CharwiseExclusive { if start.Col == end.Col && mtype == action.CharwiseExclusive {
m.SetMode(core.InsertMode) m.SetMode(action.InsertMode)
return return
} }
// Exclusive motion: end position not included, so back up one // Exclusive motion: end position not included, so back up one
if mtype == core.CharwiseExclusive { if mtype == action.CharwiseExclusive {
end.Col-- end.Col--
} }
if end.Col >= start.Col { if end.Col >= start.Col {
changeCharSelection(m, start, end) changeCharSelection(m, start, end)
} else { } else {
m.SetMode(core.InsertMode) m.SetMode(action.InsertMode)
} }
return return
} }
@ -60,26 +57,22 @@ func changeNormalMode(m action.Model, start, end core.Position, mtype core.Motio
changeCharSelection(m, start, end) changeCharSelection(m, start, end)
} }
// changeCharSelection: Changes a character-wise selection and enters insert mode. func changeCharSelection(m action.Model, start, end action.Position) {
func changeCharSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
var deletedText string var deletedText string
if start.Line == end.Line { if start.Line == end.Line {
line := buf.Lines[start.Line] line := m.Line(start.Line)
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
deletedText = line[start.Col:endCol] deletedText = line[start.Col:endCol]
buf.SetLine(start.Line, line[:start.Col]+line[endCol:]) m.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := buf.Lines[start.Line] startLine := m.Line(start.Line)
endLine := buf.Lines[end.Line] endLine := m.Line(end.Line)
// Extract deleted text // Extract deleted text
deletedText = startLine[start.Col:] + "\n" deletedText = startLine[start.Col:] + "\n"
for y := start.Line + 1; y < end.Line; y++ { for y := start.Line + 1; y < end.Line; y++ {
deletedText += buf.Lines[y] + "\n" deletedText += m.Line(y) + "\n"
} }
endCol := min(end.Col+1, len(endLine)) endCol := min(end.Col+1, len(endLine))
deletedText += endLine[:endCol] deletedText += endLine[:endCol]
@ -92,98 +85,89 @@ func changeCharSelection(m action.Model, start, end core.Position) {
// Delete from end back to start to preserve indices // Delete from end back to start to preserve indices
for i := end.Line; i >= start.Line; i-- { for i := end.Line; i >= start.Line; i-- {
buf.DeleteLine(i) m.DeleteLine(i)
} }
buf.InsertLine(start.Line, prefix+suffix) m.InsertLine(start.Line, prefix+suffix)
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(start.Col) m.SetCursorX(start.Col)
m.SetMode(core.InsertMode) m.ClampCursorX()
m.SetMode(action.InsertMode)
// Update register with deleted text // Update register with deleted text
m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText}) m.UpdateDefaultRegister(action.CharwiseRegister, []string{deletedText})
} }
// changeLineSelection: Changes entire lines and enters insert mode. func changeLineSelection(m action.Model, start, end action.Position) {
func changeLineSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
var lines []string var lines []string
for i := end.Line; i >= start.Line; i-- { for i := end.Line; i >= start.Line; i-- {
lines = append([]string{buf.Lines[i]}, lines...) lines = append([]string{m.Line(i)}, lines...)
buf.DeleteLine(i) m.DeleteLine(i)
} }
// Insert an empty line for editing // Insert an empty line for editing
insertY := min(start.Line, buf.LineCount()) insertY := min(start.Line, m.LineCount())
buf.InsertLine(insertY, "") m.InsertLine(insertY, "")
win.SetCursorLine(insertY) m.SetCursorY(insertY)
win.SetCursorCol(0) m.SetCursorX(0)
m.SetMode(core.InsertMode) m.SetMode(action.InsertMode)
// Update registers // Update registers
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(action.LinewiseRegister, lines)
} }
// changeBlockSelection: Changes a rectangular block selection and enters insert mode. func changeBlockSelection(m action.Model, start, end action.Position) {
func changeBlockSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
startCol := min(start.Col, end.Col) startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col) endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y] line := m.Line(y)
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
buf.SetLine(y, line[:startCol]+line[ec:]) m.SetLine(y, line[:startCol]+line[ec:])
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(startCol) m.SetCursorX(startCol)
m.SetMode(core.InsertMode) m.ClampCursorX()
m.SetMode(action.InsertMode)
} }
// Verify ChangeOperator implements DoublePresser // Verify ChangeOperator implements DoublePresser
var _ action.DoublePresser = ChangeOperator{} var _ action.DoublePresser = ChangeOperator{}
// ChangeOperator.DoublePress: Handles cc - changes Count entire lines. // Double press handles cc - change the entire line
func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd { func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd {
win := m.ActiveWindow() startY := m.CursorY()
buf := m.ActiveBuffer()
startY := win.Cursor.Line
// If we have a higher value than lines remaining, we can only run so many times // If we have a higher value than lines remaining, we can only run so many times
opCount := min(count, buf.LineCount()-startY) opCount := min(count, m.LineCount()-startY)
var lines []string var lines []string
// Collect lines to delete (always delete at startY since lines shift up) // Collect lines to delete (always delete at startY since lines shift up)
for range opCount { for range opCount {
lines = append(lines, buf.Lines[startY]) lines = append(lines, m.Line(startY))
buf.DeleteLine(startY) m.DeleteLine(startY)
} }
// Put deleted lines in register // Put deleted lines in register
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(action.LinewiseRegister, lines)
// Insert empty line at the original position for editing // Insert empty line at the original position for editing
// If we deleted everything, startY might be past end, so clamp it // If we deleted everything, startY might be past end, so clamp it
insertY := min(startY, buf.LineCount()) insertY := min(startY, m.LineCount())
buf.InsertLine(insertY, "") m.InsertLine(insertY, "")
// Position cursor on the new empty line // Position cursor on the new empty line
win.SetCursorLine(insertY) m.SetCursorY(insertY)
win.SetCursorCol(0) m.SetCursorX(0)
m.SetMode(core.InsertMode) m.SetMode(action.InsertMode)
return nil return nil
} }

View File

@ -2,23 +2,21 @@ package operator
import ( import (
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// DeleteOperator implements Operator (d) - deletes text in various modes. // Implements Operator (d)
type DeleteOperator struct{} type DeleteOperator struct{}
// DeleteOperator.Operate: Deletes text based on the current mode and motion type. func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd {
func (o DeleteOperator) Operate(m action.Model, start, end core.Position, mtype core.MotionType) tea.Cmd {
switch m.Mode() { switch m.Mode() {
case core.VisualMode: case action.VisualMode:
deleteCharSelection(m, start, end) deleteCharSelection(m, start, end)
case core.VisualLineMode: case action.VisualLineMode:
deleteLineSelection(m, start, end) deleteLineSelection(m, start, end)
case core.VisualBlockMode: case action.VisualBlockMode:
deleteBlockSelection(m, start, end) deleteBlockSelection(m, start, end)
case core.NormalMode: case action.NormalMode:
deleteNormalMode(m, start, end, mtype) deleteNormalMode(m, start, end, mtype)
} }
return nil return nil
@ -27,48 +25,45 @@ func (o DeleteOperator) Operate(m action.Model, start, end core.Position, mtype
// Verify DeleteOperator implements DoublePresser // Verify DeleteOperator implements DoublePresser
var _ action.DoublePresser = DeleteOperator{} var _ action.DoublePresser = DeleteOperator{}
// DeleteOperator.DoublePress: Handles dd - deletes Count entire lines. // Double press handles dd - delete the entire line
func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd { func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// If we have a higher value than lines remaining, we can only run so many times // If we have a higher value than lines remaining, we can only run so many times
opCount := min(count, buf.LineCount()-win.Cursor.Line) opCount := min(count, m.LineCount()-m.CursorY())
var lines []string var lines []string
for range opCount { for range opCount {
y := win.Cursor.Line y := m.CursorY()
lines = append(lines, buf.Lines[y]) lines = append(lines, m.Line(y))
buf.DeleteLine(y) m.DeleteLine(y)
if buf.LineCount() == 0 { if m.LineCount() == 0 {
buf.InsertLine(0, "") m.InsertLine(0, "")
} }
if y >= buf.LineCount() { if y >= m.LineCount() {
y = buf.LineCount() - 1 y = m.LineCount() - 1
} }
win.SetCursorLine(y) m.SetCursorY(y)
m.ClampCursorX()
} }
// Put her in the register! // Put her in the register!
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(action.LinewiseRegister, lines)
return nil return nil
} }
// deleteNormalMode: Deletes text in normal mode based on motion type. func deleteNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) {
func deleteNormalMode(m action.Model, start, end core.Position, mtype core.MotionType) {
// Normalize so start is always before or equal to end // Normalize so start is always before or equal to end
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
start, end = end, start start, end = end, start
} }
// Linewise motions (j, k, G, gg) always operate on whole lines // Linewise motions (j, k, G, gg) always operate on whole lines
if mtype == core.Linewise { if mtype == action.Linewise {
deleteLineSelection(m, start, end) deleteLineSelection(m, start, end)
return return
} }
@ -76,11 +71,11 @@ func deleteNormalMode(m action.Model, start, end core.Position, mtype core.Motio
// Charwise motions on same line // Charwise motions on same line
if start.Line == end.Line { if start.Line == end.Line {
// No movement = nothing to delete // No movement = nothing to delete
if start.Col == end.Col && mtype == core.CharwiseExclusive { if start.Col == end.Col && mtype == action.CharwiseExclusive {
return return
} }
// Exclusive motion: end position not included, so back up one // Exclusive motion: end position not included, so back up one
if mtype == core.CharwiseExclusive { if mtype == action.CharwiseExclusive {
end.Col-- end.Col--
} }
if end.Col >= start.Col { if end.Col >= start.Col {
@ -93,18 +88,14 @@ func deleteNormalMode(m action.Model, start, end core.Position, mtype core.Motio
deleteCharSelection(m, start, end) deleteCharSelection(m, start, end)
} }
// deleteCharSelection: Deletes a character-wise selection. func deleteCharSelection(m action.Model, start, end action.Position) {
func deleteCharSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if start.Line == end.Line { if start.Line == end.Line {
line := buf.Lines[start.Line] line := m.Line(start.Line)
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
buf.SetLine(start.Line, line[:start.Col]+line[endCol:]) m.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := buf.Lines[start.Line] startLine := m.Line(start.Line)
endLine := buf.Lines[end.Line] endLine := m.Line(end.Line)
prefix := startLine[:start.Col] prefix := startLine[:start.Col]
suffix := "" suffix := ""
@ -114,59 +105,54 @@ func deleteCharSelection(m action.Model, start, end core.Position) {
// Delete from end back to start to preserve indices // Delete from end back to start to preserve indices
for i := end.Line; i >= start.Line; i-- { for i := end.Line; i >= start.Line; i-- {
buf.DeleteLine(i) m.DeleteLine(i)
} }
buf.InsertLine(start.Line, prefix+suffix) m.InsertLine(start.Line, prefix+suffix)
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(start.Col) m.SetCursorX(start.Col)
m.ClampCursorX()
} }
// deleteLineSelection: Deletes entire lines in a selection range. func deleteLineSelection(m action.Model, start, end action.Position) {
func deleteLineSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
var lines []string var lines []string
for i := end.Line; i >= start.Line; i-- { for i := end.Line; i >= start.Line; i-- {
lines = append(lines, buf.Lines[i]) lines = append(lines, m.Line(i))
buf.DeleteLine(i) m.DeleteLine(i)
} }
if buf.LineCount() == 0 { if m.LineCount() == 0 {
buf.InsertLine(0, "") m.InsertLine(0, "")
} }
y := start.Line y := start.Line
if y >= buf.LineCount() { if y >= m.LineCount() {
y = buf.LineCount() - 1 y = m.LineCount() - 1
} }
win.SetCursorLine(y) m.SetCursorY(y)
m.ClampCursorX()
// Update registers // Update registers
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(action.LinewiseRegister, lines)
} }
// deleteBlockSelection: Deletes a rectangular block selection. func deleteBlockSelection(m action.Model, start, end action.Position) {
func deleteBlockSelection(m action.Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
startCol := min(start.Col, end.Col) startCol := min(start.Col, end.Col)
endCol := max(start.Col, end.Col) endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ { for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y] line := m.Line(y)
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
buf.SetLine(y, line[:startCol]+line[ec:]) m.SetLine(y, line[:startCol]+line[ec:])
} }
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
win.SetCursorCol(startCol) m.SetCursorX(startCol)
m.ClampCursorX()
} }

View File

@ -4,64 +4,53 @@ import (
"fmt" "fmt"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// YankOperator implements Operator (y) - copies text to register in various modes. // Implements Operator (y)
type YankOperator struct{} type YankOperator struct{}
// YankOperator.Operate: Copies text to register based on the current mode and motion type. func (o YankOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd {
func (o YankOperator) Operate(m action.Model, start, end core.Position, mtype core.MotionType) tea.Cmd {
win := m.ActiveWindow()
switch m.Mode() { switch m.Mode() {
case core.VisualMode: case action.VisualMode:
yankVisualMode(m, start, end) yankVisualMode(m, start, end)
case core.VisualLineMode: case action.VisualLineMode:
yankVisualLineMode(m, start, end) yankVisualLineMode(m, start, end)
case core.VisualBlockMode: case action.VisualBlockMode:
yankVisualBlockMode(m, start, end) yankVisualBlockMode(m, start, end)
case core.NormalMode: case action.NormalMode:
yankNormalMode(m, start, end, mtype) yankNormalMode(m, start, end, mtype)
default: default:
m.SetCommandError(fmt.Errorf("'y' operator not yet implemented.")) m.SetCommandError(fmt.Errorf("'y' operator not yet implemented."))
} }
win.SetCursorCol(start.Col) m.SetCursorX(start.Col)
win.SetCursorLine(start.Line) m.SetCursorY(start.Line)
return nil return nil
} }
// Verify YankOperator implements DoublePresser // Verify YankOperator implements DoublePresser
var _ action.DoublePresser = YankOperator{} var _ action.DoublePresser = YankOperator{}
// YankOperator.DoublePress: Handles yy - copies Count entire lines to register.
func (o YankOperator) DoublePress(m action.Model, count int) tea.Cmd { func (o YankOperator) DoublePress(m action.Model, count int) tea.Cmd {
win := m.ActiveWindow() y := m.CursorY()
buf := m.ActiveBuffer()
y := win.Cursor.Line
// If we have a higher value than lines remaining, we can only run so many times // If we have a higher value than lines remaining, we can only run so many times
opCount := min(count, buf.LineCount()-y) opCount := min(count, m.LineCount()-y)
var lines []string var lines []string
for i := range opCount { for i := range opCount {
lines = append(lines, buf.Lines[y+i]) lines = append(lines, m.Line(y+i))
} }
// Put her in the register! // Put her in the register!
m.UpdateDefaultRegister(core.LinewiseRegister, lines) m.UpdateDefaultRegister(action.LinewiseRegister, lines)
return nil return nil
} }
// yankNormalMode: Copies text to register in normal mode based on motion type. func yankNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) {
func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionType) {
buf := m.ActiveBuffer()
switch { switch {
case mtype.IsCharwise(): case mtype.IsCharwise():
// This shouldn't happen // This shouldn't happen
@ -70,22 +59,22 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT
return return
} }
line := buf.Lines[start.Line] line := m.Line(start.Line)
startX := min(start.Col, end.Col) startX := min(start.Col, end.Col)
endX := max(start.Col, end.Col) endX := max(start.Col, end.Col)
// Inclusive motions include the end character // Inclusive motions include the end character
if mtype == core.CharwiseInclusive { if mtype == action.CharwiseInclusive {
endX++ endX++
} }
endX = min(endX, len(line)) // Catch overflow endX = min(endX, len(line)) // Catch overflow
cnt := line[startX:endX] cnt := line[startX:endX]
m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt}) m.UpdateDefaultRegister(action.CharwiseRegister, []string{cnt})
case mtype == core.Linewise: case mtype == action.Linewise:
// This shouldn't happen // This shouldn't happen
if start.Col != end.Col { if start.Col != end.Col {
m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations.")) m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations."))
@ -96,15 +85,12 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT
startY := min(start.Line, end.Line) startY := min(start.Line, end.Line)
endY := max(start.Line, end.Line) endY := max(start.Line, end.Line)
cnt := buf.Lines[startY : endY+1] cnt := m.Lines()[startY : endY+1]
m.UpdateDefaultRegister(core.LinewiseRegister, cnt) m.UpdateDefaultRegister(action.LinewiseRegister, cnt)
} }
} }
// yankVisualMode: Copies character-wise visual selection to register. func yankVisualMode(m action.Model, start, end action.Position) {
func yankVisualMode(m action.Model, start, end core.Position) {
buf := m.ActiveBuffer()
// Normalize so start is before end // Normalize so start is before end
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
start, end = end, start start, end = end, start
@ -112,11 +98,11 @@ func yankVisualMode(m action.Model, start, end core.Position) {
// Single line selection // Single line selection
if start.Line == end.Line { if start.Line == end.Line {
line := buf.Lines[start.Line] line := m.Line(start.Line)
endCol := min(end.Col+1, len(line)) // +1 because visual selection is inclusive endCol := min(end.Col+1, len(line)) // +1 because visual selection is inclusive
startCol := min(start.Col, len(line)) startCol := min(start.Col, len(line))
cnt := line[startCol:endCol] cnt := line[startCol:endCol]
m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt}) m.UpdateDefaultRegister(action.CharwiseRegister, []string{cnt})
return return
} }
@ -124,27 +110,24 @@ func yankVisualMode(m action.Model, start, end core.Position) {
var content []string var content []string
// First line: from start.Col to end of line // First line: from start.Col to end of line
firstLine := buf.Lines[start.Line] firstLine := m.Line(start.Line)
startCol := min(start.Col, len(firstLine)) startCol := min(start.Col, len(firstLine))
content = append(content, firstLine[startCol:]) content = append(content, firstLine[startCol:])
// Middle lines: entire lines // Middle lines: entire lines
for y := start.Line + 1; y < end.Line; y++ { for y := start.Line + 1; y < end.Line; y++ {
content = append(content, buf.Lines[y]) content = append(content, m.Line(y))
} }
// Last line: from beginning to end.Col (inclusive) // Last line: from beginning to end.Col (inclusive)
lastLine := buf.Lines[end.Line] lastLine := m.Line(end.Line)
endCol := min(end.Col+1, len(lastLine)) endCol := min(end.Col+1, len(lastLine))
content = append(content, lastLine[:endCol]) content = append(content, lastLine[:endCol])
m.UpdateDefaultRegister(core.CharwiseRegister, content) m.UpdateDefaultRegister(action.CharwiseRegister, content)
} }
// yankVisualLineMode: Copies line-wise visual selection to register. func yankVisualLineMode(m action.Model, start, end action.Position) {
func yankVisualLineMode(m action.Model, start, end core.Position) {
buf := m.ActiveBuffer()
// This shouldn't happen // This shouldn't happen
if start.Col != end.Col { if start.Col != end.Col {
m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations.")) m.SetCommandError(fmt.Errorf("Start column and end column must match for linewise yank operations."))
@ -155,15 +138,12 @@ func yankVisualLineMode(m action.Model, start, end core.Position) {
startY := min(start.Line, end.Line) startY := min(start.Line, end.Line)
endY := max(start.Line, end.Line) endY := max(start.Line, end.Line)
cnt := buf.Lines[startY : endY+1] cnt := m.Lines()[startY : endY+1]
m.UpdateDefaultRegister(core.LinewiseRegister, cnt) m.UpdateDefaultRegister(action.LinewiseRegister, cnt)
} }
// yankVisualBlockMode: Copies block-wise visual selection to register. func yankVisualBlockMode(m action.Model, start, end action.Position) {
func yankVisualBlockMode(m action.Model, start, end core.Position) {
buf := m.ActiveBuffer()
// Normalize so startY <= endY and startX <= endX // Normalize so startY <= endY and startX <= endX
startY := min(start.Line, end.Line) startY := min(start.Line, end.Line)
endY := max(start.Line, end.Line) endY := max(start.Line, end.Line)
@ -173,7 +153,7 @@ func yankVisualBlockMode(m action.Model, start, end core.Position) {
var content []string var content []string
for y := startY; y <= endY; y++ { for y := startY; y <= endY; y++ {
line := buf.Lines[y] line := m.Line(y)
// Handle lines shorter than the block selection // Handle lines shorter than the block selection
if startX >= len(line) { if startX >= len(line) {
@ -185,5 +165,5 @@ func yankVisualBlockMode(m action.Model, start, end core.Position) {
content = append(content, line[startX:lineEndX]) content = append(content, line[startX:lineEndX])
} }
m.UpdateDefaultRegister(core.BlockwiseRegister, content) m.UpdateDefaultRegister(action.BlockwiseRegister, content)
} }

View File

@ -1,120 +0,0 @@
package program
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/editor"
tea "github.com/charmbracelet/bubbletea"
)
type ProgramBuilder struct {
model *editor.Model
opts []tea.ProgramOption
}
func NewProgramBuilder() *ProgramBuilder {
return &ProgramBuilder{
model: &editor.Model{},
opts: []tea.ProgramOption{},
}
}
// ProgramBuilder.FileProgram: Sets the internal state of the builder to the required
// state to start the program (editor) with a filename. This is what will happen when
// a user runs 'gim <filename>'.
func (p *ProgramBuilder) FileProgram(filename string) *ProgramBuilder {
// Only difference: open the file
ext := filepath.Ext(filename)
file, err := os.Open(filename)
notFound := errors.Is(err, os.ErrNotExist)
if err != nil && !notFound {
// TODO: Handle this
panic(fmt.Errorf("Failed to find file: %w", err))
}
if file != nil {
defer file.Close()
}
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithOptions(core.NewDefaultWinOptions()).
Build()
p.model = editor.NewModelBuilder().
AddBuffer(&buf).
AddWindow(&win).
WithActiveWindowId(win.Id).
Build()
// If we did not find the file, all we need to do is set the filename and type
if notFound {
return p
}
// Otherwise we have to create everything, then read the file (since we need settings)
// COPIED FROM `internal/command/handlers.go`
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSuffix(line, "\r")
// BUG: This is bad, we don't want to this, but we have to
cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", p.model.Settings().TabStop))
lines = append(lines, cleaned)
}
// Only setting lines if we found some
if len(lines) > 0 {
p.model.ActiveBuffer().SetLines(lines)
}
return p
}
// ProgramBuilder.EmptyProgram: Sets the internal state of the builder to the required
// state to start the program (editor) in the current directory. This is what will
// happen when a user runs 'gim'.
func (p *ProgramBuilder) EmptyProgram() *ProgramBuilder {
buf := core.NewBufferBuilder().
ReadOnly().
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithOptions(core.NewDefaultWinOptions()).
Build()
p.model = editor.NewModelBuilder().
AddBuffer(&buf).
AddWindow(&win).
WithActiveWindowId(win.Id).
Build()
return p
}
// ProgramBuilder.WithOpt: Add an option to the list of options that will be used when
// the program is built.
func (p *ProgramBuilder) WithOpt(opt tea.ProgramOption) *ProgramBuilder {
p.opts = append(p.opts, opt)
return p
}
// ProgramBuilder.Build: Build and return the configured tea.Program instance.
func (p *ProgramBuilder) Build() *tea.Program {
return tea.NewProgram(p.model, p.opts...)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +0,0 @@
package style
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/charmbracelet/lipgloss"
)
// Styles holds all the visual styling for the editor.
type Styles struct {
// Cursor styles by mode
CursorNormal lipgloss.Style
CursorInsert lipgloss.Style
CursorCommand lipgloss.Style
// Gutter (line numbers)
Gutter lipgloss.Style
GutterCurrentLine lipgloss.Style
// Visual mode
VisualHighlight lipgloss.Style
VisualAnchor lipgloss.Style // debugging
// Status bar
StatusBar lipgloss.Style
StatusBarActive lipgloss.Style
// Command line
CommandError lipgloss.Style
}
// DefaultStyles returns the default editor color scheme.
func DefaultStyles() Styles {
return Styles{
CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("243")),
GutterCurrentLine: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("#d69d00")),
VisualHighlight: lipgloss.NewStyle().
Background(lipgloss.Color("#7a6a00")),
VisualAnchor: lipgloss.NewStyle().
Background(lipgloss.Color("#a89020")),
StatusBar: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("243")),
StatusBarActive: lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("230")),
CommandError: lipgloss.NewStyle().
Foreground(lipgloss.Color("#e3203a")),
}
}
// CursorStyle returns the appropriate cursor style for the given mode.
func (s Styles) CursorStyle(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return s.CursorInsert
case core.CommandMode:
return s.CursorCommand
default:
return s.CursorNormal
}
}