Compare commits
24 Commits
db52b63db1
...
f12ce37beb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f12ce37beb | ||
|
|
d4980c5532 | ||
|
|
8364d8b880 | ||
|
|
c963d66e3b | ||
|
|
997c4143ca | ||
|
|
41fd9bd45a | ||
|
|
eb872fcfdf | ||
|
|
a103af0a83 | ||
|
|
354fbc4f9b | ||
|
|
15d847e3c8 | ||
|
|
c126242ee1 | ||
|
|
93968e7333 | ||
|
|
098641f5c0 | ||
|
|
dc9a814508 | ||
|
|
03c3a41162 | ||
|
|
ccb061989a | ||
|
|
154558b790 | ||
|
|
b1b3edf810 | ||
|
|
9b1bf35a8e | ||
|
|
770cbcceb7 | ||
|
|
ea4638d815 | ||
|
|
3339dd4409 | ||
|
|
65f96a5089 | ||
|
|
88fa53a4d7 |
30
.github/workflows/EditorTests.yml
vendored
Normal file
30
.github/workflows/EditorTests.yml
vendored
Normal 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 ./...
|
||||
92
FEATURES.md
92
FEATURES.md
@ -74,8 +74,8 @@
|
||||
- [x] `yy` - Yank line (double press)
|
||||
|
||||
### Not Implemented
|
||||
- [ ] `c` - Change operator
|
||||
- [ ] `cc` - Change line
|
||||
- [x] `c` - Change operator
|
||||
- [x] `cc` - Change line
|
||||
- [ ] `>` - Indent right
|
||||
- [ ] `<` - Indent left
|
||||
- [ ] `=` - Auto-indent
|
||||
@ -96,9 +96,9 @@
|
||||
- [x] `A` - Insert at end of line
|
||||
- [x] `o` - Open line below
|
||||
- [x] `O` - Open line above
|
||||
- [ ] `s` - Substitute character (delete + insert)
|
||||
- [ ] `S` - Substitute line (delete line + insert)
|
||||
- [ ] `C` - Change to end of line
|
||||
- [x] `s` - Substitute character (delete + insert)
|
||||
- [x] `S` - Substitute line (delete line + insert)
|
||||
- [x] `C` - Change to end of line
|
||||
- [ ] `gi` - Insert at last insert position
|
||||
|
||||
### Delete Actions
|
||||
@ -213,11 +213,11 @@
|
||||
- [x] `:set number!` - Toggle line numbers
|
||||
- [x] `:set tabstop=N` - Set tab width
|
||||
- [x] `:register {name}` - Show register contents
|
||||
- [ ] `:w` - Write file
|
||||
- [ ] `:q` - Quit
|
||||
- [ ] `:wq` - Write and quit
|
||||
- [ ] `:q!` - Force quit
|
||||
- [ ] `:e {file}` - Edit file
|
||||
- [x] `:w` - Write file
|
||||
- [x] `:q` - Quit
|
||||
- [x] `:wq` - Write and quit
|
||||
- [x] `:q!` - Force quit
|
||||
- [x] `:e {file}` - Edit file
|
||||
- [ ] `:bn` / `:bp` - Next/previous buffer
|
||||
- [ ] `:{range}` - Go to line
|
||||
- [ ] `:%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.
|
||||
|
||||
### Buffer Model
|
||||
- [ ] Buffer struct (id, filename, lines, modified flag, cursor position)
|
||||
- [x] Buffer struct (id, filename, lines, modified flag, cursor position)
|
||||
- [ ] Buffer list/manager
|
||||
- [ ] Current buffer tracking
|
||||
- [x] Current buffer tracking
|
||||
- [ ] Buffer-local settings (tabstop, filetype, etc.)
|
||||
- [ ] Modified/dirty state tracking
|
||||
- [ ] Read-only buffer support
|
||||
- [x] Modified/dirty state tracking
|
||||
- [x] Read-only buffer support
|
||||
|
||||
### 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
|
||||
- [ ] `:bp` / `:bprev` - Previous buffer
|
||||
- [ ] `: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!` - Force delete buffer (discard changes)
|
||||
- [ ] `:bw` / `:bwipeout` - Wipe buffer (remove completely)
|
||||
- [ ] `:w` - Write current buffer to file
|
||||
- [ ] `:w {file}` - Write buffer to specific file
|
||||
- [ ] `:wa` - Write all modified buffers
|
||||
- [x] `:w` - Write current buffer to file
|
||||
- [x] `:w {file}` - Write buffer to specific file
|
||||
- [x] `:wa` - Write all modified buffers
|
||||
- [ ] `:sav {file}` - Save as (write to new file, switch to it)
|
||||
|
||||
### Buffer State
|
||||
@ -309,8 +309,8 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
|
||||
|
||||
### Hidden Buffers
|
||||
- [ ] `:set hidden` - Allow switching with unsaved changes
|
||||
- [ ] Prompt to save when closing modified buffer
|
||||
- [ ] `:q` behavior with modified buffers
|
||||
- [x] Prompt to save when closing modified buffer
|
||||
- [x] `:q` behavior with modified buffers
|
||||
|
||||
### Argument List (Advanced)
|
||||
- [ ] `: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)
|
||||
|
||||
### Files
|
||||
- [ ] File reading
|
||||
- [ ] File writing
|
||||
- [x] File reading
|
||||
- [x] File writing
|
||||
- [ ] Auto-save
|
||||
- [ ] Backup 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] Word motions (w, e, b)
|
||||
- [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] Yank operator (y, yy)
|
||||
- [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 editing (enter, backspace, delete, tab, ctrl+w)
|
||||
- [x] Visual modes (v, V, ctrl+v)
|
||||
- [x] Visual mode with motions
|
||||
- [x] Delete actions (x, D)
|
||||
- [x] Command mode basics
|
||||
- [x] Register behavior
|
||||
|
||||
|
||||
@ -1,24 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/editor"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/program"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func generateLines(n int) []string {
|
||||
lines := make([]string, n)
|
||||
for i := range n {
|
||||
lines[i] = fmt.Sprintf("line %d", i+1)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// main: Entry point for the Gim text editor. Creates a buffer and window,
|
||||
// initializes the editor model, and runs the BubbleTea TUI program.
|
||||
func main() {
|
||||
tea.NewProgram(
|
||||
editor.NewModel(generateLines(64), action.Position{Line: 0, Col: 0}),
|
||||
tea.WithAltScreen(),
|
||||
).Run()
|
||||
// <exe> <filename>
|
||||
args := os.Args[1:]
|
||||
|
||||
var prog *tea.Program
|
||||
if len(args) < 1 {
|
||||
prog = program.NewProgramBuilder().
|
||||
EmptyProgram().
|
||||
WithOpt(tea.WithAltScreen()).
|
||||
Build()
|
||||
} else {
|
||||
prog = program.NewProgramBuilder().
|
||||
FileProgram(args[0]).
|
||||
WithOpt(tea.WithAltScreen()).
|
||||
Build()
|
||||
}
|
||||
|
||||
if _, err := prog.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,26 +1,33 @@
|
||||
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
|
||||
type ChangeToEndOfLine struct {
|
||||
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 {
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
pos := win.Cursor.Col
|
||||
line := buf.Lines[win.Cursor.Line]
|
||||
|
||||
// Save deleted text to register
|
||||
if pos < len(line) {
|
||||
m.UpdateDefaultRegister(CharwiseRegister, []string{line[pos:]})
|
||||
m.UpdateDefaultRegister(core.CharwiseRegister, []string{line[pos:]})
|
||||
}
|
||||
|
||||
// Delete to end of line
|
||||
m.SetLine(m.CursorY(), line[:pos])
|
||||
buf.SetLine(win.Cursor.Line, line[:pos])
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -28,6 +35,7 @@ func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd {
|
||||
// Ensure ChangeToEndOfLine implements Repeatable
|
||||
var _ Repeatable = ChangeToEndOfLine{}
|
||||
|
||||
// ChangeToEndOfLine.WithCount: Returns a new ChangeToEndOfLine with the given count.
|
||||
func (a ChangeToEndOfLine) WithCount(n int) Action {
|
||||
return ChangeToEndOfLine{Count: n}
|
||||
}
|
||||
@ -37,23 +45,27 @@ type SubstituteChar struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// SubstituteChar.Execute: Deletes Count characters and enters insert mode (s key).
|
||||
func (a SubstituteChar) Execute(m Model) tea.Cmd {
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
pos := win.Cursor.Col
|
||||
line := buf.Lines[win.Cursor.Line]
|
||||
|
||||
// Calculate how many chars to delete (limited by line length)
|
||||
count := min(a.Count, len(line)-pos)
|
||||
|
||||
if count > 0 {
|
||||
// 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
|
||||
m.SetLine(m.CursorY(), line[:pos]+line[pos+count:])
|
||||
buf.SetLine(win.Cursor.Line, line[:pos]+line[pos+count:])
|
||||
}
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -61,6 +73,7 @@ func (a SubstituteChar) Execute(m Model) tea.Cmd {
|
||||
// Ensure SubstituteChar implements Repeatable
|
||||
var _ Repeatable = SubstituteChar{}
|
||||
|
||||
// SubstituteChar.WithCount: Returns a new SubstituteChar with the given count.
|
||||
func (a SubstituteChar) WithCount(n int) Action {
|
||||
return SubstituteChar{Count: n}
|
||||
}
|
||||
@ -70,33 +83,36 @@ type SubstituteLine struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// SubstituteLine.Execute: Clears Count lines and enters insert mode (S key).
|
||||
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
|
||||
count := min(a.Count, m.LineCount()-y)
|
||||
count := min(a.Count, buf.LineCount()-y)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Collect and delete lines
|
||||
for range count {
|
||||
lines = append(lines, m.Line(y))
|
||||
m.DeleteLine(y)
|
||||
lines = append(lines, buf.Lines[y])
|
||||
buf.DeleteLine(y)
|
||||
}
|
||||
|
||||
// Save deleted lines to register
|
||||
m.UpdateDefaultRegister(LinewiseRegister, lines)
|
||||
m.UpdateDefaultRegister(core.LinewiseRegister, lines)
|
||||
|
||||
// Insert empty line at original position
|
||||
insertY := min(y, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
insertY := min(y, buf.LineCount())
|
||||
buf.InsertLine(insertY, "")
|
||||
|
||||
// Position cursor
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
win.SetCursorPos(insertY, 0)
|
||||
|
||||
// Enter insert mode
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -104,6 +120,7 @@ func (a SubstituteLine) Execute(m Model) tea.Cmd {
|
||||
// Ensure SubstituteLine implements Repeatable
|
||||
var _ Repeatable = SubstituteLine{}
|
||||
|
||||
// SubstituteLine.WithCount: Returns a new SubstituteLine with the given count.
|
||||
func (a SubstituteLine) WithCount(n int) Action {
|
||||
return SubstituteLine{Count: n}
|
||||
}
|
||||
|
||||
@ -1,24 +1,29 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ExitCommandMode implements Action - exits command mode and returns to normal mode.
|
||||
type ExitCommandMode struct{}
|
||||
|
||||
// ExitCommandMode.Execute: Exits command mode and returns to normal mode (Esc key).
|
||||
func (a ExitCommandMode) Execute(m Model) tea.Cmd {
|
||||
m.SetCommandCursor(0)
|
||||
m.SetCommand("")
|
||||
m.SetCommandOutput("")
|
||||
m.SetCommandError(nil)
|
||||
m.SetMode(NormalMode)
|
||||
m.SetMode(core.NormalMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertCommandChar implements Action - inserts a character in command mode.
|
||||
type InsertCommandChar struct {
|
||||
Char string
|
||||
}
|
||||
|
||||
// InsertCommandChar.Execute: Inserts a character at the command cursor position.
|
||||
func (a InsertCommandChar) Execute(m Model) tea.Cmd {
|
||||
cur := m.CommandCursor()
|
||||
cmd := m.Command()
|
||||
@ -28,8 +33,10 @@ func (a InsertCommandChar) Execute(m Model) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandBackspace implements Action - deletes character before cursor in command mode.
|
||||
type CommandBackspace struct{}
|
||||
|
||||
// CommandBackspace.Execute: Deletes the character before the command cursor (Backspace key).
|
||||
func (a CommandBackspace) Execute(m Model) tea.Cmd {
|
||||
cur := m.CommandCursor()
|
||||
cmd := m.Command()
|
||||
@ -42,8 +49,10 @@ func (a CommandBackspace) Execute(m Model) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandDelete implements Action - deletes character at cursor in command mode.
|
||||
type CommandDelete struct{}
|
||||
|
||||
// CommandDelete.Execute: Deletes the character at the command cursor (Delete key).
|
||||
func (a CommandDelete) Execute(m Model) tea.Cmd {
|
||||
cur := m.CommandCursor()
|
||||
cmd := m.Command()
|
||||
@ -62,8 +71,10 @@ func (a CommandDelete) Execute(m Model) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandDeletePreviousWord implements Action - deletes word before cursor in command mode.
|
||||
type CommandDeletePreviousWord struct{}
|
||||
|
||||
// CommandDeletePreviousWord.Execute: Deletes the word before the command cursor (Ctrl+W).
|
||||
func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd {
|
||||
cur := m.CommandCursor()
|
||||
cmd := m.Command()
|
||||
@ -101,22 +112,24 @@ func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandExecute implements Action - executes the command line.
|
||||
type CommandExecute struct {
|
||||
Registry CommandRegistry
|
||||
}
|
||||
|
||||
// CommandRegistry interface for executing commands
|
||||
// CommandRegistry: Interface for executing commands.
|
||||
type CommandRegistry interface {
|
||||
Execute(m Model, cmdLine string) (tea.Cmd, error)
|
||||
}
|
||||
|
||||
// CommandExecute.Execute: Executes the command line (Enter key).
|
||||
func (a CommandExecute) Execute(m Model) tea.Cmd {
|
||||
cmdLine := m.Command()
|
||||
|
||||
// Clear command state and return to normal mode
|
||||
m.SetCommandCursor(0)
|
||||
m.SetCommandError(nil)
|
||||
m.SetMode(NormalMode)
|
||||
m.SetMode(core.NormalMode)
|
||||
|
||||
if a.Registry == nil || cmdLine == "" {
|
||||
return nil
|
||||
@ -124,7 +137,6 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
|
||||
|
||||
cmd, err := a.Registry.Execute(m, cmdLine)
|
||||
if err != nil {
|
||||
// TODO: Display error message to user
|
||||
m.SetCommandError(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -7,70 +7,80 @@ type DeleteChar struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// DeleteChar.Execute: Deletes Count characters at the cursor position (x key).
|
||||
func (a DeleteChar) Execute(m Model) tea.Cmd {
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
pos := win.Cursor.Col
|
||||
line := buf.Lines[win.Cursor.Line]
|
||||
for i := 0; i < a.Count && pos < len(line); i++ {
|
||||
line = line[:pos] + line[pos+1:]
|
||||
m.SetLine(m.CursorY(), line)
|
||||
buf.SetLine(win.Cursor.Line, line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChar.WithCount: Returns a new DeleteChar with the given count.
|
||||
func (a DeleteChar) WithCount(n int) Action {
|
||||
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 {
|
||||
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 {
|
||||
// Delete to end of line
|
||||
pos := m.CursorX()
|
||||
line := m.Line(m.CursorY())
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
m.SetLine(m.CursorY(), line[:pos])
|
||||
m.SetCursorX(pos - 1)
|
||||
// Delete to end of line
|
||||
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
|
||||
initY := m.CursorY()
|
||||
initY := win.Cursor.Line
|
||||
if a.Count > 1 {
|
||||
// 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
|
||||
m.SetCursorY(initY + 1)
|
||||
win.SetCursorLine(initY + 1)
|
||||
|
||||
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
|
||||
if y == initY {
|
||||
break
|
||||
}
|
||||
m.DeleteLine(y)
|
||||
buf.DeleteLine(y)
|
||||
|
||||
if m.LineCount() == 0 {
|
||||
m.InsertLine(0, "")
|
||||
if buf.LineCount() == 0 {
|
||||
buf.InsertLine(0, "")
|
||||
}
|
||||
|
||||
if y >= m.LineCount() {
|
||||
y = m.LineCount() - 1
|
||||
if y >= buf.LineCount() {
|
||||
y = buf.LineCount() - 1
|
||||
}
|
||||
|
||||
m.SetCursorY(y)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(y)
|
||||
}
|
||||
}
|
||||
|
||||
m.SetCursorY(initY)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(initY)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteToEndOfLine.WithCount: Returns a new DeleteToEndOfLine with the given count.
|
||||
func (a DeleteToEndOfLine) WithCount(n int) Action {
|
||||
return DeleteToEndOfLine{Count: n}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package action
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
@ -11,13 +12,15 @@ type EnterInsert struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// EnterInsert.Execute: Enters insert mode at the cursor position (i key).
|
||||
func (a EnterInsert) Execute(m Model) tea.Cmd {
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterInsert.WithCount: Returns a new EnterInsert with the given count.
|
||||
func (a EnterInsert) WithCount(n int) Action {
|
||||
return EnterInsert{Count: n}
|
||||
}
|
||||
@ -27,16 +30,18 @@ type EnterInsertAfter struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// EnterInsertAfter.Execute: Enters insert mode after the cursor position (a key).
|
||||
func (a EnterInsertAfter) Execute(m Model) tea.Cmd {
|
||||
m.SetCursorX(m.CursorX() + 1)
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
win.SetCursorCol(win.Cursor.Col + 1)
|
||||
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterInsertAfter.WithCount: Returns a new EnterInsertAfter with the given count.
|
||||
func (a EnterInsertAfter) WithCount(n int) Action {
|
||||
return EnterInsertAfter{Count: n}
|
||||
}
|
||||
@ -46,16 +51,18 @@ type EnterInsertLineStart struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// EnterInsertLineStart.Execute: Enters insert mode at the start of the line (I key).
|
||||
func (a EnterInsertLineStart) Execute(m Model) tea.Cmd {
|
||||
m.SetCursorX(0)
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
win.SetCursorCol(0)
|
||||
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterInsertLineStart.WithCount: Returns a new EnterInsertLineStart with the given count.
|
||||
func (a EnterInsertLineStart) WithCount(n int) Action {
|
||||
return EnterInsertLineStart{Count: n}
|
||||
}
|
||||
@ -65,16 +72,19 @@ type EnterInsertLineEnd struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// EnterInsertLineEnd.Execute: Enters insert mode at the end of the line (A key).
|
||||
func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd {
|
||||
m.SetCursorX(len(m.Line(m.CursorY())))
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
|
||||
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnterInsertLineEnd.WithCount: Returns a new EnterInsertLineEnd with the given count.
|
||||
func (a EnterInsertLineEnd) WithCount(n int) Action {
|
||||
return EnterInsertLineEnd{Count: n}
|
||||
}
|
||||
@ -84,24 +94,28 @@ type OpenLineBelow struct {
|
||||
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 {
|
||||
pos := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
if pos >= m.LineCount() {
|
||||
m.InsertLine(m.LineCount(), "")
|
||||
pos := win.Cursor.Line
|
||||
|
||||
if pos >= buf.LineCount() {
|
||||
buf.InsertLine(buf.LineCount(), "")
|
||||
} else {
|
||||
m.InsertLine(pos+1, "")
|
||||
buf.InsertLine(pos+1, "")
|
||||
}
|
||||
|
||||
m.SetCursorY(m.CursorY() + 1)
|
||||
m.SetCursorX(0)
|
||||
win.SetCursorPos(win.Cursor.Line+1, 0)
|
||||
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenLineBelow.WithCount: Returns a new OpenLineBelow with the given count.
|
||||
func (a OpenLineBelow) WithCount(n int) Action {
|
||||
return OpenLineBelow{Count: n}
|
||||
}
|
||||
@ -111,17 +125,22 @@ type OpenLineAbove struct {
|
||||
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 {
|
||||
pos := m.CursorY()
|
||||
m.InsertLine(pos, "")
|
||||
m.SetCursorX(0)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
pos := win.Cursor.Line
|
||||
buf.InsertLine(pos, "")
|
||||
win.SetCursorCol(0)
|
||||
|
||||
// Start recording
|
||||
m.SetInsertRecording(a.Count, a)
|
||||
m.SetMode(InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenLineAbove.WithCount: Returns a new OpenLineAbove with the given count.
|
||||
func (a OpenLineAbove) WithCount(n int) Action {
|
||||
return OpenLineAbove{Count: n}
|
||||
}
|
||||
@ -133,51 +152,61 @@ type InsertChar struct {
|
||||
Char string
|
||||
}
|
||||
|
||||
// InsertChar.Execute: Inserts a single character at the cursor position.
|
||||
func (a InsertChar) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
l := m.Line(y)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x, y := win.Cursor.Col, win.Cursor.Line
|
||||
l := buf.Lines[y]
|
||||
if x < len(l) {
|
||||
m.SetLine(y, l[:x]+a.Char+l[x:])
|
||||
buf.SetLine(y, l[:x]+a.Char+l[x:])
|
||||
} 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
|
||||
}
|
||||
|
||||
// InsertNewline splits the current line at the cursor (enter key)
|
||||
type InsertNewline struct{}
|
||||
|
||||
// InsertNewline.Execute: Splits the current line at the cursor (Enter key).
|
||||
func (a InsertNewline) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
l := m.Line(y)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x, y := win.Cursor.Col, win.Cursor.Line
|
||||
l := buf.Lines[y]
|
||||
if x == len(l) {
|
||||
m.InsertLine(y+1, "")
|
||||
buf.InsertLine(y+1, "")
|
||||
} else {
|
||||
m.SetLine(y, l[:x])
|
||||
m.InsertLine(y+1, l[x:])
|
||||
buf.SetLine(y, l[:x])
|
||||
buf.InsertLine(y+1, l[x:])
|
||||
}
|
||||
m.SetCursorY(y + 1)
|
||||
m.SetCursorX(0)
|
||||
win.SetCursorPos(y+1, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertBackspace deletes the character before the cursor
|
||||
type InsertBackspace struct{}
|
||||
|
||||
// InsertBackspace.Execute: Deletes the character before the cursor (Backspace key).
|
||||
func (a InsertBackspace) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
l := m.Line(y)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x, y := win.Cursor.Col, win.Cursor.Line
|
||||
l := buf.Lines[y]
|
||||
if x > 0 {
|
||||
m.SetLine(y, l[:x-1]+l[x:])
|
||||
m.SetCursorX(x - 1)
|
||||
buf.SetLine(y, l[:x-1]+l[x:])
|
||||
win.SetCursorCol(x - 1)
|
||||
} else if y > 0 {
|
||||
prevLine := m.Line(y - 1)
|
||||
prevLine := buf.Lines[y-1]
|
||||
newX := len(prevLine)
|
||||
m.SetLine(y-1, prevLine+l)
|
||||
m.DeleteLine(y)
|
||||
m.SetCursorY(y - 1)
|
||||
m.SetCursorX(newX)
|
||||
buf.SetLine(y-1, prevLine+l)
|
||||
buf.DeleteLine(y)
|
||||
win.SetCursorPos(y-1, newX)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -185,15 +214,19 @@ func (a InsertBackspace) Execute(m Model) tea.Cmd {
|
||||
// InsertDelete deletes the character under/after the cursor (delete key)
|
||||
type InsertDelete struct{}
|
||||
|
||||
// InsertDelete.Execute: Deletes the character at the cursor position (Delete key).
|
||||
func (a InsertDelete) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
l := m.Line(y)
|
||||
if x == len(l) && y < m.LineCount()-1 {
|
||||
nextLine := m.Line(y + 1)
|
||||
m.SetLine(y, l+nextLine)
|
||||
m.DeleteLine(y + 1)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x, y := win.Cursor.Col, win.Cursor.Line
|
||||
l := buf.Lines[y]
|
||||
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) {
|
||||
m.SetLine(y, l[:x]+l[x+1:])
|
||||
buf.SetLine(y, l[:x]+l[x+1:])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -201,22 +234,28 @@ func (a InsertDelete) Execute(m Model) tea.Cmd {
|
||||
// InsertTab inserts spaces equal to the tab size
|
||||
type InsertTab struct{}
|
||||
|
||||
// InsertTab.Execute: Inserts spaces equal to the tab size (Tab key).
|
||||
func (a InsertTab) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
l := m.Line(y)
|
||||
tabs := strings.Repeat(" ", m.Settings().TabSize)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x, y := win.Cursor.Col, win.Cursor.Line
|
||||
l := buf.Lines[y]
|
||||
tabs := strings.Repeat(" ", m.Settings().TabStop)
|
||||
if x < len(l) {
|
||||
m.SetLine(y, l[:x]+tabs+l[x:])
|
||||
buf.SetLine(y, l[:x]+tabs+l[x:])
|
||||
} else {
|
||||
m.SetLine(y, l+tabs)
|
||||
buf.SetLine(y, l+tabs)
|
||||
}
|
||||
m.SetCursorX(x + len(tabs))
|
||||
win.SetCursorCol(x + len(tabs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertDeletePreviousWord deletes the word before the cursor (ctrl+w)
|
||||
type InsertDeletePreviousWord struct{}
|
||||
|
||||
// isWordChar: Returns true if the character is a word character (alphanumeric
|
||||
// or underscore).
|
||||
func isWordChar(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
@ -224,23 +263,28 @@ func isWordChar(c byte) bool {
|
||||
c == '_'
|
||||
}
|
||||
|
||||
// isPunctuation: Returns true if the character is punctuation (not whitespace
|
||||
// and not a word character).
|
||||
func isPunctuation(c byte) bool {
|
||||
return c != ' ' && c != '\t' && !isWordChar(c)
|
||||
}
|
||||
|
||||
// InsertDeletePreviousWord.Execute: Deletes the word before the cursor (Ctrl+W).
|
||||
func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
|
||||
x, y := m.CursorX(), m.CursorY()
|
||||
line := m.Line(y)
|
||||
win := m.ActiveWindow()
|
||||
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)
|
||||
if x == 0 {
|
||||
if y > 0 {
|
||||
prevLine := m.Line(y - 1)
|
||||
prevLine := buf.Lines[y-1]
|
||||
newX := len(prevLine)
|
||||
m.SetLine(y-1, prevLine+line)
|
||||
m.DeleteLine(y)
|
||||
m.SetCursorY(y - 1)
|
||||
m.SetCursorX(newX)
|
||||
buf.SetLine(y-1, prevLine+line)
|
||||
buf.DeleteLine(y)
|
||||
win.SetCursorPos(y-1, newX)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -254,8 +298,8 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
|
||||
newX--
|
||||
}
|
||||
|
||||
m.SetLine(y, line[:newX]+line[x:])
|
||||
m.SetCursorX(newX)
|
||||
buf.SetLine(y, line[:newX]+line[x:])
|
||||
win.SetCursorCol(newX)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -271,8 +315,8 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
|
||||
}
|
||||
|
||||
// Delete everything from newX up to x in one operation
|
||||
m.SetLine(y, line[:newX]+line[x:])
|
||||
m.SetCursorX(newX)
|
||||
buf.SetLine(y, line[:newX]+line[x:])
|
||||
win.SetCursorCol(newX)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
85
internal/action/interface.go
Normal file
85
internal/action/interface.go
Normal 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
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
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)
|
||||
type Quit struct{}
|
||||
|
||||
// Quit.Execute: Quits the editor (Ctrl+C).
|
||||
func (a Quit) Execute(m Model) tea.Cmd {
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
// Quit implements Action (:)
|
||||
// EnterComandMode implements Action (:) - enters command mode.
|
||||
type EnterComandMode struct{}
|
||||
|
||||
// EnterComandMode.Execute: Enters command mode (: key).
|
||||
func (a EnterComandMode) Execute(m Model) tea.Cmd {
|
||||
m.SetMode(CommandMode)
|
||||
m.SetMode(core.CommandMode)
|
||||
m.SetCommand("")
|
||||
m.SetCommandOutput("")
|
||||
m.SetCommandError(nil)
|
||||
@ -21,32 +26,38 @@ func (a EnterComandMode) Execute(m Model) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Quit implements Action (v)
|
||||
// EnterVisualMode implements Action (v) - enters visual character mode.
|
||||
type EnterVisualMode struct{}
|
||||
|
||||
// EnterVisualMode.Execute: Enters visual character mode (v key).
|
||||
func (a EnterVisualMode) Execute(m Model) tea.Cmd {
|
||||
m.SetAnchorX(m.CursorX())
|
||||
m.SetAnchorY(m.CursorY())
|
||||
m.SetMode(VisualMode)
|
||||
win := m.ActiveWindow()
|
||||
win.SetAnchorCol(win.Cursor.Col)
|
||||
win.SetAnchorLine(win.Cursor.Line)
|
||||
m.SetMode(core.VisualMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Quit implements Action (V)
|
||||
// EnterVisualLineMode implements Action (V) - enters visual line mode.
|
||||
type EnterVisualLineMode struct{}
|
||||
|
||||
// EnterVisualLineMode.Execute: Enters visual line mode (V key).
|
||||
func (a EnterVisualLineMode) Execute(m Model) tea.Cmd {
|
||||
m.SetAnchorX(m.CursorX())
|
||||
m.SetAnchorY(m.CursorY())
|
||||
m.SetMode(VisualLineMode)
|
||||
win := m.ActiveWindow()
|
||||
win.SetAnchorCol(win.Cursor.Col)
|
||||
win.SetAnchorLine(win.Cursor.Line)
|
||||
m.SetMode(core.VisualLineMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Quit implements Action (ctrl+v)
|
||||
// EnterVisualBlockMode implements Action (Ctrl+V) - enters visual block mode.
|
||||
type EnterVisualBlockMode struct{}
|
||||
|
||||
// EnterVisualBlockMode.Execute: Enters visual block mode (Ctrl+V).
|
||||
func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
|
||||
m.SetAnchorX(m.CursorX())
|
||||
m.SetAnchorY(m.CursorY())
|
||||
m.SetMode(VisualBlockMode)
|
||||
win := m.ActiveWindow()
|
||||
win.SetAnchorCol(win.Cursor.Col)
|
||||
win.SetAnchorLine(win.Cursor.Line)
|
||||
m.SetMode(core.VisualBlockMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
@ -12,7 +13,11 @@ type Paste struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// Paste.Execute: Pastes register content after the cursor position (p key).
|
||||
func (a Paste) Execute(m Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
// Get reg
|
||||
reg, found := m.GetRegister('"')
|
||||
if !found {
|
||||
@ -26,25 +31,25 @@ func (a Paste) Execute(m Model) tea.Cmd {
|
||||
}
|
||||
|
||||
switch reg.Type {
|
||||
case LinewiseRegister:
|
||||
case core.LinewiseRegister:
|
||||
{
|
||||
initY := m.CursorY()
|
||||
initY := win.Cursor.Line
|
||||
lines := reg.Content
|
||||
insertPos := initY + 1
|
||||
|
||||
// Run count times
|
||||
for range a.Count {
|
||||
for _, line := range lines {
|
||||
m.InsertLine(insertPos, line)
|
||||
buf.InsertLine(insertPos, line)
|
||||
insertPos++
|
||||
}
|
||||
}
|
||||
|
||||
if m.LineCount() > 1 {
|
||||
m.SetCursorY(initY + 1)
|
||||
if buf.LineCount() > 1 {
|
||||
win.SetCursorLine(initY + 1)
|
||||
}
|
||||
}
|
||||
case CharwiseRegister:
|
||||
case core.CharwiseRegister:
|
||||
{
|
||||
lines := reg.Content
|
||||
|
||||
@ -54,22 +59,21 @@ func (a Paste) Execute(m Model) tea.Cmd {
|
||||
break
|
||||
}
|
||||
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
|
||||
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
|
||||
insertAt := min(x+1, len(curLine))
|
||||
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
||||
m.SetLine(y, newLine)
|
||||
buf.SetLine(y, newLine)
|
||||
|
||||
m.SetCursorX(x + len(cnt))
|
||||
m.ClampCursorX()
|
||||
win.SetCursorCol(x + len(cnt))
|
||||
}
|
||||
default:
|
||||
m.SetCommandError(fmt.Errorf("Register type is not implemented."))
|
||||
m.SetCommandError(fmt.Errorf("core.Register type is not implemented."))
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -78,6 +82,7 @@ func (a Paste) Execute(m Model) tea.Cmd {
|
||||
// Ensure Paste implements Repeatable
|
||||
var _ Repeatable = Paste{}
|
||||
|
||||
// Paste.WithCount: Returns a new Paste with the given count.
|
||||
func (a Paste) WithCount(n int) Action {
|
||||
return Paste{Count: n}
|
||||
}
|
||||
@ -87,7 +92,11 @@ type PasteBefore struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// PasteBefore.Execute: Pastes register content before the cursor position (P key).
|
||||
func (a PasteBefore) Execute(m Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
// Get reg
|
||||
reg, found := m.GetRegister('"')
|
||||
if !found {
|
||||
@ -96,21 +105,21 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
|
||||
}
|
||||
|
||||
switch reg.Type {
|
||||
case LinewiseRegister:
|
||||
case core.LinewiseRegister:
|
||||
{
|
||||
initY := m.CursorY()
|
||||
initY := win.Cursor.Line
|
||||
lines := reg.Content
|
||||
insertPos := initY // Leave here, this will effectively move the lines below
|
||||
|
||||
// Run count times
|
||||
for range a.Count {
|
||||
for _, line := range lines {
|
||||
m.InsertLine(insertPos, line)
|
||||
buf.InsertLine(insertPos, line)
|
||||
insertPos++
|
||||
}
|
||||
}
|
||||
}
|
||||
case CharwiseRegister:
|
||||
case core.CharwiseRegister:
|
||||
{
|
||||
lines := reg.Content
|
||||
|
||||
@ -120,22 +129,21 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
|
||||
break
|
||||
}
|
||||
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
|
||||
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
|
||||
insertAt := min(x, len(curLine))
|
||||
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
||||
m.SetLine(y, newLine)
|
||||
buf.SetLine(y, newLine)
|
||||
|
||||
m.SetCursorX(x + len(cnt))
|
||||
m.ClampCursorX()
|
||||
win.SetCursorCol(x + len(cnt))
|
||||
}
|
||||
default:
|
||||
m.SetCommandError(fmt.Errorf("Register type is not implemented."))
|
||||
m.SetCommandError(fmt.Errorf("core.Register type is not implemented."))
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -144,6 +152,7 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
|
||||
// Ensure PasteBefore implements Repeatable
|
||||
var _ Repeatable = PasteBefore{}
|
||||
|
||||
// PasteBefore.WithCount: Returns a new PasteBefore with the given count.
|
||||
func (a PasteBefore) WithCount(n int) Action {
|
||||
return PasteBefore{Count: n}
|
||||
}
|
||||
@ -153,6 +162,7 @@ type VisualPaste struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// VisualPaste.Execute: Replaces visual selection with register content (p in visual mode).
|
||||
func (a VisualPaste) Execute(m Model) tea.Cmd {
|
||||
// Get register content to paste
|
||||
reg, found := m.GetRegister('"')
|
||||
@ -167,27 +177,26 @@ func (a VisualPaste) Execute(m Model) tea.Cmd {
|
||||
start, end := normalizeSelection(m)
|
||||
|
||||
switch mode {
|
||||
case VisualMode:
|
||||
case core.VisualMode:
|
||||
visualCharPaste(m, reg, start, end)
|
||||
case VisualBlockMode:
|
||||
case core.VisualBlockMode:
|
||||
visualBlockPaste(m, reg, start, end)
|
||||
case VisualLineMode:
|
||||
case core.VisualLineMode:
|
||||
visualLinePaste(m, reg, start, end)
|
||||
}
|
||||
|
||||
// Exit visual mode
|
||||
m.SetMode(NormalMode)
|
||||
m.SetMode(core.NormalMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSelection returns start and end positions with start always before end
|
||||
func normalizeSelection(m Model) (Position, Position) {
|
||||
anchorX, anchorY := m.AnchorX(), m.AnchorY()
|
||||
cursorX, cursorY := m.CursorX(), m.CursorY()
|
||||
// normalizeSelection: Returns start and end positions with start always before end.
|
||||
func normalizeSelection(m Model) (core.Position, core.Position) {
|
||||
win := m.ActiveWindow()
|
||||
|
||||
start := Position{Line: anchorY, Col: anchorX}
|
||||
end := Position{Line: cursorY, Col: cursorX}
|
||||
start := core.Position{Line: win.Anchor.Line, Col: win.Anchor.Col}
|
||||
end := core.Position{Line: win.Cursor.Line, Col: win.Cursor.Col}
|
||||
|
||||
// Normalize so start is always before end
|
||||
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
|
||||
}
|
||||
|
||||
// visualCharPaste handles paste in visual (character) mode
|
||||
func visualCharPaste(m Model, reg Register, start, end Position) {
|
||||
// visualCharPaste: Handles paste operation in visual (character) mode.
|
||||
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)
|
||||
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
|
||||
if len(reg.Content) == 0 {
|
||||
// Empty register - just delete (already done)
|
||||
} else if reg.Type == CharwiseRegister {
|
||||
} else if reg.Type == core.CharwiseRegister {
|
||||
// Charwise paste: insert text at cursor position
|
||||
if len(reg.Content) == 1 {
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.Line]
|
||||
insertAt := min(start.Col, len(line))
|
||||
newLine := line[:insertAt] + reg.Content[0] + line[insertAt:]
|
||||
m.SetLine(start.Line, newLine)
|
||||
buf.SetLine(start.Line, newLine)
|
||||
|
||||
// Cursor at end of pasted text
|
||||
m.SetCursorX(insertAt + len(reg.Content[0]) - 1)
|
||||
m.SetCursorY(start.Line)
|
||||
win.SetCursorCol(insertAt + len(reg.Content[0]) - 1)
|
||||
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
|
||||
// Insert each line from register
|
||||
for i, content := range reg.Content {
|
||||
if i == 0 {
|
||||
// First line: insert at start position
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.Line]
|
||||
insertAt := min(start.Col, len(line))
|
||||
newLine := line[:insertAt] + content
|
||||
if len(reg.Content) == 1 {
|
||||
// Single line register - append rest of line
|
||||
newLine += line[insertAt:]
|
||||
}
|
||||
m.SetLine(start.Line, newLine)
|
||||
buf.SetLine(start.Line, newLine)
|
||||
} else {
|
||||
// Subsequent lines: insert new lines
|
||||
m.InsertLine(start.Line+i, content)
|
||||
buf.InsertLine(start.Line+i, content)
|
||||
}
|
||||
}
|
||||
m.SetCursorY(start.Line)
|
||||
m.SetCursorX(start.Col)
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(start.Col)
|
||||
}
|
||||
|
||||
m.ClampCursorX()
|
||||
|
||||
// Update register with deleted text
|
||||
m.UpdateDefaultRegister(CharwiseRegister, []string{deletedText})
|
||||
m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText})
|
||||
}
|
||||
|
||||
// visualBlockPaste handles paste in visual block mode
|
||||
func visualBlockPaste(m Model, reg Register, start, end Position) {
|
||||
// visualBlockPaste: Handles paste operation in visual block mode.
|
||||
func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
startCol := min(start.Col, end.Col)
|
||||
endCol := max(start.Col, end.Col)
|
||||
|
||||
// Extract deleted text (for register)
|
||||
var deletedLines []string
|
||||
for y := start.Line; y <= end.Line; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
if startCol < len(line) {
|
||||
ec := min(endCol+1, len(line))
|
||||
deletedLines = append(deletedLines, line[startCol:ec])
|
||||
@ -268,94 +281,97 @@ func visualBlockPaste(m Model, reg Register, start, end Position) {
|
||||
|
||||
// Delete the block selection
|
||||
for y := start.Line; y <= end.Line; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
if startCol >= len(line) {
|
||||
continue
|
||||
}
|
||||
ec := min(endCol+1, len(line))
|
||||
m.SetLine(y, line[:startCol]+line[ec:])
|
||||
buf.SetLine(y, line[:startCol]+line[ec:])
|
||||
}
|
||||
|
||||
// Insert register content
|
||||
if len(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]
|
||||
}
|
||||
|
||||
for y := start.Line; y <= end.Line; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
insertAt := min(startCol, len(line))
|
||||
// Pad with spaces if needed
|
||||
for len(line) < insertAt {
|
||||
line += " "
|
||||
}
|
||||
newLine := line[:insertAt] + pasteContent + line[insertAt:]
|
||||
m.SetLine(y, newLine)
|
||||
buf.SetLine(y, newLine)
|
||||
}
|
||||
}
|
||||
|
||||
m.SetCursorY(start.Line)
|
||||
m.SetCursorX(startCol)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(startCol)
|
||||
|
||||
// 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
|
||||
func visualLinePaste(m Model, reg Register, start, end Position) {
|
||||
// visualLinePaste: Handles paste operation in visual line mode.
|
||||
func visualLinePaste(m Model, reg core.Register, start, end core.Position) {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
// Extract deleted lines (for register)
|
||||
var deletedLines []string
|
||||
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)
|
||||
for y := end.Line; y >= start.Line; y-- {
|
||||
m.DeleteLine(y)
|
||||
buf.DeleteLine(y)
|
||||
}
|
||||
|
||||
// Insert register content
|
||||
if len(reg.Content) == 0 {
|
||||
// Empty register - ensure at least one empty line exists
|
||||
if m.LineCount() == 0 {
|
||||
m.InsertLine(0, "")
|
||||
if buf.LineCount() == 0 {
|
||||
buf.InsertLine(0, "")
|
||||
}
|
||||
} else if reg.Type == LinewiseRegister {
|
||||
} else if reg.Type == core.LinewiseRegister {
|
||||
// Linewise register: insert each line
|
||||
insertPos := start.Line
|
||||
for _, content := range reg.Content {
|
||||
m.InsertLine(insertPos, content)
|
||||
buf.InsertLine(insertPos, content)
|
||||
insertPos++
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
if m.LineCount() == 0 {
|
||||
m.InsertLine(0, "")
|
||||
if buf.LineCount() == 0 {
|
||||
buf.InsertLine(0, "")
|
||||
}
|
||||
|
||||
// Position cursor at start of pasted content
|
||||
// core.Position cursor at start of pasted content
|
||||
y := start.Line
|
||||
if y >= m.LineCount() {
|
||||
y = m.LineCount() - 1
|
||||
if y >= buf.LineCount() {
|
||||
y = buf.LineCount() - 1
|
||||
}
|
||||
m.SetCursorY(y)
|
||||
m.SetCursorX(0)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(y)
|
||||
win.SetCursorCol(0)
|
||||
|
||||
// Update register with deleted lines
|
||||
m.UpdateDefaultRegister(LinewiseRegister, deletedLines)
|
||||
m.UpdateDefaultRegister(core.LinewiseRegister, deletedLines)
|
||||
}
|
||||
|
||||
// extractCharSelection extracts text from a character selection
|
||||
func extractCharSelection(m Model, start, end Position) string {
|
||||
// extractCharSelection: Extracts text from a character selection range.
|
||||
func extractCharSelection(m Model, start, end core.Position) string {
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
if start.Line == end.Line {
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.Line]
|
||||
endCol := min(end.Col+1, len(line))
|
||||
startCol := min(start.Col, len(line))
|
||||
if startCol >= endCol {
|
||||
@ -368,7 +384,7 @@ func extractCharSelection(m Model, start, end Position) string {
|
||||
var result strings.Builder
|
||||
|
||||
// First line: from start.Col to end
|
||||
firstLine := m.Line(start.Line)
|
||||
firstLine := buf.Lines[start.Line]
|
||||
if start.Col < len(firstLine) {
|
||||
result.WriteString(firstLine[start.Col:])
|
||||
}
|
||||
@ -376,27 +392,30 @@ func extractCharSelection(m Model, start, end Position) string {
|
||||
|
||||
// Middle lines: entire lines
|
||||
for y := start.Line + 1; y < end.Line; y++ {
|
||||
result.WriteString(m.Line(y))
|
||||
result.WriteString(buf.Lines[y])
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
// Last line: from beginning to end.Col
|
||||
lastLine := m.Line(end.Line)
|
||||
lastLine := buf.Lines[end.Line]
|
||||
endCol := min(end.Col+1, len(lastLine))
|
||||
result.WriteString(lastLine[:endCol])
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// deleteCharSelectionForPaste deletes a character selection (similar to operator/delete.go)
|
||||
func deleteCharSelectionForPaste(m Model, start, end Position) {
|
||||
// deleteCharSelectionForPaste: Deletes a character selection for paste operations.
|
||||
func deleteCharSelectionForPaste(m Model, start, end core.Position) {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
if start.Line == end.Line {
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.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 {
|
||||
startLine := m.Line(start.Line)
|
||||
endLine := m.Line(end.Line)
|
||||
startLine := buf.Lines[start.Line]
|
||||
endLine := buf.Lines[end.Line]
|
||||
|
||||
prefix := ""
|
||||
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
|
||||
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)
|
||||
m.SetCursorX(start.Col)
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(start.Col)
|
||||
}
|
||||
|
||||
// Ensure VisualPaste implements Repeatable
|
||||
var _ Repeatable = VisualPaste{}
|
||||
|
||||
// VisualPaste.WithCount: Returns a new VisualPaste with the given count.
|
||||
func (a VisualPaste) WithCount(n int) Action {
|
||||
return VisualPaste{Count: n}
|
||||
}
|
||||
|
||||
@ -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{},
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
|
||||
hello
|
||||
@ -1,60 +1,222 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// QuitMsg signals the application should quit
|
||||
// QuitMsg: Message signaling the application should quit.
|
||||
type QuitMsg struct{}
|
||||
|
||||
// ErrorMsg signals an error to display
|
||||
// ErrorMsg: Message signaling an error to display.
|
||||
type ErrorMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// cmdQuit handles :quit / :q
|
||||
func cmdQuit(m action.Model, args []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return tea.Quit()
|
||||
// --------------------------------------------------
|
||||
// Quit Commands
|
||||
// --------------------------------------------------
|
||||
|
||||
// cmdQuit: Handles :quit / :q command.
|
||||
func cmdQuit(m action.Model, args []string, force bool) tea.Cmd {
|
||||
// :q! forces quit, ignoring unsaved changes
|
||||
if force {
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
bufs := m.Buffers()
|
||||
|
||||
// Cannot exit if any buffer has unsaved changes
|
||||
for _, buf := range bufs {
|
||||
if buf.Modified {
|
||||
m.SetCommandError(fmt.Errorf("unsaved changes to '%s'", buf.Filename))
|
||||
m.ActiveWindow().SetBuffer(buf)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
// cmdQuitAll handles :qall / :qa
|
||||
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)
|
||||
}
|
||||
|
||||
// cmdWrite handles :write / :w
|
||||
func cmdWrite(m action.Model, args []string) tea.Cmd {
|
||||
// TODO: Implement file saving
|
||||
// If args provided, save to that filename
|
||||
// Otherwise save to current file
|
||||
// --------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// cmdWriteQuit: Handles :wq command
|
||||
func cmdWriteQuit(m action.Model, args []string, force bool) tea.Cmd {
|
||||
buf := m.ActiveBuffer()
|
||||
cmd, err := writeBuffer(m, buf, args, force)
|
||||
if err != nil {
|
||||
m.SetCommandError(err)
|
||||
return cmd
|
||||
}
|
||||
|
||||
return tea.Batch(cmd, tea.Quit)
|
||||
}
|
||||
|
||||
// cmdWriteQuitAll: Handles :wqall / :wqa / :xa command.
|
||||
// Writes all modified buffers then quits.
|
||||
func cmdWriteQuitAll(m action.Model, args []string, force bool) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
bufs := m.Buffers()
|
||||
for _, buf := range bufs {
|
||||
if buf.Modified {
|
||||
cmd, err := writeBuffer(m, buf, args, force)
|
||||
if err != nil {
|
||||
m.SetCommandError(err)
|
||||
return nil
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
cmds = append(cmds, tea.Quit)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// cmdEdit: Handles :edit / :e
|
||||
func cmdEdit(m action.Model, args []string, force bool) tea.Cmd {
|
||||
// must have arguments, cant edit nothing
|
||||
if len(args) < 1 {
|
||||
m.SetCommandError(fmt.Errorf(":edit requires an argument"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Vim's Approach:
|
||||
// " When you do :edit filename.txt
|
||||
// 1. Check if file exists and is readable (if not, open new buffer)
|
||||
// 2. Detect file encoding (UTF-8, etc.)
|
||||
// 3. Read entire file into memory
|
||||
// 4. Split by line endings (respecting fileformat)
|
||||
// 5. Create new buffer with these lines
|
||||
// 6. Set buffer metadata:
|
||||
// - buftype = "" (normal file)
|
||||
// - modified = 0 (not modified)
|
||||
// - fileformat = "unix" | "dos" | "mac"
|
||||
// - fileencoding = "utf-8" (etc.)
|
||||
|
||||
filename := args[0]
|
||||
ext := filepath.Ext(filename)
|
||||
|
||||
// If the buffer already exists, just switch to it.
|
||||
bufs := m.Buffers()
|
||||
for _, buf := range bufs {
|
||||
if buf.Filename == filename {
|
||||
m.ActiveWindow().SetBuffer(buf)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Open(filename)
|
||||
notFound := errors.Is(err, os.ErrNotExist)
|
||||
|
||||
if err != nil && !notFound {
|
||||
m.SetCommandError(err)
|
||||
return nil
|
||||
}
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
// Create a buffer with the new file name, writing the file will
|
||||
// handle the saving logic
|
||||
if notFound {
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithFiletype(ext).
|
||||
Listed().
|
||||
Loaded().
|
||||
Build()
|
||||
|
||||
m.SetBuffers(append(m.Buffers(), &buf))
|
||||
m.ActiveWindow().SetBuffer(&buf)
|
||||
|
||||
// Need to adjust the cursor when we make a new file
|
||||
m.ActiveWindow().ClampCursor()
|
||||
return nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// BUG: We are unable to open and edit files owned by root. How do we handle that?
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
|
||||
// BUG: This is bad, we don't want to this, but we have to
|
||||
cleaned := strings.ReplaceAll(line, "\t", strings.Repeat(" ", m.Settings().TabStop))
|
||||
lines = append(lines, cleaned)
|
||||
}
|
||||
|
||||
buf := core.NewBufferBuilder().
|
||||
WithType(core.FileBuffer).
|
||||
WithFilename(filename).
|
||||
WithFiletype(ext).
|
||||
WithLines(lines).
|
||||
Listed().
|
||||
Loaded().
|
||||
Build()
|
||||
|
||||
m.SetBuffers(append(m.Buffers(), &buf))
|
||||
m.ActiveWindow().SetBuffer(&buf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdWriteAll handles :wall / :wa
|
||||
func cmdWriteAll(m action.Model, args []string) tea.Cmd {
|
||||
// TODO: Implement saving all buffers
|
||||
return nil
|
||||
}
|
||||
// --------------------------------------------------
|
||||
// Register Commands
|
||||
// --------------------------------------------------
|
||||
|
||||
// cmdWriteQuit handles :wq
|
||||
func cmdWriteQuit(m action.Model, args []string) tea.Cmd {
|
||||
// TODO: Save then quit
|
||||
return func() tea.Msg {
|
||||
return tea.Quit()
|
||||
}
|
||||
}
|
||||
|
||||
// cmdRegisters handles :register
|
||||
func cmdRegisters(m action.Model, args []string) tea.Cmd {
|
||||
// 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
|
||||
if len(args) < 1 {
|
||||
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
|
||||
}
|
||||
|
||||
// cmdSet handles :set option[=value]
|
||||
// --------------------------------------------------
|
||||
// Settings Commands
|
||||
// --------------------------------------------------
|
||||
|
||||
// cmdSet: Handles :set option[=value] command for configuring editor settings.
|
||||
// Examples:
|
||||
//
|
||||
// :set number - enable number
|
||||
@ -87,7 +253,7 @@ func cmdRegisters(m action.Model, args []string) tea.Cmd {
|
||||
// :set number! - toggle number
|
||||
// :set tabstop=4 - set tabstop to 4
|
||||
// :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 {
|
||||
out := fmt.Sprintf("%+v", m.Settings())
|
||||
m.SetCommandOutput(out)
|
||||
@ -104,15 +270,16 @@ func cmdSet(m action.Model, args []string) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setting represents a configurable option
|
||||
// Setting: Represents a configurable editor option.
|
||||
type Setting struct {
|
||||
Name string
|
||||
ShortForm string
|
||||
Type SettingType
|
||||
Get func(s action.Settings) any
|
||||
Get func(m action.Model) any
|
||||
Set func(m action.Model, val any)
|
||||
}
|
||||
|
||||
// SettingType: Enumeration of setting value types.
|
||||
type SettingType int
|
||||
|
||||
const (
|
||||
@ -121,54 +288,72 @@ const (
|
||||
StringSetting
|
||||
)
|
||||
|
||||
// settingsMap defines all available settings
|
||||
// settingsMap defines all available settings (both global and window-local)
|
||||
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",
|
||||
ShortForm: "nu",
|
||||
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) {
|
||||
s := m.Settings()
|
||||
s.Number = val.(bool)
|
||||
m.SetSettings(s)
|
||||
w := m.ActiveWindow()
|
||||
o := w.Options
|
||||
o.Number = val.(bool)
|
||||
w.SetOptions(o)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "relativenumber",
|
||||
ShortForm: "rnu",
|
||||
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) {
|
||||
s := m.Settings()
|
||||
s.RelativeNumber = val.(bool)
|
||||
m.SetSettings(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
w := m.ActiveWindow()
|
||||
o := w.Options
|
||||
o.RelativeNumber = val.(bool)
|
||||
w.SetOptions(o)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "scrolloff",
|
||||
ShortForm: "so",
|
||||
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) {
|
||||
s := m.Settings()
|
||||
s.ScrollOff = val.(int)
|
||||
m.SetSettings(s)
|
||||
w := m.ActiveWindow()
|
||||
o := w.Options
|
||||
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 {
|
||||
for i := range settingsMap {
|
||||
s := &settingsMap[i]
|
||||
@ -183,16 +368,13 @@ func lookupSetting(name string) *Setting {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSetOption: Parses and applies a single :set option.
|
||||
func parseSetOption(m action.Model, opt string) error {
|
||||
// Handle toggle: option!
|
||||
if name, ok := strings.CutSuffix(opt, "!"); ok {
|
||||
setting := lookupSetting(name)
|
||||
if setting == nil {
|
||||
return nil // Unknown setting
|
||||
}
|
||||
if setting.Type == BoolSetting {
|
||||
// Toggle the boolean
|
||||
currentVal := setting.Get(m.Settings()).(bool)
|
||||
if setting != nil && setting.Type == BoolSetting {
|
||||
currentVal := setting.Get(m).(bool)
|
||||
setting.Set(m, !currentVal)
|
||||
}
|
||||
return nil
|
||||
@ -201,10 +383,7 @@ func parseSetOption(m action.Model, opt string) error {
|
||||
// Handle disable: nooption
|
||||
if name, ok := strings.CutPrefix(opt, "no"); ok {
|
||||
setting := lookupSetting(name)
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
if setting.Type == BoolSetting {
|
||||
if setting != nil && setting.Type == BoolSetting {
|
||||
setting.Set(m, false)
|
||||
}
|
||||
return nil
|
||||
@ -214,33 +393,30 @@ func parseSetOption(m action.Model, opt string) error {
|
||||
if strings.Contains(opt, "=") {
|
||||
parts := strings.SplitN(opt, "=", 2)
|
||||
name, value := parts[0], parts[1]
|
||||
|
||||
setting := lookupSetting(name)
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
switch setting.Type {
|
||||
case IntSetting:
|
||||
intVal, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
if setting != nil {
|
||||
switch setting.Type {
|
||||
case IntSetting:
|
||||
intVal, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting.Set(m, intVal)
|
||||
case StringSetting:
|
||||
setting.Set(m, value)
|
||||
case BoolSetting:
|
||||
// Handle :set option=true / :set option=false
|
||||
boolVal := value == "true" || value == "1" || value == "yes"
|
||||
setting.Set(m, boolVal)
|
||||
}
|
||||
setting.Set(m, intVal)
|
||||
case StringSetting:
|
||||
setting.Set(m, value)
|
||||
case BoolSetting:
|
||||
// Handle :set option=true / :set option=false
|
||||
boolVal := value == "true" || value == "1" || value == "yes"
|
||||
setting.Set(m, boolVal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle enable: option (boolean only)
|
||||
setting := lookupSetting(opt)
|
||||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
if setting.Type == BoolSetting {
|
||||
if setting != nil && setting.Type == BoolSetting {
|
||||
setting.Set(m, true)
|
||||
}
|
||||
|
||||
|
||||
3883
internal/command/handlers_test.go
Normal file
3883
internal/command/handlers_test.go
Normal file
File diff suppressed because it is too large
Load Diff
83
internal/command/io.go
Normal file
83
internal/command/io.go
Normal 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
|
||||
}
|
||||
@ -8,32 +8,31 @@ import (
|
||||
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 {
|
||||
Name string // Full name: "quit"
|
||||
ShortForm string // Minimum abbreviation: "q"
|
||||
Handler func(m action.Model, args []string) tea.Cmd // Handler function
|
||||
Name string // Full name: "quit"
|
||||
ShortForm string // Minimum abbreviation: "q"
|
||||
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 {
|
||||
commands []Command
|
||||
}
|
||||
|
||||
// NewRegistry creates a new command registry with default commands
|
||||
// NewRegistry: Creates a new command registry with default commands.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{}
|
||||
r.registerDefaults()
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds a command to the registry
|
||||
// Registry.Register: Adds a command to the registry.
|
||||
func (r *Registry) Register(cmd Command) {
|
||||
r.commands = append(r.commands, cmd)
|
||||
}
|
||||
|
||||
// Lookup finds a command by name or abbreviation
|
||||
// Returns the command and any error (unknown or ambiguous)
|
||||
// Registry.Lookup: Finds a command by name or abbreviation with error handling.
|
||||
func (r *Registry) Lookup(input string) (*Command, error) {
|
||||
if input == "" {
|
||||
return nil, fmt.Errorf("no command given")
|
||||
@ -75,31 +74,41 @@ func (r *Registry) Lookup(input string) (*Command, error) {
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
// Parse splits a command line into command name and arguments
|
||||
func Parse(cmdLine string) (name string, args []string) {
|
||||
// Parse: Splits a command line into command name and arguments.
|
||||
func Parse(cmdLine string) (name string, args []string, force bool) {
|
||||
parts := strings.Fields(cmdLine)
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
return "", nil, false
|
||||
}
|
||||
return parts[0], parts[1:]
|
||||
|
||||
name = parts[0]
|
||||
args = parts[1:]
|
||||
|
||||
// Check if command ends with ! (force flag)
|
||||
if strings.HasSuffix(name, "!") {
|
||||
name = strings.TrimSuffix(name, "!")
|
||||
force = true
|
||||
}
|
||||
|
||||
return name, args, force
|
||||
}
|
||||
|
||||
// Execute parses and executes a command line
|
||||
// Registry.Execute: Parses and executes a command line.
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cmd.Handler(m, args), nil
|
||||
return cmd.Handler(m, args, force), nil
|
||||
}
|
||||
|
||||
// DefaultRegistry is the global command registry
|
||||
var DefaultRegistry = NewRegistry()
|
||||
|
||||
// registerDefaults registers the built-in commands
|
||||
// Registry.registerDefaults: Registers the built-in commands.
|
||||
func (r *Registry) registerDefaults() {
|
||||
// Quit commands
|
||||
r.Register(Command{
|
||||
@ -133,6 +142,12 @@ func (r *Registry) registerDefaults() {
|
||||
Handler: cmdWriteQuit,
|
||||
})
|
||||
|
||||
r.Register(Command{
|
||||
Name: "wqall",
|
||||
ShortForm: "wqa",
|
||||
Handler: cmdWriteQuitAll,
|
||||
})
|
||||
|
||||
// Set command
|
||||
r.Register(Command{
|
||||
Name: "set",
|
||||
@ -146,4 +161,11 @@ func (r *Registry) registerDefaults() {
|
||||
ShortForm: "reg",
|
||||
Handler: cmdRegisters,
|
||||
})
|
||||
|
||||
// File commands
|
||||
r.Register(Command{
|
||||
Name: "edit",
|
||||
ShortForm: "e",
|
||||
Handler: cmdEdit,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
_, err := r.Lookup("xyz")
|
||||
if err == nil {
|
||||
@ -106,17 +126,20 @@ func TestRegistryLookup(t *testing.T) {
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
t.Run("command only", func(t *testing.T) {
|
||||
name, args := Parse("quit")
|
||||
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 false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command with one arg", func(t *testing.T) {
|
||||
name, args := Parse("set number")
|
||||
name, args, force := Parse("set number")
|
||||
if name != "set" {
|
||||
t.Errorf("name = %q, want \"set\"", name)
|
||||
}
|
||||
@ -126,10 +149,13 @@ func TestParse(t *testing.T) {
|
||||
if args[0] != "number" {
|
||||
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) {
|
||||
name, args := Parse("set number tabstop=4")
|
||||
name, args, force := Parse("set number tabstop=4")
|
||||
if name != "set" {
|
||||
t.Errorf("name = %q, want \"set\"", name)
|
||||
}
|
||||
@ -142,26 +168,74 @@ func TestParse(t *testing.T) {
|
||||
if args[1] != "tabstop=4" {
|
||||
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) {
|
||||
name, args := Parse("")
|
||||
name, args, force := Parse("")
|
||||
if name != "" {
|
||||
t.Errorf("name = %q, want \"\"", name)
|
||||
}
|
||||
if args != nil {
|
||||
t.Errorf("args = %v, want nil", args)
|
||||
}
|
||||
if force {
|
||||
t.Error("force should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace only", func(t *testing.T) {
|
||||
name, args := Parse(" ")
|
||||
name, args, force := Parse(" ")
|
||||
if name != "" {
|
||||
t.Errorf("name = %q, want \"\"", name)
|
||||
}
|
||||
if args != nil {
|
||||
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
130
internal/core/buffer.go
Normal 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
|
||||
}
|
||||
89
internal/core/buffer_builder.go
Normal file
89
internal/core/buffer_builder.go
Normal 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
|
||||
}
|
||||
302
internal/core/buffer_test.go
Normal file
302
internal/core/buffer_test.go
Normal 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
41
internal/core/mode.go
Normal 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
|
||||
}
|
||||
6
internal/core/position.go
Normal file
6
internal/core/position.go
Normal 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
93
internal/core/register.go
Normal 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
15
internal/core/settings.go
Normal 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
15
internal/core/types.go
Normal 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
205
internal/core/window.go
Normal 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
|
||||
}
|
||||
111
internal/core/window_builder.go
Normal file
111
internal/core/window_builder.go
Normal 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
|
||||
}
|
||||
493
internal/core/window_test.go
Normal file
493
internal/core/window_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"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
|
||||
@ -19,18 +19,20 @@ func TestHelperExamples(t *testing.T) {
|
||||
WithLines([]string{"hello", "world"}),
|
||||
)
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 2 {
|
||||
t.Errorf("expected 2 lines, got %d", len(m.lines))
|
||||
buf := m.ActiveBuffer()
|
||||
if buf.LineCount() != 2 {
|
||||
t.Errorf("expected 2 lines, got %d", buf.LineCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom cursor position", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithCursorPos(action.Position{Line: 2, Col: 3}),
|
||||
WithCursorPos(core.Position{Line: 2, Col: 3}),
|
||||
)
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 2 || m.CursorX() != 3 {
|
||||
t.Errorf("expected cursor at (2,3), got (%d,%d)", m.CursorY(), m.CursorX())
|
||||
win := m.ActiveWindow()
|
||||
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),
|
||||
)
|
||||
m := getFinalModel(t, tm)
|
||||
if m.WinW() != 120 || m.WinH() != 40 {
|
||||
t.Errorf("expected size 120x40, got %dx%d", m.WinW(), m.WinH())
|
||||
win := m.ActiveWindow()
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithRegister('"', action.CharwiseRegister, []string{"foo"}),
|
||||
WithRegister('"', core.CharwiseRegister, []string{"foo"}),
|
||||
)
|
||||
m := getFinalModel(t, tm)
|
||||
|
||||
@ -55,7 +58,7 @@ func TestHelperExamples(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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),
|
||||
WithRegister('"', action.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}),
|
||||
WithRegister('"', core.LinewiseRegister, []string{"deleted line 1", "deleted line 2"}),
|
||||
)
|
||||
m := getFinalModel(t, tm)
|
||||
|
||||
// Verify all options were applied
|
||||
if len(m.Lines()) != 3 {
|
||||
t.Errorf("expected 3 lines, got %d", len(m.Lines()))
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
if buf.LineCount() != 3 {
|
||||
t.Errorf("expected 3 lines, got %d", buf.LineCount())
|
||||
}
|
||||
if m.CursorY() != 1 || m.CursorX() != 5 {
|
||||
t.Errorf("expected cursor at (1,5), got (%d,%d)", m.CursorY(), m.CursorX())
|
||||
if win.Cursor.Line != 1 || win.Cursor.Col != 5 {
|
||||
t.Errorf("expected cursor at (1,5), got (%d,%d)", win.Cursor.Line, win.Cursor.Col)
|
||||
}
|
||||
if m.WinW() != 100 || m.WinH() != 30 {
|
||||
t.Errorf("expected size 100x30, got %dx%d", m.WinW(), m.WinH())
|
||||
if win.Width != 100 || win.Height != 30 {
|
||||
t.Errorf("expected size 100x30, got %dx%d", win.Width, win.Height)
|
||||
}
|
||||
|
||||
reg, ok := m.GetRegister('"')
|
||||
if !ok || reg.Type != action.LinewiseRegister {
|
||||
if !ok || reg.Type != core.LinewiseRegister {
|
||||
t.Error("register not set correctly")
|
||||
}
|
||||
})
|
||||
@ -93,25 +99,29 @@ func TestHelperExamples(t *testing.T) {
|
||||
// Old style helpers still work for existing tests
|
||||
tm1 := newTestModelWithLines(t, []string{"a", "b"})
|
||||
m1 := getFinalModel(t, tm1)
|
||||
if len(m1.Lines()) != 2 {
|
||||
buf1 := m1.ActiveBuffer()
|
||||
if buf1.LineCount() != 2 {
|
||||
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)
|
||||
if m2.CursorY() != 1 {
|
||||
win2 := m2.ActiveWindow()
|
||||
if win2.Cursor.Line != 1 {
|
||||
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)
|
||||
if len(m3.Lines()) != 1 {
|
||||
buf3 := m3.ActiveBuffer()
|
||||
if buf3.LineCount() != 1 {
|
||||
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)
|
||||
if m4.WinW() != 50 {
|
||||
win4 := m4.ActiveWindow()
|
||||
if win4.Width != 50 {
|
||||
t.Error("newTestModelWithTermSize failed")
|
||||
}
|
||||
})
|
||||
|
||||
@ -4,11 +4,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"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
|
||||
func sendKeys(tm *teatest.TestModel, keys ...string) {
|
||||
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
|
||||
type TestModelOption func(*testModelConfig)
|
||||
|
||||
type testModelConfig struct {
|
||||
lines []string
|
||||
pos action.Position
|
||||
pos core.Position
|
||||
width int
|
||||
height int
|
||||
regName rune
|
||||
regType action.RegisterType
|
||||
regType core.RegisterType
|
||||
regContent []string
|
||||
}
|
||||
|
||||
@ -56,7 +66,7 @@ func WithLines(lines []string) TestModelOption {
|
||||
}
|
||||
|
||||
// WithCursorPos sets the initial cursor position
|
||||
func WithCursorPos(pos action.Position) TestModelOption {
|
||||
func WithCursorPos(pos core.Position) TestModelOption {
|
||||
return func(c *testModelConfig) {
|
||||
c.pos = pos
|
||||
}
|
||||
@ -71,7 +81,7 @@ func WithTermSize(width, height int) TestModelOption {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c.regName = name
|
||||
c.regType = regType
|
||||
@ -84,7 +94,7 @@ func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel {
|
||||
// Default configuration
|
||||
cfg := testModelConfig{
|
||||
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,
|
||||
height: 24,
|
||||
}
|
||||
@ -94,12 +104,31 @@ func newTestModel(t *testing.T, opts ...TestModelOption) *teatest.TestModel {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
// Create model
|
||||
m := NewModel(cfg.lines, cfg.pos)
|
||||
buf := core.NewBufferBuilder().
|
||||
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 {
|
||||
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))
|
||||
@ -110,21 +139,21 @@ func newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel {
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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})
|
||||
fm := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
|
||||
return fm.(Model)
|
||||
return fm.(*Model)
|
||||
}
|
||||
|
||||
@ -3,13 +3,11 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// NOTE: AI Generated tests
|
||||
|
||||
// Default settings are: Number=true, RelativeNumber=true, TabSize=2, ScrollOff=8
|
||||
|
||||
func TestCommandSetBoolean(t *testing.T) {
|
||||
t.Run("':set nonumber' disables line numbers", func(t *testing.T) {
|
||||
// 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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().Number {
|
||||
if m.ActiveWindow().Options.Number {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if !m.Settings().Number {
|
||||
if !m.ActiveWindow().Options.Number {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().Number {
|
||||
if m.ActiveWindow().Options.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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if !m.Settings().Number {
|
||||
if !m.ActiveWindow().Options.Number {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().RelativeNumber {
|
||||
if m.ActiveWindow().Options.RelativeNumber {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if !m.Settings().RelativeNumber {
|
||||
if !m.ActiveWindow().Options.RelativeNumber {
|
||||
t.Error("expected RelativeNumber to be true after :set rnu")
|
||||
}
|
||||
})
|
||||
@ -95,15 +93,15 @@ func TestCommandSetBoolean(t *testing.T) {
|
||||
|
||||
func TestCommandSetInteger(t *testing.T) {
|
||||
t.Run("':set tabstop=4' sets tab size", func(t *testing.T) {
|
||||
// Default TabSize=2
|
||||
// Default TabStop=2
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
|
||||
sendKeys(tm, ":", "s", "e", "t", " ", "t", "a", "b", "s", "t", "o", "p", "=", "4", "enter")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().TabSize != 4 {
|
||||
t.Errorf("TabSize = %d, want 4", m.Settings().TabSize)
|
||||
if m.Settings().TabStop != 4 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().TabSize != 8 {
|
||||
t.Errorf("TabSize = %d, want 8", m.Settings().TabSize)
|
||||
if m.Settings().TabStop != 8 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().ScrollOff != 5 {
|
||||
t.Errorf("ScrollOff = %d, want 5", m.Settings().ScrollOff)
|
||||
if m.ActiveWindow().Options.ScrollOff != 5 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Settings().ScrollOff != 10 {
|
||||
t.Errorf("ScrollOff = %d, want 10", m.Settings().ScrollOff)
|
||||
if m.ActiveWindow().Options.ScrollOff != 10 {
|
||||
t.Errorf("ScrollOff = %d, want 10", m.ActiveWindow().Options.ScrollOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -153,7 +151,7 @@ func TestCommandModeNavigation(t *testing.T) {
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// 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())
|
||||
}
|
||||
})
|
||||
@ -164,7 +162,7 @@ func TestCommandModeNavigation(t *testing.T) {
|
||||
sendKeys(tm, ":", "esc")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
func TestDeleteChar(t *testing.T) {
|
||||
@ -13,30 +13,30 @@ func TestDeleteChar(t *testing.T) {
|
||||
sendKeys(tm, "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "ello" {
|
||||
t.Errorf("lines[0] = %q, want 'ello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "ello" {
|
||||
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' in middle of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helo" {
|
||||
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helo" {
|
||||
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' at end of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hell" {
|
||||
t.Errorf("lines[0] = %q, want 'hell'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hell" {
|
||||
t.Errorf("lines[0] = %q, want 'hell'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
@ -46,8 +46,8 @@ func TestDeleteChar(t *testing.T) {
|
||||
sendKeys(tm, "x", "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "llo" {
|
||||
t.Errorf("lines[0] = %q, want 'llo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "llo" {
|
||||
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -59,8 +59,8 @@ func TestDeleteCharWithCount(t *testing.T) {
|
||||
sendKeys(tm, "3", "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "lo" {
|
||||
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "lo" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '2x' from middle", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hlo" {
|
||||
t.Errorf("lines[0] = %q, want 'hlo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hlo" {
|
||||
t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -94,11 +94,11 @@ func TestDeleteCharEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -108,30 +108,30 @@ func TestDeleteCharEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' at last char deletes it", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "a" {
|
||||
t.Errorf("Line(0) = %q, want 'a'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "a" {
|
||||
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' with whitespace", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "ab c" {
|
||||
t.Errorf("Line(0) = %q, want 'ab c'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "ab c" {
|
||||
t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
@ -141,11 +141,11 @@ func TestDeleteCharEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 2 {
|
||||
t.Errorf("LineCount() = %d, want 2", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 2 {
|
||||
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(1) != "world" {
|
||||
t.Errorf("Line(1) = %q, want 'world'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "world" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "lo" {
|
||||
t.Errorf("Line(0) = %q, want 'lo'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "lo" {
|
||||
t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' on line with tabs", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "ab" {
|
||||
t.Errorf("Line(0) = %q, want 'ab'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "ab" {
|
||||
t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
@ -177,19 +177,19 @@ func TestDeleteCharEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "5", "x")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'x' in middle preserves surrounding chars", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "abde" {
|
||||
t.Errorf("Line(0) = %q, want 'abde'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "abde" {
|
||||
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) {
|
||||
t.Run("test 'D' deletes to end of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
@ -212,31 +212,31 @@ func TestDeleteToEndOfLine(t *testing.T) {
|
||||
sendKeys(tm, "D")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' at last character", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hell" {
|
||||
t.Errorf("Line(0) = %q, want 'hell'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hell" {
|
||||
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' cursor position after delete", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Cursor should move to last character of remaining text
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -246,39 +246,39 @@ func TestDeleteToEndOfLine(t *testing.T) {
|
||||
sendKeys(tm, "D")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' with count deletes following lines", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %q, want '3'", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %q, want '3'", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "hi" {
|
||||
t.Errorf("Line(1) = %q, want 'hi'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "hi" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %q, want '1'", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %q, want '1'", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -290,51 +290,51 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "D")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' at end of file", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 2 {
|
||||
t.Errorf("LineCount() = %d, want 2", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 2 {
|
||||
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(1) != "" {
|
||||
t.Errorf("Line(1) = %q, want ''", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "" {
|
||||
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' preserves lines above", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "line 1" {
|
||||
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "line 1" {
|
||||
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(2) != "line 3" {
|
||||
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "line 3" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "he" {
|
||||
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
// Cursor should clamp to last char
|
||||
if m.CursorX() != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -344,77 +344,77 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
|
||||
sendKeys(tm, "D")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != " " {
|
||||
t.Errorf("Line(0) = %q, want ' '", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != " " {
|
||||
t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' with tabs", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "a" {
|
||||
t.Errorf("Line(0) = %q, want 'a'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "a" {
|
||||
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' does not affect line below", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(1) != "world" {
|
||||
t.Errorf("Line(1) = %q, want 'world'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "world" {
|
||||
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' with multiple lines", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "first" {
|
||||
t.Errorf("Line(0) = %q, want 'first'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "first" {
|
||||
t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'D' preserves cursor Y position", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// --- Insert Mode Entry Tests ---
|
||||
@ -14,8 +14,8 @@ func TestEnterInsert(t *testing.T) {
|
||||
sendKeys(tm, "i")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.mode != action.InsertMode {
|
||||
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
|
||||
if m.mode != core.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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'i' insert in middle", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'i' cursor moves back on esc", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -59,8 +59,8 @@ func TestEnterInsertAfter(t *testing.T) {
|
||||
sendKeys(tm, "a")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.mode != action.InsertMode {
|
||||
t.Errorf("mode = %d, want InsertMode (%d)", m.mode, action.InsertMode)
|
||||
if m.mode != core.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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hXello" {
|
||||
t.Errorf("lines[0] = %q, want 'hXello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hXello" {
|
||||
t.Errorf("lines[0] = %q, want 'hXello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'a' from middle of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helXlo" {
|
||||
t.Errorf("lines[0] = %q, want 'helXlo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helXlo" {
|
||||
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) {
|
||||
t.Run("test 'I' enters insert mode at line start", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'I' from end of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "Xhello" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helloX" {
|
||||
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloX" {
|
||||
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'A' from middle of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helloX" {
|
||||
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloX" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[1] != "new" {
|
||||
t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "new" {
|
||||
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'o' from middle of file", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[2] != "new" {
|
||||
t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
|
||||
if m.ActiveBuffer().Lines[2] != "new" {
|
||||
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'o' at end of file", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[2] != "new" {
|
||||
t.Errorf("lines[2] = %q, want 'new'", m.lines[2])
|
||||
if m.ActiveBuffer().Lines[2] != "new" {
|
||||
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
@ -186,11 +186,11 @@ func TestOpenLineBelow(t *testing.T) {
|
||||
sendKeys(tm, "o", "esc")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 1 {
|
||||
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.cursor.x != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if m.lines[i] != "x" {
|
||||
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
|
||||
if m.ActiveBuffer().Lines[i] != "x" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[1] != "ab" {
|
||||
t.Errorf("lines[1] = %q, want 'ab'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "ab" {
|
||||
t.Errorf("lines[1] = %q, want 'ab'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.lines[2] != "ab" {
|
||||
t.Errorf("lines[2] = %q, want 'ab'", m.lines[2])
|
||||
if m.ActiveBuffer().Lines[2] != "ab" {
|
||||
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) {
|
||||
t.Run("test 'O' creates line above", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[1] != "new" {
|
||||
t.Errorf("lines[1] = %q, want 'new'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "new" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "new" {
|
||||
t.Errorf("lines[0] = %q, want 'new'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "new" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
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) {
|
||||
t.Run("test '3O' creates 3 lines above", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 4 {
|
||||
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if m.lines[i] != "x" {
|
||||
t.Errorf("lines[%d] = %q, want 'x'", i, m.lines[i])
|
||||
if m.ActiveBuffer().Lines[i] != "x" {
|
||||
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) {
|
||||
t.Run("test enter splits line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.lines[1] != " world" {
|
||||
t.Errorf("lines[1] = %q, want ' world'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != " world" {
|
||||
t.Errorf("lines[1] = %q, want ' world'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test enter at end of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.lines[1] != "" {
|
||||
t.Errorf("lines[1] = %q, want ''", m.lines[1])
|
||||
if m.ActiveBuffer().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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.lines[1] != "hello" {
|
||||
t.Errorf("lines[1] = %q, want 'hello'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "hello" {
|
||||
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) {
|
||||
t.Run("test backspace deletes character", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helo" {
|
||||
t.Errorf("lines[0] = %q, want 'helo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helo" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "helloworld" {
|
||||
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloworld" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test multiple backspaces", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "he" {
|
||||
t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "he" {
|
||||
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) {
|
||||
t.Run("test delete deletes character", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "word" {
|
||||
t.Errorf("lines[0] = %q, want 'word'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "word" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "helloworld" {
|
||||
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloworld" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.lines) != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", len(m.lines))
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "world" {
|
||||
t.Errorf("lines[0] = %q, want 'world'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "world" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test multiple delete", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "ho" {
|
||||
t.Errorf("lines[0] = %q, want 'he'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "ho" {
|
||||
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) {
|
||||
t.Run("test left arrow moves cursor left", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test right arrow moves cursor right", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test up arrow moves cursor up", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.lines[1] != "world" {
|
||||
t.Errorf("lines[1] = %q, want 'world'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "world" {
|
||||
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test down arrow moves cursor down", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.lines[1] != "woXrld" {
|
||||
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "woXrld" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "Xhello" {
|
||||
t.Errorf("lines[0] = %q, want 'Xhello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "Xhello" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "helloX" {
|
||||
t.Errorf("lines[0] = %q, want 'helloX'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloX" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "heXllo" {
|
||||
t.Errorf("lines[0] = %q, want 'heXllo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "heXllo" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hiX" {
|
||||
t.Errorf("lines[0] = %q, want 'hiX'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hiX" {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[1] != "hiX" {
|
||||
t.Errorf("lines[1] = %q, want 'hiX'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "hiX" {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[1] != "woXrld" {
|
||||
t.Errorf("lines[1] = %q, want 'woXrld'", m.lines[1])
|
||||
if m.ActiveBuffer().Lines[1] != "woXrld" {
|
||||
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) {
|
||||
t.Run("test 'ctrl+w' deletes word", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello " {
|
||||
t.Errorf("lines[0] = %q, want 'hello '", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello " {
|
||||
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 5 {
|
||||
t.Errorf("CursorX() = %d, want '5'", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 5 {
|
||||
t.Errorf("CursorX() = %d, want '5'", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' deletes word with whitespace", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' deletes word until period", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello wo" {
|
||||
t.Errorf("lines[0] = %q, want 'hello wo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello wo" {
|
||||
t.Errorf("lines[0] = %q, want 'hello wo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 7 {
|
||||
t.Errorf("CursorX() = %d, want '7'", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 7 {
|
||||
t.Errorf("CursorX() = %d, want '7'", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' deletes line when blank", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want '1'", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want '0'", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want '1'", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %s, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want '0'", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' from middle of word", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "lo" {
|
||||
t.Errorf("lines[0] = %q, want 'lo'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "lo" {
|
||||
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' deletes word after punctuation", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "..." {
|
||||
t.Errorf("lines[0] = %q, want '...'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "..." {
|
||||
t.Errorf("lines[0] = %q, want '...'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' with tabs as whitespace", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "hello\t" {
|
||||
t.Errorf("lines[0] = %q, want 'hello\\t'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "hello\t" {
|
||||
t.Errorf("lines[0] = %q, want 'hello\\t'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 5 {
|
||||
t.Errorf("CursorX() = %d, want 5", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 5 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.lines[0] != "helloworld" {
|
||||
t.Errorf("lines[0] = %q, want 'helloworld'", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "helloworld" {
|
||||
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+w' with underscore in word", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.lines[0])
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
func TestMoveDown(t *testing.T) {
|
||||
@ -12,8 +12,8 @@ func TestMoveDown(t *testing.T) {
|
||||
sendKeys(tm, "j")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 1 {
|
||||
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 4 {
|
||||
t.Errorf("cursor.y = %d, want 4", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 4 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 5 {
|
||||
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
t.Errorf("cursor.y = %d, want 5", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -44,8 +44,8 @@ func TestMoveDownWithCount(t *testing.T) {
|
||||
sendKeys(tm, "3", "j")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 3 {
|
||||
t.Errorf("cursor.y = %d, want 3", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 3 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 5 {
|
||||
t.Errorf("cursor.y = %d, want 5", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
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"}
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[1])
|
||||
if m.cursor.x != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveUp(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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 1 {
|
||||
t.Errorf("cursor.y = %d, want 1", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("cursor.y = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 0 {
|
||||
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 0 {
|
||||
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("cursor.y = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveUpWithCount(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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 2 {
|
||||
t.Errorf("cursor.y = %d, want 2", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("cursor.y = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.y != 0 {
|
||||
t.Errorf("cursor.y = %d, want 0", m.cursor.y)
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
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"}
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.cursor.x != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -170,8 +170,8 @@ func TestMoveRight(t *testing.T) {
|
||||
sendKeys(tm, "l")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 1 {
|
||||
t.Errorf("cursor.x = %d, want 1", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 4 {
|
||||
t.Errorf("cursor.x = %d, want 4", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("cursor.x = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -192,8 +192,8 @@ func TestMoveRight(t *testing.T) {
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.cursor.x != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
|
||||
if m.ActiveWindow().Cursor.Col != 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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("cursor.x = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -216,30 +216,30 @@ func TestMoveRightWithCount(t *testing.T) {
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.cursor.x != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.cursor.x, want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("cursor.x = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveLeft(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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveLeftWithCount(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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("cursor.x = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.cursor.x != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.cursor.x)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("cursor.x = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// --- G and gg Tests ---
|
||||
@ -14,43 +14,43 @@ func TestMoveToBottom(t *testing.T) {
|
||||
sendKeys(tm, "G")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'G' clamps CursorX()", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
want := len(lines[1])
|
||||
if m.CursorX() != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
@ -60,30 +60,30 @@ func TestMoveToBottom(t *testing.T) {
|
||||
sendKeys(tm, "G")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveToTop(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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
@ -92,23 +92,23 @@ func TestMoveToTop(t *testing.T) {
|
||||
sendKeys(tm, "g", "g")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'gg' clamps CursorX()", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
want := len(lines[0])
|
||||
if m.CursorX() != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
|
||||
if m.ActiveWindow().Cursor.Col != 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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '0' from end of line", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -142,8 +142,8 @@ func TestMoveToLineStart(t *testing.T) {
|
||||
sendKeys(tm, "0")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
@ -153,18 +153,18 @@ func TestMoveToLineStart(t *testing.T) {
|
||||
sendKeys(tm, "0")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -177,32 +177,32 @@ func TestMoveToLineEnd(t *testing.T) {
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.CursorX() != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '$' from middle of line", func(t *testing.T) {
|
||||
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, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.CursorX() != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '$' already at end", func(t *testing.T) {
|
||||
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, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
want := len(lines[0])
|
||||
if m.CursorX() != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.CursorX(), want)
|
||||
if m.ActiveWindow().Cursor.Col != want {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
|
||||
}
|
||||
})
|
||||
|
||||
@ -212,18 +212,18 @@ func TestMoveToLineEnd(t *testing.T) {
|
||||
sendKeys(tm, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
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, "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -231,78 +231,78 @@ func TestMoveToLineEnd(t *testing.T) {
|
||||
func TestMoveToLineContentStart(t *testing.T) {
|
||||
t.Run("test '_' from middle of line with no leading whitespace", func(t *testing.T) {
|
||||
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, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '_' from middle of line with leading whitespace", func(t *testing.T) {
|
||||
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, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '_' from start of line with leading whitespace", func(t *testing.T) {
|
||||
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, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
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) {
|
||||
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, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '_' from middle of line with only whitespace", func(t *testing.T) {
|
||||
lines := []string{" "}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||
sendKeys(tm, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '_' from end of line with only whitespace", func(t *testing.T) {
|
||||
lines := []string{" "}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
|
||||
sendKeys(tm, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '_' on empty line", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||
sendKeys(tm, "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -310,78 +310,78 @@ func TestMoveToLineContentStart(t *testing.T) {
|
||||
func TestMoveToLineContentStartAlias(t *testing.T) {
|
||||
t.Run("test '^' from middle of line with no leading whitespace", func(t *testing.T) {
|
||||
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, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '^' from middle of line with leading whitespace", func(t *testing.T) {
|
||||
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, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '^' from start of line with leading whitespace", func(t *testing.T) {
|
||||
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, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
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) {
|
||||
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, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '^' from middle of line with only whitespace", func(t *testing.T) {
|
||||
lines := []string{" "}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 2, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||
sendKeys(tm, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '^' from end of line with only whitespace", func(t *testing.T) {
|
||||
lines := []string{" "}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 4, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
|
||||
sendKeys(tm, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '^' on empty line", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||
sendKeys(tm, "^")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -395,70 +395,70 @@ func TestMoveToLineContentStartAlias(t *testing.T) {
|
||||
func TestMoveToColumn(t *testing.T) {
|
||||
t.Run("test '|' alone goes to column 1 (index 0)", func(t *testing.T) {
|
||||
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, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// | with no count = 1| = column 1 = index 0
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '1|' goes to column 1 (index 0)", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '5|' goes to column 5 (index 4)", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 5 = index 4 (the 'o' in hello)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '10|' goes to column 10 (index 9)", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 10 = index 9 (the 'l' in world)
|
||||
if m.CursorX() != 9 {
|
||||
t.Errorf("CursorX() = %d, want 9", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 9 {
|
||||
t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '|' already at column 1", func(t *testing.T) {
|
||||
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, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '5|' already at column 5", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -466,71 +466,71 @@ func TestMoveToColumn(t *testing.T) {
|
||||
func TestMoveToColumnClamp(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
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||
sendKeys(tm, "2", "0", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 20 exceeds line length, should clamp to last char (index 4)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '100|' clamps to end of line", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Should clamp to last char (index 10)
|
||||
if m.CursorX() != 10 {
|
||||
t.Errorf("CursorX() = %d, want 10", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 10 {
|
||||
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '6|' clamps on 5-char line", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 6 = index 5, but line only has 5 chars (max index 4)
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '|' on empty line stays at 0", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, action.Position{Col: 0, Line: 0})
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||
sendKeys(tm, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '5|' on empty line stays at 0", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '3|' on 2-char line clamps", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 3 = index 2, but line only has 2 chars (max index 1)
|
||||
if m.CursorX() != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -538,37 +538,37 @@ func TestMoveToColumnClamp(t *testing.T) {
|
||||
func TestMoveToColumnPreservesLine(t *testing.T) {
|
||||
t.Run("test '|' preserves Y position", func(t *testing.T) {
|
||||
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, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '5|' preserves Y position", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '|' on different lines", func(t *testing.T) {
|
||||
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, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -576,49 +576,49 @@ func TestMoveToColumnPreservesLine(t *testing.T) {
|
||||
func TestMoveToColumnWithWhitespace(t *testing.T) {
|
||||
t.Run("test '5|' with leading whitespace", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 5 = index 4 = 'h' in " hello"
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '3|' lands on whitespace", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 3 = index 2 = third space
|
||||
if m.CursorX() != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '|' with tabs", func(t *testing.T) {
|
||||
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, "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// | goes to column 1 = index 0 = the tab
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test '2|' with tabs", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Column 2 = index 1 = 'h' in "\thello"
|
||||
if m.CursorX() != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -626,34 +626,34 @@ func TestMoveToColumnWithWhitespace(t *testing.T) {
|
||||
func TestMoveToColumnWithOperator(t *testing.T) {
|
||||
t.Run("test 'd|' deletes to column 1", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes from column 1 to current position (exclusive), so "hello" deleted
|
||||
// Result depends on inclusive/exclusive behavior
|
||||
// In Vim: d| from col 5 deletes chars 0-4, leaving " world"
|
||||
if m.Line(0) != " world" {
|
||||
t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != " world" {
|
||||
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'd5|' deletes to column 5", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes from cursor (0) to column 5 (index 4), so "hell" deleted
|
||||
// Result: "o world"
|
||||
if m.Line(0) != "o world" {
|
||||
t.Errorf("Line(0) = %q, want 'o world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "o world" {
|
||||
t.Errorf("Line(0) = %q, want 'o world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'y5|' yanks to column 5", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
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) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
@ -687,41 +687,41 @@ func TestMoveToColumnWithOperator(t *testing.T) {
|
||||
func TestMoveToColumnInVisualMode(t *testing.T) {
|
||||
t.Run("test 'v5|' selects to column 5", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v|' selects backward to column 1", func(t *testing.T) {
|
||||
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", "|")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 5 {
|
||||
t.Errorf("AnchorX() = %d, want 5", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 5 {
|
||||
t.Errorf("AnchorX() = %d, want 5", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v5|d' deletes selection to column 5", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Visual selection from 0 to 4 inclusive, delete "hello"
|
||||
if m.Line(0) != " world" {
|
||||
t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != " world" {
|
||||
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
@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// NOTE: AI Generated tests
|
||||
@ -22,12 +22,12 @@ func TestScrollBasic(t *testing.T) {
|
||||
t.Run("small file does not scroll", func(t *testing.T) {
|
||||
// 10 lines, viewport 24 -> no scrolling needed
|
||||
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
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
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)
|
||||
// Moving to line 11+ should trigger scroll
|
||||
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
|
||||
for range 15 {
|
||||
@ -45,21 +45,21 @@ func TestScrollBasic(t *testing.T) {
|
||||
}
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
// With scrollOff=8, viewport=19, cursor at 15 means:
|
||||
// cursor should be at position 10 from top (19-1-8=10)
|
||||
// so scrollY = 15 - 10 = 5
|
||||
if m.ScrollY() < 1 {
|
||||
t.Errorf("ScrollY() = %d, want > 0 (should have scrolled)", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY < 1 {
|
||||
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) {
|
||||
// Start at line 20, move up
|
||||
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)
|
||||
// Then move up 15 times
|
||||
@ -68,43 +68,43 @@ func TestScrollBasic(t *testing.T) {
|
||||
}
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 5 {
|
||||
t.Errorf("CursorY() = %d, want 5", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
// Cursor at line 5 with scrollOff=8 means scrollY should be 0
|
||||
// (can't scroll negative, and cursor is within safe zone from top)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("G jumps to bottom and scrolls", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 99 {
|
||||
t.Errorf("CursorY() = %d, want 99", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 99 {
|
||||
t.Errorf("CursorY() = %d, want 99", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
// With 100 lines and viewport 18 (height - 2 for status + command bar),
|
||||
// max scrollY = 100 - 18 = 82
|
||||
if m.ScrollY() != 82 {
|
||||
t.Errorf("ScrollY() = %d, want 82", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 82 {
|
||||
t.Errorf("ScrollY() = %d, want 82", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gg jumps to top and scrolls", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -112,7 +112,7 @@ func TestScrollBasic(t *testing.T) {
|
||||
func TestScrollEdgeCases(t *testing.T) {
|
||||
t.Run("scrollY never goes negative", func(t *testing.T) {
|
||||
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
|
||||
for range 5 {
|
||||
@ -120,27 +120,27 @@ func TestScrollEdgeCases(t *testing.T) {
|
||||
}
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() < 0 {
|
||||
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY < 0 {
|
||||
t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scrollY clamped to max scroll", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// 30 lines, viewport 18 (height - 2) -> maxScroll = 30 - 18 = 12
|
||||
maxScroll := 30 - 18
|
||||
if m.ScrollY() > maxScroll {
|
||||
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
|
||||
if m.ActiveWindow().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) {
|
||||
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
|
||||
sendKeys(tm, "d", "d", "d", "d")
|
||||
@ -148,9 +148,9 @@ func TestScrollEdgeCases(t *testing.T) {
|
||||
m := getFinalModel(t, tm)
|
||||
// Cursor should still be visible
|
||||
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)",
|
||||
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
|
||||
// After ctrl+d: newScrollY=14, newCursorY=14+15=29
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 14 {
|
||||
t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 14 {
|
||||
t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 29 {
|
||||
t.Errorf("CursorY() = %d, want 29", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 29 {
|
||||
t.Errorf("CursorY() = %d, want 29", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ctrl+d preserves cursor relative position in viewport", func(t *testing.T) {
|
||||
// relY=15 before and after
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
relY := m.CursorY() - m.ScrollY()
|
||||
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY
|
||||
if relY != 15 {
|
||||
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
|
||||
// After ctrl+d: newScrollY=14, newCursorY=14+8=22
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 14 {
|
||||
t.Errorf("ScrollY() = %d, want 14", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 14 {
|
||||
t.Errorf("ScrollY() = %d, want 14", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 22 {
|
||||
t.Errorf("CursorY() = %d, want 22", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 22 {
|
||||
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
|
||||
// After ctrl+d: newScrollY clamped to 12, relY=23>19 clamped to 19, newCursorY=31
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
maxScroll := 40 - 28
|
||||
if m.ScrollY() > maxScroll {
|
||||
t.Errorf("ScrollY() = %d, want <= %d", m.ScrollY(), maxScroll)
|
||||
if m.ActiveWindow().ScrollY > maxScroll {
|
||||
t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll)
|
||||
}
|
||||
if m.CursorY() != 31 {
|
||||
t.Errorf("CursorY() = %d, want 31", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 31 {
|
||||
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
|
||||
// relY=0 < scrollOff, clamp to 8; newCursorY=0+8=8
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 8 {
|
||||
t.Errorf("CursorY() = %d, want 8", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 8 {
|
||||
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++ {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 22 {
|
||||
t.Errorf("CursorY() = %d, want 22", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 22 {
|
||||
t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.CursorX() > len(m.Line(m.CursorY())) {
|
||||
t.Errorf("CursorX() = %d exceeds line length %d", 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.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 #2: scrollY=28, cursorY=43, relY=15
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 28 {
|
||||
t.Errorf("ScrollY() = %d, want 28", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 28 {
|
||||
t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 43 {
|
||||
t.Errorf("CursorY() = %d, want 43", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 43 {
|
||||
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
|
||||
// After ctrl+u: newScrollY=17, newCursorY=17+19=36
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 17 {
|
||||
t.Errorf("ScrollY() = %d, want 17", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 17 {
|
||||
t.Errorf("ScrollY() = %d, want 17", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 36 {
|
||||
t.Errorf("CursorY() = %d, want 36", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 36 {
|
||||
t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ctrl+u preserves cursor relative position in viewport", func(t *testing.T) {
|
||||
// relY=19 before and after
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
relY := m.CursorY() - m.ScrollY()
|
||||
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY
|
||||
if relY != 19 {
|
||||
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)
|
||||
// ctrl+u: newScrollY=max(0,-14)=0, relY=10 preserved, cursorY=10
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() < 0 {
|
||||
t.Errorf("ScrollY() = %d, want >= 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY < 0 {
|
||||
t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 10 {
|
||||
t.Errorf("CursorY() = %d, want 10", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 10 {
|
||||
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
|
||||
// ctrl+u: newScrollY=0; relY clamp to 8; newCursorY=8
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 8 {
|
||||
t.Errorf("CursorY() = %d, want 8", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 8 {
|
||||
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 #2: newScrollY=33, cursorY=52
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 33 {
|
||||
t.Errorf("ScrollY() = %d, want 33", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 33 {
|
||||
t.Errorf("ScrollY() = %d, want 33", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 52 {
|
||||
t.Errorf("CursorY() = %d, want 52", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 52 {
|
||||
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+u: newScrollY=max(0,14-14)=0, cursorY=0+15=15
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 15 {
|
||||
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+d: scrollY=31, cursorY=50, relY=19
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 31 {
|
||||
t.Errorf("ScrollY() = %d, want 31", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 31 {
|
||||
t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 50 {
|
||||
t.Errorf("CursorY() = %d, want 50", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 50 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ScrollY() != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY != 0 {
|
||||
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
if m.CursorY() != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 15 {
|
||||
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) {
|
||||
t.Run("5j scrolls appropriately", func(t *testing.T) {
|
||||
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
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 15 {
|
||||
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
// Should have scrolled since we moved past the safe zone
|
||||
if m.ScrollY() == 0 {
|
||||
t.Errorf("ScrollY() = %d, want > 0", m.ScrollY())
|
||||
if m.ActiveWindow().ScrollY == 0 {
|
||||
t.Errorf("ScrollY() = %d, want > 0", m.ActiveWindow().ScrollY)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("5k scrolls appropriately", func(t *testing.T) {
|
||||
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
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 10 {
|
||||
t.Errorf("CursorY() = %d, want 10", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 10 {
|
||||
t.Errorf("CursorY() = %d, want 10", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ package editor
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// NOTE: Lots of AI tests here
|
||||
@ -13,18 +13,18 @@ import (
|
||||
func TestVisualModeSelectionState(t *testing.T) {
|
||||
t.Run("test 'v' enters visual mode and sets anchor at cursor", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.VisualMode {
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.AnchorX() != 3 {
|
||||
t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 3 {
|
||||
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.AnchorY() != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
@ -34,73 +34,73 @@ func TestVisualModeSelectionState(t *testing.T) {
|
||||
sendKeys(tm, "v", "l")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.CursorX() != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vh' creates backward selection", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 3 {
|
||||
t.Errorf("AnchorX() = %d, want 3", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 3 {
|
||||
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.CursorX() != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vj' extends selection down", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 2 {
|
||||
t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 2 {
|
||||
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.AnchorY() != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.VisualLineMode {
|
||||
if m.Mode() != core.VisualLineMode {
|
||||
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
|
||||
}
|
||||
if m.AnchorY() != 1 {
|
||||
t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 1 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.VisualBlockMode {
|
||||
if m.Mode() != core.VisualBlockMode {
|
||||
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
|
||||
}
|
||||
if m.AnchorX() != 2 {
|
||||
t.Errorf("AnchorX() = %d, want 2", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 2 {
|
||||
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.AnchorY() != 1 {
|
||||
t.Errorf("AnchorY() = %d, want 1", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 1 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
|
||||
}
|
||||
})
|
||||
@ -125,11 +125,11 @@ func TestVisualModeDelete(t *testing.T) {
|
||||
sendKeys(tm, "v", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "ello" {
|
||||
t.Errorf("Line(0) = %q, want \"ello\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "ello" {
|
||||
t.Errorf("Line(0) = %q, want \"ello\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "o world" {
|
||||
t.Errorf("Line(0) = %q, want \"o world\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "o world" {
|
||||
t.Errorf("Line(0) = %q, want \"o world\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
// anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho"
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "ho" {
|
||||
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "ho" {
|
||||
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 1 {
|
||||
t.Errorf("CursorX() = %d, want 1", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
// start=(2,0), end=(2,1) → prefix="he", suffix="ld" → "held"
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "held" {
|
||||
t.Errorf("Line(0) = %q, want \"held\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "held" {
|
||||
t.Errorf("Line(0) = %q, want \"held\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorX() != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
@ -189,14 +189,14 @@ func TestVisualModeDelete(t *testing.T) {
|
||||
sendKeys(tm, "V", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "world" {
|
||||
t.Errorf("Line(0) = %q, want \"world\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "world" {
|
||||
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
@ -206,29 +206,29 @@ func TestVisualModeDelete(t *testing.T) {
|
||||
sendKeys(tm, "V", "j", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "testing" {
|
||||
t.Errorf("Line(0) = %q, want \"testing\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "testing" {
|
||||
t.Errorf("Line(0) = %q, want \"testing\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'Vkd' deletes two lines with backward selection", func(t *testing.T) {
|
||||
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")
|
||||
|
||||
// anchor=line2, cursor=line1 → normalized start=line1, end=line2 → delete both
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "hello" {
|
||||
t.Errorf("Line(0) = %q, want \"hello\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
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"
|
||||
// "world"[:0]+"world"[2:] = "rld"
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "llo" {
|
||||
t.Errorf("Line(0) = %q, want \"llo\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "llo" {
|
||||
t.Errorf("Line(0) = %q, want \"llo\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "rld" {
|
||||
t.Errorf("Line(1) = %q, want \"rld\"", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "rld" {
|
||||
t.Errorf("Line(1) = %q, want \"rld\"", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
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) {
|
||||
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")
|
||||
|
||||
// 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"
|
||||
// "world"[:1]+"world"[4:] = "w"+"d" = "wd"
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "ho" {
|
||||
t.Errorf("Line(0) = %q, want \"ho\"", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "ho" {
|
||||
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "wd" {
|
||||
t.Errorf("Line(1) = %q, want \"wd\"", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "wd" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "w")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// w moves to start of "world" at col 6
|
||||
if m.CursorX() != 6 {
|
||||
t.Errorf("CursorX() = %d, want 6", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 6 {
|
||||
t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vwd' deletes word plus space", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "w", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes from 0 to 6 inclusive = "hello w", leaves "orld"
|
||||
if m.Line(0) != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 've' selects to end of word", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "e")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// e moves to end of "hello" at col 4
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ved' deletes word", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "e", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes "hello"
|
||||
if m.Line(0) != " world" {
|
||||
t.Errorf("Line(0) = %q, want ' world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != " world" {
|
||||
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vb' selects backward word", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 6 {
|
||||
t.Errorf("AnchorX() = %d, want 6", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 6 {
|
||||
t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// b moves to start of "hello" at col 0
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vbd' deletes backward to word start", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes from "h" (0) to "w" (6) inclusive
|
||||
if m.Line(0) != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v2w' selects two words", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"one two three"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "2", "w")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// 2w moves past "one " and "two " to start of "three" at col 8
|
||||
if m.CursorX() != 8 {
|
||||
t.Errorf("CursorX() = %d, want 8", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 8 {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "$")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// $ moves past end of line
|
||||
if m.CursorX() != 11 {
|
||||
t.Errorf("CursorX() = %d, want 11", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 11 {
|
||||
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v$d' deletes to end of line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello " {
|
||||
t.Errorf("Line(0) = %q, want 'hello '", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello " {
|
||||
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v0' selects to beginning of line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 6}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 6}),
|
||||
)
|
||||
sendKeys(tm, "v", "0")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 6 {
|
||||
t.Errorf("AnchorX() = %d, want 6", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 6 {
|
||||
t.Errorf("AnchorX() = %d, want 6", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v0d' deletes to beginning of line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Deletes from 'h' (0) to 'w' (6) inclusive
|
||||
if m.Line(0) != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "orld" {
|
||||
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v_' selects to first non-whitespace", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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", "_")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorX() != 10 {
|
||||
t.Errorf("AnchorX() = %d, want 10", m.AnchorX())
|
||||
if m.ActiveWindow().Anchor.Col != 10 {
|
||||
t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
// _ moves to first non-ws at col 4
|
||||
if m.CursorX() != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vG' selects to bottom of file", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorY() != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.CursorY() != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vGd' deletes to bottom of file", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// 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"
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "lin 3" {
|
||||
t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "lin 3" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorY() != 2 {
|
||||
t.Errorf("AnchorY() = %d, want 2", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 2 {
|
||||
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'vggd' deletes to top of file", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// gg goes to first line at same col, deletes selection
|
||||
// Keeps "lin" from first line + " 3" from last line = "lin 3"
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "lin 3" {
|
||||
t.Errorf("Line(0) = %q, want 'lin 3'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "lin 3" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorY() != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.CursorY() != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'VGd' deletes all lines", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// All lines deleted, should have empty buffer
|
||||
if m.LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'Vgg' selects lines to top", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.AnchorY() != 2 {
|
||||
t.Errorf("AnchorY() = %d, want 2", m.AnchorY())
|
||||
if m.ActiveWindow().Anchor.Line != 2 {
|
||||
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -24,7 +24,7 @@ func TestYankLineBasic(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "line 1" {
|
||||
t.Errorf("Line(0) = %q, want 'line 1'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "line 1" {
|
||||
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "line 2" {
|
||||
t.Errorf("Line(1) = %q, want 'line 2'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "line 2" {
|
||||
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.Line(2) != "line 3" {
|
||||
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "line 3" {
|
||||
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yy does not move cursor", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.CursorX() != 3 {
|
||||
t.Errorf("CursorX() = %d, want 3", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yy from middle of file", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"first", "second", "third", "fourth"}),
|
||||
WithCursorPos(action.Position{Line: 2, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 2, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -90,7 +90,7 @@ func TestYankLineBasic(t *testing.T) {
|
||||
t.Run("yy at last line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -106,7 +106,7 @@ func TestYankLineWithCount(t *testing.T) {
|
||||
t.Run("2yy yanks two lines", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -126,7 +126,7 @@ func TestYankLineWithCount(t *testing.T) {
|
||||
t.Run("3yy yanks three lines", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -149,7 +149,7 @@ func TestYankLineWithCount(t *testing.T) {
|
||||
t.Run("yy with count overflow clamps to available lines", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
|
||||
@ -169,13 +169,13 @@ func TestYankLineWithCount(t *testing.T) {
|
||||
t.Run("yy with count does not modify buffer", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"line 1", "", "line 3"}),
|
||||
WithCursorPos(action.Position{Line: 1, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 1, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -201,7 +201,7 @@ func TestYankLineEdgeCases(t *testing.T) {
|
||||
t.Run("yy on single line buffer", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"only line"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -215,7 +215,7 @@ func TestYankLineEdgeCases(t *testing.T) {
|
||||
t.Run("yy preserves whitespace", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{" indented", "\ttabbed", " spaces "}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -250,7 +250,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -276,7 +276,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -302,7 +302,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -328,7 +328,7 @@ func TestYankWithLinewiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -371,26 +371,26 @@ func TestYankWithLinewiseMotions(t *testing.T) {
|
||||
t.Run("yj does not move cursor", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorY() != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.CursorY())
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yG does not modify buffer", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "w")
|
||||
|
||||
@ -412,7 +412,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// 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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "e")
|
||||
|
||||
@ -436,7 +436,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// 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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -460,7 +460,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// 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) {
|
||||
tm := newTestModel(t,
|
||||
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", "$")
|
||||
|
||||
@ -484,7 +484,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -507,7 +507,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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", "_")
|
||||
|
||||
@ -530,7 +530,7 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// 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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -565,26 +565,26 @@ func TestYankWithCharwiseMotions(t *testing.T) {
|
||||
t.Run("yw does not move cursor", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "w")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.CursorX() != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.CursorX())
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yw does not modify buffer", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "w")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello world" {
|
||||
t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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"
|
||||
|
||||
@ -606,7 +606,7 @@ func TestYankVisualCharwise(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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"
|
||||
|
||||
@ -629,7 +629,7 @@ func TestYankVisualCharwise(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// Multi-line charwise yank
|
||||
@ -641,12 +641,12 @@ func TestYankVisualCharwise(t *testing.T) {
|
||||
t.Run("visual yank exits visual mode", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "v", "l", "l", "y")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello world" {
|
||||
t.Errorf("Line(0) = %q, want 'hello world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
|
||||
@ -678,7 +678,7 @@ func TestYankVisualLinewise(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"line 1", "line 2"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "V", "y")
|
||||
|
||||
@ -704,7 +704,7 @@ func TestYankVisualLinewise(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
|
||||
@ -742,12 +742,12 @@ func TestYankVisualLinewise(t *testing.T) {
|
||||
t.Run("visual line yank exits visual mode", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"line 1", "line 2"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "V", "y")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
|
||||
@ -766,7 +766,7 @@ func TestYankVisualBlock(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
// 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) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != action.NormalMode {
|
||||
if m.Mode() != core.NormalMode {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
|
||||
@ -824,7 +824,7 @@ func TestYankRegisterBehavior(t *testing.T) {
|
||||
t.Run("yy updates register 0 and unnamed register", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"line 1", "line 2"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -858,7 +858,7 @@ func TestYankRegisterBehavior(t *testing.T) {
|
||||
t.Run("multiple yanks shift numbered registers", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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, "j")
|
||||
@ -894,18 +894,18 @@ func TestYankRegisterBehavior(t *testing.T) {
|
||||
t.Run("yank then paste uses correct content", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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, "k") // move up
|
||||
sendKeys(tm, "p") // paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(1) != "to copy" {
|
||||
t.Errorf("Line(1) = %q, want 'to copy'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "to copy" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"line 1", " ", "line 3"}),
|
||||
WithCursorPos(action.Position{Line: 1, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 1, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -938,7 +938,7 @@ func TestYankEdgeCases(t *testing.T) {
|
||||
t.Run("yw at end of line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -959,7 +959,7 @@ func TestYankEdgeCases(t *testing.T) {
|
||||
t.Run("y$ at beginning of line yanks entire line content", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello world"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"hello"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "0")
|
||||
|
||||
@ -1001,7 +1001,7 @@ func TestYankEdgeCases(t *testing.T) {
|
||||
longLine := strings.Repeat("a", 1000)
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{longLine}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y")
|
||||
|
||||
@ -1021,7 +1021,7 @@ func TestYankEdgeCases(t *testing.T) {
|
||||
t.Run("yy with special characters", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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")
|
||||
|
||||
@ -1047,178 +1047,178 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
|
||||
t.Run("visual charwise yank then paste single line", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "v", "l", "l", "l", "l", "y", "$", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello worldhello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello worldhello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual charwise yank then paste before", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "v", "$", "y", "0", "P")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "worldhello world" {
|
||||
t.Errorf("Line(0) = %q, want 'worldhello world'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "worldhello world" {
|
||||
t.Errorf("Line(0) = %q, want 'worldhello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual line yank then paste", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "V", "y", "j", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 4 {
|
||||
t.Errorf("LineCount() = %d, want 4", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 4 {
|
||||
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(2) != "line 1" {
|
||||
t.Errorf("Line(2) = %q, want 'line 1'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "line 1" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "V", "j", "y", "G", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 6 {
|
||||
t.Errorf("LineCount() = %d, want 6", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 6 {
|
||||
t.Errorf("LineCount() = %d, want 6", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(4) != "line 1" {
|
||||
t.Errorf("Line(4) = %q, want 'line 1'", m.Line(4))
|
||||
if m.ActiveBuffer().Lines[4] != "line 1" {
|
||||
t.Errorf("Line(4) = %q, want 'line 1'", m.ActiveBuffer().Lines[4])
|
||||
}
|
||||
if m.Line(5) != "line 2" {
|
||||
t.Errorf("Line(5) = %q, want 'line 2'", m.Line(5))
|
||||
if m.ActiveBuffer().Lines[5] != "line 2" {
|
||||
t.Errorf("Line(5) = %q, want 'line 2'", m.ActiveBuffer().Lines[5])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual line yank then paste before", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "V", "y", "g", "g", "P")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 4 {
|
||||
t.Errorf("LineCount() = %d, want 4", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 4 {
|
||||
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "line 3" {
|
||||
t.Errorf("Line(0) = %q, want 'line 3'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "line 3" {
|
||||
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "line 1" {
|
||||
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "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) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"original", "other"}),
|
||||
WithCursorPos(action.Position{Line: 0, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "original" {
|
||||
t.Errorf("Line(0) = %q, want 'original'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "original" {
|
||||
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "original" {
|
||||
t.Errorf("Line(1) = %q, want 'original'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "original" {
|
||||
t.Errorf("Line(1) = %q, want 'original'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.Line(2) != "other" {
|
||||
t.Errorf("Line(2) = %q, want 'other'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "other" {
|
||||
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yy then P duplicates line above", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
WithLines([]string{"original", "other"}),
|
||||
WithCursorPos(action.Position{Line: 1, Col: 0}),
|
||||
WithCursorPos(core.Position{Line: 1, Col: 0}),
|
||||
)
|
||||
sendKeys(tm, "y", "y", "P")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "original" {
|
||||
t.Errorf("Line(0) = %q, want 'original'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "original" {
|
||||
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "other" {
|
||||
t.Errorf("Line(1) = %q, want 'other'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "other" {
|
||||
t.Errorf("Line(1) = %q, want 'other'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.Line(2) != "other" {
|
||||
t.Errorf("Line(2) = %q, want 'other'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "other" {
|
||||
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("yw then p pastes word after cursor", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "y", "w", "$", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello worldhello " {
|
||||
t.Errorf("Line(0) = %q, want 'hello worldhello '", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello worldhello " {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "y", "e", "$", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "hello worldhello" {
|
||||
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "hello worldhello" {
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "v", "l", "l", "y", "$", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Line(0) != "abcdefghcde" {
|
||||
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "abcdefghcde" {
|
||||
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual yank empty selection does nothing", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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)
|
||||
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) {
|
||||
tm := newTestModel(t,
|
||||
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)
|
||||
sendKeys(tm, "d", "d", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 3 {
|
||||
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.Line(0) != "line 2" {
|
||||
t.Errorf("Line(0) = %q, want 'line 2'", m.Line(0))
|
||||
if m.ActiveBuffer().Lines[0] != "line 2" {
|
||||
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.Line(1) != "line 1" {
|
||||
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "line 1" {
|
||||
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.Line(2) != "line 3" {
|
||||
t.Errorf("Line(2) = %q, want 'line 3'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "line 3" {
|
||||
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("2yy then 2p pastes twice", func(t *testing.T) {
|
||||
tm := newTestModel(t,
|
||||
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
|
||||
sendKeys(tm, "2", "y", "y", "2", "p")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.LineCount() != 7 {
|
||||
t.Errorf("LineCount() = %d, want 7", m.LineCount())
|
||||
if m.ActiveBuffer().LineCount() != 7 {
|
||||
t.Errorf("LineCount() = %d, want 7", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
// Original + 2 copies of 2 lines = 3 + 4 = 7
|
||||
if m.Line(1) != "line 1" {
|
||||
t.Errorf("Line(1) = %q, want 'line 1'", m.Line(1))
|
||||
if m.ActiveBuffer().Lines[1] != "line 1" {
|
||||
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
if m.Line(2) != "line 2" {
|
||||
t.Errorf("Line(2) = %q, want 'line 2'", m.Line(2))
|
||||
if m.ActiveBuffer().Lines[2] != "line 2" {
|
||||
t.Errorf("Line(2) = %q, want 'line 2'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
if m.Line(3) != "line 1" {
|
||||
t.Errorf("Line(3) = %q, want 'line 1'", m.Line(3))
|
||||
if m.ActiveBuffer().Lines[3] != "line 1" {
|
||||
t.Errorf("Line(3) = %q, want 'line 1'", m.ActiveBuffer().Lines[3])
|
||||
}
|
||||
if m.Line(4) != "line 2" {
|
||||
t.Errorf("Line(4) = %q, want 'line 2'", m.Line(4))
|
||||
if m.ActiveBuffer().Lines[4] != "line 2" {
|
||||
t.Errorf("Line(4) = %q, want 'line 2'", m.ActiveBuffer().Lines[4])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -5,136 +5,93 @@ import (
|
||||
"strings"
|
||||
|
||||
"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/style"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type cursor struct {
|
||||
x int
|
||||
y int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
lines []string
|
||||
cursor cursor
|
||||
anchor cursor // starting point for visual modes
|
||||
scrollY int
|
||||
mode action.Mode
|
||||
win_h int
|
||||
win_w int
|
||||
input *input.Handler
|
||||
// Buffers
|
||||
buffers []*core.Buffer
|
||||
//next buffer id?
|
||||
|
||||
// Insert repetition
|
||||
// Windows
|
||||
windows []*core.Window
|
||||
activeWindowId int
|
||||
|
||||
// Editor wide state
|
||||
mode core.Mode
|
||||
|
||||
// Terminal dimensions
|
||||
termWidth int
|
||||
termHeight int
|
||||
|
||||
// Input and key handling
|
||||
input *input.Handler
|
||||
|
||||
// Insert mode state & repetition (applied to active window)
|
||||
insertCount int
|
||||
insertKeys []string
|
||||
insertAction action.Action
|
||||
|
||||
// Command mode
|
||||
// Command line state
|
||||
command string
|
||||
commandCursor int
|
||||
commandError error
|
||||
commandOutput string
|
||||
|
||||
// Settings
|
||||
settings action.Settings
|
||||
// Global settings
|
||||
settings core.EditorSettings
|
||||
|
||||
// Registers
|
||||
registers map[rune]action.Register // name -> register
|
||||
}
|
||||
|
||||
func NewModel(lines []string, pos action.Position) Model {
|
||||
return Model{
|
||||
lines: lines,
|
||||
cursor: cursor{
|
||||
x: pos.Col,
|
||||
y: pos.Line,
|
||||
},
|
||||
scrollY: 0,
|
||||
mode: action.NormalMode,
|
||||
command: "",
|
||||
input: input.NewHandler(),
|
||||
settings: action.NewDefaultSettings(),
|
||||
registers: action.DefaultRegisters(),
|
||||
}
|
||||
registers map[rune]core.Register // name -> register
|
||||
|
||||
// Visual styles
|
||||
styles style.Styles
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if idx < 0 || idx >= len(m.lines) {
|
||||
return ""
|
||||
func (m *Model) ActiveWindow() *core.Window {
|
||||
winId := m.activeWindowId
|
||||
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) {
|
||||
if idx >= 0 && idx < len(m.lines) {
|
||||
m.lines[idx] = content
|
||||
}
|
||||
func (m *Model) Buffers() []*core.Buffer {
|
||||
return m.buffers
|
||||
}
|
||||
|
||||
func (m *Model) InsertLine(idx int, content string) {
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx > len(m.lines) {
|
||||
idx = len(m.lines)
|
||||
}
|
||||
m.lines = append(m.lines[:idx], append([]string{content}, m.lines[idx:]...)...)
|
||||
func (m *Model) SetBuffers(bufs []*core.Buffer) {
|
||||
m.buffers = bufs
|
||||
}
|
||||
|
||||
func (m *Model) DeleteLine(idx int) {
|
||||
if idx >= 0 && idx < len(m.lines) {
|
||||
m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
|
||||
}
|
||||
func (m *Model) ActiveBuffer() *core.Buffer {
|
||||
win := m.ActiveWindow()
|
||||
return win.Buffer
|
||||
}
|
||||
|
||||
func (m *Model) LineCount() int {
|
||||
return len(m.lines)
|
||||
}
|
||||
|
||||
func (m *Model) CursorX() int {
|
||||
return m.cursor.x
|
||||
}
|
||||
|
||||
func (m *Model) CursorY() int {
|
||||
return m.cursor.y
|
||||
}
|
||||
|
||||
func (m *Model) SetCursorX(x int) {
|
||||
m.cursor.x = x
|
||||
}
|
||||
|
||||
func (m *Model) SetCursorY(y int) {
|
||||
m.cursor.y = y
|
||||
}
|
||||
|
||||
// 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
|
||||
// ==================================================
|
||||
// Insert Mode Methods
|
||||
// ==================================================
|
||||
func (m *Model) InsertKeys() []string {
|
||||
return m.insertKeys
|
||||
}
|
||||
@ -143,7 +100,143 @@ func (m *Model) SetInsertKeys(keys []string) {
|
||||
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 {
|
||||
return m.command
|
||||
}
|
||||
@ -182,38 +275,60 @@ func (m *Model) SetCommandOutput(out string) {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *Model) SetSettings(s action.Settings) {
|
||||
func (m *Model) SetSettings(s core.EditorSettings) {
|
||||
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
|
||||
func (m *Model) Registers() map[rune]action.Register {
|
||||
// ==================================================
|
||||
func (m *Model) Registers() map[rune]core.Register {
|
||||
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]
|
||||
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 {
|
||||
return fmt.Errorf("Register '%c' does not exist.", name)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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)
|
||||
for i := rune('9'); i > '0'; i-- {
|
||||
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('"', 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))
|
||||
}
|
||||
}
|
||||
|
||||
140
internal/editor/model_builder.go
Normal file
140
internal/editor/model_builder.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -4,14 +4,49 @@ import (
|
||||
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
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.win_h = msg.Height
|
||||
m.win_w = msg.Width
|
||||
m.termHeight = msg.Height
|
||||
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:
|
||||
// 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 {
|
||||
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
|
||||
m.AdjustScroll()
|
||||
win := m.ActiveWindow()
|
||||
win.AdjustScroll()
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
@ -4,22 +4,290 @@ import (
|
||||
"fmt"
|
||||
"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 {
|
||||
switch m.Mode() {
|
||||
case action.VisualLineMode:
|
||||
startY := min(m.AnchorY(), m.CursorY())
|
||||
endY := max(m.AnchorY(), m.CursorY())
|
||||
// Model.View: Renders the complete editor view including buffer content, line
|
||||
// numbers, status bar, and command line.
|
||||
func (m Model) View() string {
|
||||
win := m.ActiveWindow()
|
||||
|
||||
// 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
|
||||
|
||||
case action.VisualMode:
|
||||
ax := m.AnchorX()
|
||||
ay := m.AnchorY()
|
||||
case core.VisualMode:
|
||||
ax := w.Anchor.Col
|
||||
ay := w.Anchor.Line
|
||||
|
||||
cx := m.CursorX()
|
||||
cy := m.CursorY()
|
||||
cx := w.Cursor.Col
|
||||
cy := w.Cursor.Line
|
||||
|
||||
// Normalize so start is always before end in document order
|
||||
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)
|
||||
return afterStart && beforeEnd
|
||||
|
||||
case action.VisualBlockMode:
|
||||
startX := min(m.AnchorX(), m.CursorX())
|
||||
startY := min(m.AnchorY(), m.CursorY())
|
||||
endX := max(m.AnchorX(), m.CursorX())
|
||||
endY := max(m.AnchorY(), m.CursorY())
|
||||
case core.VisualBlockMode:
|
||||
startX := min(w.Anchor.Col, w.Cursor.Col)
|
||||
startY := min(w.Anchor.Line, w.Cursor.Line)
|
||||
endX := max(w.Anchor.Col, w.Cursor.Col)
|
||||
endY := max(w.Anchor.Line, w.Cursor.Line)
|
||||
|
||||
return col >= startX && col <= endX &&
|
||||
line >= startY && line <= endY
|
||||
@ -49,152 +317,3 @@ func posInsideSelection(m Model, col, line int) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@ package input
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// InputState: Represents the current state of the input handler state machine.
|
||||
type InputState int
|
||||
|
||||
const (
|
||||
@ -14,11 +16,8 @@ const (
|
||||
StateMotionCount
|
||||
)
|
||||
|
||||
// PositionGetter is used to get cursor position for operator ranges
|
||||
type PositionGetter interface {
|
||||
GetCursorPosition() action.Position
|
||||
}
|
||||
|
||||
// Handler: Manages input processing with a state machine for vim-style commands.
|
||||
// Handles counts, operators, motions, and multi-key sequences.
|
||||
type Handler struct {
|
||||
state InputState
|
||||
count1 int
|
||||
@ -37,6 +36,7 @@ type Handler struct {
|
||||
currentKeymap *Keymap
|
||||
}
|
||||
|
||||
// NewHandler: Creates a new input handler with initialized keymaps for all modes.
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
// 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 {
|
||||
// ESC always resets everything
|
||||
if key == "esc" {
|
||||
h.Reset()
|
||||
if m.Mode() == action.InsertMode {
|
||||
if m.Mode() == core.InsertMode {
|
||||
m.ExitInsertMode()
|
||||
} else {
|
||||
m.SetMode(action.NormalMode)
|
||||
m.SetMode(core.NormalMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert/command mode bypasses the normal state machine entirely
|
||||
switch m.Mode() {
|
||||
case action.InsertMode:
|
||||
case core.InsertMode:
|
||||
return h.handleInsertKey(m, key)
|
||||
case action.CommandMode:
|
||||
case core.CommandMode:
|
||||
return h.handleCommandKey(m, key)
|
||||
}
|
||||
|
||||
@ -78,11 +80,11 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
|
||||
|
||||
// Set working keymap
|
||||
switch m.Mode() {
|
||||
case action.NormalMode:
|
||||
case core.NormalMode:
|
||||
h.currentKeymap = h.normalKeymap
|
||||
case action.VisualMode,
|
||||
action.VisualLineMode,
|
||||
action.VisualBlockMode:
|
||||
case core.VisualMode,
|
||||
core.VisualLineMode,
|
||||
core.VisualBlockMode:
|
||||
h.currentKeymap = h.visualKeymap
|
||||
}
|
||||
|
||||
@ -116,7 +118,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
|
||||
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 {
|
||||
switch h.state {
|
||||
case StateReady, StateCount:
|
||||
@ -128,6 +130,8 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string)
|
||||
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 {
|
||||
count := h.effectiveCount()
|
||||
|
||||
@ -147,14 +151,14 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
if m.Mode().IsVisualMode() {
|
||||
start, end := normalizeVisualSelection(m)
|
||||
// Visual line mode is linewise, others are charwise inclusive
|
||||
mtype := action.CharwiseInclusive
|
||||
if m.Mode() == action.VisualLineMode {
|
||||
mtype = action.Linewise
|
||||
mtype := core.CharwiseInclusive
|
||||
if m.Mode() == core.VisualLineMode {
|
||||
mtype = core.Linewise
|
||||
}
|
||||
cmd := op.Operate(m, start, end, mtype)
|
||||
// Only reset to normal mode if operator didn't enter insert mode
|
||||
if m.Mode() != action.InsertMode {
|
||||
m.SetMode(action.NormalMode)
|
||||
if m.Mode() != core.InsertMode {
|
||||
m.SetMode(core.NormalMode)
|
||||
}
|
||||
h.Reset()
|
||||
return cmd
|
||||
@ -179,8 +183,11 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
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 {
|
||||
count := h.effectiveCount()
|
||||
win := m.ActiveWindow()
|
||||
|
||||
// dd, yy, cc - same operator key pressed twice
|
||||
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)
|
||||
}
|
||||
// Get range and motion type
|
||||
pg := m.(PositionGetter)
|
||||
start := pg.GetCursorPosition()
|
||||
start := win.Cursor
|
||||
mot.Execute(m)
|
||||
end := pg.GetCursorPosition()
|
||||
end := win.Cursor
|
||||
cmd := h.operator.Operate(m, start, end, mot.Type())
|
||||
h.Reset()
|
||||
return cmd
|
||||
@ -214,6 +220,8 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
||||
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 {
|
||||
if len(key) != 1 || key[0] < '0' || key[0] > '9' {
|
||||
return false
|
||||
@ -239,6 +247,7 @@ func (h *Handler) tryAccumulateCount(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handler.currentCount: Returns the count currently being accumulated based on state.
|
||||
func (h *Handler) currentCount() int {
|
||||
if h.state == StateOperatorPending || h.state == StateMotionCount {
|
||||
return h.count2
|
||||
@ -246,6 +255,8 @@ func (h *Handler) currentCount() int {
|
||||
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 {
|
||||
c1, c2 := h.count1, h.count2
|
||||
if c1 == 0 {
|
||||
@ -257,6 +268,7 @@ func (h *Handler) effectiveCount() int {
|
||||
return c1 * c2
|
||||
}
|
||||
|
||||
// Handler.Reset: Clears all handler state including counts, operators, and buffers.
|
||||
func (h *Handler) Reset() {
|
||||
h.state = StateReady
|
||||
h.count1 = 0
|
||||
@ -267,10 +279,13 @@ func (h *Handler) Reset() {
|
||||
h.pending = ""
|
||||
}
|
||||
|
||||
// Handler.Pending: Returns the accumulated input buffer for display.
|
||||
func (h *Handler) Pending() string {
|
||||
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 {
|
||||
// Record the key for count replay (e.g. 5i...)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
kind, binding := h.commandKeymap.Lookup(key)
|
||||
switch kind {
|
||||
@ -301,9 +318,12 @@ func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
|
||||
return action.InsertCommandChar{Char: key}.Execute(m)
|
||||
}
|
||||
|
||||
func normalizeVisualSelection(m action.Model) (action.Position, action.Position) {
|
||||
a := action.Position{Line: m.AnchorY(), Col: m.AnchorX()}
|
||||
c := action.Position{Line: m.CursorY(), Col: m.CursorX()}
|
||||
// normalizeVisualSelection: Returns the visual selection with start before end,
|
||||
// regardless of which direction the selection was made.
|
||||
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) {
|
||||
return a, c
|
||||
}
|
||||
|
||||
@ -7,12 +7,14 @@ import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/operator"
|
||||
)
|
||||
|
||||
// Keymap: Maps key sequences to motions, operators, and actions.
|
||||
type Keymap struct {
|
||||
motions map[string]action.Motion
|
||||
operators map[string]action.Operator
|
||||
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 {
|
||||
return &Keymap{
|
||||
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 {
|
||||
return &Keymap{
|
||||
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 {
|
||||
return &Keymap{
|
||||
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 {
|
||||
return &Keymap{
|
||||
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) {
|
||||
if m, ok := km.motions[key]; ok {
|
||||
return "motion", m
|
||||
@ -149,7 +154,7 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
|
||||
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 {
|
||||
for key := range km.motions {
|
||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
package motion
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// MoveDown implements Motion (j) - linewise
|
||||
@ -10,15 +11,18 @@ type MoveDown struct {
|
||||
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 {
|
||||
for i := 0; i < a.Count && m.CursorY() < m.LineCount()-1; i++ {
|
||||
m.SetCursorY(m.CursorY() + 1)
|
||||
win := m.ActiveWindow()
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return MoveDown{Count: n}
|
||||
@ -29,15 +33,17 @@ type MoveUp struct {
|
||||
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 {
|
||||
for i := 0; i < a.Count && m.CursorY() > 0; i++ {
|
||||
m.SetCursorY(m.CursorY() - 1)
|
||||
win := m.ActiveWindow()
|
||||
for i := 0; i < a.Count && win.Cursor.Line > 0; i++ {
|
||||
win.SetCursorLine(win.Cursor.Line - 1)
|
||||
}
|
||||
m.ClampCursorX()
|
||||
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 {
|
||||
return MoveUp{Count: n}
|
||||
@ -48,15 +54,17 @@ type MoveLeft struct {
|
||||
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 {
|
||||
for i := 0; i < a.Count && m.CursorX() > 0; i++ {
|
||||
m.SetCursorX(m.CursorX() - 1)
|
||||
win := m.ActiveWindow()
|
||||
for i := 0; i < a.Count && win.Cursor.Col > 0; i++ {
|
||||
win.SetCursorCol(win.Cursor.Col - 1)
|
||||
}
|
||||
m.ClampCursorX()
|
||||
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 {
|
||||
return MoveLeft{Count: n}
|
||||
@ -67,16 +75,19 @@ type MoveRight struct {
|
||||
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 {
|
||||
lineLen := len(m.Line(m.CursorY()))
|
||||
for i := 0; i < a.Count && m.CursorX() <= lineLen; i++ {
|
||||
m.SetCursorX(m.CursorX() + 1)
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return MoveRight{Count: n}
|
||||
|
||||
@ -2,25 +2,32 @@ package motion
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// MoveCommandLeft implements Motion - moves cursor left in command line.
|
||||
type MoveCommandLeft struct{}
|
||||
|
||||
// MoveCommandLeft.Execute: Moves the command line cursor one position to the left.
|
||||
func (a MoveCommandLeft) Execute(m action.Model) tea.Cmd {
|
||||
// The set function handles bounds
|
||||
m.SetCommandCursor(m.CommandCursor() - 1)
|
||||
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{}
|
||||
|
||||
// MoveCommandRight.Execute: Moves the command line cursor one position to the right.
|
||||
func (a MoveCommandRight) Execute(m action.Model) tea.Cmd {
|
||||
// The set function handles bounds
|
||||
m.SetCommandCursor(m.CommandCursor() + 1)
|
||||
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 }
|
||||
|
||||
@ -2,58 +2,70 @@ package motion
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// MoveToTop implements Motion (gg) - linewise
|
||||
type MoveToTop struct{}
|
||||
|
||||
// MoveToTop.Execute: Moves the cursor to the first line of the buffer.
|
||||
func (a MoveToTop) Execute(m action.Model) tea.Cmd {
|
||||
m.SetCursorY(0)
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
win.SetCursorLine(0)
|
||||
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
|
||||
type MoveToBottom struct{}
|
||||
|
||||
// MoveToBottom.Execute: Moves the cursor to the last line of the buffer.
|
||||
func (a MoveToBottom) Execute(m action.Model) tea.Cmd {
|
||||
m.SetCursorY(m.LineCount() - 1)
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
win.SetCursorLine(buf.LineCount() - 1)
|
||||
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
|
||||
type MoveToLineStart struct{}
|
||||
|
||||
// MoveToLineStart.Execute: Moves the cursor to the beginning of the current line.
|
||||
func (a MoveToLineStart) Execute(m action.Model) tea.Cmd {
|
||||
m.SetCursorX(0)
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
win.SetCursorCol(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveToLineStart) Type() action.MotionType { return action.CharwiseExclusive }
|
||||
func (a MoveToLineStart) Type() core.MotionType { return core.CharwiseExclusive }
|
||||
|
||||
// MoveToLineEnd implements Motion ($) - charwise
|
||||
type MoveToLineEnd struct{}
|
||||
|
||||
// MoveToLineEnd.Execute: Moves the cursor to the end of the current line.
|
||||
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
|
||||
m.SetCursorX(len(m.Line(m.CursorY())))
|
||||
m.ClampCursorX()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveToLineEnd) Type() action.MotionType { return action.CharwiseInclusive }
|
||||
func (a MoveToLineEnd) Type() core.MotionType { return core.CharwiseInclusive }
|
||||
|
||||
// MoveToLineContentStart implements Motion (_) - charwise
|
||||
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 {
|
||||
line := m.Line(m.CursorY())
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
line := buf.Lines[win.Cursor.Line]
|
||||
x := 0
|
||||
for x < len(line) {
|
||||
ch := line[x]
|
||||
@ -68,27 +80,30 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
|
||||
x--
|
||||
}
|
||||
|
||||
m.SetCursorX(x)
|
||||
win.SetCursorCol(x)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveToLineContentStart) Type() action.MotionType { return action.CharwiseExclusive }
|
||||
func (a MoveToLineContentStart) Type() core.MotionType { return core.CharwiseExclusive }
|
||||
|
||||
// MoveToColumn implements Motion (|) - charwise
|
||||
type MoveToColumn struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// MoveToColumn.Execute: Moves the cursor to the column specified by Count.
|
||||
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)
|
||||
|
||||
m.SetCursorX(col)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorCol(col)
|
||||
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 {
|
||||
return MoveToColumn{Count: n}
|
||||
@ -99,23 +114,28 @@ func (a MoveToColumn) WithCount(n int) action.Action {
|
||||
// ScrollDownHalfPage implements Motion (ctrl+d) - linewise
|
||||
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 {
|
||||
viewportHeight := m.ViewPortH()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
viewportHeight := win.Height - 2
|
||||
if viewportHeight <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scroll := viewportHeight / 2
|
||||
scrollOff := m.Settings().ScrollOff
|
||||
scrollOff := win.Options.ScrollOff
|
||||
|
||||
// Current relative position in viewport
|
||||
relY := m.CursorY() - m.ScrollY()
|
||||
relY := win.Cursor.Line - win.ScrollY
|
||||
|
||||
// Scroll down, clamped to valid range
|
||||
newScrollY := m.ScrollY() + scroll
|
||||
maxScroll := max(0, m.LineCount()-viewportHeight)
|
||||
newScrollY := win.ScrollY + scroll
|
||||
maxScroll := max(0, buf.LineCount()-viewportHeight)
|
||||
newScrollY = min(newScrollY, maxScroll)
|
||||
m.SetScrollY(newScrollY)
|
||||
win.SetScrollY(newScrollY)
|
||||
|
||||
// Maintain relative position, respecting scrollOff
|
||||
if relY < scrollOff {
|
||||
@ -126,33 +146,37 @@ func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
|
||||
}
|
||||
|
||||
newCursorY := newScrollY + relY
|
||||
newCursorY = max(0, min(newCursorY, m.LineCount()-1))
|
||||
m.SetCursorY(newCursorY)
|
||||
m.ClampCursorX()
|
||||
newCursorY = max(0, min(newCursorY, buf.LineCount()-1))
|
||||
win.SetCursorLine(newCursorY)
|
||||
|
||||
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
|
||||
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 {
|
||||
viewportHeight := m.ViewPortH()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
viewportHeight := win.Height - 2
|
||||
if viewportHeight <= 0 {
|
||||
return nil
|
||||
}
|
||||
scroll := viewportHeight / 2
|
||||
scrollOff := m.Settings().ScrollOff
|
||||
scrollOff := win.Options.ScrollOff
|
||||
|
||||
// Current relative position in viewport
|
||||
relY := m.CursorY() - m.ScrollY()
|
||||
relY := win.Cursor.Line - win.ScrollY
|
||||
|
||||
// Scroll up, clamped to valid range
|
||||
newScrollY := m.ScrollY() - scroll
|
||||
newScrollY := win.ScrollY - scroll
|
||||
newScrollY = max(0, newScrollY)
|
||||
m.SetScrollY(newScrollY)
|
||||
win.SetScrollY(newScrollY)
|
||||
|
||||
// Maintain relative position, respecting scrollOff
|
||||
if relY < scrollOff {
|
||||
@ -163,11 +187,10 @@ func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
|
||||
}
|
||||
|
||||
newCursorY := newScrollY + relY
|
||||
newCursorY = max(0, min(newCursorY, m.LineCount()-1))
|
||||
m.SetCursorY(newCursorY)
|
||||
m.ClampCursorX()
|
||||
newCursorY = max(0, min(newCursorY, buf.LineCount()-1))
|
||||
win.SetCursorLine(newCursorY)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a ScrollUpHalfPage) Type() action.MotionType { return action.Linewise }
|
||||
func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise }
|
||||
|
||||
@ -2,9 +2,12 @@ package motion
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// isWordChar: Returns true if the character is a word character (alphanumeric
|
||||
// or underscore).
|
||||
func isWordChar(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') ||
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
@ -12,12 +15,16 @@ func isWordChar(c byte) bool {
|
||||
c == '_'
|
||||
}
|
||||
|
||||
// isWordPunctuation: Returns true if the character is punctuation (not whitespace
|
||||
// and not a word character).
|
||||
func isWordPunctuation(c byte) bool {
|
||||
return c != ' ' && c != '\t' && !isWordChar(c)
|
||||
}
|
||||
|
||||
func nextWordStart(m action.Model, x, y int) (int, int) {
|
||||
line := m.Line(y)
|
||||
// nextWordStart: Finds the start of the next word from position (x,y), handling
|
||||
// word boundaries and line crossing.
|
||||
func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
|
||||
line := buf.Lines[y]
|
||||
|
||||
// Skip current class
|
||||
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 y+1 >= m.LineCount() {
|
||||
if y+1 >= buf.LineCount() {
|
||||
return x, y
|
||||
}
|
||||
|
||||
// Move to first char of next line
|
||||
y++
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = 0
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func nextWORDStart(m action.Model, x, y int) (int, int) {
|
||||
line := m.Line(y)
|
||||
// nextWORDStart: Finds the start of the next WORD from position (x,y), treating
|
||||
// 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)
|
||||
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 y+1 >= m.LineCount() {
|
||||
if y+1 >= buf.LineCount() {
|
||||
return x, y
|
||||
}
|
||||
|
||||
// Move to first char of next line
|
||||
y++
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = 0
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func nextWordEnd(m action.Model, x, y int) (int, int) {
|
||||
line := m.Line(y)
|
||||
// nextWordEnd: Finds the end of the next word from position (x,y), respecting
|
||||
// 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
|
||||
x++
|
||||
if x >= len(line) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Otherwise, move to next line
|
||||
y++
|
||||
x = 0
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
}
|
||||
|
||||
// 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 y+1 >= m.LineCount() {
|
||||
if y+1 >= buf.LineCount() {
|
||||
return x, y
|
||||
}
|
||||
|
||||
// Move to first char of next line
|
||||
y++
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = 0
|
||||
}
|
||||
|
||||
@ -160,21 +171,23 @@ func nextWordEnd(m action.Model, x, y int) (int, int) {
|
||||
return x, y
|
||||
}
|
||||
|
||||
func nextWORDEnd(m action.Model, x, y int) (int, int) {
|
||||
line := m.Line(y)
|
||||
// nextWORDEnd: Finds the end of the next WORD from position (x,y), treating
|
||||
// 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
|
||||
x++
|
||||
if x >= len(line) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Otherwise, move to next line
|
||||
y++
|
||||
x = 0
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
}
|
||||
|
||||
// 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 y+1 >= m.LineCount() {
|
||||
if y+1 >= buf.LineCount() {
|
||||
return x, y
|
||||
}
|
||||
|
||||
// Move to first char of next line
|
||||
y++
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = 0
|
||||
}
|
||||
|
||||
@ -208,8 +221,10 @@ func nextWORDEnd(m action.Model, x, y int) (int, int) {
|
||||
return x, y
|
||||
}
|
||||
|
||||
func prevWordStart(m action.Model, x, y int) (int, int) {
|
||||
line := m.Line(y)
|
||||
// prevWordStart: Finds the start of the previous word from position (x,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
|
||||
x--
|
||||
@ -218,7 +233,7 @@ func prevWordStart(m action.Model, x, y int) (int, int) {
|
||||
return 0, 0 // beginning of file, stay put
|
||||
}
|
||||
y--
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = len(line) - 1
|
||||
if x < 0 {
|
||||
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
|
||||
}
|
||||
y--
|
||||
line = m.Line(y)
|
||||
line = buf.Lines[y]
|
||||
x = len(line) - 1
|
||||
if len(line) == 0 {
|
||||
return 0, y // empty line acts as a word boundary
|
||||
@ -263,19 +278,25 @@ type MoveForwardWord struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// MoveForwardWord.Execute: Moves the cursor forward by Count words (w motion).
|
||||
func (a MoveForwardWord) Execute(m action.Model) tea.Cmd {
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
for i := 0; i < a.Count; i++ {
|
||||
x, y = nextWordStart(m, x, y)
|
||||
x, y = nextWordStart(buf, x, y)
|
||||
}
|
||||
m.SetCursorX(x)
|
||||
m.SetCursorY(y)
|
||||
win.SetCursorCol(x)
|
||||
win.SetCursorLine(y)
|
||||
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 {
|
||||
return MoveForwardWord{Count: n}
|
||||
}
|
||||
@ -285,19 +306,25 @@ type MoveForwardWORD struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// MoveForwardWORD.Execute: Moves the cursor forward by Count WORDs (W motion).
|
||||
func (a MoveForwardWORD) Execute(m action.Model) tea.Cmd {
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
for i := 0; i < a.Count; i++ {
|
||||
x, y = nextWORDStart(m, x, y)
|
||||
x, y = nextWORDStart(buf, x, y)
|
||||
}
|
||||
m.SetCursorX(x)
|
||||
m.SetCursorY(y)
|
||||
win.SetCursorCol(x)
|
||||
win.SetCursorLine(y)
|
||||
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 {
|
||||
return MoveForwardWORD{Count: n}
|
||||
}
|
||||
@ -307,19 +334,25 @@ type MoveForwardWordEnd struct {
|
||||
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 {
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
for i := 0; i < a.Count; i++ {
|
||||
x, y = nextWordEnd(m, x, y)
|
||||
x, y = nextWordEnd(buf, x, y)
|
||||
}
|
||||
m.SetCursorX(x)
|
||||
m.SetCursorY(y)
|
||||
win.SetCursorCol(x)
|
||||
win.SetCursorLine(y)
|
||||
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 {
|
||||
return MoveForwardWordEnd{Count: n}
|
||||
}
|
||||
@ -329,19 +362,25 @@ type MoveForwardWORDEnd struct {
|
||||
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 {
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
for i := 0; i < a.Count; i++ {
|
||||
x, y = nextWORDEnd(m, x, y)
|
||||
x, y = nextWORDEnd(buf, x, y)
|
||||
}
|
||||
m.SetCursorX(x)
|
||||
m.SetCursorY(y)
|
||||
win.SetCursorCol(x)
|
||||
win.SetCursorLine(y)
|
||||
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 {
|
||||
return MoveForwardWORDEnd{Count: n}
|
||||
}
|
||||
@ -351,19 +390,25 @@ type MoveBackwardWord struct {
|
||||
Count int
|
||||
}
|
||||
|
||||
// MoveBackwardWord.Execute: Moves the cursor backward by Count words (b motion).
|
||||
func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd {
|
||||
x := m.CursorX()
|
||||
y := m.CursorY()
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
x := win.Cursor.Col
|
||||
y := win.Cursor.Line
|
||||
for i := 0; i < a.Count; i++ {
|
||||
x, y = prevWordStart(m, x, y)
|
||||
x, y = prevWordStart(buf, x, y)
|
||||
}
|
||||
m.SetCursorX(x)
|
||||
m.SetCursorY(y)
|
||||
win.SetCursorCol(x)
|
||||
win.SetCursorLine(y)
|
||||
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 {
|
||||
return MoveBackwardWord{Count: n}
|
||||
}
|
||||
|
||||
@ -2,34 +2,37 @@ package operator
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Implements Operator (c)
|
||||
// ChangeOperator implements Operator (c) - changes (deletes and enters insert mode) text.
|
||||
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() {
|
||||
case action.VisualMode:
|
||||
case core.VisualMode:
|
||||
changeCharSelection(m, start, end)
|
||||
case action.VisualLineMode:
|
||||
case core.VisualLineMode:
|
||||
changeLineSelection(m, start, end)
|
||||
case action.VisualBlockMode:
|
||||
case core.VisualBlockMode:
|
||||
changeBlockSelection(m, start, end)
|
||||
case action.NormalMode:
|
||||
case core.NormalMode:
|
||||
changeNormalMode(m, start, end, mtype)
|
||||
}
|
||||
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
|
||||
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
// Linewise motions (j, k, G, gg) always operate on whole lines
|
||||
if mtype == action.Linewise {
|
||||
if mtype == core.Linewise {
|
||||
changeLineSelection(m, start, end)
|
||||
return
|
||||
}
|
||||
@ -37,18 +40,18 @@ func changeNormalMode(m action.Model, start, end action.Position, mtype action.M
|
||||
// Charwise motions on same line
|
||||
if start.Line == end.Line {
|
||||
// No movement = nothing to change
|
||||
if start.Col == end.Col && mtype == action.CharwiseExclusive {
|
||||
m.SetMode(action.InsertMode)
|
||||
if start.Col == end.Col && mtype == core.CharwiseExclusive {
|
||||
m.SetMode(core.InsertMode)
|
||||
return
|
||||
}
|
||||
// Exclusive motion: end position not included, so back up one
|
||||
if mtype == action.CharwiseExclusive {
|
||||
if mtype == core.CharwiseExclusive {
|
||||
end.Col--
|
||||
}
|
||||
if end.Col >= start.Col {
|
||||
changeCharSelection(m, start, end)
|
||||
} else {
|
||||
m.SetMode(action.InsertMode)
|
||||
m.SetMode(core.InsertMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -57,22 +60,26 @@ func changeNormalMode(m action.Model, start, end action.Position, mtype action.M
|
||||
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
|
||||
|
||||
if start.Line == end.Line {
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.Line]
|
||||
endCol := min(end.Col+1, len(line))
|
||||
deletedText = line[start.Col:endCol]
|
||||
m.SetLine(start.Line, line[:start.Col]+line[endCol:])
|
||||
buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
|
||||
} else {
|
||||
startLine := m.Line(start.Line)
|
||||
endLine := m.Line(end.Line)
|
||||
startLine := buf.Lines[start.Line]
|
||||
endLine := buf.Lines[end.Line]
|
||||
|
||||
// Extract deleted text
|
||||
deletedText = startLine[start.Col:] + "\n"
|
||||
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))
|
||||
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
|
||||
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)
|
||||
m.SetCursorX(start.Col)
|
||||
m.ClampCursorX()
|
||||
m.SetMode(action.InsertMode)
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(start.Col)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
// 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
|
||||
|
||||
for i := end.Line; i >= start.Line; i-- {
|
||||
lines = append([]string{m.Line(i)}, lines...)
|
||||
m.DeleteLine(i)
|
||||
lines = append([]string{buf.Lines[i]}, lines...)
|
||||
buf.DeleteLine(i)
|
||||
}
|
||||
|
||||
// Insert an empty line for editing
|
||||
insertY := min(start.Line, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
insertY := min(start.Line, buf.LineCount())
|
||||
buf.InsertLine(insertY, "")
|
||||
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
m.SetMode(action.InsertMode)
|
||||
win.SetCursorLine(insertY)
|
||||
win.SetCursorCol(0)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
// 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)
|
||||
endCol := max(start.Col, end.Col)
|
||||
|
||||
for y := start.Line; y <= end.Line; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
if startCol >= len(line) {
|
||||
continue
|
||||
}
|
||||
ec := min(endCol+1, len(line))
|
||||
m.SetLine(y, line[:startCol]+line[ec:])
|
||||
buf.SetLine(y, line[:startCol]+line[ec:])
|
||||
}
|
||||
|
||||
m.SetCursorY(start.Line)
|
||||
m.SetCursorX(startCol)
|
||||
m.ClampCursorX()
|
||||
m.SetMode(action.InsertMode)
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(startCol)
|
||||
m.SetMode(core.InsertMode)
|
||||
}
|
||||
|
||||
// Verify ChangeOperator implements DoublePresser
|
||||
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 {
|
||||
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
|
||||
opCount := min(count, m.LineCount()-startY)
|
||||
opCount := min(count, buf.LineCount()-startY)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Collect lines to delete (always delete at startY since lines shift up)
|
||||
for range opCount {
|
||||
lines = append(lines, m.Line(startY))
|
||||
m.DeleteLine(startY)
|
||||
lines = append(lines, buf.Lines[startY])
|
||||
buf.DeleteLine(startY)
|
||||
}
|
||||
|
||||
// Put deleted lines in register
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
m.UpdateDefaultRegister(core.LinewiseRegister, lines)
|
||||
|
||||
// Insert empty line at the original position for editing
|
||||
// If we deleted everything, startY might be past end, so clamp it
|
||||
insertY := min(startY, m.LineCount())
|
||||
m.InsertLine(insertY, "")
|
||||
insertY := min(startY, buf.LineCount())
|
||||
buf.InsertLine(insertY, "")
|
||||
|
||||
// Position cursor on the new empty line
|
||||
m.SetCursorY(insertY)
|
||||
m.SetCursorX(0)
|
||||
m.SetMode(action.InsertMode)
|
||||
win.SetCursorLine(insertY)
|
||||
win.SetCursorCol(0)
|
||||
m.SetMode(core.InsertMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,21 +2,23 @@ package operator
|
||||
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Implements Operator (d)
|
||||
// DeleteOperator implements Operator (d) - deletes text in various modes.
|
||||
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() {
|
||||
case action.VisualMode:
|
||||
case core.VisualMode:
|
||||
deleteCharSelection(m, start, end)
|
||||
case action.VisualLineMode:
|
||||
case core.VisualLineMode:
|
||||
deleteLineSelection(m, start, end)
|
||||
case action.VisualBlockMode:
|
||||
case core.VisualBlockMode:
|
||||
deleteBlockSelection(m, start, end)
|
||||
case action.NormalMode:
|
||||
case core.NormalMode:
|
||||
deleteNormalMode(m, start, end, mtype)
|
||||
}
|
||||
return nil
|
||||
@ -25,45 +27,48 @@ func (o DeleteOperator) Operate(m action.Model, start, end action.Position, mtyp
|
||||
// Verify DeleteOperator implements DoublePresser
|
||||
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 {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
// 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
|
||||
|
||||
for range opCount {
|
||||
y := m.CursorY()
|
||||
lines = append(lines, m.Line(y))
|
||||
y := win.Cursor.Line
|
||||
lines = append(lines, buf.Lines[y])
|
||||
|
||||
m.DeleteLine(y)
|
||||
buf.DeleteLine(y)
|
||||
|
||||
if m.LineCount() == 0 {
|
||||
m.InsertLine(0, "")
|
||||
if buf.LineCount() == 0 {
|
||||
buf.InsertLine(0, "")
|
||||
}
|
||||
|
||||
if y >= m.LineCount() {
|
||||
y = m.LineCount() - 1
|
||||
if y >= buf.LineCount() {
|
||||
y = buf.LineCount() - 1
|
||||
}
|
||||
|
||||
m.SetCursorY(y)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(y)
|
||||
}
|
||||
|
||||
// Put her in the register!
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
m.UpdateDefaultRegister(core.LinewiseRegister, lines)
|
||||
|
||||
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
|
||||
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
// Linewise motions (j, k, G, gg) always operate on whole lines
|
||||
if mtype == action.Linewise {
|
||||
if mtype == core.Linewise {
|
||||
deleteLineSelection(m, start, end)
|
||||
return
|
||||
}
|
||||
@ -71,11 +76,11 @@ func deleteNormalMode(m action.Model, start, end action.Position, mtype action.M
|
||||
// Charwise motions on same line
|
||||
if start.Line == end.Line {
|
||||
// No movement = nothing to delete
|
||||
if start.Col == end.Col && mtype == action.CharwiseExclusive {
|
||||
if start.Col == end.Col && mtype == core.CharwiseExclusive {
|
||||
return
|
||||
}
|
||||
// Exclusive motion: end position not included, so back up one
|
||||
if mtype == action.CharwiseExclusive {
|
||||
if mtype == core.CharwiseExclusive {
|
||||
end.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)
|
||||
}
|
||||
|
||||
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 {
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.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 {
|
||||
startLine := m.Line(start.Line)
|
||||
endLine := m.Line(end.Line)
|
||||
startLine := buf.Lines[start.Line]
|
||||
endLine := buf.Lines[end.Line]
|
||||
|
||||
prefix := startLine[:start.Col]
|
||||
suffix := ""
|
||||
@ -105,54 +114,59 @@ func deleteCharSelection(m action.Model, start, end action.Position) {
|
||||
|
||||
// Delete from end back to start to preserve indices
|
||||
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)
|
||||
m.SetCursorX(start.Col)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(start.Col)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
for i := end.Line; i >= start.Line; i-- {
|
||||
lines = append(lines, m.Line(i))
|
||||
m.DeleteLine(i)
|
||||
lines = append(lines, buf.Lines[i])
|
||||
buf.DeleteLine(i)
|
||||
}
|
||||
|
||||
if m.LineCount() == 0 {
|
||||
m.InsertLine(0, "")
|
||||
if buf.LineCount() == 0 {
|
||||
buf.InsertLine(0, "")
|
||||
}
|
||||
|
||||
y := start.Line
|
||||
if y >= m.LineCount() {
|
||||
y = m.LineCount() - 1
|
||||
if y >= buf.LineCount() {
|
||||
y = buf.LineCount() - 1
|
||||
}
|
||||
|
||||
m.SetCursorY(y)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(y)
|
||||
|
||||
// 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)
|
||||
endCol := max(start.Col, end.Col)
|
||||
|
||||
for y := start.Line; y <= end.Line; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
if startCol >= len(line) {
|
||||
continue
|
||||
}
|
||||
ec := min(endCol+1, len(line))
|
||||
m.SetLine(y, line[:startCol]+line[ec:])
|
||||
buf.SetLine(y, line[:startCol]+line[ec:])
|
||||
}
|
||||
|
||||
m.SetCursorY(start.Line)
|
||||
m.SetCursorX(startCol)
|
||||
m.ClampCursorX()
|
||||
win.SetCursorLine(start.Line)
|
||||
win.SetCursorCol(startCol)
|
||||
}
|
||||
|
||||
@ -4,53 +4,64 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Implements Operator (y)
|
||||
// YankOperator implements Operator (y) - copies text to register in various modes.
|
||||
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() {
|
||||
case action.VisualMode:
|
||||
case core.VisualMode:
|
||||
yankVisualMode(m, start, end)
|
||||
case action.VisualLineMode:
|
||||
case core.VisualLineMode:
|
||||
yankVisualLineMode(m, start, end)
|
||||
case action.VisualBlockMode:
|
||||
case core.VisualBlockMode:
|
||||
yankVisualBlockMode(m, start, end)
|
||||
case action.NormalMode:
|
||||
case core.NormalMode:
|
||||
yankNormalMode(m, start, end, mtype)
|
||||
default:
|
||||
m.SetCommandError(fmt.Errorf("'y' operator not yet implemented."))
|
||||
}
|
||||
|
||||
m.SetCursorX(start.Col)
|
||||
m.SetCursorY(start.Line)
|
||||
win.SetCursorCol(start.Col)
|
||||
win.SetCursorLine(start.Line)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify YankOperator implements DoublePresser
|
||||
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 {
|
||||
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
|
||||
opCount := min(count, m.LineCount()-y)
|
||||
opCount := min(count, buf.LineCount()-y)
|
||||
|
||||
var lines []string
|
||||
|
||||
for i := range opCount {
|
||||
lines = append(lines, m.Line(y+i))
|
||||
lines = append(lines, buf.Lines[y+i])
|
||||
}
|
||||
|
||||
// Put her in the register!
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, lines)
|
||||
m.UpdateDefaultRegister(core.LinewiseRegister, lines)
|
||||
|
||||
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 {
|
||||
case mtype.IsCharwise():
|
||||
// This shouldn't happen
|
||||
@ -59,22 +70,22 @@ func yankNormalMode(m action.Model, start, end action.Position, mtype action.Mot
|
||||
return
|
||||
}
|
||||
|
||||
line := m.Line(start.Line)
|
||||
line := buf.Lines[start.Line]
|
||||
|
||||
startX := min(start.Col, end.Col)
|
||||
endX := max(start.Col, end.Col)
|
||||
|
||||
// Inclusive motions include the end character
|
||||
if mtype == action.CharwiseInclusive {
|
||||
if mtype == core.CharwiseInclusive {
|
||||
endX++
|
||||
}
|
||||
|
||||
endX = min(endX, len(line)) // Catch overflow
|
||||
|
||||
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
|
||||
if start.Col != end.Col {
|
||||
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)
|
||||
endY := max(start.Line, end.Line)
|
||||
|
||||
cnt := m.Lines()[startY : endY+1]
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, cnt)
|
||||
cnt := buf.Lines[startY : endY+1]
|
||||
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
|
||||
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
|
||||
start, end = end, start
|
||||
@ -98,11 +112,11 @@ func yankVisualMode(m action.Model, start, end action.Position) {
|
||||
|
||||
// Single line selection
|
||||
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
|
||||
startCol := min(start.Col, len(line))
|
||||
cnt := line[startCol:endCol]
|
||||
m.UpdateDefaultRegister(action.CharwiseRegister, []string{cnt})
|
||||
m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt})
|
||||
return
|
||||
}
|
||||
|
||||
@ -110,24 +124,27 @@ func yankVisualMode(m action.Model, start, end action.Position) {
|
||||
var content []string
|
||||
|
||||
// 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))
|
||||
content = append(content, firstLine[startCol:])
|
||||
|
||||
// Middle lines: entire lines
|
||||
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)
|
||||
lastLine := m.Line(end.Line)
|
||||
lastLine := buf.Lines[end.Line]
|
||||
endCol := min(end.Col+1, len(lastLine))
|
||||
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
|
||||
if start.Col != end.Col {
|
||||
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)
|
||||
endY := max(start.Line, end.Line)
|
||||
|
||||
cnt := m.Lines()[startY : endY+1]
|
||||
m.UpdateDefaultRegister(action.LinewiseRegister, cnt)
|
||||
cnt := buf.Lines[startY : endY+1]
|
||||
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
|
||||
startY := min(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
|
||||
|
||||
for y := startY; y <= endY; y++ {
|
||||
line := m.Line(y)
|
||||
line := buf.Lines[y]
|
||||
|
||||
// Handle lines shorter than the block selection
|
||||
if startX >= len(line) {
|
||||
@ -165,5 +185,5 @@ func yankVisualBlockMode(m action.Model, start, end action.Position) {
|
||||
content = append(content, line[startX:lineEndX])
|
||||
}
|
||||
|
||||
m.UpdateDefaultRegister(action.BlockwiseRegister, content)
|
||||
m.UpdateDefaultRegister(core.BlockwiseRegister, content)
|
||||
}
|
||||
|
||||
120
internal/program/program_builder.go
Normal file
120
internal/program/program_builder.go
Normal 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...)
|
||||
}
|
||||
1066
internal/program/program_builder_test.go
Normal file
1066
internal/program/program_builder_test.go
Normal file
File diff suppressed because it is too large
Load Diff
75
internal/style/style.go
Normal file
75
internal/style/style.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user