Compare commits
No commits in common. "master" and "feature/gap-buffer" have entirely different histories.
master
...
feature/ga
74
FEATURES.md
74
FEATURES.md
@ -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
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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
39
V0.1.md
@ -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
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 "-----"
|
||||
}
|
||||
|
||||
@ -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)})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
25
qodo.md
@ -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."`
|
||||
Loading…
x
Reference in New Issue
Block a user