Compare commits

..

No commits in common. "master" and "feature/gap-buffer" have entirely different histories.

26 changed files with 267 additions and 3624 deletions

View File

@ -14,8 +14,8 @@
- [x] `b` - Backward to start of word
- [x] `W` - Forward to start of WORD (whitespace-delimited)
- [x] `E` - Forward to end of WORD
- [x] `B` - Backward to start of WORD
- [x] `ge` - Backward to end of word
- [ ] `B` - Backward to start of WORD
- [ ] `ge` - Backward to end of word
### Line Movement
- [x] `0` - Move to start of line
@ -34,8 +34,8 @@
### Scroll
- [x] `ctrl+u` - Scroll up half page
- [x] `ctrl+d` - Scroll down half page
- [x] `ctrl+b` - Scroll up full page
- [x] `ctrl+f` - Scroll down full page
- [ ] `ctrl+b` - Scroll up full page
- [ ] `ctrl+f` - Scroll down full page
- [ ] `ctrl+y` - Scroll up one line
- [ ] `ctrl+e` - Scroll down one line
- [ ] `zz` - Center cursor on screen
@ -133,8 +133,8 @@
- [ ] `U` - Undo all changes on line
### Other Normal Mode
- [x] `r{char}` - Replace character
- [x] `R` - Replace mode
- [ ] `r{char}` - Replace character
- [ ] `R` - Replace mode
- [ ] `~` - Swap case of character
- [ ] `ctrl+a` - Increment number
- [ ] `ctrl+x` - Decrement number
@ -219,7 +219,7 @@
- [x] `:q!` - Force quit
- [x] `:e {file}` - Edit file
- [x] `:bn` / `:bp` - Next/previous buffer
- [x] `:{range}` - Go to line
- [ ] `:{range}` - Go to line
- [ ] `:%s/old/new/g` - Search and replace
- [ ] `:!{cmd}` - Run shell command
- [ ] `:help` - Show help
@ -408,3 +408,63 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [ ] Spell check
---
### Well Tested - Editor Core
#### 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, $, _, ^, |)
- [x] Scroll actions (ctrl+u, ctrl+d)
- [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] Register behavior

View File

@ -64,18 +64,6 @@ While the undo tree method that vim uses is powerful, I rarely find myself using
approach is more natural to "non-vim" users and much simpler to implement. Implementing a feature similar
to Vims undo tree would many times longer than a simple stack.
#### Vim-like Replace vs. Custom Replace
The way that vim's replace mode is implemented is quite complex, keeping track of the previous
line backspace can only delete newly replaced characters. This is a complex feature, one that
I rarely use, and even find a bit un-intuitive. Implementing replace mode in a way where all
actions function the same as insert mode (other than the actual character typing) allows for
a much simpler implementation, as well as a more intuitive user experience.
Replace mode implements and replaces (no pun intended) the last inserted keys of insert mode. Due to
the infrequent use of replace mode, and the '.' action for insert mode, this felt like a natural
trade off.
---
## TODO List

39
V0.1.md
View File

@ -1,39 +0,0 @@
# Gim v0.1 MVP
## Must Have (Blocker for Release)
- [x] Modal editing (normal, insert, visual)
- [x] Core motions and operators
- [x] Text objects
- [x] Undo/redo
- [x] File I/O
- [x] Buffer switching
- [ ] Search (/, ?, n, N) with highlighting
- [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS)
- [ ] % (matching bracket)
- [ ] J (join lines)
- [ ] H/M/L (screen movement)
- [ ] Status line (mode, filename, position, modified flag)
## Should Have (Makes it Usable)
- [ ] :substitute (%s/old/new/g) - at least basic version
- [ ] Better command-mode autocomplete/hints
- [ ] Configuration file support (~/.gimrc or similar)
- [ ] Persistent undo history
- [ ] Line wrapping display option
- [ ] Handle large files gracefully (>10k lines)
## Nice to Have (Polish)
- [ ] Incremental search (search as you type)
- [ ] * and # (search word under cursor)
- [ ] Cursor line highlight
- [ ] Scroll cursor to center (zz, zt, zb)
- [ ] Named registers (a-z)
- [ ] Black hole register (_)
## Won't Have in v0.1 (Future)
- LSP integration → v0.2
- Fuzzy finder → v0.3
- Splits/windows → v0.3
- Macros → v0.2
- Git integration → v0.3
- Plugin system → v1.0

View File

@ -23,13 +23,11 @@ func main() {
prog = program.NewProgramBuilder().
EmptyProgram().
WithOpt(tea.WithAltScreen()).
WithOpt(tea.WithMouseCellMotion()).
Build()
} else {
prog = program.NewProgramBuilder().
FileProgram(args[0]).
WithOpt(tea.WithAltScreen()).
WithOpt(tea.WithMouseCellMotion()).
Build()
}

View File

@ -24,8 +24,6 @@
glibc_multi
];
name = "Gim";
# Define the shell that will be executed.
# Here, we explicitly use zsh.
# Note: pkgs.zsh needs to be included in `packages` or `nativeBuildInputs`

View File

