Compare commits

..

24 Commits

Author SHA1 Message Date
Hayden Hargreaves
f12ce37beb chore: updated FEATURES.md
All checks were successful
Run Test Suite / test (push) Successful in 13s
2026-03-10 14:25:56 -07:00
Hayden Hargreaves
d4980c5532 feat: created (tested) program_builder.
All checks were successful
Run Test Suite / test (push) Successful in 15s
Also adjusted some of the IO tests for writing and force writing.
2026-03-10 14:18:20 -07:00
Hayden Hargreaves
8364d8b880 feat: lots of IO fixes! Writing and forcing seems to be working. 2026-03-10 12:32:32 -07:00
Hayden Hargreaves
c963d66e3b cicd: trying without go cache. Otherwise ill just use a go image
All checks were successful
Run Test Suite / test (push) Successful in 14s
2026-03-06 18:37:57 -07:00
Hayden Hargreaves
997c4143ca cicd: fixing this maybe? Using go.mod instead of version
Some checks failed
Run Test Suite / test (push) Has been cancelled
2026-03-06 18:33:02 -07:00
Hayden Hargreaves
41fd9bd45a cicd: renamed workflow 2026-03-06 18:28:54 -07:00
Hayden Hargreaves
eb872fcfdf cicd: rename .github/actions/* to .github/workflows/*
Some checks failed
Run Test Suite / test (push) Has been cancelled
2026-03-06 18:28:08 -07:00
Hayden Hargreaves
a103af0a83 cicd: wrote a simple CI/CD script to run tests. 2026-03-06 18:27:24 -07:00
Hayden Hargreaves
354fbc4f9b feat: added tests for the buffer and window 2026-03-06 18:27:16 -07:00
Hayden Hargreaves
15d847e3c8 chore: removed some todos 2026-03-06 18:20:19 -07:00
Hayden Hargreaves
c126242ee1 fix: fixed the settings back to their original implementation 2026-03-06 18:11:33 -07:00
Hayden Hargreaves
93968e7333 fix: fixed most of the tests, but not all of them, some still fail 2026-03-05 14:42:11 -07:00
Hayden Hargreaves
098641f5c0 fix: cleaned up and fixed the settings that needed updating 2026-03-05 14:34:21 -07:00
Hayden Hargreaves
dc9a814508 chore: cleanup depreciated code 2026-03-05 14:09:08 -07:00
Hayden Hargreaves
03c3a41162 fix: viewing is much better, dynamic as well :) 2026-03-05 14:07:51 -07:00
Hayden Hargreaves
ccb061989a refactor: huge refactor, this looks amazing.
Lots of comments from the AI. Some tests are not passing though
2026-03-04 21:45:47 -07:00
Hayden Hargreaves
154558b790 checkpoint: this code does not work, just want a fallback
Going to start a LARGE ai refactor of the arch of the project.
2026-03-01 23:20:37 -07:00
Hayden Hargreaves
b1b3edf810 feat: this is huge, and needs some review, but for now its good
The tests are not passing, something to do with view I think.
2026-02-26 23:18:18 -07:00
Hayden Hargreaves
9b1bf35a8e wip: implemented model builder, this is nice :)
Builder pattern is actually so goated
2026-02-26 22:21:29 -07:00
Hayden Hargreaves
770cbcceb7 wip: cleaned up the separation of concerns in the MWB model 2026-02-26 21:59:32 -07:00
Hayden Hargreaves
ea4638d815 wip: implement windows, buffers and builders 2026-02-26 13:20:21 -07:00
Hayden Hargreaves
3339dd4409 wip: first initial commit. Not sure if I like this 2026-02-26 12:14:59 -07:00
Hayden Hargreaves
65f96a5089 chore: cleaned up the file system a bit 2026-02-24 12:16:13 -07:00
Hayden Hargreaves
88fa53a4d7 doc: updated FEATURES.md 2026-02-24 12:07:49 -07:00
61 changed files with 11263 additions and 3861 deletions

30
.github/workflows/EditorTests.yml vendored Normal file
View File

@ -0,0 +1,30 @@
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
- [ ] `c` - Change operator - [x] `c` - Change operator
- [ ] `cc` - Change line - [x] `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
- [ ] `s` - Substitute character (delete + insert) - [x] `s` - Substitute character (delete + insert)
- [ ] `S` - Substitute line (delete line + insert) - [x] `S` - Substitute line (delete line + insert)
- [ ] `C` - Change to end of line - [x] `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
- [ ] `:w` - Write file - [x] `:w` - Write file
- [ ] `:q` - Quit - [x] `:q` - Quit
- [ ] `:wq` - Write and quit - [x] `:wq` - Write and quit
- [ ] `:q!` - Force quit - [x] `:q!` - Force quit
- [ ] `:e {file}` - Edit file - [x] `: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
- [ ] Buffer struct (id, filename, lines, modified flag, cursor position) - [x] Buffer struct (id, filename, lines, modified flag, cursor position)
- [ ] Buffer list/manager - [ ] Buffer list/manager
- [ ] Current buffer tracking - [x] Current buffer tracking
- [ ] Buffer-local settings (tabstop, filetype, etc.) - [ ] Buffer-local settings (tabstop, filetype, etc.)
- [ ] Modified/dirty state tracking - [x] Modified/dirty state tracking
- [ ] Read-only buffer support - [x] Read-only buffer support
### Buffer Navigation ### Buffer Navigation
- [ ] `:e {file}` - Edit file (open in new buffer or switch to existing) - [x] `: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)
- [ ] `:w` - Write current buffer to file - [x] `:w` - Write current buffer to file
- [ ] `:w {file}` - Write buffer to specific file - [x] `:w {file}` - Write buffer to specific file
- [ ] `:wa` - Write all modified buffers - [x] `: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
- [ ] Prompt to save when closing modified buffer - [x] Prompt to save when closing modified buffer
- [ ] `:q` behavior with modified buffers - [x] `: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
- [ ] File reading - [x] File reading
- [ ] File writing - [x] File writing
- [ ] Auto-save - [ ] Auto-save
- [ ] Backup files - [ ] Backup files
- [ ] Swap files - [ ] Swap files
@ -405,9 +405,49 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
--- ---
## Testing Coverage ### Well Tested - Editor Core
### Well Tested #### Command Execution (179 tests)
- [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, $, _, ^, |)
@ -415,10 +455,12 @@ 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,24 +1,32 @@
package main package main
import ( import (
"fmt" "os"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/program"
"git.gophernest.net/azpect/TextEditor/internal/editor"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
func generateLines(n int) []string { // main: Entry point for the Gim text editor. Creates a buffer and window,
lines := make([]string, n) // initializes the editor model, and runs the BubbleTea TUI program.
for i := range n { func main() {
lines[i] = fmt.Sprintf("line %d", i+1) // <exe> <filename>
} args := os.Args[1:]
return lines
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()
} }
func main() { if _, err := prog.Run(); err != nil {
tea.NewProgram( panic(err)
editor.NewModel(generateLines(64), action.Position{Line: 0, Col: 0}), }
tea.WithAltScreen(),
).Run()
} }

View File

@ -1,152 +0,0 @@
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,26 +1,33 @@
package action package action
import tea "github.com/charmbracelet/bubbletea" import (
"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 {
pos := m.CursorX() win := m.ActiveWindow()
line := m.Line(m.CursorY()) buf := m.ActiveBuffer()
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(CharwiseRegister, []string{line[pos:]}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{line[pos:]})
} }
// Delete to end of line // Delete to end of line
m.SetLine(m.CursorY(), line[:pos]) buf.SetLine(win.Cursor.Line, line[:pos])
// Enter insert mode // Enter insert mode
m.SetMode(InsertMode) m.SetMode(core.InsertMode)
return nil return nil
} }
@ -28,6 +35,7 @@ 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}
} }
@ -37,23 +45,27 @@ 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 {
pos := m.CursorX() win := m.ActiveWindow()
line := m.Line(m.CursorY()) buf := m.ActiveBuffer()
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(CharwiseRegister, []string{line[pos : pos+count]}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{line[pos : pos+count]})
// Delete the characters // Delete the characters
m.SetLine(m.CursorY(), line[:pos]+line[pos+count:]) buf.SetLine(win.Cursor.Line, line[:pos]+line[pos+count:])
} }
// Enter insert mode // Enter insert mode
m.SetMode(InsertMode) m.SetMode(core.InsertMode)
return nil return nil
} }
@ -61,6 +73,7 @@ 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}
} }
@ -70,33 +83,36 @@ 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 {
y := m.CursorY() win := m.ActiveWindow()
buf := m.ActiveBuffer()
y := win.Cursor.Line
// Calculate how many lines to substitute // Calculate how many lines to substitute
count := min(a.Count, m.LineCount()-y) count := min(a.Count, buf.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, m.Line(y)) lines = append(lines, buf.Lines[y])
m.DeleteLine(y) buf.DeleteLine(y)
} }
// Save deleted lines to register // Save deleted lines to register
m.UpdateDefaultRegister(LinewiseRegister, lines) m.UpdateDefaultRegister(core.LinewiseRegister, lines)
// Insert empty line at original position // Insert empty line at original position
insertY := min(y, m.LineCount()) insertY := min(y, buf.LineCount())
m.InsertLine(insertY, "") buf.InsertLine(insertY, "")
// Position cursor // Position cursor
m.SetCursorY(insertY) win.SetCursorPos(insertY, 0)
m.SetCursorX(0)
// Enter insert mode // Enter insert mode
m.SetMode(InsertMode) m.SetMode(core.InsertMode)
return nil return nil
} }
@ -104,6 +120,7 @@ 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,24 +1,29 @@
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(NormalMode) m.SetMode(core.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()
@ -28,8 +33,10 @@ 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()
@ -42,8 +49,10 @@ 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()
@ -62,8 +71,10 @@ 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()
@ -101,22 +112,24 @@ 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(NormalMode) m.SetMode(core.NormalMode)
if a.Registry == nil || cmdLine == "" { if a.Registry == nil || cmdLine == "" {
return nil return nil
@ -124,7 +137,6 @@ 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,70 +7,80 @@ 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 {
pos := m.CursorX() win := m.ActiveWindow()
line := m.Line(m.CursorY()) buf := m.ActiveBuffer()
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:]
m.SetLine(m.CursorY(), line) buf.SetLine(win.Cursor.Line, 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 {
// Delete to end of line win := m.ActiveWindow()
pos := m.CursorX() buf := m.ActiveBuffer()
line := m.Line(m.CursorY())
m.SetLine(m.CursorY(), line[:pos]) // Delete to end of line
m.SetCursorX(pos - 1) pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
buf.SetLine(win.Cursor.Line, line[:pos])
win.SetCursorCol(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 := m.CursorY() initY := win.Cursor.Line
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, m.LineCount()-m.CursorY()) opCount := min(a.Count-1, buf.LineCount()-win.Cursor.Line)
// Down one // Down one
m.SetCursorY(initY + 1) win.SetCursorLine(initY + 1)
for range opCount { for range opCount {
y := m.CursorY() // Changed from the copied code y := win.Cursor.Line // 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
} }
m.DeleteLine(y) buf.DeleteLine(y)
if m.LineCount() == 0 { if buf.LineCount() == 0 {
m.InsertLine(0, "") buf.InsertLine(0, "")
} }
if y >= m.LineCount() { if y >= buf.LineCount() {
y = m.LineCount() - 1 y = buf.LineCount() - 1
} }
m.SetCursorY(y) win.SetCursorLine(y)
m.ClampCursorX()
} }
} }
m.SetCursorY(initY) win.SetCursorLine(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,6 +3,7 @@ package action
import ( import (
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -11,13 +12,15 @@ 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(InsertMode) m.SetMode(core.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}
} }
@ -27,16 +30,18 @@ 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 {
m.SetCursorX(m.CursorX() + 1) win := m.ActiveWindow()
m.ClampCursorX() win.SetCursorCol(win.Cursor.Col + 1)
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode) m.SetMode(core.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}
} }
@ -46,16 +51,18 @@ 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 {
m.SetCursorX(0) win := m.ActiveWindow()
m.ClampCursorX() win.SetCursorCol(0)
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode) m.SetMode(core.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}
} }
@ -65,16 +72,19 @@ 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 {
m.SetCursorX(len(m.Line(m.CursorY()))) win := m.ActiveWindow()
m.ClampCursorX() buf := m.ActiveBuffer()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode) m.SetMode(core.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}
} }
@ -84,24 +94,28 @@ 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 {
pos := m.CursorY() win := m.ActiveWindow()
buf := m.ActiveBuffer()
if pos >= m.LineCount() { pos := win.Cursor.Line
m.InsertLine(m.LineCount(), "")
if pos >= buf.LineCount() {
buf.InsertLine(buf.LineCount(), "")
} else { } else {
m.InsertLine(pos+1, "") buf.InsertLine(pos+1, "")
} }
m.SetCursorY(m.CursorY() + 1) win.SetCursorPos(win.Cursor.Line+1, 0)
m.SetCursorX(0)
// Start recording // Start recording
m.SetInsertRecording(a.Count, a) m.SetInsertRecording(a.Count, a)
m.SetMode(InsertMode) m.SetMode(core.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}
} }
@ -111,17 +125,22 @@ 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 {
pos := m.CursorY() win := m.ActiveWindow()
m.InsertLine(pos, "") buf := m.ActiveBuffer()
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(InsertMode) m.SetMode(core.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}
} }
@ -133,51 +152,61 @@ 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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
l := m.Line(y) buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x < len(l) { if x < len(l) {
m.SetLine(y, l[:x]+a.Char+l[x:]) buf.SetLine(y, l[:x]+a.Char+l[x:])
} else { } else {
m.SetLine(y, l+a.Char) buf.SetLine(y, l+a.Char)
} }
m.SetCursorX(x + len(a.Char)) win.SetCursorCol(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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
l := m.Line(y) buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x == len(l) { if x == len(l) {
m.InsertLine(y+1, "") buf.InsertLine(y+1, "")
} else { } else {
m.SetLine(y, l[:x]) buf.SetLine(y, l[:x])
m.InsertLine(y+1, l[x:]) buf.InsertLine(y+1, l[x:])
} }
m.SetCursorY(y + 1) win.SetCursorPos(y+1, 0)
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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
l := m.Line(y) buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
if x > 0 { if x > 0 {
m.SetLine(y, l[:x-1]+l[x:]) buf.SetLine(y, l[:x-1]+l[x:])
m.SetCursorX(x - 1) win.SetCursorCol(x - 1)
} else if y > 0 { } else if y > 0 {
prevLine := m.Line(y - 1) prevLine := buf.Lines[y-1]
newX := len(prevLine) newX := len(prevLine)
m.SetLine(y-1, prevLine+l) buf.SetLine(y-1, prevLine+l)
m.DeleteLine(y) buf.DeleteLine(y)
m.SetCursorY(y - 1) win.SetCursorPos(y-1, newX)
m.SetCursorX(newX)
} }
return nil return nil
} }
@ -185,15 +214,19 @@ 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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
l := m.Line(y) buf := m.ActiveBuffer()
if x == len(l) && y < m.LineCount()-1 {
nextLine := m.Line(y + 1) x, y := win.Cursor.Col, win.Cursor.Line
m.SetLine(y, l+nextLine) l := buf.Lines[y]
m.DeleteLine(y + 1) if x == len(l) && y < buf.LineCount()-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) {
m.SetLine(y, l[:x]+l[x+1:]) buf.SetLine(y, l[:x]+l[x+1:])
} }
return nil return nil
} }
@ -201,22 +234,28 @@ 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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
l := m.Line(y) buf := m.ActiveBuffer()
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) {
m.SetLine(y, l[:x]+tabs+l[x:]) buf.SetLine(y, l[:x]+tabs+l[x:])
} else { } else {
m.SetLine(y, l+tabs) buf.SetLine(y, l+tabs)
} }
m.SetCursorX(x + len(tabs)) win.SetCursorCol(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') ||
@ -224,23 +263,28 @@ 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 {
x, y := m.CursorX(), m.CursorY() win := m.ActiveWindow()
line := m.Line(y) buf := m.ActiveBuffer()
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 := m.Line(y - 1) prevLine := buf.Lines[y-1]
newX := len(prevLine) newX := len(prevLine)
m.SetLine(y-1, prevLine+line) buf.SetLine(y-1, prevLine+line)
m.DeleteLine(y) buf.DeleteLine(y)
m.SetCursorY(y - 1) win.SetCursorPos(y-1, newX)
m.SetCursorX(newX)
} }
return nil return nil
} }
@ -254,8 +298,8 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
newX-- newX--
} }
m.SetLine(y, line[:newX]+line[x:]) buf.SetLine(y, line[:newX]+line[x:])
m.SetCursorX(newX) win.SetCursorCol(newX)
return nil return nil
} }
@ -271,8 +315,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
m.SetLine(y, line[:newX]+line[x:]) buf.SetLine(y, line[:newX]+line[x:])
m.SetCursorX(newX) win.SetCursorCol(newX)
return nil return nil
} }

View File

@ -0,0 +1,85 @@
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,19 +1,24 @@
package action package action
import tea "github.com/charmbracelet/bubbletea" import (
"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
} }
// Quit implements Action (:) // EnterComandMode implements Action (:) - enters command mode.
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(CommandMode) m.SetMode(core.CommandMode)
m.SetCommand("") m.SetCommand("")
m.SetCommandOutput("") m.SetCommandOutput("")
m.SetCommandError(nil) m.SetCommandError(nil)
@ -21,32 +26,38 @@ func (a EnterComandMode) Execute(m Model) tea.Cmd {
return nil return nil
} }
// Quit implements Action (v) // EnterVisualMode implements Action (v) - enters visual character mode.
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 {
m.SetAnchorX(m.CursorX()) win := m.ActiveWindow()
m.SetAnchorY(m.CursorY()) win.SetAnchorCol(win.Cursor.Col)
m.SetMode(VisualMode) win.SetAnchorLine(win.Cursor.Line)
m.SetMode(core.VisualMode)
return nil return nil
} }
// Quit implements Action (V) // EnterVisualLineMode implements Action (V) - enters visual line mode.
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 {
m.SetAnchorX(m.CursorX()) win := m.ActiveWindow()
m.SetAnchorY(m.CursorY()) win.SetAnchorCol(win.Cursor.Col)
m.SetMode(VisualLineMode) win.SetAnchorLine(win.Cursor.Line)
m.SetMode(core.VisualLineMode)
return nil return nil
} }
// Quit implements Action (ctrl+v) // EnterVisualBlockMode implements Action (Ctrl+V) - enters visual block mode.
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 {
m.SetAnchorX(m.CursorX()) win := m.ActiveWindow()
m.SetAnchorY(m.CursorY()) win.SetAnchorCol(win.Cursor.Col)
m.SetMode(VisualBlockMode) win.SetAnchorLine(win.Cursor.Line)
m.SetMode(core.VisualBlockMode)
return nil return nil
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -12,7 +13,11 @@ 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 {
@ -26,25 +31,25 @@ func (a Paste) Execute(m Model) tea.Cmd {
} }
switch reg.Type { switch reg.Type {
case LinewiseRegister: case core.LinewiseRegister:
{ {
initY := m.CursorY() initY := win.Cursor.Line
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 {
m.InsertLine(insertPos, line) buf.InsertLine(insertPos, line)
insertPos++ insertPos++
} }
} }
if m.LineCount() > 1 { if buf.LineCount() > 1 {
m.SetCursorY(initY + 1) win.SetCursorLine(initY + 1)
} }
} }
case CharwiseRegister: case core.CharwiseRegister:
{ {
lines := reg.Content lines := reg.Content
@ -54,22 +59,21 @@ func (a Paste) Execute(m Model) tea.Cmd {
break break
} }
x := m.CursorX() x := win.Cursor.Col
y := m.CursorY() y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count)) cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := m.Line(y) curLine := buf.Lines[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:]
m.SetLine(y, newLine) buf.SetLine(y, newLine)
m.SetCursorX(x + len(cnt)) win.SetCursorCol(x + len(cnt))
m.ClampCursorX()
} }
default: default:
m.SetCommandError(fmt.Errorf("Register type is not implemented.")) m.SetCommandError(fmt.Errorf("core.Register type is not implemented."))
} }
return nil return nil
@ -78,6 +82,7 @@ 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}
} }
@ -87,7 +92,11 @@ 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 {
@ -96,21 +105,21 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
} }
switch reg.Type { switch reg.Type {
case LinewiseRegister: case core.LinewiseRegister:
{ {
initY := m.CursorY() initY := win.Cursor.Line
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 {
m.InsertLine(insertPos, line) buf.InsertLine(insertPos, line)
insertPos++ insertPos++
} }
} }
} }
case CharwiseRegister: case core.CharwiseRegister:
{ {
lines := reg.Content lines := reg.Content
@ -120,22 +129,21 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
break break
} }
x := m.CursorX() x := win.Cursor.Col
y := m.CursorY() y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count)) cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := m.Line(y) curLine := buf.Lines[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:]
m.SetLine(y, newLine) buf.SetLine(y, newLine)
m.SetCursorX(x + len(cnt)) win.SetCursorCol(x + len(cnt))
m.ClampCursorX()
} }
default: default:
m.SetCommandError(fmt.Errorf("Register type is not implemented.")) m.SetCommandError(fmt.Errorf("core.Register type is not implemented."))
} }
return nil return nil
@ -144,6 +152,7 @@ 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}
} }
@ -153,6 +162,7 @@ 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('"')
@ -167,27 +177,26 @@ func (a VisualPaste) Execute(m Model) tea.Cmd {
start, end := normalizeSelection(m) start, end := normalizeSelection(m)
switch mode { switch mode {
case VisualMode: case core.VisualMode:
visualCharPaste(m, reg, start, end) visualCharPaste(m, reg, start, end)
case VisualBlockMode: case core.VisualBlockMode:
visualBlockPaste(m, reg, start, end) visualBlockPaste(m, reg, start, end)
case VisualLineMode: case core.VisualLineMode:
visualLinePaste(m, reg, start, end) visualLinePaste(m, reg, start, end)
} }
// Exit visual mode // Exit visual mode
m.SetMode(NormalMode) m.SetMode(core.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) (Position, Position) { func normalizeSelection(m Model) (core.Position, core.Position) {
anchorX, anchorY := m.AnchorX(), m.AnchorY() win := m.ActiveWindow()
cursorX, cursorY := m.CursorX(), m.CursorY()
start := Position{Line: anchorY, Col: anchorX} start := core.Position{Line: win.Anchor.Line, Col: win.Anchor.Col}
end := Position{Line: cursorY, Col: cursorX} end := core.Position{Line: win.Cursor.Line, Col: win.Cursor.Col}
// 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) {
@ -197,8 +206,11 @@ func normalizeSelection(m Model) (Position, Position) {
return start, end return start, end
} }
// visualCharPaste handles paste in visual (character) mode // visualCharPaste: Handles paste operation in visual (character) mode.
func visualCharPaste(m Model, reg Register, start, end Position) { func visualCharPaste(m Model, reg core.Register, start, end core.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)
@ -208,56 +220,57 @@ func visualCharPaste(m Model, reg Register, start, end 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 == CharwiseRegister { } else if reg.Type == core.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 := m.Line(start.Line) line := buf.Lines[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:]
m.SetLine(start.Line, newLine) buf.SetLine(start.Line, newLine)
// Cursor at end of pasted text // Cursor at end of pasted text
m.SetCursorX(insertAt + len(reg.Content[0]) - 1) win.SetCursorCol(insertAt + len(reg.Content[0]) - 1)
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
} }
} else if reg.Type == LinewiseRegister { } else if reg.Type == core.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 := m.Line(start.Line) line := buf.Lines[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:]
} }
m.SetLine(start.Line, newLine) buf.SetLine(start.Line, newLine)
} else { } else {
// Subsequent lines: insert new lines // Subsequent lines: insert new lines
m.InsertLine(start.Line+i, content) buf.InsertLine(start.Line+i, content)
} }
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(start.Col) win.SetCursorCol(start.Col)
} }
m.ClampCursorX()
// Update register with deleted text // Update register with deleted text
m.UpdateDefaultRegister(CharwiseRegister, []string{deletedText}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText})
} }
// visualBlockPaste handles paste in visual block mode // visualBlockPaste: Handles paste operation in visual block mode.
func visualBlockPaste(m Model, reg Register, start, end Position) { func visualBlockPaste(m Model, reg core.Register, 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)
// 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 := m.Line(y) line := buf.Lines[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])
@ -268,94 +281,97 @@ func visualBlockPaste(m Model, reg Register, start, end 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 := m.Line(y) line := buf.Lines[y]
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
m.SetLine(y, line[:startCol]+line[ec:]) buf.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 == LinewiseRegister && len(reg.Content) > 0 { if reg.Type == core.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 := m.Line(y) line := buf.Lines[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:]
m.SetLine(y, newLine) buf.SetLine(y, newLine)
} }
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(startCol) win.SetCursorCol(startCol)
m.ClampCursorX()
// Update register with deleted block text (joined) // Update register with deleted block text (joined)
m.UpdateDefaultRegister(CharwiseRegister, []string{strings.Join(deletedLines, "\n")}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{strings.Join(deletedLines, "\n")})
} }
// visualLinePaste handles paste in visual line mode // visualLinePaste: Handles paste operation in visual line mode.
func visualLinePaste(m Model, reg Register, start, end Position) { func visualLinePaste(m Model, reg core.Register, start, end core.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, m.Line(y)) deletedLines = append(deletedLines, buf.Lines[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-- {
m.DeleteLine(y) buf.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 m.LineCount() == 0 { if buf.LineCount() == 0 {
m.InsertLine(0, "") buf.InsertLine(0, "")
} }
} else if reg.Type == LinewiseRegister { } else if reg.Type == core.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 {
m.InsertLine(insertPos, content) buf.InsertLine(insertPos, content)
insertPos++ insertPos++
} }
} else { } else {
// Charwise register: insert as a single line // Charwise register: insert as a single line
m.InsertLine(start.Line, reg.Content[0]) buf.InsertLine(start.Line, reg.Content[0])
} }
// Ensure we have at least one line // Ensure we have at least one line
if m.LineCount() == 0 { if buf.LineCount() == 0 {
m.InsertLine(0, "") buf.InsertLine(0, "")
} }
// Position cursor at start of pasted content // core.Position cursor at start of pasted content
y := start.Line y := start.Line
if y >= m.LineCount() { if y >= buf.LineCount() {
y = m.LineCount() - 1 y = buf.LineCount() - 1
} }
m.SetCursorY(y) win.SetCursorLine(y)
m.SetCursorX(0) win.SetCursorCol(0)
m.ClampCursorX()
// Update register with deleted lines // Update register with deleted lines
m.UpdateDefaultRegister(LinewiseRegister, deletedLines) m.UpdateDefaultRegister(core.LinewiseRegister, deletedLines)
} }
// extractCharSelection extracts text from a character selection // extractCharSelection: Extracts text from a character selection range.
func extractCharSelection(m Model, start, end Position) string { func extractCharSelection(m Model, start, end core.Position) string {
buf := m.ActiveBuffer()
if start.Line == end.Line { if start.Line == end.Line {
line := m.Line(start.Line) line := buf.Lines[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 {
@ -368,7 +384,7 @@ func extractCharSelection(m Model, start, end 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 := m.Line(start.Line) firstLine := buf.Lines[start.Line]
if start.Col < len(firstLine) { if start.Col < len(firstLine) {
result.WriteString(firstLine[start.Col:]) result.WriteString(firstLine[start.Col:])
} }
@ -376,27 +392,30 @@ func extractCharSelection(m Model, start, end 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(m.Line(y)) result.WriteString(buf.Lines[y])
result.WriteString("\n") result.WriteString("\n")
} }
// Last line: from beginning to end.Col // Last line: from beginning to end.Col
lastLine := m.Line(end.Line) lastLine := buf.Lines[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 (similar to operator/delete.go) // deleteCharSelectionForPaste: Deletes a character selection for paste operations.
func deleteCharSelectionForPaste(m Model, start, end Position) { func deleteCharSelectionForPaste(m Model, start, end core.Position) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if start.Line == end.Line { if start.Line == end.Line {
line := m.Line(start.Line) line := buf.Lines[start.Line]
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
m.SetLine(start.Line, line[:start.Col]+line[endCol:]) buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := m.Line(start.Line) startLine := buf.Lines[start.Line]
endLine := m.Line(end.Line) endLine := buf.Lines[end.Line]
prefix := "" prefix := ""
if start.Col < len(startLine) { if start.Col < len(startLine) {
@ -410,18 +429,19 @@ func deleteCharSelectionForPaste(m Model, start, end 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-- {
m.DeleteLine(i) buf.DeleteLine(i)
} }
m.InsertLine(start.Line, prefix+suffix) buf.InsertLine(start.Line, prefix+suffix)
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(start.Col) win.SetCursorCol(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

@ -1,75 +0,0 @@
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

@ -1,20 +0,0 @@
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,
}
}

View File

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

View File

@ -1,60 +1,222 @@
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 signals the application should quit // QuitMsg: Message signaling the application should quit.
type QuitMsg struct{} type QuitMsg struct{}
// ErrorMsg signals an error to display // ErrorMsg: Message signaling an error to display.
type ErrorMsg struct { type ErrorMsg struct {
Err error Err error
} }
// cmdQuit handles :quit / :q // --------------------------------------------------
func cmdQuit(m action.Model, args []string) tea.Cmd { // Quit Commands
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
} }
} }
// cmdQuitAll handles :qall / :qa return tea.Quit
func cmdQuitAll(m action.Model, args []string) tea.Cmd { }
return func() tea.Msg {
return tea.Quit() // cmdQuitAll: Handles :qall / :qa command.
func cmdQuitAll(m action.Model, args []string, force bool) tea.Cmd {
// TODO: Until splits are implemented, this is the same as cmdQuit
return cmdQuit(m, args, force)
}
// --------------------------------------------------
// File Commands (write & edit)
// --------------------------------------------------
// cmdWrite: Handles :write / :w command
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)
} }
} }
// cmdWrite handles :write / :w return tea.Batch(cmds...)
func cmdWrite(m action.Model, args []string) tea.Cmd { }
// TODO: Implement file saving
// If args provided, save to that filename // cmdWriteQuit: Handles :wq command
// Otherwise save to current file 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 return nil
} }
// cmdWriteAll handles :wall / :wa // Vim's Approach:
func cmdWriteAll(m action.Model, args []string) tea.Cmd { // " When you do :edit filename.txt
// TODO: Implement saving all buffers // 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 return nil
} }
// cmdWriteQuit handles :wq var lines []string
func cmdWriteQuit(m action.Model, args []string) tea.Cmd {
// TODO: Save then quit // BUG: We are unable to open and edit files owned by root. How do we handle that?
return func() tea.Msg {
return tea.Quit() 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)
} }
// cmdRegisters handles :register buf := core.NewBufferBuilder().
func cmdRegisters(m action.Model, args []string) tea.Cmd { WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
WithLines(lines).
Listed().
Loaded().
Build()
m.SetBuffers(append(m.Buffers(), &buf))
m.ActiveWindow().SetBuffer(&buf)
return nil
}
// --------------------------------------------------
// Register Commands
// --------------------------------------------------
// cmdRegisters: Handles :register command (debug - displays register content).
func cmdRegisters(m action.Model, args []string, force bool) 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."))
@ -79,7 +241,11 @@ func cmdRegisters(m action.Model, args []string) 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
@ -87,7 +253,7 @@ func cmdRegisters(m action.Model, args []string) 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) tea.Cmd { func cmdSet(m action.Model, args []string, force bool) 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)
@ -104,15 +270,16 @@ func cmdSet(m action.Model, args []string) tea.Cmd {
return nil return nil
} }
// Setting represents a configurable option // Setting: Represents a configurable editor option.
type Setting struct { type Setting struct {
Name string Name string
ShortForm string ShortForm string
Type SettingType Type SettingType
Get func(s action.Settings) any Get func(m action.Model) 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 (
@ -121,54 +288,72 @@ const (
StringSetting StringSetting
) )
// settingsMap defines all available settings // settingsMap defines all available settings (both global and window-local)
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(s action.Settings) any { return s.Number }, Get: func(m action.Model) any { return m.ActiveWindow().Options.Number },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
s := m.Settings() w := m.ActiveWindow()
s.Number = val.(bool) o := w.Options
m.SetSettings(s) o.Number = val.(bool)
w.SetOptions(o)
}, },
}, },
{ {
Name: "relativenumber", Name: "relativenumber",
ShortForm: "rnu", ShortForm: "rnu",
Type: BoolSetting, Type: BoolSetting,
Get: func(s action.Settings) any { return s.RelativeNumber }, Get: func(m action.Model) any { return m.ActiveWindow().Options.RelativeNumber },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
s := m.Settings() w := m.ActiveWindow()
s.RelativeNumber = val.(bool) o := w.Options
m.SetSettings(s) o.RelativeNumber = val.(bool)
}, 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(s action.Settings) any { return s.ScrollOff }, Get: func(m action.Model) any { return m.ActiveWindow().Options.ScrollOff },
Set: func(m action.Model, val any) { Set: func(m action.Model, val any) {
s := m.Settings() w := m.ActiveWindow()
s.ScrollOff = val.(int) o := w.Options
m.SetSettings(s) o.ScrollOff = val.(int)
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]
@ -183,16 +368,13 @@ 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 { if setting != nil && setting.Type == BoolSetting {
return nil // Unknown setting currentVal := setting.Get(m).(bool)
}
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
@ -201,10 +383,7 @@ 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 { if setting != nil && setting.Type == BoolSetting {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, false) setting.Set(m, false)
} }
return nil return nil
@ -214,10 +393,9 @@ 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 {
return nil
}
switch setting.Type { switch setting.Type {
case IntSetting: case IntSetting:
intVal, err := strconv.Atoi(value) intVal, err := strconv.Atoi(value)
@ -232,15 +410,13 @@ func parseSetOption(m action.Model, opt string) error {
boolVal := value == "true" || value == "1" || value == "yes" boolVal := value == "true" || value == "1" || value == "yes"
setting.Set(m, boolVal) 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 { if setting != nil && setting.Type == BoolSetting {
return nil
}
if setting.Type == BoolSetting {
setting.Set(m, true) setting.Set(m, true)
} }

File diff suppressed because it is too large Load Diff

83
internal/command/io.go Normal file
View File

@ -0,0 +1,83 @@
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,32 +8,31 @@ 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) tea.Cmd // Handler function Handler func(m action.Model, args []string, force bool) 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
} }
// Register adds a command to the registry // 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)
} }
// Lookup finds a command by name or abbreviation // Registry.Lookup: Finds a command by name or abbreviation with error handling.
// 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")
@ -75,31 +74,41 @@ 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) { func Parse(cmdLine string) (name string, args []string, force bool) {
parts := strings.Fields(cmdLine) parts := strings.Fields(cmdLine)
if len(parts) == 0 { if len(parts) == 0 {
return "", nil return "", nil, false
}
return parts[0], parts[1:]
} }
// Execute parses and executes a command line 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.
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 := Parse(cmdLine) name, args, force := 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), nil return cmd.Handler(m, args, force), nil
} }
// DefaultRegistry is the global command registry // DefaultRegistry is the global command registry
var DefaultRegistry = NewRegistry() var DefaultRegistry = NewRegistry()
// registerDefaults registers the built-in commands // Registry.registerDefaults: Registers the built-in commands.
func (r *Registry) registerDefaults() { func (r *Registry) registerDefaults() {
// Quit commands // Quit commands
r.Register(Command{ r.Register(Command{
@ -133,6 +142,12 @@ 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",
@ -146,4 +161,11 @@ 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,6 +89,26 @@ 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 {
@ -106,17 +126,20 @@ 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 := Parse("quit") name, args, force := 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 := Parse("set number") name, args, force := Parse("set number")
if name != "set" { if name != "set" {
t.Errorf("name = %q, want \"set\"", name) t.Errorf("name = %q, want \"set\"", name)
} }
@ -126,10 +149,13 @@ 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 := Parse("set number tabstop=4") name, args, force := 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)
} }
@ -142,26 +168,74 @@ 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 := Parse("") name, args, force := 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 := Parse(" ") name, args, force := 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")
}
}) })
} }

130
internal/core/buffer.go Normal file
View File

@ -0,0 +1,130 @@
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

@ -0,0 +1,89 @@
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

@ -0,0 +1,302 @@
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)
}
})
}

41
internal/core/mode.go Normal file
View File

@ -0,0 +1,41 @@
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

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

93
internal/core/register.go Normal file
View File

@ -0,0 +1,93 @@
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{},
}
}

15
internal/core/settings.go Normal file
View File

@ -0,0 +1,15 @@
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,
}
}

15
internal/core/types.go Normal file
View File

@ -0,0 +1,15 @@
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
}

205
internal/core/window.go Normal file
View File

@ -0,0 +1,205 @@
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

@ -0,0 +1,111 @@
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

@ -0,0 +1,493 @@
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/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// TestHelperExamples demonstrates the different ways to use the test helpers // TestHelperExamples demonstrates the different ways to use the test helpers
@ -19,18 +19,20 @@ func TestHelperExamples(t *testing.T) {
WithLines([]string{"hello", "world"}), WithLines([]string{"hello", "world"}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if len(m.lines) != 2 { buf := m.ActiveBuffer()
t.Errorf("expected 2 lines, got %d", len(m.lines)) if buf.LineCount() != 2 {
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(action.Position{Line: 2, Col: 3}), WithCursorPos(core.Position{Line: 2, Col: 3}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 2 || m.CursorX() != 3 { win := m.ActiveWindow()
t.Errorf("expected cursor at (2,3), got (%d,%d)", m.CursorY(), m.CursorX()) if win.Cursor.Line != 2 || win.Cursor.Col != 3 {
t.Errorf("expected cursor at (2,3), got (%d,%d)", win.Cursor.Line, win.Cursor.Col)
} }
}) })
@ -39,15 +41,16 @@ func TestHelperExamples(t *testing.T) {
WithTermSize(120, 40), WithTermSize(120, 40),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.WinW() != 120 || m.WinH() != 40 { win := m.ActiveWindow()
t.Errorf("expected size 120x40, got %dx%d", m.WinW(), m.WinH()) if win.Width != 120 || win.Height != 40 {
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('"', action.CharwiseRegister, []string{"foo"}), WithRegister('"', core.CharwiseRegister, []string{"foo"}),
) )
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
@ -55,7 +58,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 != action.CharwiseRegister { if reg.Type != core.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" {
@ -66,25 +69,28 @@ 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(action.Position{Line: 1, Col: 5}), WithCursorPos(core.Position{Line: 1, Col: 5}),
WithTermSize(100, 30), WithTermSize(100, 30),
WithRegister('"', action.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}), WithRegister('"', core.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
if len(m.Lines()) != 3 { win := m.ActiveWindow()
t.Errorf("expected 3 lines, got %d", len(m.Lines())) buf := m.ActiveBuffer()
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
} }
if m.CursorY() != 1 || m.CursorX() != 5 { if win.Cursor.Line != 1 || win.Cursor.Col != 5 {
t.Errorf("expected cursor at (1,5), got (%d,%d)", m.CursorY(), m.CursorX()) t.Errorf("expected cursor at (1,5), got (%d,%d)", win.Cursor.Line, win.Cursor.Col)
} }
if m.WinW() != 100 || m.WinH() != 30 { if win.Width != 100 || win.Height != 30 {
t.Errorf("expected size 100x30, got %dx%d", m.WinW(), m.WinH()) t.Errorf("expected size 100x30, got %dx%d", win.Width, win.Height)
} }
reg, ok := m.GetRegister('"') reg, ok := m.GetRegister('"')
if !ok || reg.Type != action.LinewiseRegister { if !ok || reg.Type != core.LinewiseRegister {
t.Error("register not set correctly") t.Error("register not set correctly")
} }
}) })
@ -93,25 +99,29 @@ 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)
if len(m1.Lines()) != 2 { buf1 := m1.ActiveBuffer()
if buf1.LineCount() != 2 {
t.Error("newTestModelWithLines failed") t.Error("newTestModelWithLines failed")
} }
tm2 := newTestModelWithCursorPos(t, action.Position{Line: 1, Col: 2}) tm2 := newTestModelWithCursorPos(t, core.Position{Line: 1, Col: 2})
m2 := getFinalModel(t, tm2) m2 := getFinalModel(t, tm2)
if m2.CursorY() != 1 { win2 := m2.ActiveWindow()
if win2.Cursor.Line != 1 {
t.Error("newTestModelWithCursorPos failed") t.Error("newTestModelWithCursorPos failed")
} }
tm3 := newTestModelWithLinesAndCursorPos(t, []string{"x"}, action.Position{Line: 0, Col: 0}) tm3 := newTestModelWithLinesAndCursorPos(t, []string{"x"}, core.Position{Line: 0, Col: 0})
m3 := getFinalModel(t, tm3) m3 := getFinalModel(t, tm3)
if len(m3.Lines()) != 1 { buf3 := m3.ActiveBuffer()
if buf3.LineCount() != 1 {
t.Error("newTestModelWithLinesAndCursorPos failed") t.Error("newTestModelWithLinesAndCursorPos failed")
} }
tm4 := newTestModelWithTermSize(t, []string{"y"}, action.Position{Line: 0, Col: 0}, 50, 20) tm4 := newTestModelWithTermSize(t, []string{"y"}, core.Position{Line: 0, Col: 0}, 50, 20)
m4 := getFinalModel(t, tm4) m4 := getFinalModel(t, tm4)
if m4.WinW() != 50 { win4 := m4.ActiveWindow()
if win4.Width != 50 {
t.Error("newTestModelWithTermSize failed") t.Error("newTestModelWithTermSize failed")
} }
}) })

View File

@ -4,11 +4,14 @@ import (
"testing" "testing"
"time" "time"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
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 {
@ -35,16 +38,23 @@ 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 action.Position pos core.Position
width int width int
height int height int
regName rune regName rune
regType action.RegisterType regType core.RegisterType
regContent []string regContent []string
} }
@ -56,7 +66,7 @@ func WithLines(lines []string) TestModelOption {
} }
// WithCursorPos sets the initial cursor position // WithCursorPos sets the initial cursor position
func WithCursorPos(pos action.Position) TestModelOption { func WithCursorPos(pos core.Position) TestModelOption {
return func(c *testModelConfig) { return func(c *testModelConfig) {
c.pos = pos c.pos = pos
} }
@ -71,7 +81,7 @@ func WithTermSize(width, height int) TestModelOption {
} }
// WithRegister sets a register's content // WithRegister sets a register's content
func WithRegister(name rune, regType action.RegisterType, content []string) TestModelOption { func WithRegister(name rune, regType core.RegisterType, content []string) TestModelOption {
return func(c *testModelConfig) { return func(c *testModelConfig) {
c.regName = name c.regName = name
c.regType = regType c.regType = regType
@ -84,7 +94,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: action.Position{Col: 0, Line: 0}, pos: core.Position{Col: 0, Line: 0},
width: 80, width: 80,
height: 24, height: 24,
} }
@ -94,12 +104,31 @@ func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel {
opt(&cfg) opt(&cfg)
} }
// Create model buf := core.NewBufferBuilder().
m := NewModel(cfg.lines, cfg.pos) WithLines(cfg.lines).
Build()
// Set register if provided win := core.NewWindowBuilder().
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 {
m.SetRegister(cfg.regName, cfg.regType, cfg.regContent) err := 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))
@ -110,21 +139,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 action.Position) *teatest.TestModel { func newTestModelWithCursorPos(t *testing.T, pos core.Position) *teatest.TestModel {
return newTestModel(t, WithCursorPos(pos)) return newTestModel(t, WithCursorPos(pos))
} }
func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos action.Position) *teatest.TestModel { func newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.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 action.Position, width, height int) *teatest.TestModel { func newTestModelWithTermSize(t *testing.T, lines []string, pos core.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,13 +3,11 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// 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
@ -19,7 +17,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.Settings().Number { if m.ActiveWindow().Options.Number {
t.Error("expected Number to be false after :set nonumber") t.Error("expected Number to be false after :set nonumber")
} }
}) })
@ -33,7 +31,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.Settings().Number { if !m.ActiveWindow().Options.Number {
t.Error("expected Number to be true after :set nu") t.Error("expected Number to be true after :set nu")
} }
}) })
@ -46,7 +44,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.Settings().Number { if m.ActiveWindow().Options.Number {
t.Error("expected Number to be false after :set number!") t.Error("expected Number to be false after :set number!")
} }
}) })
@ -60,7 +58,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.Settings().Number { if !m.ActiveWindow().Options.Number {
t.Error("expected Number to be true after double toggle") t.Error("expected Number to be true after double toggle")
} }
}) })
@ -73,7 +71,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.Settings().RelativeNumber { if m.ActiveWindow().Options.RelativeNumber {
t.Error("expected RelativeNumber to be false after :set nornu") t.Error("expected RelativeNumber to be false after :set nornu")
} }
}) })
@ -87,7 +85,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.Settings().RelativeNumber { if !m.ActiveWindow().Options.RelativeNumber {
t.Error("expected RelativeNumber to be true after :set rnu") t.Error("expected RelativeNumber to be true after :set rnu")
} }
}) })
@ -95,15 +93,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 TabSize=2 // Default TabStop=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().TabSize != 4 { if m.Settings().TabStop != 4 {
t.Errorf("TabSize = %d, want 4", m.Settings().TabSize) t.Errorf("TabStop = %d, want 4", m.Settings().TabStop)
} }
}) })
@ -114,8 +112,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().TabSize != 8 { if m.Settings().TabStop != 8 {
t.Errorf("TabSize = %d, want 8", m.Settings().TabSize) t.Errorf("TabStop = %d, want 8", m.Settings().TabStop)
} }
}) })
@ -127,8 +125,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.Settings().ScrollOff != 5 { if m.ActiveWindow().Options.ScrollOff != 5 {
t.Errorf("ScrollOff = %d, want 5", m.Settings().ScrollOff) t.Errorf("ScrollOff = %d, want 5", m.ActiveWindow().Options.ScrollOff)
} }
}) })
@ -139,8 +137,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.Settings().ScrollOff != 10 { if m.ActiveWindow().Options.ScrollOff != 10 {
t.Errorf("ScrollOff = %d, want 10", m.Settings().ScrollOff) t.Errorf("ScrollOff = %d, want 10", m.ActiveWindow().Options.ScrollOff)
} }
}) })
} }
@ -153,7 +151,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() != action.NormalMode { if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode after esc", m.Mode()) t.Errorf("Mode() = %v, want NormalMode after esc", m.Mode())
} }
}) })
@ -164,7 +162,7 @@ func TestCommandModeNavigation(t *testing.T) {
sendKeys(tm, ":", "esc") sendKeys(tm, ":", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != action.NormalMode { if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -175,7 +173,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() != action.NormalMode { if m.Mode() != core.NormalMode {
t.Errorf("Mode() = %v, want NormalMode", m.Mode()) t.Errorf("Mode() = %v, want NormalMode", m.Mode())
} }
}) })
@ -339,3 +337,16 @@ 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/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
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.lines[0] != "ello" { if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.lines[0] != "helo" { if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.lines[0] != "hell" { if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("lines[0] = %q, want 'hell'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hell'", m.ActiveBuffer().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.lines[0] != "llo" { if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().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.lines[0] != "lo" { if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().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.lines[0] != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0]) t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "2", "x") sendKeys(tm, "2", "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.lines[0] != "hlo" { if m.ActiveBuffer().Lines[0] != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "a" { if m.ActiveBuffer().Lines[0] != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.Line(0)) t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "ab c" { if m.ActiveBuffer().Lines[0] != "ab c" {
t.Errorf("Line(0) = %q, want 'ab c'", m.Line(0)) t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[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.LineCount() != 2 { if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount()) t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
} }
if m.Line(1) != "world" { if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.Line(1)) t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[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.Line(0) != "lo" { if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("Line(0) = %q, want 'lo'", m.Line(0)) t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "ab" { if m.ActiveBuffer().Lines[0] != "ab" {
t.Errorf("Line(0) = %q, want 'ab'", m.Line(0)) t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "x") sendKeys(tm, "x")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "abde" { if m.ActiveBuffer().Lines[0] != "abde" {
t.Errorf("Line(0) = %q, want 'abde'", m.Line(0)) t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "hell" { if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "2", "D") sendKeys(tm, "2", "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %q, want '3'", m.LineCount()) t.Errorf("LineCount() = %q, want '3'", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "he" { if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.Line(0)) t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "hi" { if m.ActiveBuffer().Lines[1] != "hi" {
t.Errorf("Line(1) = %q, want 'hi'", m.Line(1)) t.Errorf("Line(1) = %q, want 'hi'", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "8", "D") sendKeys(tm, "8", "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %q, want '1'", m.LineCount()) t.Errorf("LineCount() = %q, want '1'", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "he" { if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.Line(0)) t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.LineCount() != 2 { if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.LineCount()) t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
} }
if m.Line(1) != "" { if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.Line(1)) t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "line 1" { if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
} }
if m.Line(2) != "line 3" { if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2)) t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "he" { if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.Line(0)) t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
} }
// Cursor should clamp to last char // Cursor should clamp to last char
if m.CursorX() != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX()) t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != " " { if m.ActiveBuffer().Lines[0] != " " {
t.Errorf("Line(0) = %q, want ' '", m.Line(0)) t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "a" { if m.ActiveBuffer().Lines[0] != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.Line(0)) t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(1) != "world" { if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.Line(1)) t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "first" { if m.ActiveBuffer().Lines[0] != "first" {
t.Errorf("Line(0) = %q, want 'first'", m.Line(0)) t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0])
} }
if m.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().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, action.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 1})
sendKeys(tm, "D") sendKeys(tm, "D")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// --- 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 != action.InsertMode { if m.mode != core.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode) t.Errorf("mode = %d, want InsertMode (%d)", m.mode, core.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.lines[0] != "Xhello" { if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.cursor.x != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x) t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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 != action.InsertMode { if m.mode != core.InsertMode {
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode) t.Errorf("mode = %d, want InsertMode (%d)", m.mode, core.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.lines[0] != "hXello" { if m.ActiveBuffer().Lines[0] != "hXello" {
t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hXello'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "helXlo" { if m.ActiveBuffer().Lines[0] != "helXlo" {
t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helXlo'", m.ActiveBuffer().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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "Xhello" { if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "Xhello" { if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().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.lines[0] != "helloX" { if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "helloX" { if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().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 len(m.lines) != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines)) t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.lines[1] != "new" { if m.ActiveBuffer().Lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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 len(m.lines) != 4 { if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines)) t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
} }
if m.lines[2] != "new" { if m.ActiveBuffer().Lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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 len(m.lines) != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines)) t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.lines[2] != "new" { if m.ActiveBuffer().Lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.lines[2]) t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().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.cursor.y != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y) t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
if m.cursor.x != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x) t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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 len(m.lines) != 4 { if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines)) t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
} }
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
if m.lines[i] != "x" { if m.ActiveBuffer().Lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().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 len(m.lines) != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines)) t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.lines[1] != "ab" { if m.ActiveBuffer().Lines[1] != "ab" {
t.Errorf("lines[1] = %q, want 'ab'", m.lines[1]) t.Errorf("lines[1] = %q, want 'ab'", m.ActiveBuffer().Lines[1])
} }
if m.lines[2] != "ab" { if m.ActiveBuffer().Lines[2] != "ab" {
t.Errorf("lines[2] = %q, want 'ab'", m.lines[2]) t.Errorf("lines[2] = %q, want 'ab'", m.ActiveBuffer().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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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 len(m.lines) != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines)) t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.lines[1] != "new" { if m.ActiveBuffer().Lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.lines[1]) t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().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 len(m.lines) != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", len(m.lines)) t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "new" { if m.ActiveBuffer().Lines[0] != "new" {
t.Errorf("lines[0] = %q, want 'new'", m.lines[0]) t.Errorf("lines[0] = %q, want 'new'", m.ActiveBuffer().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, action.Position{Col: 3, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1})
sendKeys(tm, "O", "esc") sendKeys(tm, "O", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.x != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x) t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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 len(m.lines) != 4 { if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("len(lines) = %d, want 4", len(m.lines)) t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
} }
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
if m.lines[i] != "x" { if m.ActiveBuffer().Lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i]) t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc") sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if len(m.lines) != 2 { if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines)) t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
} }
if m.lines[1] != " world" { if m.ActiveBuffer().Lines[1] != " world" {
t.Errorf("lines[1] = %q, want ' world'", m.lines[1]) t.Errorf("lines[1] = %q, want ' world'", m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "enter", "esc") sendKeys(tm, "i", "enter", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if len(m.lines) != 2 { if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines)) t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
} }
if m.lines[1] != "" { if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("lines[1] = %q, want ''", m.lines[1]) t.Errorf("lines[1] = %q, want ''", m.ActiveBuffer().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 len(m.lines) != 2 { if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", len(m.lines)) t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0]) t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
} }
if m.lines[1] != "hello" { if m.ActiveBuffer().Lines[1] != "hello" {
t.Errorf("lines[1] = %q, want 'hello'", m.lines[1]) t.Errorf("lines[1] = %q, want 'hello'", m.ActiveBuffer().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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "helo" { if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "i", "backspace", "esc") sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if len(m.lines) != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines)) t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "helloworld" { if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().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.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "he" { if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("lines[0] = %q, want 'he'", m.lines[0]) t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "word" { if m.ActiveBuffer().Lines[0] != "word" {
t.Errorf("lines[0] = %q, want 'word'", m.lines[0]) t.Errorf("lines[0] = %q, want 'word'", m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "i", "delete", "esc") sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if len(m.lines) != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines)) t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "helloworld" { if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().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 len(m.lines) != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", len(m.lines)) t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "world" { if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.lines[0]) t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "ho" { if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("lines[0] = %q, want 'he'", m.lines[0]) t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().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, action.Position{Col: 1, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
} }
if m.lines[1] != "world" { if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.lines[1]) t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
} }
if m.lines[1] != "woXrld" { if m.ActiveBuffer().Lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1]) t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().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.lines[0] != "Xhello" { if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "helloX" { if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "heXllo" { if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().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, action.Position{Col: 4, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hiX" { if m.ActiveBuffer().Lines[0] != "hiX" {
t.Errorf("lines[0] = %q, want 'hiX'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hiX'", m.ActiveBuffer().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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[1] != "hiX" { if m.ActiveBuffer().Lines[1] != "hiX" {
t.Errorf("lines[1] = %q, want 'hiX'", m.lines[1]) t.Errorf("lines[1] = %q, want 'hiX'", m.ActiveBuffer().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.lines[1] != "woXrld" { if m.ActiveBuffer().Lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1]) t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hello " { if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 5 { if m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("CursorX() = %d, want '5'", m.CursorX()) t.Errorf("CursorX() = %d, want '5'", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0]) t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX()) t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hello wo" { if m.ActiveBuffer().Lines[0] != "hello wo" {
t.Errorf("lines[0] = %q, want 'hello wo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello wo'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 7 { if m.ActiveWindow().Cursor.Col != 7 {
t.Errorf("CursorX() = %d, want '7'", m.CursorX()) t.Errorf("CursorX() = %d, want '7'", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.LineCount()) t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount())
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX()) t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.CursorY()) t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.LineCount()) t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %s, want ''", m.Line(0)) t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.CursorX()) t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.CursorY()) t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.lines[0] != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "lo" { if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0]) t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 7, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "..." { if m.ActiveBuffer().Lines[0] != "..." {
t.Errorf("lines[0] = %q, want '...'", m.lines[0]) t.Errorf("lines[0] = %q, want '...'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX()) t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "hello\t" { if m.ActiveBuffer().Lines[0] != "hello\t" {
t.Errorf("lines[0] = %q, want 'hello\\t'", m.lines[0]) t.Errorf("lines[0] = %q, want 'hello\\t'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 5 { if m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("CursorX() = %d, want 5", m.CursorX()) t.Errorf("CursorX() = %d, want 5", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.lines[0] != "helloworld" { if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0]) t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.lines[0] != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.lines[0]) t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
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.cursor.y != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y) t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.cursor.y != 4 { if m.ActiveWindow().Cursor.Line != 4 {
t.Errorf("cursor.y = %d, want 4", m.cursor.y) t.Errorf("cursor.y = %d, want 4", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.cursor.y != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y) t.Errorf("cursor.y = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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.cursor.y != 3 { if m.ActiveWindow().Cursor.Line != 3 {
t.Errorf("cursor.y = %d, want 3", m.cursor.y) t.Errorf("cursor.y = %d, want 3", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.cursor.y != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("cursor.y = %d, want 5", m.cursor.y) t.Errorf("cursor.y = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 8, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.cursor.x != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "j") sendKeys(tm, "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.x != 3 { if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x) t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
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, action.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2})
sendKeys(tm, "k") sendKeys(tm, "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.y != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("cursor.y = %d, want 1", m.cursor.y) t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
}) })
t.Run("test 'kkkk'", func(t *testing.T) { t.Run("test 'kkkk'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 4}) tm := newTestModelWithCursorPos(t, core.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.cursor.y != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y) t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.cursor.y != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y) t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
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, action.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5})
sendKeys(tm, "3", "k") sendKeys(tm, "3", "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.y != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("cursor.y = %d, want 2", m.cursor.y) t.Errorf("cursor.y = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
t.Run("test '10k' with overflow", func(t *testing.T) { t.Run("test '10k' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3}) tm := newTestModelWithCursorPos(t, core.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.cursor.y != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("cursor.y = %d, want 0", m.cursor.y) t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.cursor.x != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: 3, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1})
sendKeys(tm, "k") sendKeys(tm, "k")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.x != 3 { if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x) t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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.cursor.x != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("cursor.x = %d, want 1", m.cursor.x) t.Errorf("cursor.x = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.cursor.x != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("cursor.x = %d, want 4", m.cursor.x) t.Errorf("cursor.x = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.cursor.x != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, 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.cursor.x != 3 { if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("cursor.x = %d, want 3", m.cursor.x) t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.cursor.x != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want) t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 0})
sendKeys(tm, "h") sendKeys(tm, "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.x != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x) t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
t.Run("test 'hhhh'", func(t *testing.T) { t.Run("test 'hhhh'", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 4, Line: 0}) tm := newTestModelWithCursorPos(t, core.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.cursor.x != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x) t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.cursor.x != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x) t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithCursorPos(t, core.Position{Col: 5, Line: 0})
sendKeys(tm, "3", "h") sendKeys(tm, "3", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.cursor.x != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("cursor.x = %d, want 2", m.cursor.x) t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
t.Run("test '10h' with overflow", func(t *testing.T) { t.Run("test '10h' with overflow", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, core.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.cursor.x != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor.x = %d, want 0", m.cursor.x) t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// --- 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.CursorY() != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY()) t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
}) })
t.Run("test 'G' from middle", func(t *testing.T) { t.Run("test 'G' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY()) t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY()) t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
want := len(lines[1]) want := len(lines[1])
if m.CursorX() != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, 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.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
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, action.Position{Col: 0, Line: 5}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 5})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
t.Run("test 'gg' from middle", func(t *testing.T) { t.Run("test 'gg' from middle", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 3}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 3})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 1})
sendKeys(tm, "g", "g") sendKeys(tm, "g", "g")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
want := len(lines[0]) want := len(lines[0])
if m.CursorX() != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 0})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: len(lines[0]), Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: len(lines[0]), Line: 0})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
t.Run("test '0' preserves line", func(t *testing.T) { t.Run("test '0' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 3, Line: 2}) tm := newTestModelWithCursorPos(t, core.Position{Col: 3, Line: 2})
sendKeys(tm, "0") sendKeys(tm, "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY()) t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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.CursorX() != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, 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, action.Position{Col: len(lines[0]), Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != want { if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want) t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
} }
}) })
@ -212,18 +212,18 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
t.Run("test '$' preserves line", func(t *testing.T) { t.Run("test '$' preserves line", func(t *testing.T) {
tm := newTestModelWithCursorPos(t, action.Position{Col: 0, Line: 2}) tm := newTestModelWithCursorPos(t, core.Position{Col: 0, Line: 2})
sendKeys(tm, "$") sendKeys(tm, "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY()) t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 6, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "_") sendKeys(tm, "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 6, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 10, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "^") sendKeys(tm, "^")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "1", "|") sendKeys(tm, "1", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 9 { if m.ActiveWindow().Cursor.Col != 9 {
t.Errorf("CursorX() = %d, want 9", m.CursorX()) t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 4, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 10 { if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.CursorX()) t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX()) t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 5, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 1})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 2}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "5", "|") sendKeys(tm, "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY()) t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 10, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 1})
sendKeys(tm, "|") sendKeys(tm, "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX()) t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.CursorX() != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX()) t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.Line(0) != " world" { if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.Line(0) != "o world" { if m.ActiveBuffer().Lines[0] != "o world" {
t.Errorf("Line(0) = %q, want 'o world'", m.Line(0)) t.Errorf("Line(0) = %q, want 'o world'", m.ActiveBuffer().Lines[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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "v", "5", "|") sendKeys(tm, "v", "5", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 5, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "v", "|") sendKeys(tm, "v", "|")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 5 { if m.ActiveWindow().Anchor.Col != 5 {
t.Errorf("AnchorX() = %d, want 5", m.AnchorX()) t.Errorf("AnchorX() = %d, want 5", m.ActiveWindow().Anchor.Col)
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 0, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.Line(0) != " world" { if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[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/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// 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, action.Position{Col: 0, Line: 0}, 80, 24) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().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, action.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 15 { if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY()) t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
} }
// 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.ScrollY() < 1 { if m.ActiveWindow().ScrollY < 1 {
t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ScrollY()) t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ActiveWindow().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, action.Position{Col: 0, Line: 20}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 5 { if m.ActiveWindow().Cursor.Line != 5 {
t.Errorf("CursorY() = %d, want 5", m.CursorY()) t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
} }
// 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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().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, action.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
sendKeys(tm, "G") sendKeys(tm, "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 99 { if m.ActiveWindow().Cursor.Line != 99 {
t.Errorf("CursorY() = %d, want 99", m.CursorY()) t.Errorf("CursorY() = %d, want 99", m.ActiveWindow().Cursor.Line)
} }
// 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.ScrollY() != 82 { if m.ActiveWindow().ScrollY != 82 {
t.Errorf("ScrollY() = %d, want 82", m.ScrollY()) t.Errorf("ScrollY() = %d, want 82", m.ActiveWindow().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, action.Position{Col: 0, Line: 50}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
if m.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().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, action.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() < 0 { if m.ActiveWindow().ScrollY < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().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, action.Position{Col: 0, Line: 0}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() > maxScroll { if m.ActiveWindow().ScrollY > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll) t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().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, action.Position{Col: 0, Line: 29}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() < m.ScrollY() || m.CursorY() >= m.ScrollY()+viewportHeight { if m.ActiveWindow().Cursor.Line < m.ActiveWindow().ScrollY || m.ActiveWindow().Cursor.Line >= m.ActiveWindow().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.CursorY(), m.ScrollY(), m.ScrollY()+viewportHeight) m.ActiveWindow().Cursor.Line, m.ActiveWindow().ScrollY, m.ActiveWindow().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, action.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 14 { if m.ActiveWindow().ScrollY != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ScrollY()) t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 29 { if m.ActiveWindow().Cursor.Line != 29 {
t.Errorf("CursorY() = %d, want 29", m.CursorY()) t.Errorf("CursorY() = %d, want 29", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() - m.ScrollY() relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().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, action.Position{Col: 0, Line: 0}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 14 { if m.ActiveWindow().ScrollY != 14 {
t.Errorf("ScrollY() = %d, want 14", m.ScrollY()) t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 22 { if m.ActiveWindow().Cursor.Line != 22 {
t.Errorf("CursorY() = %d, want 22", m.CursorY()) t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 0, Line: 35}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() > maxScroll { if m.ActiveWindow().ScrollY > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll) t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll)
} }
if m.CursorY() != 31 { if m.ActiveWindow().Cursor.Line != 31 {
t.Errorf("CursorY() = %d, want 31", m.CursorY()) t.Errorf("CursorY() = %d, want 31", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 0, Line: 0}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 8 { if m.ActiveWindow().Cursor.Line != 8 {
t.Errorf("CursorY() = %d, want 8", m.CursorY()) t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 10, Line: 5}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 22 { if m.ActiveWindow().Cursor.Line != 22 {
t.Errorf("CursorY() = %d, want 22", m.CursorY()) t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line)
} }
if m.CursorX() > len(m.Line(m.CursorY())) { if 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()))) t.Errorf("CursorX() = %d exceeds line length %d", m.ActiveWindow().Cursor.Col, len(m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line]))
} }
}) })
@ -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, action.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 28 { if m.ActiveWindow().ScrollY != 28 {
t.Errorf("ScrollY() = %d, want 28", m.ScrollY()) t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 43 { if m.ActiveWindow().Cursor.Line != 43 {
t.Errorf("CursorY() = %d, want 43", m.CursorY()) t.Errorf("CursorY() = %d, want 43", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 17 { if m.ActiveWindow().ScrollY != 17 {
t.Errorf("ScrollY() = %d, want 17", m.ScrollY()) t.Errorf("ScrollY() = %d, want 17", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 36 { if m.ActiveWindow().Cursor.Line != 36 {
t.Errorf("CursorY() = %d, want 36", m.CursorY()) t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() - m.ScrollY() relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().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, action.Position{Col: 0, Line: 10}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() < 0 { if m.ActiveWindow().ScrollY < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY)
} }
if m.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 10 { if m.ActiveWindow().Cursor.Line != 10 {
t.Errorf("CursorY() = %d, want 10", m.CursorY()) t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 0, Line: 5}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 8 { if m.ActiveWindow().Cursor.Line != 8 {
t.Errorf("CursorY() = %d, want 8", m.CursorY()) t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 0, Line: 80}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 33 { if m.ActiveWindow().ScrollY != 33 {
t.Errorf("ScrollY() = %d, want 33", m.ScrollY()) t.Errorf("ScrollY() = %d, want 33", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 52 { if m.ActiveWindow().Cursor.Line != 52 {
t.Errorf("CursorY() = %d, want 52", m.CursorY()) t.Errorf("CursorY() = %d, want 52", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 15 { if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY()) t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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, action.Position{Col: 0, Line: 50}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 31 { if m.ActiveWindow().ScrollY != 31 {
t.Errorf("ScrollY() = %d, want 31", m.ScrollY()) t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 50 { if m.ActiveWindow().Cursor.Line != 50 {
t.Errorf("CursorY() = %d, want 50", m.CursorY()) t.Errorf("CursorY() = %d, want 50", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 15}, 80, 30) tm := newTestModelWithTermSize(t, lines, core.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.ScrollY() != 0 { if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ScrollY()) t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY)
} }
if m.CursorY() != 15 { if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.CursorY()) t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.ActiveWindow().Cursor.Line)
} }
}) })
} }
@ -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, action.Position{Col: 0, Line: 5}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 15 { if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15", m.CursorY()) t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
} }
// Should have scrolled since we moved past the safe zone // Should have scrolled since we moved past the safe zone
if m.ScrollY() == 0 { if m.ActiveWindow().ScrollY == 0 {
t.Errorf("ScrollY() = %d, want > 0", m.ScrollY()) t.Errorf("ScrollY() = %d, want > 0", m.ActiveWindow().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, action.Position{Col: 0, Line: 25}, 80, 20) tm := newTestModelWithTermSize(t, lines, core.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.CursorY() != 10 { if m.ActiveWindow().Cursor.Line != 10 {
t.Errorf("CursorY() = %d, want 10", m.CursorY()) t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line)
} }
}) })
} }

View File

@ -3,7 +3,7 @@ package editor
import ( import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// 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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v") sendKeys(tm, "v")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != action.VisualMode { if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode()) t.Errorf("Mode() = %v, want VisualMode", m.Mode())
} }
if m.AnchorX() != 3 { if m.ActiveWindow().Anchor.Col != 3 {
t.Errorf("AnchorX() = %d, want 3", m.AnchorX()) t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
} }
if m.AnchorY() != 0 { if m.ActiveWindow().Anchor.Line != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
} }
}) })
@ -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.AnchorX() != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
if m.CursorX() != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX()) t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "h") sendKeys(tm, "v", "h")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 3 { if m.ActiveWindow().Anchor.Col != 3 {
t.Errorf("AnchorX() = %d, want 3", m.AnchorX()) t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
} }
if m.CursorX() != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX()) t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "j") sendKeys(tm, "v", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 2 { if m.ActiveWindow().Anchor.Col != 2 {
t.Errorf("AnchorX() = %d, want 2", m.AnchorX()) t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
} }
if m.AnchorY() != 0 { if m.ActiveWindow().Anchor.Line != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
} }
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "V") sendKeys(tm, "V")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != action.VisualLineMode { if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
} }
if m.AnchorY() != 1 { if m.ActiveWindow().Anchor.Line != 1 {
t.Errorf("AnchorY() = %d, want 1", m.AnchorY()) t.Errorf("AnchorY() = %d, want 1", m.ActiveWindow().Anchor.Line)
} }
}) })
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, action.Position{Col: 2, Line: 1}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 1})
sendKeys(tm, "ctrl+v") sendKeys(tm, "ctrl+v")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != action.VisualBlockMode { if m.Mode() != core.VisualBlockMode {
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
} }
if m.AnchorX() != 2 { if m.ActiveWindow().Anchor.Col != 2 {
t.Errorf("AnchorX() = %d, want 2", m.AnchorX()) t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
} }
if m.AnchorY() != 1 { if m.ActiveWindow().Anchor.Line != 1 {
t.Errorf("AnchorY() = %d, want 1", m.AnchorY()) t.Errorf("AnchorY() = %d, want 1", m.ActiveWindow().Anchor.Line)
} }
}) })
@ -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() != action.NormalMode { if m.Mode() != core.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.Line(0) != "ello" { if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("Line(0) = %q, want \"ello\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"ello\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
@ -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.Line(0) != "o world" { if m.ActiveBuffer().Lines[0] != "o world" {
t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"o world\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.Line(0) != "ho" { if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 1 { if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.CursorX()) t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
} }
}) })
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, action.Position{Col: 2, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "held" { if m.ActiveBuffer().Lines[0] != "held" {
t.Errorf("Line(0) = %q, want \"held\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"held\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorX() != 2 { if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.CursorX()) t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "world" { if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
@ -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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "testing" { if m.ActiveBuffer().Lines[0] != "testing" {
t.Errorf("Line(0) = %q, want \"testing\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"testing\"", m.ActiveBuffer().Lines[0])
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 0, Line: 2}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "hello" { if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[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.Line(0) != "llo" { if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("Line(0) = %q, want \"llo\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"llo\"", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "rld" { if m.ActiveBuffer().Lines[1] != "rld" {
t.Errorf("Line(1) = %q, want \"rld\"", m.Line(1)) t.Errorf("Line(1) = %q, want \"rld\"", m.ActiveBuffer().Lines[1])
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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, action.Position{Col: 3, Line: 0}) tm := newTestModelWithLinesAndCursorPos(t, lines, core.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.Line(0) != "ho" { if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0)) t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "wd" { if m.ActiveBuffer().Lines[1] != "wd" {
t.Errorf("Line(1) = %q, want \"wd\"", m.Line(1)) t.Errorf("Line(1) = %q, want \"wd\"", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "w") sendKeys(tm, "v", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
// w moves to start of "world" at col 6 // w moves to start of "world" at col 6
if m.CursorX() != 6 { if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("CursorX() = %d, want 6", m.CursorX()) t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != "orld" { if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0)) t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "e") sendKeys(tm, "v", "e")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
// e moves to end of "hello" at col 4 // e moves to end of "hello" at col 4
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != " world" { if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.Line(0)) t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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.AnchorX() != 6 { if m.ActiveWindow().Anchor.Col != 6 {
t.Errorf("AnchorX() = %d, want 6", m.AnchorX()) t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col)
} }
// b moves to start of "hello" at col 0 // b moves to start of "hello" at col 0
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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.Line(0) != "orld" { if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0)) t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.CursorX() != 8 { if m.ActiveWindow().Cursor.Col != 8 {
t.Errorf("CursorX() = %d, want 8", m.CursorX()) t.Errorf("CursorX() = %d, want 8", m.ActiveWindow().Cursor.Col)
} }
}) })
} }
@ -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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "$") sendKeys(tm, "v", "$")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 0 { if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.AnchorX()) t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
} }
// $ moves past end of line // $ moves past end of line
if m.CursorX() != 11 { if m.ActiveWindow().Cursor.Col != 11 {
t.Errorf("CursorX() = %d, want 11", m.CursorX()) t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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.Line(0) != "hello " { if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 6}), WithCursorPos(core.Position{Line: 0, Col: 6}),
) )
sendKeys(tm, "v", "0") sendKeys(tm, "v", "0")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 6 { if m.ActiveWindow().Anchor.Col != 6 {
t.Errorf("AnchorX() = %d, want 6", m.AnchorX()) t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col)
} }
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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.Line(0) != "orld" { if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0)) t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 10}), // on 'w' WithCursorPos(core.Position{Line: 0, Col: 10}), // on 'w'
) )
sendKeys(tm, "v", "_") sendKeys(tm, "v", "_")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorX() != 10 { if m.ActiveWindow().Anchor.Col != 10 {
t.Errorf("AnchorX() = %d, want 10", m.AnchorX()) t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col)
} }
// _ moves to first non-ws at col 4 // _ moves to first non-ws at col 4
if m.CursorX() != 4 { if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.CursorX()) t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "v", "G") sendKeys(tm, "v", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorY() != 0 { if m.ActiveWindow().Anchor.Line != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
} }
if m.CursorY() != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY()) t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
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(action.Position{Line: 0, Col: 3}), // on 'e' of "line" WithCursorPos(core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "lin 3" { if m.ActiveBuffer().Lines[0] != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0)) t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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.AnchorY() != 2 { if m.ActiveWindow().Anchor.Line != 2 {
t.Errorf("AnchorY() = %d, want 2", m.AnchorY()) t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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(action.Position{Line: 2, Col: 3}), WithCursorPos(core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "lin 3" { if m.ActiveBuffer().Lines[0] != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0)) t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "G") sendKeys(tm, "V", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.AnchorY() != 0 { if m.ActiveWindow().Anchor.Line != 0 {
t.Errorf("AnchorY() = %d, want 0", m.AnchorY()) t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
} }
if m.CursorY() != 2 { if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.CursorY()) t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 1 { if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.LineCount()) t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "" { if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.Line(0)) t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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.AnchorY() != 2 { if m.ActiveWindow().Anchor.Line != 2 {
t.Errorf("AnchorY() = %d, want 2", m.AnchorY()) t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
} }
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
} }

View File

@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
) )
// ============================================================================= // =============================================================================
@ -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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.Position{Line: 1, Col: 0}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "line 1" { if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0)) t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "line 2" { if m.ActiveBuffer().Lines[1] != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1)) t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
} }
if m.Line(2) != "line 3" { if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2)) t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[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(action.Position{Line: 1, Col: 3}), WithCursorPos(core.Position{Line: 1, Col: 3}),
) )
sendKeys(tm, "y", "y") sendKeys(tm, "y", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 1 { if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.CursorY()) t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
} }
if m.CursorX() != 3 { if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.CursorX()) t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 3}), WithCursorPos(core.Position{Line: 0, Col: 3}),
) )
sendKeys(tm, "y", "j") sendKeys(tm, "y", "j")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorY() != 0 { if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.CursorY()) t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "G") sendKeys(tm, "y", "G")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 10}), // on 'w' WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.CursorX() != 0 { if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.CursorX()) t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
} }
}) })
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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "y", "w") sendKeys(tm, "y", "w")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Line(0) != "hello world" { if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 3}), WithCursorPos(core.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 != action.CharwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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() != action.NormalMode { if m.Mode() != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != "hello world" { if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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 != action.LinewiseRegister { if reg.Type != core.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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.Position{Line: 0, Col: 0}),
) )
sendKeys(tm, "V", "y") sendKeys(tm, "V", "y")
m := getFinalModel(t, tm) m := getFinalModel(t, tm)
if m.Mode() != action.NormalMode { if m.Mode() != core.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(action.Position{Line: 0, Col: 1}), WithCursorPos(core.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 != action.BlockwiseRegister { if reg.Type != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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() != action.NormalMode { if m.Mode() != core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.Line(1) != "to copy" { if m.ActiveBuffer().Lines[1] != "to copy" {
t.Errorf("Line(1) = %q, want 'to copy'", m.Line(1)) t.Errorf("Line(1) = %q, want 'to copy'", m.ActiveBuffer().Lines[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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 4}), // on 'o' WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != "hello worldhello" { if m.ActiveBuffer().Lines[0] != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 6}), // on 'w' WithCursorPos(core.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.Line(0) != "worldhello world" { if m.ActiveBuffer().Lines[0] != "worldhello world" {
t.Errorf("Line(0) = %q, want 'worldhello world'", m.Line(0)) t.Errorf("Line(0) = %q, want 'worldhello world'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 4 { if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.LineCount()) t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
} }
if m.Line(2) != "line 1" { if m.ActiveBuffer().Lines[2] != "line 1" {
t.Errorf("Line(2) = %q, want 'line 1'", m.Line(2)) t.Errorf("Line(2) = %q, want 'line 1'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 6 { if m.ActiveBuffer().LineCount() != 6 {
t.Errorf("LineCount() = %d, want 6", m.LineCount()) t.Errorf("LineCount() = %d, want 6", m.ActiveBuffer().LineCount())
} }
if m.Line(4) != "line 1" { if m.ActiveBuffer().Lines[4] != "line 1" {
t.Errorf("Line(4) = %q, want 'line 1'", m.Line(4)) t.Errorf("Line(4) = %q, want 'line 1'", m.ActiveBuffer().Lines[4])
} }
if m.Line(5) != "line 2" { if m.ActiveBuffer().Lines[5] != "line 2" {
t.Errorf("Line(5) = %q, want 'line 2'", m.Line(5)) t.Errorf("Line(5) = %q, want 'line 2'", m.ActiveBuffer().Lines[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(action.Position{Line: 2, Col: 0}), WithCursorPos(core.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.LineCount() != 4 { if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.LineCount()) t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "line 3" { if m.ActiveBuffer().Lines[0] != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0)) t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "line 1" { if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1)) t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "original" { if m.ActiveBuffer().Lines[0] != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.Line(0)) t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "original" { if m.ActiveBuffer().Lines[1] != "original" {
t.Errorf("Line(1) = %q, want 'original'", m.Line(1)) t.Errorf("Line(1) = %q, want 'original'", m.ActiveBuffer().Lines[1])
} }
if m.Line(2) != "other" { if m.ActiveBuffer().Lines[2] != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.Line(2)) t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[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(action.Position{Line: 1, Col: 0}), WithCursorPos(core.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.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "original" { if m.ActiveBuffer().Lines[0] != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.Line(0)) t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "other" { if m.ActiveBuffer().Lines[1] != "other" {
t.Errorf("Line(1) = %q, want 'other'", m.Line(1)) t.Errorf("Line(1) = %q, want 'other'", m.ActiveBuffer().Lines[1])
} }
if m.Line(2) != "other" { if m.ActiveBuffer().Lines[2] != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.Line(2)) t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != "hello worldhello " { if m.ActiveBuffer().Lines[0] != "hello worldhello " {
t.Errorf("Line(0) = %q, want 'hello worldhello '", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello worldhello '", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.Line(0) != "hello worldhello" { if m.ActiveBuffer().Lines[0] != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0)) t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 2}), // on 'c' WithCursorPos(core.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.Line(0) != "abcdefghcde" { if m.ActiveBuffer().Lines[0] != "abcdefghcde" {
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.Line(0)) t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 2}), WithCursorPos(core.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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 3 { if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.LineCount()) t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
} }
if m.Line(0) != "line 2" { if m.ActiveBuffer().Lines[0] != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.Line(0)) t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0])
} }
if m.Line(1) != "line 1" { if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1)) t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
} }
if m.Line(2) != "line 3" { if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2)) t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[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(action.Position{Line: 0, Col: 0}), WithCursorPos(core.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.LineCount() != 7 { if m.ActiveBuffer().LineCount() != 7 {
t.Errorf("LineCount() = %d, want 7", m.LineCount()) t.Errorf("LineCount() = %d, want 7", m.ActiveBuffer().LineCount())
} }
// Original + 2 copies of 2 lines = 3 + 4 = 7 // Original + 2 copies of 2 lines = 3 + 4 = 7
if m.Line(1) != "line 1" { if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1)) t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
} }
if m.Line(2) != "line 2" { if m.ActiveBuffer().Lines[2] != "line 2" {
t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2)) t.Errorf("Line(2) = %q, want 'line 2'", m.ActiveBuffer().Lines[2])
} }
if m.Line(3) != "line 1" { if m.ActiveBuffer().Lines[3] != "line 1" {
t.Errorf("Line(3) = %q, want 'line 1'", m.Line(3)) t.Errorf("Line(3) = %q, want 'line 1'", m.ActiveBuffer().Lines[3])
} }
if m.Line(4) != "line 2" { if m.ActiveBuffer().Lines[4] != "line 2" {
t.Errorf("Line(4) = %q, want 'line 2'", m.Line(4)) t.Errorf("Line(4) = %q, want 'line 2'", m.ActiveBuffer().Lines[4])
} }
}) })
} }

View File

@ -5,136 +5,93 @@ 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 {
lines []string // Buffers
cursor cursor buffers []*core.Buffer
anchor cursor // starting point for visual modes //next buffer id?
scrollY int
mode action.Mode // Windows
win_h int windows []*core.Window
win_w int activeWindowId int
// Editor wide state
mode core.Mode
// Terminal dimensions
termWidth int
termHeight int
// Input and key handling
input *input.Handler input *input.Handler
// Insert repetition // Insert mode state & repetition (applied to active window)
insertCount int insertCount int
insertKeys []string insertKeys []string
insertAction action.Action insertAction action.Action
// Command mode // Command line state
command string command string
commandCursor int commandCursor int
commandError error commandError error
commandOutput string commandOutput string
// Settings // Global settings
settings action.Settings settings core.EditorSettings
// Registers // Registers
registers map[rune]action.Register // name -> register registers map[rune]core.Register // name -> register
}
// Visual styles
func NewModel(lines []string, pos action.Position) Model { styles style.Styles
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 { // ==================================================
return m.lines // Core Data Access
// ==================================================
func (m *Model) Windows() []*core.Window {
return m.windows
} }
func (m *Model) Line(idx int) string { func (m *Model) ActiveWindow() *core.Window {
if idx < 0 || idx >= len(m.lines) { winId := m.activeWindowId
return "" for i := range m.Windows() {
if m.windows[i].Id == winId {
return m.windows[i]
} }
return m.lines[idx] }
panic("Could not find window")
} }
func (m *Model) SetLine(idx int, content string) { func (m *Model) Buffers() []*core.Buffer {
if idx >= 0 && idx < len(m.lines) { return m.buffers
m.lines[idx] = content
}
} }
func (m *Model) InsertLine(idx int, content string) { func (m *Model) SetBuffers(bufs []*core.Buffer) {
if idx < 0 { m.buffers = bufs
idx = 0
}
if idx > len(m.lines) {
idx = len(m.lines)
}
m.lines = append(m.lines[:idx], append([]string{content}, m.lines[idx:]...)...)
} }
func (m *Model) DeleteLine(idx int) { func (m *Model) ActiveBuffer() *core.Buffer {
if idx >= 0 && idx < len(m.lines) { win := m.ActiveWindow()
m.lines = append(m.lines[:idx], m.lines[idx+1:]...) return win.Buffer
}
} }
func (m *Model) LineCount() int { // ==================================================
return len(m.lines) // Insert Mode Methods
} // ==================================================
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
} }
@ -143,7 +100,143 @@ func (m *Model) SetInsertKeys(keys []string) {
m.insertKeys = keys m.insertKeys = keys
} }
// Command mode func (m *Model) SetInsertRecording(count int, act action.Action) {
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
} }
@ -182,38 +275,60 @@ func (m *Model) SetCommandOutput(out string) {
m.commandOutput = out m.commandOutput = out
} }
// Settings // ==================================================
func (m *Model) Settings() action.Settings { // Editor-wide State
// ==================================================
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 action.Settings) { func (m *Model) SetSettings(s core.EditorSettings) {
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) (action.Register, bool) { func (m *Model) GetRegister(name rune) (core.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 action.RegisterType, cnt []string) error { func (m *Model) SetRegister(name rune, t core.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 := action.Register{Type: t, Content: cnt} reg := core.Register{Type: t, Content: cnt}
m.registers[name] = reg m.registers[name] = reg
return nil return nil
} }
func (m *Model) UpdateDefaultRegister(t action.RegisterType, cnt []string) { func (m *Model) UpdateDefaultRegister(t core.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]
@ -223,199 +338,3 @@ func (m *Model) UpdateDefaultRegister(t action.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

@ -0,0 +1,140 @@
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
}

View File

@ -1,52 +0,0 @@
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,14 +4,49 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Model.Update: Handles BubbleTea messages including window resizes and key
// 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.win_h = msg.Height m.termHeight = msg.Height
m.win_w = msg.Width m.termWidth = 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.
@ -19,11 +54,12 @@ 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
m.AdjustScroll() win := m.ActiveWindow()
win.AdjustScroll()
return m, cmd return m, cmd
} }

View File

@ -4,22 +4,290 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
) )
func posInsideSelection(m Model, col, line int) bool { // Model.View: Renders the complete editor view including buffer content, line
switch m.Mode() { // numbers, status bar, and command line.
case action.VisualLineMode: func (m Model) View() string {
startY := min(m.AnchorY(), m.CursorY()) win := m.ActiveWindow()
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 action.VisualMode: case core.VisualMode:
ax := m.AnchorX() ax := w.Anchor.Col
ay := m.AnchorY() ay := w.Anchor.Line
cx := m.CursorX() cx := w.Cursor.Col
cy := m.CursorY() cy := w.Cursor.Line
// 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
@ -36,11 +304,11 @@ func posInsideSelection(m Model, 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 action.VisualBlockMode: case core.VisualBlockMode:
startX := min(m.AnchorX(), m.CursorX()) startX := min(w.Anchor.Col, w.Cursor.Col)
startY := min(m.AnchorY(), m.CursorY()) startY := min(w.Anchor.Line, w.Cursor.Line)
endX := max(m.AnchorX(), m.CursorX()) endX := max(w.Anchor.Col, w.Cursor.Col)
endY := max(m.AnchorY(), m.CursorY()) endY := max(w.Anchor.Line, w.Cursor.Line)
return col >= startX && col <= endX && return col >= startX && col <= endX &&
line >= startY && line <= endY line >= startY && line <= endY
@ -49,152 +317,3 @@ func posInsideSelection(m Model, 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,9 +2,11 @@ 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 (
@ -14,11 +16,8 @@ const (
StateMotionCount StateMotionCount
) )
// PositionGetter is used to get cursor position for operator ranges // Handler: Manages input processing with a state machine for vim-style commands.
type PositionGetter interface { // Handles counts, operators, motions, and multi-key sequences.
GetCursorPosition() action.Position
}
type Handler struct { type Handler struct {
state InputState state InputState
count1 int count1 int
@ -37,6 +36,7 @@ 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,23 +48,25 @@ 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() == action.InsertMode { if m.Mode() == core.InsertMode {
m.ExitInsertMode() m.ExitInsertMode()
} else { } else {
m.SetMode(action.NormalMode) m.SetMode(core.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 action.InsertMode: case core.InsertMode:
return h.handleInsertKey(m, key) return h.handleInsertKey(m, key)
case action.CommandMode: case core.CommandMode:
return h.handleCommandKey(m, key) return h.handleCommandKey(m, key)
} }
@ -78,11 +80,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 action.NormalMode: case core.NormalMode:
h.currentKeymap = h.normalKeymap h.currentKeymap = h.normalKeymap
case action.VisualMode, case core.VisualMode,
action.VisualLineMode, core.VisualLineMode,
action.VisualBlockMode: core.VisualBlockMode:
h.currentKeymap = h.visualKeymap h.currentKeymap = h.visualKeymap
} }
@ -116,7 +118,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
return nil return nil
} }
// dispatch routes to the right handler based on current state // Handler.dispatch: Routes to the appropriate 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:
@ -128,6 +130,8 @@ 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()
@ -147,14 +151,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 := action.CharwiseInclusive mtype := core.CharwiseInclusive
if m.Mode() == action.VisualLineMode { if m.Mode() == core.VisualLineMode {
mtype = action.Linewise mtype = core.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() != action.InsertMode { if m.Mode() != core.InsertMode {
m.SetMode(action.NormalMode) m.SetMode(core.NormalMode)
} }
h.Reset() h.Reset()
return cmd return cmd
@ -179,8 +183,11 @@ 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 {
@ -201,10 +208,9 @@ 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
pg := m.(PositionGetter) start := win.Cursor
start := pg.GetCursorPosition()
mot.Execute(m) mot.Execute(m)
end := pg.GetCursorPosition() end := win.Cursor
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
@ -214,6 +220,8 @@ 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
@ -239,6 +247,7 @@ 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
@ -246,6 +255,8 @@ 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 {
@ -257,6 +268,7 @@ 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
@ -267,10 +279,13 @@ 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))
@ -288,6 +303,8 @@ 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 {
@ -301,9 +318,12 @@ 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)
} }
func normalizeVisualSelection(m action.Model) (action.Position, action.Position) { // normalizeVisualSelection: Returns the visual selection with start before end,
a := action.Position{Line: m.AnchorY(), Col: m.AnchorX()} // regardless of which direction the selection was made.
c := action.Position{Line: m.CursorY(), Col: m.CursorX()} func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {
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,12 +7,14 @@ 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{
@ -64,6 +66,7 @@ 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{
@ -97,6 +100,7 @@ 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{
@ -117,6 +121,7 @@ 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{
@ -135,7 +140,7 @@ func NewCommandKeymap() *Keymap {
} }
// Lookup returns what type of binding a key is // Keymap.Lookup: Returns the type and value of a key binding (motion, operator, or action).
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
@ -149,7 +154,7 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
return "", nil return "", nil
} }
// HasPrefix returns true if any binding starts with this prefix // Keymap.HasPrefix: Returns true if any binding starts with the given 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,8 +1,9 @@
package motion package motion
import ( import (
tea "github.com/charmbracelet/bubbletea"
"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"
) )
// MoveDown implements Motion (j) - linewise // MoveDown implements Motion (j) - linewise
@ -10,15 +11,18 @@ 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 {
for i := 0; i < a.Count && m.CursorY() < m.LineCount()-1; i++ { win := m.ActiveWindow()
m.SetCursorY(m.CursorY() + 1) buf := m.ActiveBuffer()
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() action.MotionType { return action.Linewise } func (a MoveDown) Type() core.MotionType { return core.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}
@ -29,15 +33,17 @@ 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 {
for i := 0; i < a.Count && m.CursorY() > 0; i++ { win := m.ActiveWindow()
m.SetCursorY(m.CursorY() - 1) for i := 0; i < a.Count && win.Cursor.Line > 0; i++ {
win.SetCursorLine(win.Cursor.Line - 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveUp) Type() action.MotionType { return action.Linewise } func (a MoveUp) Type() core.MotionType { return core.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}
@ -48,15 +54,17 @@ 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 {
for i := 0; i < a.Count && m.CursorX() > 0; i++ { win := m.ActiveWindow()
m.SetCursorX(m.CursorX() - 1) for i := 0; i < a.Count && win.Cursor.Col > 0; i++ {
win.SetCursorCol(win.Cursor.Col - 1)
} }
m.ClampCursorX()
return nil return nil
} }
func (a MoveLeft) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveLeft) Type() core.MotionType { return core.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}
@ -67,16 +75,19 @@ 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 {
lineLen := len(m.Line(m.CursorY())) win := m.ActiveWindow()
for i := 0; i < a.Count && m.CursorX() <= lineLen; i++ { buf := m.ActiveBuffer()
m.SetCursorX(m.CursorX() + 1) lineLen := len(buf.Lines[win.Cursor.Line])
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() action.MotionType { return action.CharwiseExclusive } func (a MoveRight) Type() core.MotionType { return core.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,25 +2,32 @@ 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
} }
func (a MoveCommandLeft) Type() action.MotionType { return action.CharwiseExclusive } // MoveCommandLeft.Type: Returns CharwiseExclusive for command line motion.
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
} }
func (a MoveCommandRight) Type() action.MotionType { return action.CharwiseExclusive } // MoveCommandRight.Type: Returns CharwiseExclusive for command line motion.
func (a MoveCommandRight) Type() core.MotionType { return core.CharwiseExclusive }

View File

@ -2,58 +2,70 @@ 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 {
m.SetCursorY(0) win := m.ActiveWindow()
m.ClampCursorX() win.SetCursorLine(0)
return nil return nil
} }
func (a MoveToTop) Type() action.MotionType { return action.Linewise } func (a MoveToTop) Type() core.MotionType { return core.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 {
m.SetCursorY(m.LineCount() - 1) win := m.ActiveWindow()
m.ClampCursorX() buf := m.ActiveBuffer()
win.SetCursorLine(buf.LineCount() - 1)
return nil return nil
} }
func (a MoveToBottom) Type() action.MotionType { return action.Linewise } func (a MoveToBottom) Type() core.MotionType { return core.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 {
m.SetCursorX(0) win := m.ActiveWindow()
m.ClampCursorX() win.SetCursorCol(0)
return nil return nil
} }
func (a MoveToLineStart) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveToLineStart) Type() core.MotionType { return core.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 {
m.SetCursorX(len(m.Line(m.CursorY()))) win := m.ActiveWindow()
m.ClampCursorX() buf := m.ActiveBuffer()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
return nil return nil
} }
func (a MoveToLineEnd) Type() action.MotionType { return action.CharwiseInclusive } func (a MoveToLineEnd) Type() core.MotionType { return core.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 {
line := m.Line(m.CursorY()) win := m.ActiveWindow()
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]
@ -68,27 +80,30 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
x-- x--
} }
m.SetCursorX(x) win.SetCursorCol(x)
return nil return nil
} }
func (a MoveToLineContentStart) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveToLineContentStart) Type() core.MotionType { return core.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 {
line := m.Line(m.CursorY()) win := m.ActiveWindow()
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)
m.SetCursorX(col) win.SetCursorCol(col)
m.ClampCursorX()
return nil return nil
} }
func (a MoveToColumn) Type() action.MotionType { return action.CharwiseExclusive } func (a MoveToColumn) Type() core.MotionType { return core.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}
@ -99,23 +114,28 @@ 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 {
viewportHeight := m.ViewPortH() win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
if viewportHeight <= 0 { if viewportHeight <= 0 {
return nil return nil
} }
scroll := viewportHeight / 2 scroll := viewportHeight / 2
scrollOff := m.Settings().ScrollOff scrollOff := win.Options.ScrollOff
// Current relative position in viewport // Current relative position in viewport
relY := m.CursorY() - m.ScrollY() relY := win.Cursor.Line - win.ScrollY
// Scroll down, clamped to valid range // Scroll down, clamped to valid range
newScrollY := m.ScrollY() + scroll newScrollY := win.ScrollY + scroll
maxScroll := max(0, m.LineCount()-viewportHeight) maxScroll := max(0, buf.LineCount()-viewportHeight)
newScrollY = min(newScrollY, maxScroll) newScrollY = min(newScrollY, maxScroll)
m.SetScrollY(newScrollY) win.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff // Maintain relative position, respecting scrollOff
if relY < scrollOff { if relY < scrollOff {
@ -126,33 +146,37 @@ func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
} }
newCursorY := newScrollY + relY newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, m.LineCount()-1)) newCursorY = max(0, min(newCursorY, buf.LineCount()-1))
m.SetCursorY(newCursorY) win.SetCursorLine(newCursorY)
m.ClampCursorX()
return nil return nil
} }
func (a ScrollDownHalfPage) Type() action.MotionType { return action.Linewise } func (a ScrollDownHalfPage) Type() core.MotionType { return core.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 {
viewportHeight := m.ViewPortH() win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
if viewportHeight <= 0 { if viewportHeight <= 0 {
return nil return nil
} }
scroll := viewportHeight / 2 scroll := viewportHeight / 2
scrollOff := m.Settings().ScrollOff scrollOff := win.Options.ScrollOff
// Current relative position in viewport // Current relative position in viewport
relY := m.CursorY() - m.ScrollY() relY := win.Cursor.Line - win.ScrollY
// Scroll up, clamped to valid range // Scroll up, clamped to valid range
newScrollY := m.ScrollY() - scroll newScrollY := win.ScrollY - scroll
newScrollY = max(0, newScrollY) newScrollY = max(0, newScrollY)
m.SetScrollY(newScrollY) win.SetScrollY(newScrollY)
// Maintain relative position, respecting scrollOff // Maintain relative position, respecting scrollOff
if relY < scrollOff { if relY < scrollOff {
@ -163,11 +187,10 @@ func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
} }
newCursorY := newScrollY + relY newCursorY := newScrollY + relY
newCursorY = max(0, min(newCursorY, m.LineCount()-1)) newCursorY = max(0, min(newCursorY, buf.LineCount()-1))
m.SetCursorY(newCursorY) win.SetCursorLine(newCursorY)
m.ClampCursorX()
return nil return nil
} }
func (a ScrollUpHalfPage) Type() action.MotionType { return action.Linewise } func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise }

View File

@ -2,9 +2,12 @@ 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') ||
@ -12,12 +15,16 @@ 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)
} }
func nextWordStart(m action.Model, x, y int) (int, int) { // nextWordStart: Finds the start of the next word from position (x,y), handling
line := m.Line(y) // word boundaries and line crossing.
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) {
@ -46,13 +53,13 @@ func nextWordStart(m action.Model, 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 >= m.LineCount() { if y+1 >= buf.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = m.Line(y) line = buf.Lines[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!
@ -64,8 +71,10 @@ func nextWordStart(m action.Model, x, y int) (int, int) {
return x, y return x, y
} }
func nextWORDStart(m action.Model, x, y int) (int, int) { // nextWORDStart: Finds the start of the next WORD from position (x,y), treating
line := m.Line(y) // all non-whitespace as a single class.
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' {
@ -85,13 +94,13 @@ func nextWORDStart(m action.Model, 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 >= m.LineCount() { if y+1 >= buf.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = m.Line(y) line = buf.Lines[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!
@ -103,21 +112,23 @@ func nextWORDStart(m action.Model, x, y int) (int, int) {
return x, y return x, y
} }
func nextWordEnd(m action.Model, x, y int) (int, int) { // nextWordEnd: Finds the end of the next word from position (x,y), respecting
line := m.Line(y) // word character classes.
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 >= m.LineCount() { if y+1 >= buf.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 = m.Line(y) line = buf.Lines[y]
} }
// Skip whitespace and cross lines if needed // Skip whitespace and cross lines if needed
@ -133,13 +144,13 @@ func nextWordEnd(m action.Model, 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 >= m.LineCount() { if y+1 >= buf.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = m.Line(y) line = buf.Lines[y]
x = 0 x = 0
} }
@ -160,21 +171,23 @@ func nextWordEnd(m action.Model, x, y int) (int, int) {
return x, y return x, y
} }
func nextWORDEnd(m action.Model, x, y int) (int, int) { // nextWORDEnd: Finds the end of the next WORD from position (x,y), treating
line := m.Line(y) // all non-whitespace as a single class.
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 >= m.LineCount() { if y+1 >= buf.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 = m.Line(y) line = buf.Lines[y]
} }
// Skip whitespace and cross lines if needed // Skip whitespace and cross lines if needed
@ -190,13 +203,13 @@ func nextWORDEnd(m action.Model, 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 >= m.LineCount() { if y+1 >= buf.LineCount() {
return x, y return x, y
} }
// Move to first char of next line // Move to first char of next line
y++ y++
line = m.Line(y) line = buf.Lines[y]
x = 0 x = 0
} }
@ -208,8 +221,10 @@ func nextWORDEnd(m action.Model, x, y int) (int, int) {
return x, y return x, y
} }
func prevWordStart(m action.Model, x, y int) (int, int) { // prevWordStart: Finds the start of the previous word from position (x,y),
line := m.Line(y) // moving backward through character classes.
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--
@ -218,7 +233,7 @@ func prevWordStart(m action.Model, x, y int) (int, int) {
return 0, 0 // beginning of file, stay put return 0, 0 // beginning of file, stay put
} }
y-- y--
line = m.Line(y) line = buf.Lines[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
@ -237,7 +252,7 @@ func prevWordStart(m action.Model, x, y int) (int, int) {
return 0, 0 return 0, 0
} }
y-- y--
line = m.Line(y) line = buf.Lines[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
@ -263,19 +278,25 @@ 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 {
x := m.CursorX() win := m.ActiveWindow()
y := m.CursorY() buf := m.ActiveBuffer()
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(m, x, y) x, y = nextWordStart(buf, x, y)
} }
m.SetCursorX(x) win.SetCursorCol(x)
m.SetCursorY(y) win.SetCursorLine(y)
return nil return nil
} }
func (a MoveForwardWord) Type() action.MotionType { return action.CharwiseExclusive } // MoveForwardWord.Type: Returns CharwiseExclusive for word motion.
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}
} }
@ -285,19 +306,25 @@ 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 {
x := m.CursorX() win := m.ActiveWindow()
y := m.CursorY() buf := m.ActiveBuffer()
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(m, x, y) x, y = nextWORDStart(buf, x, y)
} }
m.SetCursorX(x) win.SetCursorCol(x)
m.SetCursorY(y) win.SetCursorLine(y)
return nil return nil
} }
func (a MoveForwardWORD) Type() action.MotionType { return action.CharwiseExclusive } // MoveForwardWORD.Type: Returns CharwiseExclusive for WORD motion.
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}
} }
@ -307,19 +334,25 @@ 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 {
x := m.CursorX() win := m.ActiveWindow()
y := m.CursorY() buf := m.ActiveBuffer()
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(m, x, y) x, y = nextWordEnd(buf, x, y)
} }
m.SetCursorX(x) win.SetCursorCol(x)
m.SetCursorY(y) win.SetCursorLine(y)
return nil return nil
} }
func (a MoveForwardWordEnd) Type() action.MotionType { return action.CharwiseInclusive } // MoveForwardWordEnd.Type: Returns CharwiseInclusive for word-end motion.
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}
} }
@ -329,19 +362,25 @@ 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 {
x := m.CursorX() win := m.ActiveWindow()
y := m.CursorY() buf := m.ActiveBuffer()
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(m, x, y) x, y = nextWORDEnd(buf, x, y)
} }
m.SetCursorX(x) win.SetCursorCol(x)
m.SetCursorY(y) win.SetCursorLine(y)
return nil return nil
} }
func (a MoveForwardWORDEnd) Type() action.MotionType { return action.CharwiseInclusive } // MoveForwardWORDEnd.Type: Returns CharwiseInclusive for WORD-end motion.
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}
} }
@ -351,19 +390,25 @@ 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 {
x := m.CursorX() win := m.ActiveWindow()
y := m.CursorY() buf := m.ActiveBuffer()
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(m, x, y) x, y = prevWordStart(buf, x, y)
} }
m.SetCursorX(x) win.SetCursorCol(x)
m.SetCursorY(y) win.SetCursorLine(y)
return nil return nil
} }
func (a MoveBackwardWord) Type() action.MotionType { return action.CharwiseExclusive } // MoveBackwardWord.Type: Returns CharwiseExclusive for backward word motion.
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,34 +2,37 @@ 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"
) )
// Implements Operator (c) // ChangeOperator implements Operator (c) - changes (deletes and enters insert mode) text.
type ChangeOperator struct{} type ChangeOperator struct{}
func (o ChangeOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd { // ChangeOperator.Operate: Changes text based on the current mode and motion type.
func (o ChangeOperator) Operate(m action.Model, start, end core.Position, mtype core.MotionType) tea.Cmd {
switch m.Mode() { switch m.Mode() {
case action.VisualMode: case core.VisualMode:
changeCharSelection(m, start, end) changeCharSelection(m, start, end)
case action.VisualLineMode: case core.VisualLineMode:
changeLineSelection(m, start, end) changeLineSelection(m, start, end)
case action.VisualBlockMode: case core.VisualBlockMode:
changeBlockSelection(m, start, end) changeBlockSelection(m, start, end)
case action.NormalMode: case core.NormalMode:
changeNormalMode(m, start, end, mtype) changeNormalMode(m, start, end, mtype)
} }
return nil return nil
} }
func changeNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) { // changeNormalMode: Changes text in normal mode based on motion type.
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 == action.Linewise { if mtype == core.Linewise {
changeLineSelection(m, start, end) changeLineSelection(m, start, end)
return return
} }
@ -37,18 +40,18 @@ func changeNormalMode(m action.Model, start, end action.Position, mtype action.M
// 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 == action.CharwiseExclusive { if start.Col == end.Col && mtype == core.CharwiseExclusive {
m.SetMode(action.InsertMode) m.SetMode(core.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 == action.CharwiseExclusive { if mtype == core.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(action.InsertMode) m.SetMode(core.InsertMode)
} }
return return
} }
@ -57,22 +60,26 @@ func changeNormalMode(m action.Model, start, end action.Position, mtype action.M
changeCharSelection(m, start, end) changeCharSelection(m, start, end)
} }
func changeCharSelection(m action.Model, start, end action.Position) { // changeCharSelection: Changes a character-wise selection and enters insert mode.
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 := m.Line(start.Line) line := buf.Lines[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]
m.SetLine(start.Line, line[:start.Col]+line[endCol:]) buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := m.Line(start.Line) startLine := buf.Lines[start.Line]
endLine := m.Line(end.Line) endLine := buf.Lines[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 += m.Line(y) + "\n" deletedText += buf.Lines[y] + "\n"
} }
endCol := min(end.Col+1, len(endLine)) endCol := min(end.Col+1, len(endLine))
deletedText += endLine[:endCol] deletedText += endLine[:endCol]
@ -85,89 +92,98 @@ func changeCharSelection(m action.Model, start, end action.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-- {
m.DeleteLine(i) buf.DeleteLine(i)
} }
m.InsertLine(start.Line, prefix+suffix) buf.InsertLine(start.Line, prefix+suffix)
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(start.Col) win.SetCursorCol(start.Col)
m.ClampCursorX() m.SetMode(core.InsertMode)
m.SetMode(action.InsertMode)
// Update register with deleted text // Update register with deleted text
m.UpdateDefaultRegister(action.CharwiseRegister, []string{deletedText}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText})
} }
func changeLineSelection(m action.Model, start, end action.Position) { // changeLineSelection: Changes entire lines and enters insert mode.
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{m.Line(i)}, lines...) lines = append([]string{buf.Lines[i]}, lines...)
m.DeleteLine(i) buf.DeleteLine(i)
} }
// Insert an empty line for editing // Insert an empty line for editing
insertY := min(start.Line, m.LineCount()) insertY := min(start.Line, buf.LineCount())
m.InsertLine(insertY, "") buf.InsertLine(insertY, "")
m.SetCursorY(insertY) win.SetCursorLine(insertY)
m.SetCursorX(0) win.SetCursorCol(0)
m.SetMode(action.InsertMode) m.SetMode(core.InsertMode)
// Update registers // Update registers
m.UpdateDefaultRegister(action.LinewiseRegister, lines) m.UpdateDefaultRegister(core.LinewiseRegister, lines)
} }
func changeBlockSelection(m action.Model, start, end action.Position) { // changeBlockSelection: Changes a rectangular block selection and enters insert mode.
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 := m.Line(y) line := buf.Lines[y]
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
m.SetLine(y, line[:startCol]+line[ec:]) buf.SetLine(y, line[:startCol]+line[ec:])
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(startCol) win.SetCursorCol(startCol)
m.ClampCursorX() m.SetMode(core.InsertMode)
m.SetMode(action.InsertMode)
} }
// Verify ChangeOperator implements DoublePresser // Verify ChangeOperator implements DoublePresser
var _ action.DoublePresser = ChangeOperator{} var _ action.DoublePresser = ChangeOperator{}
// Double press handles cc - change the entire line // ChangeOperator.DoublePress: Handles cc - changes Count entire lines.
func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd { func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd {
startY := m.CursorY() win := m.ActiveWindow()
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, m.LineCount()-startY) opCount := min(count, buf.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, m.Line(startY)) lines = append(lines, buf.Lines[startY])
m.DeleteLine(startY) buf.DeleteLine(startY)
} }
// Put deleted lines in register // Put deleted lines in register
m.UpdateDefaultRegister(action.LinewiseRegister, lines) m.UpdateDefaultRegister(core.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, m.LineCount()) insertY := min(startY, buf.LineCount())
m.InsertLine(insertY, "") buf.InsertLine(insertY, "")
// Position cursor on the new empty line // Position cursor on the new empty line
m.SetCursorY(insertY) win.SetCursorLine(insertY)
m.SetCursorX(0) win.SetCursorCol(0)
m.SetMode(action.InsertMode) m.SetMode(core.InsertMode)
return nil return nil
} }

View File

@ -2,21 +2,23 @@ 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"
) )
// Implements Operator (d) // DeleteOperator implements Operator (d) - deletes text in various modes.
type DeleteOperator struct{} type DeleteOperator struct{}
func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd { // DeleteOperator.Operate: Deletes text based on the current mode and motion type.
func (o DeleteOperator) Operate(m action.Model, start, end core.Position, mtype core.MotionType) tea.Cmd {
switch m.Mode() { switch m.Mode() {
case action.VisualMode: case core.VisualMode:
deleteCharSelection(m, start, end) deleteCharSelection(m, start, end)
case action.VisualLineMode: case core.VisualLineMode:
deleteLineSelection(m, start, end) deleteLineSelection(m, start, end)
case action.VisualBlockMode: case core.VisualBlockMode:
deleteBlockSelection(m, start, end) deleteBlockSelection(m, start, end)
case action.NormalMode: case core.NormalMode:
deleteNormalMode(m, start, end, mtype) deleteNormalMode(m, start, end, mtype)
} }
return nil return nil
@ -25,45 +27,48 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtyp
// Verify DeleteOperator implements DoublePresser // Verify DeleteOperator implements DoublePresser
var _ action.DoublePresser = DeleteOperator{} var _ action.DoublePresser = DeleteOperator{}
// Double press handles dd - delete the entire line // DeleteOperator.DoublePress: Handles dd - deletes Count entire lines.
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, m.LineCount()-m.CursorY()) opCount := min(count, buf.LineCount()-win.Cursor.Line)
var lines []string var lines []string
for range opCount { for range opCount {
y := m.CursorY() y := win.Cursor.Line
lines = append(lines, m.Line(y)) lines = append(lines, buf.Lines[y])
m.DeleteLine(y) buf.DeleteLine(y)
if m.LineCount() == 0 { if buf.LineCount() == 0 {
m.InsertLine(0, "") buf.InsertLine(0, "")
} }
if y >= m.LineCount() { if y >= buf.LineCount() {
y = m.LineCount() - 1 y = buf.LineCount() - 1
} }
m.SetCursorY(y) win.SetCursorLine(y)
m.ClampCursorX()
} }
// Put her in the register! // Put her in the register!
m.UpdateDefaultRegister(action.LinewiseRegister, lines) m.UpdateDefaultRegister(core.LinewiseRegister, lines)
return nil return nil
} }
func deleteNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) { // deleteNormalMode: Deletes text in normal mode based on motion type.
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 == action.Linewise { if mtype == core.Linewise {
deleteLineSelection(m, start, end) deleteLineSelection(m, start, end)
return return
} }
@ -71,11 +76,11 @@ func deleteNormalMode(m action.Model, start, end action.Position, mtype action.M
// 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 == action.CharwiseExclusive { if start.Col == end.Col && mtype == core.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 == action.CharwiseExclusive { if mtype == core.CharwiseExclusive {
end.Col-- end.Col--
} }
if end.Col >= start.Col { if end.Col >= start.Col {
@ -88,14 +93,18 @@ func deleteNormalMode(m action.Model, start, end action.Position, mtype action.M
deleteCharSelection(m, start, end) deleteCharSelection(m, start, end)
} }
func deleteCharSelection(m action.Model, start, end action.Position) { // deleteCharSelection: Deletes a character-wise selection.
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 := m.Line(start.Line) line := buf.Lines[start.Line]
endCol := min(end.Col+1, len(line)) endCol := min(end.Col+1, len(line))
m.SetLine(start.Line, line[:start.Col]+line[endCol:]) buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else { } else {
startLine := m.Line(start.Line) startLine := buf.Lines[start.Line]
endLine := m.Line(end.Line) endLine := buf.Lines[end.Line]
prefix := startLine[:start.Col] prefix := startLine[:start.Col]
suffix := "" suffix := ""
@ -105,54 +114,59 @@ func deleteCharSelection(m action.Model, start, end action.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-- {
m.DeleteLine(i) buf.DeleteLine(i)
} }
m.InsertLine(start.Line, prefix+suffix) buf.InsertLine(start.Line, prefix+suffix)
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(start.Col) win.SetCursorCol(start.Col)
m.ClampCursorX()
} }
func deleteLineSelection(m action.Model, start, end action.Position) { // deleteLineSelection: Deletes entire lines in a selection range.
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, m.Line(i)) lines = append(lines, buf.Lines[i])
m.DeleteLine(i) buf.DeleteLine(i)
} }
if m.LineCount() == 0 { if buf.LineCount() == 0 {
m.InsertLine(0, "") buf.InsertLine(0, "")
} }
y := start.Line y := start.Line
if y >= m.LineCount() { if y >= buf.LineCount() {
y = m.LineCount() - 1 y = buf.LineCount() - 1
} }
m.SetCursorY(y) win.SetCursorLine(y)
m.ClampCursorX()
// Update registers // Update registers
m.UpdateDefaultRegister(action.LinewiseRegister, lines) m.UpdateDefaultRegister(core.LinewiseRegister, lines)
} }
func deleteBlockSelection(m action.Model, start, end action.Position) { // deleteBlockSelection: Deletes a rectangular block selection.
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 := m.Line(y) line := buf.Lines[y]
if startCol >= len(line) { if startCol >= len(line) {
continue continue
} }
ec := min(endCol+1, len(line)) ec := min(endCol+1, len(line))
m.SetLine(y, line[:startCol]+line[ec:]) buf.SetLine(y, line[:startCol]+line[ec:])
} }
m.SetCursorY(start.Line) win.SetCursorLine(start.Line)
m.SetCursorX(startCol) win.SetCursorCol(startCol)
m.ClampCursorX()
} }

View File

@ -4,53 +4,64 @@ 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"
) )
// Implements Operator (y) // YankOperator implements Operator (y) - copies text to register in various modes.
type YankOperator struct{} type YankOperator struct{}
func (o YankOperator) Operate(m action.Model, start, end action.Position, mtype action.MotionType) tea.Cmd { // YankOperator.Operate: Copies text to register based on the current mode and motion type.
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 action.VisualMode: case core.VisualMode:
yankVisualMode(m, start, end) yankVisualMode(m, start, end)
case action.VisualLineMode: case core.VisualLineMode:
yankVisualLineMode(m, start, end) yankVisualLineMode(m, start, end)
case action.VisualBlockMode: case core.VisualBlockMode:
yankVisualBlockMode(m, start, end) yankVisualBlockMode(m, start, end)
case action.NormalMode: case core.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."))
} }
m.SetCursorX(start.Col) win.SetCursorCol(start.Col)
m.SetCursorY(start.Line) win.SetCursorLine(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 {
y := m.CursorY() win := m.ActiveWindow()
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, m.LineCount()-y) opCount := min(count, buf.LineCount()-y)
var lines []string var lines []string
for i := range opCount { for i := range opCount {
lines = append(lines, m.Line(y+i)) lines = append(lines, buf.Lines[y+i])
} }
// Put her in the register! // Put her in the register!
m.UpdateDefaultRegister(action.LinewiseRegister, lines) m.UpdateDefaultRegister(core.LinewiseRegister, lines)
return nil return nil
} }
func yankNormalMode(m action.Model, start, end action.Position, mtype action.MotionType) { // yankNormalMode: Copies text to register in normal mode based on motion type.
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
@ -59,22 +70,22 @@ func yankNormalMode(m action.Model, start, end action.Position, mtype action.Mot
return return
} }
line := m.Line(start.Line) line := buf.Lines[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 == action.CharwiseInclusive { if mtype == core.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(action.CharwiseRegister, []string{cnt}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt})
case mtype == action.Linewise: case mtype == core.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."))
@ -85,12 +96,15 @@ func yankNormalMode(m action.Model, start, end action.Position, mtype action.Mot
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 := m.Lines()[startY : endY+1] cnt := buf.Lines[startY : endY+1]
m.UpdateDefaultRegister(action.LinewiseRegister, cnt) m.UpdateDefaultRegister(core.LinewiseRegister, cnt)
} }
} }
func yankVisualMode(m action.Model, start, end action.Position) { // yankVisualMode: Copies character-wise visual selection to register.
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
@ -98,11 +112,11 @@ func yankVisualMode(m action.Model, start, end action.Position) {
// Single line selection // Single line selection
if start.Line == end.Line { if start.Line == end.Line {
line := m.Line(start.Line) line := buf.Lines[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(action.CharwiseRegister, []string{cnt}) m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt})
return return
} }
@ -110,24 +124,27 @@ func yankVisualMode(m action.Model, start, end action.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 := m.Line(start.Line) firstLine := buf.Lines[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, m.Line(y)) content = append(content, buf.Lines[y])
} }
// Last line: from beginning to end.Col (inclusive) // Last line: from beginning to end.Col (inclusive)
lastLine := m.Line(end.Line) lastLine := buf.Lines[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(action.CharwiseRegister, content) m.UpdateDefaultRegister(core.CharwiseRegister, content)
} }
func yankVisualLineMode(m action.Model, start, end action.Position) { // yankVisualLineMode: Copies line-wise visual selection to register.
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."))
@ -138,12 +155,15 @@ func yankVisualLineMode(m action.Model, start, end action.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 := m.Lines()[startY : endY+1] cnt := buf.Lines[startY : endY+1]
m.UpdateDefaultRegister(action.LinewiseRegister, cnt) m.UpdateDefaultRegister(core.LinewiseRegister, cnt)
} }
func yankVisualBlockMode(m action.Model, start, end action.Position) { // yankVisualBlockMode: Copies block-wise visual selection to register.
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)
@ -153,7 +173,7 @@ func yankVisualBlockMode(m action.Model, start, end action.Position) {
var content []string var content []string
for y := startY; y <= endY; y++ { for y := startY; y <= endY; y++ {
line := m.Line(y) line := buf.Lines[y]
// Handle lines shorter than the block selection // Handle lines shorter than the block selection
if startX >= len(line) { if startX >= len(line) {
@ -165,5 +185,5 @@ func yankVisualBlockMode(m action.Model, start, end action.Position) {
content = append(content, line[startX:lineEndX]) content = append(content, line[startX:lineEndX])
} }
m.UpdateDefaultRegister(action.BlockwiseRegister, content) m.UpdateDefaultRegister(core.BlockwiseRegister, content)
} }

View File

@ -0,0 +1,120 @@
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

75
internal/style/style.go Normal file
View File

@ -0,0 +1,75 @@
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
}
}