@ -1,8 +1,6 @@
package action
import (
"strconv"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
@ -141,12 +139,6 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
// history = append([]string{cmdLine}, history...)
m.SetCommandHistory(history)
// try to parse entire thing as a number
num, err := strconv.Atoi(cmdLine)
if err == nil {
return cmdGoToLine(m, num)
}
cmd, err := a.Registry.Execute(m, cmdLine)
if err != nil {
out := core.CommandOutput{
@ -160,23 +152,3 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
return cmd
}
// cmdGoToLine: DOES NOT implement command.Command. Instead, is called directly
// by CommandExecture.Execute(). Jumps the cursor to the line provided
func cmdGoToLine(m Model, line int) tea.Cmd {
// number below 0 just goes back that many
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if line <= 0 {
newLine := max(0, win.Cursor.Line+line)
win.SetCursorLine(newLine)
return nil
}
newLine := min(line-1, buf.LineCount())
win.SetCursorLine(newLine)
return nil
}

View File

@ -1,725 +0,0 @@
package action
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// mockCommandRegistry for testing - returns nil for all commands (used for numeric goto)
type mockCommandRegistry struct{}
func (r *mockCommandRegistry) Execute(m Model, cmdLine string) (tea.Cmd, error) {
return nil, nil
}
// TestCommandExecute_GoToLine tests the :<num> command functionality
func TestCommandExecute_GoToLine(t *testing.T) {
t.Run("goto positive line number within bounds", func(t *testing.T) {
// Create buffer with 10 lines
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
// Set command to "5"
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 5 (0-indexed: line 4)
if win.Cursor.Line != 4 {
t.Fatalf("expected cursor at line 4, got %d", win.Cursor.Line)
}
// Should exit command mode
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
})
t.Run("goto line 1", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(3) // Start at line 4
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 1 (0-indexed: line 0)
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("goto last line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should jump to line 5 (0-indexed: line 4)
if win.Cursor.Line != 4 {
t.Fatalf("expected cursor at line 4, got %d", win.Cursor.Line)
}
})
t.Run("goto line beyond buffer length clamps to last line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
m.SetCommand("999")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to last line (0-indexed: line 2)
// Note: cmdGoToLine uses min(line-1, buf.LineCount())
// LineCount() returns 3, so min(998, 3) = 3
// But SetCursorLine calls ClampCursor() which clamps to valid range [0, LineCount-1]
lastLine := buf.LineCount() - 1
if win.Cursor.Line != lastLine {
t.Fatalf("expected cursor at line %d (last line), got %d", lastLine, win.Cursor.Line)
}
})
t.Run("goto line zero goes to beginning", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(3)
m.SetCommand("0")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Line 0 or below goes relative: max(0, currentLine + 0) = currentLine
// But with the logic: if line <= 0, newLine = max(0, win.Cursor.Line + line)
// So for line=0: max(0, 3+0) = 3 (stays at same line)
if win.Cursor.Line != 3 {
t.Fatalf("expected cursor at line 3, got %d", win.Cursor.Line)
}
})
t.Run("negative number moves relative backwards", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5", "line6", "line7", "line8", "line9", "line10"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(5) // Start at line 6 (0-indexed)
m.SetCommand("-3")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should move back 3 lines: 5 + (-3) = 2
if win.Cursor.Line != 2 {
t.Fatalf("expected cursor at line 2, got %d", win.Cursor.Line)
}
})
t.Run("negative number beyond start clamps to line 0", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(2)
m.SetCommand("-10")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to line 0: max(0, 2 + (-10)) = max(0, -8) = 0
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("goto same line", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(2) // At line 3
m.SetCommand("3")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should stay at line 3 (0-indexed: line 2)
if win.Cursor.Line != 2 {
t.Fatalf("expected cursor at line 2, got %d", win.Cursor.Line)
}
})
t.Run("empty buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should stay at line 0
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("single line buffer goto line 1", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"only line"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if win.Cursor.Line != 0 {
t.Fatalf("expected cursor at line 0, got %d", win.Cursor.Line)
}
})
t.Run("single line buffer goto beyond", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"only line"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("10")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to the available lines
if win.Cursor.Line > 0 {
t.Fatalf("expected cursor at line 0 or 1, got %d", win.Cursor.Line)
}
})
t.Run("large line number", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
m.SetCommand("1000000")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should clamp to last available line
lineCount := buf.LineCount()
if win.Cursor.Line > lineCount {
t.Fatalf("expected cursor at or before line %d, got %d", lineCount, win.Cursor.Line)
}
})
t.Run("command with leading/trailing spaces", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
win.SetCursorLine(0)
// strconv.Atoi should handle leading spaces
m.SetCommand(" 3 ")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// strconv.Atoi(" 3 ") will fail, so this won't be treated as a line number
// It should stay at the same position and potentially show an error
// depending on the CommandRegistry behavior
})
t.Run("mixed alphanumeric command not treated as line number", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3"}).
Build()
m := NewMockModelWithBuffer(&buf)
win := m.ActiveWindow()
initialLine := 1
win.SetCursorLine(initialLine)
m.SetCommand("3abc")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// "3abc" is not a valid number, so won't trigger goto line
// Cursor should stay at original position
if win.Cursor.Line != initialLine {
t.Fatalf("expected cursor at line %d, got %d", initialLine, win.Cursor.Line)
}
})
}
// TestCommandExecute_EmptyCommand tests empty command behavior
func TestCommandExecute_EmptyCommand(t *testing.T) {
t.Run("empty command does nothing", func(t *testing.T) {
m := NewMockModel()
win := m.ActiveWindow()
initialLine := 0
win.SetCursorLine(initialLine)
m.SetCommand("")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
// Should exit command mode
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
// Cursor should not move
if win.Cursor.Line != initialLine {
t.Fatalf("expected cursor at line %d, got %d", initialLine, win.Cursor.Line)
}
})
}
// TestCommandExecute_CommandHistory tests that commands are added to history
func TestCommandExecute_CommandHistory(t *testing.T) {
t.Run("numeric command added to history", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithLines([]string{"line1", "line2", "line3", "line4", "line5"}).
Build()
m := NewMockModelWithBuffer(&buf)
m.SetCommand("3")
m.SetMode(core.CommandMode)
m.SetCommandHistory([]string{})
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Fatalf("expected 1 item in history, got %d", len(history))
}
if history[0] != "3" {
t.Fatalf("expected '3' in history, got '%s'", history[0])
}
})
t.Run("commands prepended to existing history", func(t *testing.T) {
m := NewMockModel()
m.SetCommandHistory([]string{"oldcommand1", "oldcommand2"})
m.SetCommand("5")
m.SetMode(core.CommandMode)
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 3 {
t.Fatalf("expected 3 items in history, got %d", len(history))
}
if history[0] != "5" {
t.Fatalf("expected '5' as first item, got '%s'", history[0])
}
if history[1] != "oldcommand1" {
t.Fatalf("expected 'oldcommand1' as second item, got '%s'", history[1])
}
})
}
// TestCommandExecute_ModeTransition tests command mode exit behavior
func TestCommandExecute_ModeTransition(t *testing.T) {
t.Run("exits command mode after execution", func(t *testing.T) {
m := NewMockModel()
m.SetMode(core.CommandMode)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
})
t.Run("resets command cursor to 0", func(t *testing.T) {
m := NewMockModel()
m.SetCommandCursor(10)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.CommandCursor() != 0 {
t.Fatalf("expected command cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("resets command history cursor to 0", func(t *testing.T) {
m := NewMockModel()
m.SetCommandHistoryCursor(5)
m.SetCommand("5")
action := CommandExecute{Registry: &mockCommandRegistry{}}
action.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Fatalf("expected command history cursor at 0, got %d", m.CommandHistoryCursor())
}
})
}
// TestExitCommandMode tests the Esc key behavior
func TestExitCommandMode(t *testing.T) {
t.Run("exit command mode clears state", func(t *testing.T) {
m := NewMockModel()
m.SetMode(core.CommandMode)
m.SetCommand("test command")
m.SetCommandCursor(5)
m.SetCommandHistoryCursor(3)
action := ExitCommandMode{}
action.Execute(m)
if m.Mode() != core.NormalMode {
t.Fatalf("expected normal mode, got %v", m.Mode())
}
if m.Command() != "" {
t.Fatalf("expected empty command, got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected command cursor at 0, got %d", m.CommandCursor())
}
if m.CommandHistoryCursor() != 0 {
t.Fatalf("expected command history cursor at 0, got %d", m.CommandHistoryCursor())
}
})
}
// TestInsertCommandChar tests character insertion in command mode
func TestInsertCommandChar(t *testing.T) {
t.Run("insert character at empty command", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("")
m.SetCommandCursor(0)
action := InsertCommandChar{Char: "5"}
action.Execute(m)
if m.Command() != "5" {
t.Fatalf("expected '5', got '%s'", m.Command())
}
if m.CommandCursor() != 1 {
t.Fatalf("expected cursor at 1, got %d", m.CommandCursor())
}
})
t.Run("insert character at end", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("12")
m.SetCommandCursor(2)
action := InsertCommandChar{Char: "3"}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 3 {
t.Fatalf("expected cursor at 3, got %d", m.CommandCursor())
}
})
t.Run("insert character in middle", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("13")
m.SetCommandCursor(1)
action := InsertCommandChar{Char: "2"}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandBackspace tests backspace in command mode
func TestCommandBackspace(t *testing.T) {
t.Run("backspace at beginning does nothing", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(0)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "123" {
t.Fatalf("expected '123', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("backspace in middle", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(2)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "13" {
t.Fatalf("expected '13', got '%s'", m.Command())
}
if m.CommandCursor() != 1 {
t.Fatalf("expected cursor at 1, got %d", m.CommandCursor())
}
})
t.Run("backspace at end", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(3)
action := CommandBackspace{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandDelete tests delete key in command mode
func TestCommandDelete(t *testing.T) {
t.Run("delete at cursor position 0 deletes character after cursor", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(0)
action := CommandDelete{}
action.Execute(m)
// At position 0, delete removes char at position 1 (the next char)
// Result: "1" + "3" = "13"
if m.Command() != "13" {
t.Fatalf("expected '13', got '%s'", m.Command())
}
})
t.Run("delete at cursor position 1", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(1)
action := CommandDelete{}
action.Execute(m)
// At position 1, delete removes char at position 2 (the next char)
// Result: "12" + "" = "12"
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
})
t.Run("delete at last character", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(2)
action := CommandDelete{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
})
t.Run("delete at end acts as backspace", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("123")
m.SetCommandCursor(3)
action := CommandDelete{}
action.Execute(m)
if m.Command() != "12" {
t.Fatalf("expected '12', got '%s'", m.Command())
}
if m.CommandCursor() != 2 {
t.Fatalf("expected cursor at 2, got %d", m.CommandCursor())
}
})
}
// TestCommandDeletePreviousWord tests Ctrl+W behavior in command mode
func TestCommandDeletePreviousWord(t *testing.T) {
t.Run("delete word from middle of text", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(11) // At end
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello " {
t.Fatalf("expected 'hello ', got '%s'", m.Command())
}
if m.CommandCursor() != 6 {
t.Fatalf("expected cursor at 6, got %d", m.CommandCursor())
}
})
t.Run("delete word with leading spaces", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(13) // At end
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello " {
t.Fatalf("expected 'hello ', got '%s'", m.Command())
}
})
t.Run("delete at beginning does nothing", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello")
m.SetCommandCursor(0)
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello" {
t.Fatalf("expected 'hello', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
t.Run("delete punctuation", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello...")
m.SetCommandCursor(8) // After the dots
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "hello" {
t.Fatalf("expected 'hello', got '%s'", m.Command())
}
if m.CommandCursor() != 5 {
t.Fatalf("expected cursor at 5, got %d", m.CommandCursor())
}
})
t.Run("delete word in middle of command", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("one two three")
m.SetCommandCursor(7) // After "two"
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "one three" {
t.Fatalf("expected 'one three', got '%s'", m.Command())
}
if m.CommandCursor() != 4 {
t.Fatalf("expected cursor at 4, got %d", m.CommandCursor())
}
})
t.Run("delete single character word", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("a b c")
m.SetCommandCursor(3) // After "b"
action := CommandDeletePreviousWord{}
action.Execute(m)
if m.Command() != "a c" {
t.Fatalf("expected 'a c', got '%s'", m.Command())
}
})
t.Run("delete from whitespace position", func(t *testing.T) {
m := NewMockModel()
m.SetCommand("hello world")
m.SetCommandCursor(6) // At the space after "hello"
action := CommandDeletePreviousWord{}
action.Execute(m)
// Should skip the space and delete "hello" and the space
if m.Command() != "world" {
t.Fatalf("expected 'world', got '%s'", m.Command())
}
if m.CommandCursor() != 0 {
t.Fatalf("expected cursor at 0, got %d", m.CommandCursor())
}
})
}

View File

@ -1,129 +0,0 @@
package action
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
type ReplaceChar struct {
Char string
Count int
}
func (m ReplaceChar) WithChar(char string) Motion {
m.Char = char
return m
}
func (m ReplaceChar) Type() core.MotionType {
return core.CharwiseInclusive
}
// WithCount sets the count (required by Repeatable interface)
func (m ReplaceChar) WithCount(n int) Action {
m.Count = n
return m
}
func (a ReplaceChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
buf.UndoStack.BeginBlock(win.Cursor)
}
pos := win.Cursor.Col
line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos < len(line); i++ {
line = line[:pos] + a.Char + line[pos+1:]
buf.SetLine(win.Cursor.Line, line)
pos++
}
win.SetCursorCol(pos - 1)
m.SetMode(core.NormalMode)
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return nil
}
type EnterReplace struct {
Count int
}
func (a EnterReplace) WithCount(n int) Action {
a.Count = n
return a
}
func (a EnterReplace) Execute(m Model) tea.Cmd {
m.SetMode(core.ReplaceMode)
return nil
}
type ReplaceModeChar struct {
Char string
}
// ReplaceModeChar.Execute: Inserts a single character at the cursor position, overwriting current
// character.
func (a ReplaceModeChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
if x < len(l) {
buf.SetLine(y, l[:x]+a.Char+l[x+1:])
} else {
buf.SetLine(y, l+a.Char)
}
win.SetCursorCol(x + len(a.Char))
return nil
}
// ReplaceNewline splits the current line at the cursor (enter key)
type ReplaceNewline struct{}
// ReplaceNewline.Execute: Splits the current line at the cursor (Enter key).
func (a ReplaceNewline) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
if x == len(l) {
buf.InsertLine(y+1, "")
} else {
buf.SetLine(y, l[:x])
buf.InsertLine(y+1, l[x+1:])
}
win.SetCursorPos(y+1, 0)
return nil
}
// ReplaceTab inserts spaces equal to the tab size
type ReplaceTab struct{}
// ReplaceTab.Execute: Inserts spaces equal to the tab size (Tab key).
func (a ReplaceTab) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Line(y)
tabs := strings.Repeat(" ", m.Settings().TabStop)
if x < len(l) {
buf.SetLine(y, l[:x]+tabs+l[x+1:])
} else {
buf.SetLine(y, l+tabs)
}
win.SetCursorCol(x + len(tabs))
return nil
}

View File

@ -11,15 +11,13 @@ const (
VisualMode
VisualLineMode
VisualBlockMode
ReplaceMode
WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor
)
// 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, WaitingMode:
case NormalMode:
return "NORMAL"
case InsertMode:
return "INSERT"
@ -31,8 +29,6 @@ func (m Mode) ToString() string {
return "V-LINE"
case VisualBlockMode:
return "V-BLOCK"
case ReplaceMode:
return "REPLACE"
default:
return "-----"
}

View File

@ -34,16 +34,6 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR})
case "ctrl+w":
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW})
case "tab":
tm.Send(tea.KeyMsg{Type: tea.KeyTab})
case "left":
tm.Send(tea.KeyMsg{Type: tea.KeyLeft})
case "right":
tm.Send(tea.KeyMsg{Type: tea.KeyRight})
case "up":
tm.Send(tea.KeyMsg{Type: tea.KeyUp})
case "down":
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
default:
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
}

View File

@ -176,7 +176,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0]) - 1
want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}
@ -188,7 +188,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0]) - 1
want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}
@ -200,7 +200,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0]) - 1
want := len(lines[0])
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}

View File

@ -1685,816 +1685,3 @@ func TestMoveForwardWORDEndInVisualMode(t *testing.T) {
}
})
}
// --- B Motion Tests ---
func TestMoveBackwardWORD(t *testing.T) {
t.Run("test 'B' moves backward one WORD", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
// Should move to start of "world" (index 6)
if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'BB' moves backward two WORDs", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "B", "B")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '2B' moves backward two WORDs with count", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "2", "B")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' on punctuation-heavy text", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
// "hello.world" is one WORD, should move to "next" (index 12)
if m.ActiveWindow().Cursor.Col != 12 {
t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' vs 'b' on punctuation", func(t *testing.T) {
lines := []string{"hello.world next"}
// Test 'b'
tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm1, "b")
m1 := getFinalModel(t, tm1)
// 'b' moves to start of "next" (index 12)
if m1.ActiveWindow().Cursor.Col != 12 {
t.Errorf("'b': CursorX() = %d, want 12", m1.ActiveWindow().Cursor.Col)
}
// Test 'B'
tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm2, "B")
m2 := getFinalModel(t, tm2)
// 'B' treats "hello.world" as one WORD, moves to index 12
if m2.ActiveWindow().Cursor.Col != 12 {
t.Errorf("'B': CursorX() = %d, want 12", m2.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' crosses lines backward", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 1})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' at beginning of file", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines) // Cursor at 0,0
sendKeys(tm, "B")
m := getFinalModel(t, tm)
// Should stay at 0,0
if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line)
}
})
t.Run("test 'B' with multiple spaces", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
// Should skip spaces and move to "hello"
if m.ActiveWindow().Cursor.Col != 9 {
t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' on empty lines", func(t *testing.T) {
lines := []string{"hello", "", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 2})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'B' complex code-like text", func(t *testing.T) {
lines := []string{"foo.bar(baz) next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0})
sendKeys(tm, "B")
m := getFinalModel(t, tm)
// "foo.bar(baz)" is one WORD, should move to "next" (index 13)
if m.ActiveWindow().Cursor.Col != 13 {
t.Errorf("CursorX() = %d, want 13", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'BB' complex code-like text", func(t *testing.T) {
lines := []string{"foo.bar(baz) next.thing"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 22, Line: 0})
sendKeys(tm, "B", "B")
m := getFinalModel(t, tm)
// Should move to start of first WORD "foo.bar(baz)" (index 0)
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
}
func TestMoveBackwardWORDWithOperator(t *testing.T) {
t.Run("test 'dB' deletes backward WORD including punctuation", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "d", "B")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello.world t" {
t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'dB' vs 'db' on dotted text", func(t *testing.T) {
lines := []string{"hello.world next"}
// First test 'db'
tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm1, "d", "b")
m1 := getFinalModel(t, tm1)
if m1.ActiveBuffer().Lines[0].String() != "hello.world t" {
t.Errorf("'db': Line(0) = %q, want 'hello.world t'", m1.ActiveBuffer().Lines[0].String())
}
// Now test 'dB'
tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm2, "d", "B")
m2 := getFinalModel(t, tm2)
if m2.ActiveBuffer().Lines[0].String() != "hello.world t" {
t.Errorf("'dB': Line(0) = %q, want 'hello.world t'", m2.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'd2B' deletes two WORDs backward", func(t *testing.T) {
lines := []string{"one.a two.b three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0})
sendKeys(tm, "d", "2", "B")
m := getFinalModel(t, tm)
// Should delete "two.b three" leaving "one.a e"
if m.ActiveBuffer().Lines[0].String() != "one.a e" {
t.Errorf("Line(0) = %q, want 'one.a e'", m.ActiveBuffer().Lines[0].String())
}
})
// BUG: This is a failing tests, cursor is not moving at start of yank
t.Run("test 'yB' yanks WORD including punctuation", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "y", "B")
m := getFinalModel(t, tm)
// Text should remain unchanged
if m.ActiveBuffer().Lines[0].String() != "hello.world next" {
t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String())
}
// Cursor should be at start of yanked region (index 12)
if m.ActiveWindow().Cursor.Col != 12 {
t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'cB' changes WORD backward", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "c", "B")
m := getFinalModel(t, tm)
// Should delete " next" and enter insert mode
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0].String() != "hello.world t" {
t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestMoveBackwardWORDVisualMode(t *testing.T) {
t.Run("test 'vB' selects backward WORD", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "B")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
// Cursor at start of "next" (index 12)
if m.ActiveWindow().Cursor.Col != 12 {
t.Errorf("CursorX() = %d, want 12", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'vBd' deletes selected WORD", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "B", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello.world " {
t.Errorf("Line(0) = %q, want 'hello.world '", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'v2B' selects backward two WORDs", func(t *testing.T) {
lines := []string{"foo.bar baz.qux rest"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0})
sendKeys(tm, "v", "2", "B")
m := getFinalModel(t, tm)
// Cursor at start of "baz.qux" (index 8)
if m.ActiveWindow().Cursor.Col != 8 {
t.Errorf("CursorX() = %d, want 8", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'VB' in visual line mode", func(t *testing.T) {
lines := []string{"hello.world", "next line"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 1})
sendKeys(tm, "V", "B")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
// Should select both lines
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
}
})
}
// --- ge Motion Tests ---
func TestMoveBackwardWordEnd(t *testing.T) {
t.Run("test 'ge' moves backward to previous word end", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gege' moves backward two word ends", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "g", "e", "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "one" (index 2)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '2ge' moves backward two word ends with count", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "2", "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "one" (index 2)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' on punctuation-heavy text", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "world" (index 10)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' crosses lines backward", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "hello" on previous line (index 4)
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' at beginning of file", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines) // Cursor at 0,0
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should stay at 0,0
if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line)
}
})
t.Run("test 'ge' with multiple spaces", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should skip spaces and move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' on empty lines", func(t *testing.T) {
lines := []string{"hello", "", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should skip empty line and move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' respects word classes", func(t *testing.T) {
lines := []string{"foo-bar baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should move to end of "bar" (index 6)
if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'ge' on method chain", func(t *testing.T) {
lines := []string{"obj.method().chain() next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0})
sendKeys(tm, "g", "e")
m := getFinalModel(t, tm)
// Should move to end of ")" (index 19)
if m.ActiveWindow().Cursor.Col != 19 {
t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col)
}
})
}
func TestMoveBackwardWordEndWithOperator(t *testing.T) {
t.Run("test 'dge' deletes backward to word end", func(t *testing.T) {
lines := []string{"hello world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "d", "g", "e")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello worl" {
t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'd2ge' deletes backward two word ends", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "d", "2", "g", "e")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "on" {
t.Errorf("Line(0) = %q, want 'on'", m.ActiveBuffer().Lines[0].String())
}
})
// BUG: This is a failing tests, cursor is not moving at start of yank
t.Run("test 'yge' yanks backward to word end", func(t *testing.T) {
lines := []string{"hello world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "y", "g", "e")
m := getFinalModel(t, tm)
// Text should remain unchanged
if m.ActiveBuffer().Lines[0].String() != "hello world next" {
t.Errorf("Line(0) = %q, want 'hello world next'", m.ActiveBuffer().Lines[0].String())
}
// Cursor should be at end of "world" (index 10)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'cge' changes backward to word end", func(t *testing.T) {
lines := []string{"hello world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "c", "g", "e")
m := getFinalModel(t, tm)
// Should delete and enter insert mode
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0].String() != "hello worl" {
t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestMoveBackwardWordEndVisualMode(t *testing.T) {
t.Run("test 'vge' selects backward to word end", func(t *testing.T) {
lines := []string{"hello world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "g", "e")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
// Cursor at end of "world" (index 10)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'vged' deletes selection", func(t *testing.T) {
lines := []string{"hello world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "g", "e", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello worl" {
t.Errorf("Line(0) = %q, want 'hello worl'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'v2ge' selects backward two word ends", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "v", "2", "g", "e")
m := getFinalModel(t, tm)
// Cursor at end of "one" (index 2)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'Vge' in visual line mode", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "V", "g", "e")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
// Should select both lines
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
})
}
// --- gE Motion Tests ---
func TestMoveBackwardWORDEnd(t *testing.T) {
t.Run("test 'gE' moves backward to previous WORD end", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 10, Line: 0})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Should move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gEgE' moves backward two WORD ends", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "g", "E", "g", "E")
m := getFinalModel(t, tm)
// Should move to end of "one" (index 2)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '2gE' moves backward two WORD ends with count", func(t *testing.T) {
lines := []string{"one two three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 0})
sendKeys(tm, "2", "g", "E")
m := getFinalModel(t, tm)
// Should move to end of "one" (index 2)
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' on punctuation-heavy text", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// "hello.world" is one WORD ending at index 10
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' vs 'ge' on punctuation", func(t *testing.T) {
lines := []string{"hello.world next"}
// Test 'ge'
tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm1, "g", "e")
m1 := getFinalModel(t, tm1)
// 'ge' treats punctuation as separate word
if m1.ActiveWindow().Cursor.Col != 10 {
t.Errorf("'ge': CursorX() = %d, want 10", m1.ActiveWindow().Cursor.Col)
}
// Test 'gE'
tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm2, "g", "E")
m2 := getFinalModel(t, tm2)
// 'gE' treats "hello.world" as one WORD
if m2.ActiveWindow().Cursor.Col != 10 {
t.Errorf("'gE': CursorX() = %d, want 10", m2.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' crosses lines backward", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Should move to end of "hello" on previous line (index 4)
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' at beginning of file", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines) // Cursor at 0,0
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Should stay at 0,0
if m.ActiveWindow().Cursor.Col != 0 || m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("Cursor = (%d,%d), want (0,0)", m.ActiveWindow().Cursor.Col, m.ActiveWindow().Cursor.Line)
}
})
t.Run("test 'gE' with multiple spaces", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 13, Line: 0})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Should skip spaces and move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' on empty lines", func(t *testing.T) {
lines := []string{"hello", "", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Should skip empty line and move to end of "hello" (index 4)
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' complex code-like text", func(t *testing.T) {
lines := []string{"foo.bar(baz) next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// "foo.bar(baz)" is one WORD ending at index 11
if m.ActiveWindow().Cursor.Col != 11 {
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'gE' on method chain", func(t *testing.T) {
lines := []string{"obj.method().chain() next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 24, Line: 0})
sendKeys(tm, "g", "E")
m := getFinalModel(t, tm)
// Entire chain is one WORD, ends at index 19
if m.ActiveWindow().Cursor.Col != 19 {
t.Errorf("CursorX() = %d, want 19", m.ActiveWindow().Cursor.Col)
}
})
}
func TestMoveBackwardWORDEndWithOperator(t *testing.T) {
t.Run("test 'dgE' deletes backward to WORD end", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "d", "g", "E")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello.worl" {
t.Errorf("Line(0) = %q, want 'hello.worl'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'dgE' vs 'dge' on dotted text", func(t *testing.T) {
lines := []string{"hello.world next"}
// First test 'dge'
tm1 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm1, "d", "g", "e")
m1 := getFinalModel(t, tm1)
// 'dge' should delete to end of "world"
if m1.ActiveBuffer().Lines[0].String() != "hello.worl" {
t.Errorf("'dge': Line(0) = %q, want 'hello.worl'", m1.ActiveBuffer().Lines[0].String())
}
// Now test 'dgE'
tm2 := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm2, "d", "g", "E")
m2 := getFinalModel(t, tm2)
if m2.ActiveBuffer().Lines[0].String() != "hello.worl" {
t.Errorf("'dgE': Line(0) = %q, want 'hello.worl'", m2.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'd2gE' deletes backward two WORD ends", func(t *testing.T) {
lines := []string{"one.a two.b three"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 16, Line: 0})
sendKeys(tm, "d", "2", "g", "E")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "one." {
t.Errorf("Line(0) = %q, want 'one.'", m.ActiveBuffer().Lines[0].String())
}
})
// BUG: This is a failing tests, cursor is not moving at start of yank
t.Run("test 'ygE' yanks backward to WORD end", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "y", "g", "E")
m := getFinalModel(t, tm)
// Text should remain unchanged
if m.ActiveBuffer().Lines[0].String() != "hello.world next" {
t.Errorf("Line(0) = %q, want 'hello.world next'", m.ActiveBuffer().Lines[0].String())
}
// Cursor should be at end of "hello.world" (index 10)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'cgE' changes backward to WORD end", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "c", "g", "E")
m := getFinalModel(t, tm)
// Should delete and enter insert mode
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0].String() != "hello.worl" {
t.Errorf("Line(0) = %q, want 'hello.world t'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestMoveBackwardWORDEndVisualMode(t *testing.T) {
t.Run("test 'vgE' selects backward to WORD end", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "g", "E")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualMode {
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
}
// Cursor at end of "hello.world" (index 10)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'vgEd' deletes selection", func(t *testing.T) {
lines := []string{"hello.world next"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "v", "g", "E", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello.worl" {
t.Errorf("Line(0) = %q, want 'hello.worl'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'v2gE' selects backward two WORD ends", func(t *testing.T) {
lines := []string{"foo.bar baz.qux rest"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 19, Line: 0})
sendKeys(tm, "v", "2", "g", "E")
m := getFinalModel(t, tm)
// Cursor at end of "foo.bar" (index 6)
if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("CursorX() = %d, want 6", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'VgE' in visual line mode", func(t *testing.T) {
lines := []string{"hello.world", "next line"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "V", "g", "E")
m := getFinalModel(t, tm)
if m.Mode() != core.VisualLineMode {
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
}
// Should select both lines
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
}
})
}

File diff suppressed because it is too large Load Diff

View File

@ -412,271 +412,6 @@ func TestHalfPageScrollRoundTrip(t *testing.T) {
})
}
// Tests use terminal 80x30: viewportH=28, full-scroll=28, scrollOff=8, safe zone relY 8-19.
func TestFullPageScrollDown(t *testing.T) {
t.Run("ctrl+f scrolls viewport down by full page", func(t *testing.T) {
// cursor at line 15 (relY=15, in safe zone), scrollY starts at 0
// After ctrl+f: newScrollY=28, newCursorY=28+15=43
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 28 {
t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 43 {
t.Errorf("CursorY() = %d, want 43", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+f preserves cursor relative position in viewport", func(t *testing.T) {
// relY=15 before and after
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY
if relY != 15 {
t.Errorf("relative position = %d, want 15", relY)
}
})
t.Run("ctrl+f clamps cursor to scrollOff when cursor is near top", func(t *testing.T) {
// cursor at line 0 (relY=0 < scrollOff=8), clamp to scrollOff
// After ctrl+f: newScrollY=28, newCursorY=28+8=36
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 28 {
t.Errorf("ScrollY() = %d, want 28", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 36 {
t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+f at end of file does not scroll past max", func(t *testing.T) {
// 50 lines, viewport 28: maxScroll=22
// cursor at line 45: AdjustScroll puts cursor at scrollY=22 (clamped), relY=23
// After ctrl+f: newScrollY clamped to 22, relY=23>19 clamped to 19, newCursorY=41
lines := generateLines(50)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 45}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
maxScroll := 50 - 28
if m.ActiveWindow().ScrollY > maxScroll {
t.Errorf("ScrollY() = %d, want <= %d", m.ActiveWindow().ScrollY, maxScroll)
}
if m.ActiveWindow().Cursor.Line != 41 {
t.Errorf("CursorY() = %d, want 41", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+f on file smaller than viewport does not crash", func(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, core.Position{Col: 0, Line: 0}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 8 {
t.Errorf("CursorY() = %d, want 8", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+f clamps cursor x to new line length", func(t *testing.T) {
// Lines 0-27: "hello world" (11 chars), lines 28-99: "hi" (2 chars)
// cursor at line 5, cursorX=10
// ctrl+f: relY=5<8 clamp to 8; newScrollY=28; newCursorY=36 (a "hi" line)
// ClampCursorX: 10 >= 2, so cursorX=2
lines := make([]string, 100)
for i := range 28 {
lines[i] = "hello world"
}
for i := 28; i < 100; i++ {
lines[i] = "hi"
}
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 10, Line: 5}, 80, 30)
sendKeys(tm, "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Line != 36 {
t.Errorf("CursorY() = %d, want 36", m.ActiveWindow().Cursor.Line)
}
if m.ActiveWindow().Cursor.Col > m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len() {
t.Errorf("CursorX() = %d exceeds line length %d", m.ActiveWindow().Cursor.Col, m.ActiveBuffer().Lines[m.ActiveWindow().Cursor.Line].Len())
}
})
t.Run("multiple ctrl+f presses scroll incrementally", func(t *testing.T) {
// cursor at line 15, scrollY=0, relY=15
// ctrl+f #1: scrollY=28, cursorY=43, relY=15
// ctrl+f #2: scrollY=56, cursorY=71, relY=15
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+f", "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 56 {
t.Errorf("ScrollY() = %d, want 56", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 71 {
t.Errorf("CursorY() = %d, want 71", m.ActiveWindow().Cursor.Line)
}
})
}
func TestFullPageScrollUp(t *testing.T) {
t.Run("ctrl+b scrolls viewport up by full page", func(t *testing.T) {
// cursor at line 50: AdjustScroll -> scrollY=31, relY=19
// After ctrl+b: newScrollY=3, newCursorY=3+19=22
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+b")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 3 {
t.Errorf("ScrollY() = %d, want 3", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 22 {
t.Errorf("CursorY() = %d, want 22", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+b preserves cursor relative position in viewport", func(t *testing.T) {
// relY=19 before and after
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+b")
m := getFinalModel(t, tm)
relY := m.ActiveWindow().Cursor.Line - m.ActiveWindow().ScrollY
if relY != 19 {
t.Errorf("relative position = %d, want 19", relY)
}
})
t.Run("ctrl+b at top of file does not make scrollY negative", func(t *testing.T) {
// cursor at line 20, scrollY=0, relY=20 (clamped to 19 by scrollOff)
// AdjustScroll: relY=20>19, clamp to 19, scrollY stays 0, cursorY=19
// ctrl+b: newScrollY=max(0,-28)=0, relY=19 preserved, cursorY=19
lines := generateLines(100)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 20}, 80, 30)
sendKeys(tm, "ctrl+b")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY < 0 {
t.Errorf("ScrollY() = %d, want >= 0", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
}
// Cursor should be clamped to safe zone bottom (relY=19)
if m.ActiveWindow().Cursor.Line != 19 {
t.Errorf("CursorY() = %d, want 19", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+b clamps cursor to scrollOff when cursor is near top of viewport", func(t *testing.T) {
// cursor at line 30, scrollY=0, relY=30>19 -> clamp to 19, cursorY=19
// ctrl+b: newScrollY=0; relY=19; newCursorY=19
lines := generateLines(100)
// First normalize the position
m := getFinalModel(t, newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 30}, 80, 30))
initialY := m.ActiveWindow().Cursor.Line
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: initialY}, 80, 30)
sendKeys(tm, "ctrl+b")
m2 := getFinalModel(t, tm)
if m2.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m2.ActiveWindow().ScrollY)
}
// Cursor should remain in safe zone
relY := m2.ActiveWindow().Cursor.Line - m2.ActiveWindow().ScrollY
if relY < 8 || relY > 19 {
t.Errorf("relY = %d, want in range [8, 19]", relY)
}
})
t.Run("multiple ctrl+b presses scroll incrementally", func(t *testing.T) {
// cursor at line 80: AdjustScroll -> scrollY=61, relY=19
// ctrl+b #1: newScrollY=33, cursorY=52
// ctrl+b #2: newScrollY=5, cursorY=24
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 80}, 80, 30)
sendKeys(tm, "ctrl+b", "ctrl+b")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 5 {
t.Errorf("ScrollY() = %d, want 5", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 24 {
t.Errorf("CursorY() = %d, want 24", m.ActiveWindow().Cursor.Line)
}
})
}
func TestFullPageScrollRoundTrip(t *testing.T) {
t.Run("ctrl+f then ctrl+b returns cursor to original position", func(t *testing.T) {
// cursor at line 15, scrollY=0, relY=15
// ctrl+f: scrollY=28, cursorY=43, relY=15
// ctrl+b: newScrollY=max(0,28-28)=0, cursorY=0+15=15
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+f", "ctrl+b")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15", m.ActiveWindow().Cursor.Line)
}
})
t.Run("ctrl+b then ctrl+f returns cursor to original position", func(t *testing.T) {
// cursor at line 50: AdjustScroll -> scrollY=31, relY=19
// ctrl+b: scrollY=3, cursorY=22, relY=19
// ctrl+f: scrollY=31, cursorY=50, relY=19
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 50}, 80, 30)
sendKeys(tm, "ctrl+b", "ctrl+f")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 31 {
t.Errorf("ScrollY() = %d, want 31", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 50 {
t.Errorf("CursorY() = %d, want 50", m.ActiveWindow().Cursor.Line)
}
})
t.Run("alternating ctrl+f and ctrl+b maintains scroll stability", func(t *testing.T) {
lines := generateLines(200)
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 15}, 80, 30)
sendKeys(tm, "ctrl+f", "ctrl+b", "ctrl+f", "ctrl+b")
m := getFinalModel(t, tm)
if m.ActiveWindow().ScrollY != 0 {
t.Errorf("ScrollY() = %d, want 0 after 2 round trips", m.ActiveWindow().ScrollY)
}
if m.ActiveWindow().Cursor.Line != 15 {
t.Errorf("CursorY() = %d, want 15 after 2 round trips", m.ActiveWindow().Cursor.Line)
}
})
}
func TestScrollWithCount(t *testing.T) {
t.Run("5j scrolls appropriately", func(t *testing.T) {
lines := generateLines(50)

View File

@ -398,8 +398,9 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
}
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
// $ moves past end of line
if m.ActiveWindow().Cursor.Col != 11 {
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
}
})

View File

@ -11,7 +11,76 @@ type ModelBuilder struct {
model Model
}
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
// RPGLE
// abap
// algol
// algol_nu
// arduino
// ashen
// aura-theme-dark
// aura-theme-dark-soft
// autumn
// average
// base16-snazzy
// borland
// bw
// catppuccin-frappe
// catppuccin-latte
// catppuccin-macchiato
// catppuccin-mocha
// colorful
// doom-one
// doom-one2
// dracula
// emacs
// evergarden
// friendly
// fruity
// github
// github-dark
// gruvbox
// gruvbox-light
// hr_high_contrast
// hrdark
// igor
// lovelace
// manni
// modus-operandi
// modus-vivendi
// monokai
// monokailight
// murphy
// native
// nord
// nordic
// onedark
// onesenterprise
// paraiso-dark
// paraiso-light
// pastie
// perldoc
// pygments
// rainbow_dash
// rose-pine
// rose-pine-dawn
// rose-pine-moon
// rrt
// solarized-dark
// solarized-dark256
// solarized-light
// swapoff
// tango
// tokyonight-day
// tokyonight-moon
// tokyonight-night
// tokyonight-storm
// trac
// vim
// vs
// vulcan
// witchhazel
// xcode
// xcode-dark
func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave")

View File

@ -1,95 +1,83 @@
package editor
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/motion"
tea "github.com/charmbracelet/bubbletea"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// Model.Update: Handles BubbleTea messages including window resizes and key
// presses. Routes input to the handler and adjusts scroll after updates.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.termHeight = msg.Height
m.termWidth = msg.Width
case tea.WindowSizeMsg:
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
}
// 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
}
// TODO: This is not great, totally temporary. But I don't like vim's handling, so this is up to me
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonWheelUp:
scrollAction := motion.ScrollUpPage{Divisor: 4} // Quarter page
cmd = scrollAction.Execute(m)
case tea.MouseButtonWheelDown:
scrollAction := motion.ScrollDownPage{Divisor: 4} // Quarter page
cmd = scrollAction.Execute(m)
}
case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests.
// Ctrl+C always quits regardless of mode
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
case tea.KeyMsg:
// TODO: This needs to be removed, but for now its required for the tests.
// Ctrl+C always quits regardless of mode
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
// TODO: This is not great
// TODO: Any vim action should exit also
// Simple override for command output mode for now
if m.Mode() == core.CommandOutputMode {
// TODO: Implement g/G/d/u
switch msg.String() {
case "enter":
m.SetMode(core.NormalMode)
m.SetCommandOutput(&core.CommandOutput{})
case "j":
m.CommandOutput().ScrollDown(m.termHeight)
case "k":
m.CommandOutput().ScrollUp()
}
} else {
cmd = m.input.Handle(m, msg.String())
}
}
// TODO: This is not great
// TODO: Any vim action should exit also
// Simple override for command output mode for now
if m.Mode() == core.CommandOutputMode {
// TODO: Implement g/G/d/u
switch msg.String() {
case "enter":
m.SetMode(core.NormalMode)
m.SetCommandOutput(&core.CommandOutput{})
case "j":
m.CommandOutput().ScrollDown(m.termHeight)
case "k":
m.CommandOutput().ScrollUp()
}
} else {
cmd = m.input.Handle(m, msg.String())
}
}
// Keep cursor in view after any update
win := m.ActiveWindow()
win.AdjustScroll()
// Keep cursor in view after any update
win := m.ActiveWindow()
win.AdjustScroll()
return m, cmd
return m, cmd
}

View File

@ -2,7 +2,6 @@ package editor
import (
"fmt"
"strconv"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
@ -23,10 +22,6 @@ func (m Model) View() string {
styles := m.Styles()
options := win.Options
// Adjust gutter to fit line len
maxLineLen := len(strconv.Itoa(win.Buffer.LineCount()))
options.GutterSize = max(options.GutterSize, maxLineLen+2)
// Draw window
view := viewWindow(win, styles, options, m.Mode())

View File

@ -41,7 +41,6 @@ type Handler struct {
normalKeymap *Keymap
visualKeymap *Keymap
insertKeymap *Keymap
replaceKeymap *Keymap
commandKeymap *Keymap
currentKeymap *Keymap
@ -54,7 +53,6 @@ func NewHandler() *Handler {
normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(),
insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(),
currentKeymap: nil,
}
@ -79,7 +77,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
h.recordingKeys = []string{} // Clear recording on ESC
h.Reset()
if m.Mode() == core.InsertMode || m.Mode() == core.ReplaceMode {
if m.Mode() == core.InsertMode {
// Before exiting insert mode, end the block in the undo stack
win := m.ActiveWindow()
buf := m.ActiveBuffer()
@ -97,8 +95,6 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
switch m.Mode() {
case core.InsertMode:
return h.handleInsertKey(m, key)
case core.ReplaceMode:
return h.handleReplaceKey(m, key)
case core.CommandMode:
return h.handleCommandKey(m, key)
}
@ -176,9 +172,6 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
func (h *Handler) dispatch(m action.Model, kind string, binding any, key string) tea.Cmd {
// Handle character motions (f/t/F/T) - transition to waiting state
if kind == "char_motion" {
if key == "r" {
m.SetMode(core.WaitingMode)
}
h.charMotionType = key
h.state = StateWaitingForChar
return nil
@ -369,11 +362,7 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Apply count if supported
if r, ok := mot.(action.Repeatable); ok {
result := r.WithCount(count)
// WithCount returns Action, but char motions still implement Motion
if m, ok := result.(action.Motion); ok {
mot = m
}
mot = r.WithCount(count).(action.Motion)
}
// If operator pending (e.g., "df{char}"), get range and operate
@ -389,14 +378,7 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Otherwise just execute the motion
cmd := h.executeMotion(m, mot)
// ReplaceChar modifies the buffer, so it should be repeatable with '.'
// (unlike f/t/F/T which are pure motions)
if _, isReplace := mot.(action.ReplaceChar); isReplace {
h.RecordAndReset(m)
} else {
h.Reset()
}
h.Reset()
return cmd
}
@ -574,32 +556,6 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
return action.InsertChar{Char: key}.Execute(m)
}
func (h *Handler) handleReplaceKey(m action.Model, key string) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
// Start undo block on first insert key
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
buf.UndoStack.BeginBlock(win.Cursor)
}
// Record the key for count replay (e.g. 5i...)
m.SetInsertKeys(append(m.InsertKeys(), key))
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
// Check the insert keymap first
kind, binding := h.replaceKeymap.Lookup(key)
switch kind {
case "action":
return binding.(action.Action).Execute(m)
case "motion":
return binding.(action.Motion).Execute(m)
}
// Fallback: treat as a regular character to "insert"
return action.ReplaceModeChar{Char: key}.Execute(m)
}
// Handler.handleCommandKey: Processes a keypress in command mode, executing
// it as an action or inserting it into the command line. This does not record
// anything into the undo stack.

View File

@ -38,13 +38,8 @@ func NewNormalKeymap() *Keymap {
"e": motion.MoveForwardWordEnd{Count: 1},
"E": motion.MoveForwardWORDEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
"B": motion.MoveBackwardWORD{Count: 1},
"ge": motion.MoveBackwardWordEnd{Count: 1},
"gE": motion.MoveBackwardWORDEnd{Count: 1},
"ctrl+u": motion.ScrollUpPage{Divisor: 2},
"ctrl+d": motion.ScrollDownPage{Divisor: 2},
"ctrl+b": motion.ScrollUpPage{Divisor: 1},
"ctrl+f": motion.ScrollDownPage{Divisor: 1},
"ctrl+u": motion.ScrollUpHalfPage{},
"ctrl+d": motion.ScrollDownHalfPage{},
";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true},
},
@ -77,14 +72,12 @@ func NewNormalKeymap() *Keymap {
"u": action.Undo{},
"ctrl+r": action.Redo{},
".": action.Repeat{Count: 1},
"R": action.EnterReplace{},
},
charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
"F": action.FindChar{Forward: false, Inclusive: true, Repeated: false},
"t": action.FindChar{Forward: true, Inclusive: false, Repeated: false},
"T": action.FindChar{Forward: false, Inclusive: false, Repeated: false},
"r": action.ReplaceChar{Count: 1},
},
modifiers: map[string]any{
"i": nil,
@ -115,31 +108,22 @@ func NewNormalKeymap() *Keymap {
func NewVisualKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"j": motion.MoveDown{Count: 1},
"k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1},
"G": motion.MoveToBottom{},
"gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
"_": motion.MoveToLineContentStart{},
"^": motion.MoveToLineContentStart{},
"|": motion.MoveToColumn{Count: 0},
"w": motion.MoveForwardWord{Count: 1},
"W": motion.MoveForwardWORD{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"E": motion.MoveForwardWORDEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
"B": motion.MoveBackwardWORD{Count: 1},
"ge": motion.MoveBackwardWordEnd{Count: 1},
"gE": motion.MoveBackwardWORDEnd{Count: 1},
"ctrl+u": motion.ScrollUpPage{Divisor: 2},
"ctrl+d": motion.ScrollDownPage{Divisor: 2},
"ctrl+b": motion.ScrollUpPage{Divisor: 1},
"ctrl+f": motion.ScrollDownPage{Divisor: 1},
";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true},
"j": motion.MoveDown{Count: 1},
"k": motion.MoveUp{Count: 1},
"h": motion.MoveLeft{Count: 1},
"l": motion.MoveRight{Count: 1},
"G": motion.MoveToBottom{},
"gg": motion.MoveToTop{},
"0": motion.MoveToLineStart{},
"$": motion.MoveToLineEnd{},
"_": motion.MoveToLineContentStart{},
"^": motion.MoveToLineContentStart{},
"|": motion.MoveToColumn{Count: 0},
"w": motion.MoveForwardWord{Count: 1},
"W": motion.MoveForwardWORD{Count: 1},
"e": motion.MoveForwardWordEnd{Count: 1},
"E": motion.MoveForwardWORDEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
// TODO: O and o. These are fun ones! Should be simple too
},
operators: map[string]action.Operator{
@ -148,8 +132,6 @@ func NewVisualKeymap() *Keymap {
"X": operator.DeleteOperator{},
"y": operator.YankOperator{},
"c": operator.ChangeOperator{},
"s": operator.ChangeOperator{}, // Same as c in visual mode
"R": operator.ChangeOperator{}, // Seems to do the same thing
},
actions: map[string]action.Action{
"p": action.VisualPaste{Count: 1, Replace: true},
@ -206,26 +188,7 @@ func NewInsertKeymap() *Keymap {
"ctrl+w": action.InsertDeletePreviousWord{},
},
}
}
// NewReplaceKeymap: Creates a keymap for replace mode with editing actions. All actions
func NewReplaceKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"down": motion.MoveDown{Count: 1},
"up": motion.MoveUp{Count: 1},
"left": motion.MoveLeft{Count: 1},
"right": motion.MoveRight{Count: 1},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"enter": action.ReplaceNewline{},
"backspace": action.InsertBackspace{},
"delete": action.InsertDelete{},
"tab": action.ReplaceTab{}, // TODO: This needs replacing
"ctrl+w": action.InsertDeletePreviousWord{},
},
}
}
// NewCommandKeymap: Creates a keymap for command mode with command line editing.

View File

@ -50,7 +50,7 @@ type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len() - 1)
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len())
return nil
}
@ -111,23 +111,21 @@ func (a MoveToColumn) WithCount(n int) action.Action {
// TODO: Count for these, maybe?
// ScrollDownPage implements Motion (ctrl+d) - linewise
type ScrollDownPage struct {
Divisor int
}
// 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 ScrollDownPage) Execute(m action.Model) tea.Cmd {
func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.ViewportHeight()
viewportHeight := win.Height - 2
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / a.Divisor
scroll := viewportHeight / 2
scrollOff := win.Options.ScrollOff
// Current relative position in viewport
@ -154,24 +152,22 @@ func (a ScrollDownPage) Execute(m action.Model) tea.Cmd {
return nil
}
func (a ScrollDownPage) Type() core.MotionType { return core.Linewise }
func (a ScrollDownHalfPage) Type() core.MotionType { return core.Linewise }
// ScrollUpPage implements Motion (ctrl+u) - linewise
type ScrollUpPage struct {
Divisor int
}
// 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 ScrollUpPage) Execute(m action.Model) tea.Cmd {
func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.ViewportHeight()
viewportHeight := win.Height - 2
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / a.Divisor
scroll := viewportHeight / 2
scrollOff := win.Options.ScrollOff
// Current relative position in viewport
@ -197,4 +193,4 @@ func (a ScrollUpPage) Execute(m action.Model) tea.Cmd {
return nil
}
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise }

View File

@ -412,290 +412,3 @@ func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive
func (a MoveBackwardWord) WithCount(n int) action.Action {
return MoveBackwardWord{Count: n}
}
// prevWORDStart: Finds the start of the previous WORD from position (x,y),
// treating all non-whitespace as a single class.
func prevWORDStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
// Back one to avoid being stuck on the current start
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y // landed on an empty line
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Skip to the start of the WORD (all non-whitespace is one class)
for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' {
x--
}
return x, y
}
// prevWordEnd: Finds the end of the previous word from position (x,y),
// respecting word character classes.
func prevWordEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
origY := y
// Back one to avoid being stuck on the current end
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
// Don't return early for empty line - we'll handle it in whitespace skip
}
// Skip backward through current word class if we're on one
// BUT: if we crossed lines in the "back one" step, we're already at the end of a word
if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' {
if isWordChar(line[x]) {
// Skip word characters
for x >= 0 && isWordChar(line[x]) {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
} else {
// Skip punctuation
for x >= 0 && isWordPunctuation(line[x]) {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char, this is our word end!
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Now x,y is at the start of the target word. Move forward to its end.
if x >= 0 {
if isWordChar(line[x]) {
for x+1 < len(line) && isWordChar(line[x+1]) {
x++
}
} else if isWordPunctuation(line[x]) {
for x+1 < len(line) && isWordPunctuation(line[x+1]) {
x++
}
}
}
return x, y
}
// prevWORDEnd: Finds the end of the previous WORD from position (x,y),
// treating all non-whitespace as a single class.
func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
origY := y
// Back one to avoid being stuck on the current end
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
// Don't return early for empty line - we'll handle it in whitespace skip
}
// Skip backward through current WORD if we're on one
// BUT: if we crossed lines in the "back one" step, we're already at the end of a WORD
if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' {
for x >= 0 && line[x] != ' ' && line[x] != '\t' {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char, this is our WORD end!
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Now x,y is at the start of the target WORD. Move forward to its end.
if x >= 0 {
for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' {
x++
}
}
return x, y
}
// MoveBackwardWORD implements Motion (B) - charwise
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 {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWORDStart(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// 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}
}
// MoveBackwardWordEnd implements Motion (ge) - charwise
type MoveBackwardWordEnd struct {
Count int
}
// MoveBackwardWordEnd.Execute: Moves the cursor to the end of the previous word (ge motion).
func (a MoveBackwardWordEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWordEnd(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// MoveBackwardWordEnd.Type: Returns CharwiseInclusive for backward word-end motion.
func (a MoveBackwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveBackwardWordEnd.WithCount: Returns a new MoveBackwardWordEnd with the given count.
func (a MoveBackwardWordEnd) WithCount(n int) action.Action {
return MoveBackwardWordEnd{Count: n}
}
// MoveBackwardWORDEnd implements Motion (gE) - charwise
type MoveBackwardWORDEnd struct {
Count int
}
// MoveBackwardWORDEnd.Execute: Moves the cursor to the end of the previous WORD (gE motion).
func (a MoveBackwardWORDEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWORDEnd(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// MoveBackwardWORDEnd.Type: Returns CharwiseInclusive for backward WORD-end motion.
func (a MoveBackwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveBackwardWORDEnd.WithCount: Returns a new MoveBackwardWORDEnd with the given count.
func (a MoveBackwardWORDEnd) WithCount(n int) action.Action {
return MoveBackwardWORDEnd{Count: n}
}

View File

@ -30,14 +30,8 @@ func (o YankOperator) Operate(m action.Model, start, end core.Position, mtype co
})
}
// Normalize so cursor is set to the earlier position (important for backward motions)
cursorPos := start
if end.Line < start.Line || (end.Line == start.Line && end.Col < start.Col) {
cursorPos = end
}
win.SetCursorCol(cursorPos.Col)
win.SetCursorLine(cursorPos.Line)
win.SetCursorCol(start.Col)
win.SetCursorLine(start.Line)
return nil
}
@ -72,6 +66,16 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT
switch {
case mtype.IsCharwise():
// This shouldn't happen
// if start.Line != end.Line {
// m.SetCommandOutput(&core.CommandOutput{
// Lines: []string{"Start line and end line must match for charwise yank operations."},
// Inline: true,
// IsError: true,
// })
// return
// }
line := buf.Line(start.Line)
startX := min(start.Col, end.Col)
@ -87,11 +91,17 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT
cnt := line[startX:endX]
m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt})
win := m.ActiveWindow()
win.SetCursorCol(startX)
win.SetCursorLine(start.Line)
case mtype == core.Linewise:
// This shouldn't happen
// if start.Col != end.Col {
// m.SetCommandOutput(&core.CommandOutput{
// Lines: []string{"Start column and end column must match for linewise yank operations."},
// Inline: true,
// IsError: true,
// })
// return
// }
// These don't need to be validated, they are validated before being passed into the function
startY := min(start.Line, end.Line)
endY := max(start.Line, end.Line)

View File

@ -15,7 +15,6 @@ type Styles struct {
CursorNormal lipgloss.Style
CursorInsert lipgloss.Style
CursorCommand lipgloss.Style
CursorReplace lipgloss.Style
// Gutter (line numbers)
Gutter lipgloss.Style
@ -48,7 +47,6 @@ func DefaultStyles() Styles {
CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true),
CursorReplace: lipgloss.NewStyle().Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
@ -97,17 +95,12 @@ func ChromaStyles(chromaStyle *chroma.Style) Styles {
CursorInsert: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Bold(true).
Underline(true),
CursorCommand: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Reverse(true),
CursorReplace: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()),
@ -170,8 +163,6 @@ func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
return s.CursorInsert
case core.CommandMode:
return s.CursorCommand
case core.ReplaceMode:
return s.CursorReplace
default:
return s.CursorNormal
}
@ -186,11 +177,6 @@ func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style
return lipgloss.NewStyle().
Background(style.GetForeground()).
Foreground(style.GetBackground())
case core.ReplaceMode, core.WaitingMode:
return lipgloss.NewStyle().
Background(style.GetBackground()).
Foreground(style.GetForeground()).
Underline(true)
default:
return lipgloss.NewStyle().
Background(s.BackgroundStyle.GetBackground()).

25
qodo.md
View File

@ -1,25 +0,0 @@
### The Core Commands
* `/review`
Asks Gemini to read the entire PR diff and provide a structured summary, score the PR, identify potential bugs, and suggest high-level fixes.
* `/describe`
Automatically rewrites the PR's title and description based on the actual code changes. (Great for when you just want to push code and not write documentation).
* `/improve`
Scans the code and provides actionable, copy-pasteable snippets to improve the code, focusing on performance, security, and best practices.
* `/ask "<your question>"`
Turns the PR comment section into a chat window. It uses the PR diff as context. Example: `/ask "Did I properly handle the null pointer edge cases in the new database function?"`
### Specialized Tools
* `/test`
Asks the AI to generate unit tests specifically tailored for the new or modified code in the PR.
* `/update_changelog`
Automatically drafts an update for your `CHANGELOG.md` file based on the PR's contents.
* `/generate_labels`
Analyzes the code changes and recommends appropriate labels for the PR (e.g., `bug`, `enhancement`, `refactor`).
* `/help`
Forces the bot to reply with a quick cheat sheet of all available commands and usage instructions in case you forget them.
### Pro-Tip: Steering the AI
You can actually pass arguments directly to the commands to give Gemini specific instructions for that specific run.
For example, if you want a review but want it to be hyper-paranoid about security, you can type:
`/review --pr_reviewer.extra_instructions="Focus heavily on potential security vulnerabilities and SQL injection risks."`