Compare commits

...

53 Commits

Author SHA1 Message Date
Hayden Hargreaves
064f747b55 doc: V0.1.md created
All checks were successful
Run Test Suite / test (push) Successful in 16s
2026-04-06 18:34:22 -07:00
d6323be62b Merge pull request 'Implemented the replace (r and R) actions and replace mode' (#5) from feature/replace into master
All checks were successful
Run Test Suite / test (push) Successful in 16s
Reviewed-on: #5
2026-04-06 18:30:36 -07:00
Hayden Hargreaves
881a4d6d78 doc: documented trade off regarding last insert tracking
All checks were successful
Run Test Suite / test (push) Successful in 17s
Run Test Suite / test (pull_request) Successful in 15s
2026-04-06 18:29:23 -07:00
Hayden Hargreaves
43b3992522 feat: implemented replace mode, tested!
All checks were successful
Run Test Suite / test (push) Successful in 15s
Run Test Suite / test (pull_request) Successful in 15s
Looking great, maybe I will actually use this lol
2026-04-05 23:53:24 -07:00
Hayden Hargreaves
6033e58d0e feat: implement the r action, tested
All checks were successful
Run Test Suite / test (push) Successful in 17s
Began work on replace mode, but not complete.
2026-04-05 22:58:07 -07:00
Hayden Hargreaves
58082afdd2 fix: added gutter fix and basic scroll
All checks were successful
Run Test Suite / test (push) Successful in 15s
Scrolling is pretty useless, but nice touch
2026-04-05 00:16:19 -07:00
Hayden Hargreaves
a5ff18e1de doc: updated FEATURES.md
All checks were successful
Run Test Suite / test (push) Successful in 15s
2026-04-05 00:03:17 -07:00
Hayden Hargreaves
0767ee0982 feat: added support for full page jumping 2026-04-05 00:00:40 -07:00
a9dd5c008f Merge pull request 'feat: adding word actions, not done, and lots of failing tests' (#4) from feature/word-motions into master
All checks were successful
Run Test Suite / test (push) Successful in 15s
Reviewed-on: #4
2026-04-04 11:43:46 -07:00
Hayden Hargreaves
1166a67c64 fix: updated via using feedback from Qodo
All checks were successful
Run Test Suite / test (push) Successful in 20s
Run Test Suite / test (pull_request) Successful in 15s
2026-04-04 11:38:19 -07:00
Hayden Hargreaves
b23072b43f doc: updated FEATURES.md
All checks were successful
Run Test Suite / test (push) Successful in 16s
Run Test Suite / test (pull_request) Successful in 15s
2026-04-04 11:09:53 -07:00
Hayden Hargreaves
8b7a479ecb feat: finally got the tests passing.
All checks were successful
Run Test Suite / test (push) Successful in 19s
Run Test Suite / test (pull_request) Successful in 16s
Most of the tests were just written poorly, the code was right. Though
the yank related questions were actually broken.
2026-04-04 11:08:33 -07:00
Hayden Hargreaves
7ba94eaeea doc: added notes to qodo.md
Some checks failed
Run Test Suite / test (push) Failing after 17s
Run Test Suite / test (pull_request) Failing after 14s
2026-04-03 15:20:47 -07:00
Hayden Hargreaves
5833f2312b fix: removing action
Some checks failed
Run Test Suite / test (push) Failing after 15s
Run Test Suite / test (pull_request) Failing after 14s
2026-04-03 14:38:08 -07:00
Hayden Hargreaves
194b848d6b fix: added gitea to provider
Some checks failed
Run Test Suite / test (push) Failing after 15s
Run Test Suite / test (pull_request) Failing after 14s
PR Agent (Gemini) / pr_agent_job (pull_request) Successful in 7s
2026-04-03 14:35:51 -07:00
Hayden Hargreaves
c58ec77dba fix: forgot to add sync
Some checks failed
Run Test Suite / test (push) Failing after 16s
Run Test Suite / test (pull_request) Failing after 15s
PR Agent (Gemini) / pr_agent_job (pull_request) Successful in 14s
2026-04-03 14:32:48 -07:00
Hayden Hargreaves
4a2c8895af fix: copied from docs
Some checks failed
Run Test Suite / test (push) Failing after 17s
Run Test Suite / test (pull_request) Failing after 16s
2026-04-03 14:30:39 -07:00
Hayden Hargreaves
47a867a537 fix: gemini pro attempt
Some checks failed
Run Test Suite / test (push) Failing after 18s
Run Test Suite / test (pull_request) Failing after 17s
Qodo PR Agent (Gemini) / Run PR Agent (pull_request) Successful in 13s
2026-04-03 14:26:51 -07:00
Hayden Hargreaves
b9e9fb2f5f fix: again dude wtf
Some checks failed
Run Test Suite / test (push) Failing after 14s
Run Test Suite / test (pull_request) Failing after 15s
Qodo AI PR Reviewer / qodo_review (pull_request) Successful in 8s
2026-04-03 13:44:53 -07:00
Hayden Hargreaves
e54d49109e fix: trying to fix ci/cd qodo
Some checks failed
Run Test Suite / test (push) Failing after 15s
Run Test Suite / test (pull_request) Failing after 16s
Qodo AI PR Reviewer / qodo_review (pull_request) Successful in 7s
2026-04-03 13:41:32 -07:00
Hayden Hargreaves
24eea1d08e fix: more qodo fixes
Some checks failed
Run Test Suite / test (push) Failing after 17s
Run Test Suite / test (pull_request) Failing after 14s
Qodo AI PR Reviewer / qodo_review (pull_request) Successful in 7s
2026-04-03 13:35:21 -07:00
Hayden Hargreaves
3efba6d575 fix: trying to fix qodo
Some checks failed
Run Test Suite / test (push) Failing after 18s
Run Test Suite / test (pull_request) Failing after 15s
Qodo AI PR Reviewer / qodo_review (pull_request) Successful in 7s
2026-04-03 13:27:47 -07:00
Hayden Hargreaves
4a59451d90 Merge branch 'master' into feature/word-motions
Some checks failed
Run Test Suite / test (push) Failing after 15s
Run Test Suite / test (pull_request) Failing after 14s
Qodo AI PR Reviewer / qodo_review (pull_request) Successful in 22s
2026-04-03 13:20:00 -07:00
Hayden Hargreaves
9938f0d5d3 feat: adding word actions, not done, and lots of failing tests
Some checks failed
Run Test Suite / test (push) Failing after 15s
Run Test Suite / test (pull_request) Failing after 15s
This is a test for the new AI reviewing tool
2026-04-03 13:18:23 -07:00
069d04c0cd devops: added qodo review action
All checks were successful
Run Test Suite / test (push) Successful in 17s
2026-04-03 13:17:18 -07:00
Hayden Hargreaves
ea3ebcdc83 feat: implemented :<n> command, tested
All checks were successful
Run Test Suite / test (push) Successful in 17s
Also added tons of tests for the actual command mode, since that was all
untested...
2026-04-02 14:03:36 -07:00
Hayden Hargreaves
e3b0f30c75 doc: updated FEATURES.md
All checks were successful
Run Test Suite / test (push) Successful in 17s
2026-04-02 13:47:40 -07:00
Hayden Hargreaves
0e2867b948 feat: added better lexer selection handling
All checks were successful
Run Test Suite / test (push) Successful in 18s
2026-04-02 12:55:46 -07:00
Hayden Hargreaves
e362c9f118 feat: gap buffer is implemented, tested
All checks were successful
Run Test Suite / test (push) Successful in 56s
Not sure if this is perfect, but it seems to be working
2026-04-02 12:39:30 -07:00
Hayden Hargreaves
1a98d3a4de fix: p and P differ in V mode, resolved and tested.
P should not replace the register, p should
2026-04-01 18:21:15 -07:00
Hayden Hargreaves
78dc00a5e9 doc: updated README.md
All checks were successful
Run Test Suite / test (push) Successful in 54s
2026-04-01 11:49:22 -07:00
Hayden Hargreaves
c6215a37cb test: I am far more confident now, these tests are nice to have
All checks were successful
Run Test Suite / test (push) Successful in 53s
Many more full range integration tests.
2026-03-31 18:43:58 -07:00
Hayden Hargreaves
0e8bb50c20 feat: implemented and tested the dot operator.
The content gets stored in the '.' register.
2026-03-31 18:26:18 -07:00
Hayden Hargreaves
ddbc860530 doc: updated README.md
All checks were successful
Run Test Suite / test (push) Successful in 51s
2026-03-30 23:14:01 -07:00
Hayden Hargreaves
066b817200 test: added some more tests to confirm the undo tree is "good"
Yay! These are from Sonnet 4.0, hope theyre good.
2026-03-30 23:10:18 -07:00
Hayden Hargreaves
4dedb15a36 test: initial tests are complete!
Claude says they are "production ready"
2026-03-30 23:01:46 -07:00
Hayden Hargreaves
98e02553b1 feat: working on the undo stack! Huge progress, not tested
Tests are coming, but there are some infrastructure issues with the
tests
2026-03-30 22:38:33 -07:00
Hayden Hargreaves
1e2f1b147b fix: added scroll hint to message on command output 2026-03-30 22:37:02 -07:00
Hayden Hargreaves
402c93db50 doc: adding note to TODO list in README.md
All checks were successful
Run Test Suite / test (push) Successful in 47s
2026-03-30 18:33:09 -07:00
Hayden Hargreaves
04c247cc8e fix: looks like we resolved the issues with pasting.
All checks were successful
Run Test Suite / test (push) Successful in 46s
We means me and Claude (heavy on the Claude). Originally, if we copied a
many line segment into a charwise register, the paste op would error,
this is not right, it should paste, just differently.
2026-03-30 18:27:53 -07:00
Hayden Hargreaves
ffad4f86f6 doc: wrote about tradeoff for undo functionality in README.md 2026-03-30 18:19:37 -07:00
Hayden Hargreaves
9960d5c4e2 fix: added multi line delimiter support 2026-03-30 18:13:08 -07:00
Hayden Hargreaves
21ed76bed5 fix: fixed the delimiter "same-line" issue.
With the help of Claude. I want this to be over with so I can move onto
more fun things than actions.
2026-03-30 17:58:06 -07:00
Hayden Hargreaves
5405d5a6bd fix: added b and B text objects and note about failing case.
All checks were successful
Run Test Suite / test (push) Successful in 47s
2026-03-26 14:15:59 -07:00
Hayden Hargreaves
aa156971ad feat: text objects initial impl, tested
However, it does not work for multi line delimiters.
2026-03-26 14:09:10 -07:00
Hayden Hargreaves
b0b885d57d fix: this got missed, silly fugitive
All checks were successful
Run Test Suite / test (push) Successful in 43s
2026-03-19 17:44:25 -07:00
Hayden Hargreaves
5c629496c6 feat: implemented 'X', tested
X and x are the same in visual mode I think
2026-03-19 17:43:12 -07:00
Hayden Hargreaves
a01369f407 fix: cleaned up the testing mocking. New single module
All checks were successful
Run Test Suite / test (push) Successful in 42s
2026-03-19 17:31:53 -07:00
Hayden Hargreaves
3c98dca777 feat: implement command history, tested
The tests are starting to get messy, lots of duplication. Going to
resolve that. Lots of this is due to AI generation of tests.
2026-03-19 15:23:44 -07:00
Hayden Hargreaves
5ff473d0d9 feat: implementing scrolling cmd output window: tested
All checks were successful
Run Test Suite / test (push) Successful in 42s
2026-03-19 14:32:21 -07:00
Hayden Hargreaves
b618e3a382 feat: implemented colorscheme commands. Tested
All checks were successful
Run Test Suite / test (push) Successful in 39s
There are some odd things being done in the testing files, that should
get reviewed.
2026-03-16 23:25:09 -07:00
Hayden Hargreaves
76fa55440e feat: syntax highlighting powered by chroma.
This is sick! It is not perfect, highlight groups would be cool, but for
now this works!
2026-03-16 22:48:57 -07:00
Hayden Hargreaves
c70cbeaedf chore: updated FEATURES.md
All checks were successful
Run Test Suite / test (push) Successful in 11s
2026-03-15 19:30:41 -07:00
74 changed files with 13084 additions and 2324 deletions

View File

@ -14,8 +14,8 @@
- [x] `b` - Backward to start of word
- [x] `W` - Forward to start of WORD (whitespace-delimited)
- [x] `E` - Forward to end of WORD
- [ ] `B` - Backward to start of WORD
- [ ] `ge` - Backward to end of word
- [x] `B` - Backward to start of WORD
- [x] `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
- [ ] `ctrl+b` - Scroll up full page
- [ ] `ctrl+f` - Scroll down full page
- [x] `ctrl+b` - Scroll up full page
- [x] `ctrl+f` - Scroll down full page
- [ ] `ctrl+y` - Scroll up one line
- [ ] `ctrl+e` - Scroll down one line
- [ ] `zz` - Center cursor on screen
@ -43,12 +43,12 @@
- [ ] `zb` - Scroll cursor to bottom
### Search Movement
- [ ] `f{char}` - Find char forward on line
- [ ] `F{char}` - Find char backward on line
- [ ] `t{char}` - Till char forward on line
- [ ] `T{char}` - Till char backward on line
- [ ] `;` - Repeat last f/F/t/T
- [ ] `,` - Repeat last f/F/t/T reversed
- [x] `f{char}` - Find char forward on line
- [x] `F{char}` - Find char backward on line
- [x] `t{char}` - Till char forward on line
- [x] `T{char}` - Till char backward on line
- [x] `;` - Repeat last f/F/t/T
- [x] `,` - Repeat last f/F/t/T reversed
- [ ] `/` - Search forward
- [ ] `?` - Search backward
- [ ] `n` - Next search result
@ -104,7 +104,7 @@
### Delete Actions
- [x] `x` - Delete character under cursor
- [x] `D` - Delete to end of line
- [ ] `X` - Delete character before cursor
- [x] `X` - Delete character before cursor
- [ ] `J` - Join lines
- [ ] `gJ` - Join lines without space
@ -127,14 +127,14 @@
- [ ] Last search register (`/`)
### Undo/Redo
- [ ] `u` - Undo
- [ ] `ctrl+r` - Redo
- [ ] `.` - Repeat last change
- [x] `u` - Undo
- [x] `ctrl+r` - Redo
- [x] `.` - Repeat last change
- [ ] `U` - Undo all changes on line
### Other Normal Mode
- [ ] `r{char}` - Replace character
- [ ] `R` - Replace mode
- [x] `r{char}` - Replace character
- [x] `R` - Replace mode
- [ ] `~` - Swap case of character
- [ ] `ctrl+a` - Increment number
- [ ] `ctrl+x` - Decrement number
@ -157,7 +157,7 @@
- [x] Motions work in visual mode
- [x] `d` / `x` - Delete selection
- [x] `y` - Yank selection
- [ ] `c` - Change selection
- [x] `c` - Change selection
- [ ] `>` - Indent selection
- [ ] `<` - Unindent selection
- [ ] `=` - Auto-indent selection
@ -218,8 +218,8 @@
- [x] `:wq` - Write and quit
- [x] `:q!` - Force quit
- [x] `:e {file}` - Edit file
- [ ] `:bn` / `:bp` - Next/previous buffer
- [ ] `:{range}` - Go to line
- [x] `:bn` / `:bp` - Next/previous buffer
- [x] `:{range}` - Go to line
- [ ] `:%s/old/new/g` - Search and replace
- [ ] `:!{cmd}` - Run shell command
- [ ] `:help` - Show help
@ -228,18 +228,20 @@
## Text Objects
### Implemented
- [x] `iw` / `aw` - Inner/around word
- [x] `iW` / `aW` - Inner/around WORD
- [x] `is` / `as` - Inner/around sentence
- [x] `ip` / `ap` - Inner/around paragraph
- [x] `i"` / `a"` - Inner/around double quotes
- [x] `i'` / `a'` - Inner/around single quotes
- [x] `` i` `` / `` a` `` - Inner/around backticks
- [x] `i(` / `a(` - Inner/around parentheses
- [x] `i[` / `a[` - Inner/around brackets
- [x] `i{` / `a{` - Inner/around braces
- [x] `i<` / `a<` - Inner/around angle brackets
### Not Implemented
- [ ] `iw` / `aw` - Inner/around word
- [ ] `iW` / `aW` - Inner/around WORD
- [ ] `is` / `as` - Inner/around sentence
- [ ] `ip` / `ap` - Inner/around paragraph
- [ ] `i"` / `a"` - Inner/around double quotes
- [ ] `i'` / `a'` - Inner/around single quotes
- [ ] `` i` `` / `` a` `` - Inner/around backticks
- [ ] `i(` / `a(` - Inner/around parentheses
- [ ] `i[` / `a[` - Inner/around brackets
- [ ] `i{` / `a{` - Inner/around braces
- [ ] `i<` / `a<` - Inner/around angle brackets
- [ ] `it` / `at` - Inner/around tag
---
@ -265,7 +267,7 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Buffer Model
- [x] Buffer struct (id, filename, lines, modified flag, cursor position)
- [ ] Buffer list/manager
- [x] Buffer list/manager
- [x] Current buffer tracking
- [ ] Buffer-local settings (tabstop, filetype, etc.)
- [x] Modified/dirty state tracking
@ -273,18 +275,18 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Buffer Navigation
- [x] `:e {file}` - Edit file (open in new buffer or switch to existing)
- [ ] `:bn` / `:bnext` - Next buffer
- [ ] `:bp` / `:bprev` - Previous buffer
- [ ] `:b {name}` - Switch to buffer by name (partial match)
- [ ] `:b {number}` - Switch to buffer by number
- [ ] `:bf` / `:bfirst` - First buffer
- [ ] `:bl` / `:blast` - Last buffer
- [ ] `:buffers` / `:ls` - List all buffers
- [x] `:bn` / `:bnext` - Next buffer
- [x] `:bp` / `:bprev` - Previous buffer
- [x] `:b {name}` - Switch to buffer by name (partial match)
- [x] `:b {number}` - Switch to buffer by number
- [x] `:bf` / `:bfirst` - First buffer
- [x] `:bl` / `:blast` - Last buffer
- [x] `:buffers` / `:ls` - List all buffers
- [ ] `ctrl+^` / `ctrl+6` - Switch to alternate (previous) buffer
### Buffer Operations
- [ ] `:bd` / `:bdelete` - Delete buffer (close file)
- [ ] `:bd!` - Force delete buffer (discard changes)
- [x] `:bd` / `:bdelete` - Delete buffer (close file)
- [x] `:bd!` - Force delete buffer (discard changes)
- [ ] `:bw` / `:bwipeout` - Wipe buffer (remove completely)
- [x] `:w` - Write current buffer to file
- [x] `:w {file}` - Write buffer to specific file
@ -299,13 +301,14 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [ ] Alternate buffer (`#`) tracking
### Buffer Indicators
- [ ] `%` - Current buffer (in buffer list)
- [x] `%` - Current buffer (in buffer list)
- [ ] `#` - Alternate buffer
- [ ] `a` - Active (loaded and visible)
- [ ] `h` - Hidden (loaded but not visible)
- [ ] `+` - Modified
- [ ] `-` - Read-only
- [ ] `=` - Readonly (cannot be modified)
- [x] `l` - Loaded (active and hidden do not exist yet)
- [x] `+` - Modified
- [x] `-` - Read-only
- [ ] `=` - Readonly (cannot be modified) (?)
### Hidden Buffers
- [ ] `:set hidden` - Allow switching with unsaved changes
@ -370,7 +373,8 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
### Display
- [x] Line numbers
- [x] Cursor position tracking
- [x] Viewport/scrolling
- [x] Viewport/scrolling (Y)
- [ ] Viewport/scrolling (X)
- [x] ScrollOff setting
- [x] Relative line numbers
- [ ] Cursor line highlight
@ -404,63 +408,3 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
- [ ] Spell check
---
### Well Tested - Editor Core
#### Command Execution (179 tests)
- [x] Command parsing and validation
- [x] Command lookup and prefix matching
- [x] Force flag handling (!)
- [x] Write commands (`:w`, `:w {file}`, `:w!`)
- [x] Write all commands (`:wa`, `:wall`, `:wa!`)
- [x] Quit commands (`:q`, `:q!`, `:qa`, `:qa!`)
- [x] Write-quit commands (`:wq`, `:wq!`, `:wqa`, `:wqa!`)
- [x] Edit command (`:e {file}`)
- [x] Register display (`:register`, `:reg {name}`)
- [x] Set commands (`:set number`, `:set tabstop=N`, etc.)
- [x] Setting lookup and validation
- [x] Buffer-level readonly protection
- [x] Scratch buffer write protection
- [x] Force write bypassing readonly/scratch checks
- [x] Multiple buffer write operations
- [x] File write error handling (permissions, paths)
- [x] Modified buffer tracking
- [x] Unicode filename and content handling
- [x] Edge cases (empty args, long filenames, special chars)
#### Program Initialization (70 tests)
- [x] Empty program creation
- [x] File program with nonexistent files (new file buffers)
- [x] File program with existing files (content loading)
- [x] Line ending handling (Unix `\n`, Windows `\r\n`, mixed)
- [x] Tab to space conversion based on TabStop
- [x] Unicode content preservation (CJK, emoji)
- [x] File extension and type detection
- [x] Buffer state initialization (flags, metadata)
- [x] Large file handling (10,000+ lines)
- [x] Long line handling (10,000+ chars)
- [x] Empty file handling
- [x] Builder pattern method chaining
- [x] Program option accumulation
- [x] Model state defaults (settings, registers, mode)
- [x] Error handling (permissions, invalid paths)
- [x] Integration workflows (end-to-end)
- [x] Edge cases (empty filenames, relative paths, dot files)
### Moderately Tested
- [x] Basic motions (h, j, k, l)
- [x] Word motions (w, e, b)
- [x] Jump motions (G, gg, 0, $, _, ^, |)
- [x] Scroll actions (ctrl+u, ctrl+d)
- [x] Delete operator (d, dd)
- [x] Yank operator (y, yy)
- [x] Paste actions (p, P)
- [x] Change operator (c, cc, C)
- [x] Substitute action (s, S)
- [x] Insert mode entry (i, a, I, A, o, O)
- [x] Insert mode editing (enter, backspace, delete, tab, ctrl+w)
- [x] Visual modes (v, V, ctrl+v)
- [x] Visual mode with motions
- [x] Delete actions (x, D)
- [x] Register behavior

View File

@ -56,6 +56,38 @@
---
## Trade Offs
#### Undo Tree vs. Undo Stack
While the undo tree method that vim uses is powerful, I rarely find myself using it. A stack terminal-based
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
- Ops like change, and substitute and such should add to paste reg
- Delete op should also add to paste reg
- Gap buffer implementation (this shouldn't be TOO hard)
- Alternate buffer handling and implementation
- Scroll in X direction
---
## 🎯 Features
### 🎭 Six Editor Modes

39
V0.1.md Normal file
View File

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

View File

@ -4,12 +4,17 @@ import (
"os"
"git.gophernest.net/azpect/TextEditor/internal/program"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// main: Entry point for the Gim text editor. Creates a buffer and window,
// initializes the editor model, and runs the BubbleTea TUI program.
func main() {
if err := theme.RegisterAll(); err != nil {
panic(err)
}
// <exe> <filename>
args := os.Args[1:]
@ -18,11 +23,13 @@ func main() {
prog = program.NewProgramBuilder().
EmptyProgram().
WithOpt(tea.WithAltScreen()).
WithOpt(tea.WithMouseCellMotion()).
Build()
} else {
prog = program.NewProgramBuilder().
FileProgram(args[0]).
WithOpt(tea.WithAltScreen()).
WithOpt(tea.WithMouseCellMotion()).
Build()
}

View File

@ -24,6 +24,8 @@
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`

2
go.mod
View File

@ -3,6 +3,7 @@ module git.gophernest.net/azpect/TextEditor
go 1.25.5
require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c
@ -19,6 +20,7 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

10
go.sum
View File

@ -1,3 +1,9 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@ -24,8 +30,12 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@ -16,7 +16,7 @@ func (a ChangeToEndOfLine) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
// Save deleted text to register
if pos < len(line) {
@ -51,7 +51,7 @@ func (a SubstituteChar) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
// Calculate how many chars to delete (limited by line length)
count := min(a.Count, len(line)-pos)
@ -97,7 +97,7 @@ func (a SubstituteLine) Execute(m Model) tea.Cmd {
// Collect and delete lines
for range count {
lines = append(lines, buf.Lines[y])
lines = append(lines, buf.Line(y))
buf.DeleteLine(y)
}

View File

@ -1,6 +1,8 @@
package action
import (
"strconv"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
@ -11,6 +13,7 @@ type ExitCommandMode struct{}
// ExitCommandMode.Execute: Exits command mode and returns to normal mode (Esc key).
func (a ExitCommandMode) Execute(m Model) tea.Cmd {
m.SetCommandCursor(0)
m.SetCommandHistoryCursor(0)
m.SetCommand("")
m.SetCommandOutput(&core.CommandOutput{})
m.SetMode(core.NormalMode)
@ -127,12 +130,23 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
// Clear command state and return to normal mode
m.SetCommandCursor(0)
m.SetCommandHistoryCursor(0)
m.SetMode(core.NormalMode)
if a.Registry == nil || cmdLine == "" {
return nil
}
history := append([]string{cmdLine}, m.CommandHistory()...)
// 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{
@ -146,3 +160,23 @@ func (a CommandExecute) Execute(m Model) tea.Cmd {
return cmd
}
// cmdGoToLine: DOES NOT implement command.Command. Instead, is called directly
// by CommandExecture.Execute(). Jumps the cursor to the line provided
func cmdGoToLine(m Model, line int) tea.Cmd {
// number below 0 just goes back that many
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if line <= 0 {
newLine := max(0, win.Cursor.Line+line)
win.SetCursorLine(newLine)
return nil
}
newLine := min(line-1, buf.LineCount())
win.SetCursorLine(newLine)
return nil
}

View File

@ -0,0 +1,133 @@
package action
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// mockRegistry for testing command execution without actual command handlers
type mockRegistry struct{}
func (r *mockRegistry) Execute(m Model, cmdLine string) (tea.Cmd, error) {
// Mock implementation - just returns nil
return nil, nil
}
// TestCommandExecuteUpdatesHistory tests that executing commands adds them to history
func TestCommandExecuteUpdatesHistory(t *testing.T) {
t.Run("first command added to empty history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w test.txt"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Errorf("History length = %d, want 1", len(history))
}
if history[0] != "w test.txt" {
t.Errorf("History[0] = %q, want %q", history[0], "w test.txt")
}
})
t.Run("multiple commands prepended to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w file1.txt"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
// Execute first command
action.Execute(m)
// Execute second command
m.CommandVal = "q"
action.Execute(m)
// Execute third command
m.CommandVal = "set number"
action.Execute(m)
history := m.CommandHistory()
if len(history) != 3 {
t.Errorf("History length = %d, want 3", len(history))
}
// Most recent should be first (prepended)
want := []string{"set number", "q", "w file1.txt"}
for i, cmd := range want {
if history[i] != cmd {
t.Errorf("History[%d] = %q, want %q", i, history[i], cmd)
}
}
})
t.Run("empty command not added to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = ""
m.CommandHistoryList = []string{"previous command"}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 1 {
t.Errorf("History length = %d, want 1 (empty command should not be added)", len(history))
}
if history[0] != "previous command" {
t.Errorf("History[0] = %q, want %q", history[0], "previous command")
}
})
t.Run("history cursor resets on execute", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w"
m.CommandHistoryList = []string{}
m.CommandHistoryCur = 5 // Set to non-zero
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 after execute", m.CommandHistoryCursor())
}
})
t.Run("duplicate commands are added to history", func(t *testing.T) {
m := NewMockModel()
m.ModeVal = core.CommandMode
m.CommandVal = "w"
m.CommandHistoryList = []string{"w"}
m.CommandHistoryCur = 0
registry := &mockRegistry{}
action := CommandExecute{Registry: registry}
action.Execute(m)
history := m.CommandHistory()
if len(history) != 2 {
t.Errorf("History length = %d, want 2 (duplicates should be added)", len(history))
}
if history[0] != "w" || history[1] != "w" {
t.Errorf("History = %v, want ['w', 'w']", history)
}
})
}

View File

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

View File

@ -13,7 +13,7 @@ func (a DeleteChar) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos < len(line); i++ {
line = line[:pos] + line[pos+1:]
buf.SetLine(win.Cursor.Line, line)
@ -27,6 +27,35 @@ func (a DeleteChar) WithCount(n int) Action {
return DeleteChar{Count: n}
}
// DeletePrevChar implements Action (x)
type DeletePrevChar struct {
Count int
}
// DeletePrevChar.Execute: Deletes Count characters before the cursor position (x key).
func (a DeletePrevChar) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
pos := win.Cursor.Col
line := buf.Line(win.Cursor.Line)
for i := 0; i < a.Count && pos <= len(line); i++ {
if pos > 0 {
line = line[:pos-1] + line[pos:]
buf.SetLine(win.Cursor.Line, line)
pos--
win.SetCursorCol(pos)
}
}
return nil
}
// DeletePrevChar.WithCount: Returns a new DeletePrevChar with the given count.
func (a DeletePrevChar) WithCount(n int) Action {
return DeletePrevChar{Count: n}
}
// DeleteToEndOfLine implements Action (D) - deletes from cursor to end of line
// and optionally Count-1 additional lines below.
type DeleteToEndOfLine struct {
@ -40,7 +69,7 @@ func (a DeleteToEndOfLine) Execute(m Model) tea.Cmd {
// Delete to end of line
pos := win.Cursor.Col
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
buf.SetLine(win.Cursor.Line, line[:pos])
win.SetCursorCol(pos - 1)

View File

@ -6,118 +6,6 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ==================================================
// Mock Model Implementation
// ==================================================
type mockModel struct {
windows []*core.Window
activeWindow *core.Window
buffers []*core.Buffer
settings core.EditorSettings
mode core.Mode
registers map[rune]core.Register
insertKeys []string
command string
commandCursor int
commandOutput *core.CommandOutput
lastFind core.LastFindCommand
}
func newMockModel() *mockModel {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
WithWidth(80).
Build()
return &mockModel{
windows: []*core.Window{&win},
activeWindow: &win,
buffers: []*core.Buffer{&buf},
settings: core.NewDefaultSettings(),
mode: core.NormalMode,
registers: core.DefaultRegisters(),
}
}
func newMockModelWithBuffer(buf *core.Buffer) *mockModel {
win := core.NewWindowBuilder().
WithBuffer(buf).
WithHeight(24).
WithWidth(80).
Build()
return &mockModel{
windows: []*core.Window{&win},
activeWindow: &win,
buffers: []*core.Buffer{buf},
settings: core.NewDefaultSettings(),
mode: core.NormalMode,
registers: core.DefaultRegisters(),
}
}
func newMockModelWithWindow(win *core.Window) *mockModel {
return &mockModel{
windows: []*core.Window{win},
activeWindow: win,
buffers: []*core.Buffer{win.Buffer},
settings: core.NewDefaultSettings(),
mode: core.NormalMode,
registers: core.DefaultRegisters(),
}
}
// Core Data Access
func (m *mockModel) Windows() []*core.Window { return m.windows }
func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow }
func (m *mockModel) Buffers() []*core.Buffer { return m.buffers }
func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs }
func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer }
// Insert Mode State
func (m *mockModel) InsertKeys() []string { return m.insertKeys }
func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys }
func (m *mockModel) SetInsertRecording(count int, action Action) {}
func (m *mockModel) ExitInsertMode() {}
func (m *mockModel) SetLastFind(char string, forward, inclusive bool) {
m.lastFind = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive}
}
func (m *mockModel) GetLastFind() *core.LastFindCommand { return &m.lastFind }
// Command Mode State
func (m *mockModel) Command() string { return m.command }
func (m *mockModel) SetCommand(cmd string) { m.command = cmd }
func (m *mockModel) CommandCursor() int { return m.commandCursor }
func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur }
func (m *mockModel) CommandOutput() *core.CommandOutput { return m.commandOutput }
func (m *mockModel) SetCommandOutput(out *core.CommandOutput) { m.commandOutput = out }
// Editor-wide State
func (m *mockModel) Mode() core.Mode { return m.mode }
func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode }
func (m *mockModel) Settings() core.EditorSettings { return m.settings }
func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s }
// Registers
func (m *mockModel) Registers() map[rune]core.Register { return m.registers }
func (m *mockModel) GetRegister(name rune) (core.Register, bool) {
reg, ok := m.registers[name]
return reg, ok
}
func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error {
m.registers[name] = core.Register{Type: t, Content: cnt}
return nil
}
func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.registers['"'] = core.Register{Type: t, Content: cnt}
}
// ==================================================
// f (find forward inclusive) Tests
// ==================================================
@ -133,7 +21,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -160,7 +48,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 4). // At first 'o'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -187,7 +75,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -214,7 +102,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "d",
@ -241,7 +129,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 5). // At space after 'hello'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -268,7 +156,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -295,7 +183,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -322,7 +210,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -349,7 +237,7 @@ func TestFindCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -382,7 +270,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -409,7 +297,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 7). // At 'o' in 'world'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -436,7 +324,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -463,7 +351,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "h",
@ -490,7 +378,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 6). // At 'w'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -517,7 +405,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -544,7 +432,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -571,7 +459,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -598,7 +486,7 @@ func TestFindCharBackward(t *testing.T) {
WithCursorPos(0, 9). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -631,7 +519,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -658,7 +546,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -685,7 +573,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0). // At 'a'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -713,7 +601,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "d",
@ -740,7 +628,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -767,7 +655,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -794,7 +682,7 @@ func TestTillCharForward(t *testing.T) {
WithCursorPos(0, 5). // At space
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -827,7 +715,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -854,7 +742,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "x",
@ -881,7 +769,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 1). // At 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -909,7 +797,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "h",
@ -936,7 +824,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -963,7 +851,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: " ",
@ -990,7 +878,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 6). // At 'w'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "o",
@ -1017,7 +905,7 @@ func TestTillCharBackward(t *testing.T) {
WithCursorPos(0, 9). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1050,7 +938,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0). // At 'h'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1081,7 +969,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1112,7 +1000,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1139,7 +1027,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "w",
@ -1166,7 +1054,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1194,7 +1082,7 @@ func TestFindCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 4). // At first 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1231,7 +1119,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10). // At 'd'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1262,7 +1150,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1294,7 +1182,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1321,7 +1209,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10). // At last 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -1349,7 +1237,7 @@ func TestFindCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 6). // At middle 'b'
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1386,7 +1274,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1413,7 +1301,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1440,7 +1328,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1467,7 +1355,7 @@ func TestTillCharForwardWithCount(t *testing.T) {
WithCursorPos(0, 0).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "a",
@ -1501,7 +1389,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1528,7 +1416,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1555,7 +1443,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "l",
@ -1582,7 +1470,7 @@ func TestTillCharBackwardWithCount(t *testing.T) {
WithCursorPos(0, 10).
Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
action := FindChar{
Char: "b",
@ -1721,7 +1609,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
t.Run("basic: lands on next inclusive match", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1735,7 +1623,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
t.Run("no further match: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1749,7 +1637,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
t.Run("cursor at end of line: no move", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 10).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1765,7 +1653,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
t.Run("count=2 skips first match lands on second", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m)
@ -1779,7 +1667,7 @@ func TestRepeatFind_Semicolon_After_f(t *testing.T) {
t.Run("does not overwrite lastFind when Repeated", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1800,7 +1688,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
t.Run("basic: lands on previous inclusive match", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1814,7 +1702,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
t.Run("no earlier match: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1828,7 +1716,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
t.Run("cursor at start of line: no move", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m)
@ -1843,7 +1731,7 @@ func TestRepeatFind_Semicolon_After_F(t *testing.T) {
t.Run("count=2 backward skips one lands on second", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"XaXbX"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, true)
FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m)
@ -1866,7 +1754,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
t.Run("basic: skips adjacent target, lands before next", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1881,7 +1769,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
t.Run("no further match after second repeat: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1897,7 +1785,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
t.Run("col+2 out of bounds: no move", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"Xo"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1914,7 +1802,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
t.Run("three chained repeats advance correctly", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"aXbXcXd"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, false)
// First repeat
@ -1942,7 +1830,7 @@ func TestRepeatFind_Semicolon_After_t(t *testing.T) {
t.Run("count=2 repeated exclusive forward", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m)
@ -1964,7 +1852,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
t.Run("basic: skips adjacent target, lands after previous", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1978,7 +1866,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
t.Run("no earlier match after second repeat: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -1993,7 +1881,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"oX"}).Build()
// Cursor at col 1 (as if `TX` landed at x+1=1 where x=0).
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2011,7 +1899,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
t.Run("three chained repeats advance correctly backward", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2037,7 +1925,7 @@ func TestRepeatFind_Semicolon_After_T(t *testing.T) {
t.Run("count=2 repeated exclusive backward", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m)
@ -2080,7 +1968,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
// Simulate with two sequential ; presses
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
@ -2088,7 +1976,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
// Simulate with a single 2; (Count=2)
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2103,7 +1991,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
@ -2111,7 +1999,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", true, true)
FindChar{Char: "X", Forward: true, Inclusive: true, Count: 3, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2131,14 +2019,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", false, true)
FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1)
twoSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", false, true)
FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2159,14 +2047,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
twoSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2181,7 +2069,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
@ -2189,7 +2077,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", true, false)
FindChar{Char: "X", Forward: true, Inclusive: false, Count: 3, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2209,14 +2097,14 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
twoSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2231,7 +2119,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{line}).Build()
win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m1 := newMockModelWithWindow(&win1)
m1 := NewMockModelWithWindow(&win1)
m1.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1)
@ -2239,7 +2127,7 @@ func TestRepeatFind_CountedRepeats(t *testing.T) {
threeSemicolons := m1.ActiveWindow().Cursor.Col
win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m2 := newMockModelWithWindow(&win2)
m2 := NewMockModelWithWindow(&win2)
m2.SetLastFind("X", false, false)
FindChar{Char: "X", Forward: false, Inclusive: false, Count: 3, Repeated: true}.Execute(m2)
counted := m2.ActiveWindow().Cursor.Col
@ -2266,7 +2154,7 @@ func TestRepeatFind_Comma_After_f(t *testing.T) {
t.Run("no previous match after fo: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
// Simulate: lastFind was set by `fo`
m.SetLastFind("o", true, true)
@ -2283,7 +2171,7 @@ func TestRepeatFind_Comma_After_f(t *testing.T) {
t.Run("after ;, comma returns to previous match", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// , reversed → backward inclusive from col 7, start at col 6: finds 'o' at 4
@ -2305,7 +2193,7 @@ func TestRepeatFind_Comma_After_F(t *testing.T) {
t.Run("no further match forward: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
// , reversed → forward inclusive
@ -2320,7 +2208,7 @@ func TestRepeatFind_Comma_After_F(t *testing.T) {
t.Run("after ;, comma returns forward to next match", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, true)
// , reversed → forward inclusive from col 4, start at col 5: finds 'o' at 7
@ -2343,7 +2231,7 @@ func TestRepeatFind_Comma_After_t(t *testing.T) {
t.Run("no previous exclusive match: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
// , reversed → backward exclusive, repeated
@ -2359,7 +2247,7 @@ func TestRepeatFind_Comma_After_t(t *testing.T) {
t.Run("after ;, comma goes backward exclusive", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2381,7 +2269,7 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
t.Run("no further exclusive match forward: cursor stays", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
// , reversed → forward exclusive, repeated
@ -2397,7 +2285,7 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
t.Run("after ;, comma goes forward exclusive", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m)
@ -2413,10 +2301,10 @@ func TestRepeatFind_Comma_After_T(t *testing.T) {
// --------------------------------------------------
func TestRepeatFind_Resolve(t *testing.T) {
makeMock := func(char string, forward, inclusive bool) *mockModel {
makeMock := func(char string, forward, inclusive bool) *MockModel {
buf := core.NewBufferBuilder().WithLines([]string{"x"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind(char, forward, inclusive)
return m
}
@ -2538,7 +2426,7 @@ func TestRepeatFind_Execute(t *testing.T) {
t.Run("Execute via ; after f moves cursor correctly", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// ; = RepeatFind{Reverse: false}
@ -2552,7 +2440,7 @@ func TestRepeatFind_Execute(t *testing.T) {
t.Run("Execute via , after f reverses and moves cursor correctly", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, true)
// , = RepeatFind{Reverse: true}
@ -2566,7 +2454,7 @@ func TestRepeatFind_Execute(t *testing.T) {
t.Run("Execute via ; after t skips adjacent and moves cursor", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", true, false)
RepeatFind{Count: 1, Reverse: false}.Execute(m)
@ -2579,7 +2467,7 @@ func TestRepeatFind_Execute(t *testing.T) {
t.Run("Execute via ; after T skips adjacent backward and moves cursor", func(t *testing.T) {
buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build()
win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build()
m := newMockModelWithWindow(&win)
m := NewMockModelWithWindow(&win)
m.SetLastFind("o", false, false)
RepeatFind{Count: 1, Reverse: false}.Execute(m)

View File

@ -76,7 +76,7 @@ type EnterInsertLineEnd struct {
func (a EnterInsertLineEnd) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len())
// Start recording
m.SetInsertRecording(a.Count, a)
@ -158,7 +158,7 @@ func (a InsertChar) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
l := buf.Line(y)
if x < len(l) {
buf.SetLine(y, l[:x]+a.Char+l[x:])
} else {
@ -177,7 +177,7 @@ func (a InsertNewline) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
l := buf.Line(y)
if x == len(l) {
buf.InsertLine(y+1, "")
} else {
@ -197,12 +197,12 @@ func (a InsertBackspace) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
l := buf.Line(y)
if x > 0 {
buf.SetLine(y, l[:x-1]+l[x:])
win.SetCursorCol(x - 1)
} else if y > 0 {
prevLine := buf.Lines[y-1]
prevLine := buf.Line(y - 1)
newX := len(prevLine)
buf.SetLine(y-1, prevLine+l)
buf.DeleteLine(y)
@ -220,9 +220,9 @@ func (a InsertDelete) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
l := buf.Line(y)
if x == len(l) && y < buf.LineCount()-1 {
nextLine := buf.Lines[y+1]
nextLine := buf.Line(y + 1)
buf.SetLine(y, l+nextLine)
buf.DeleteLine(y + 1)
} else if x < len(l) {
@ -240,7 +240,7 @@ func (a InsertTab) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
l := buf.Lines[y]
l := buf.Line(y)
tabs := strings.Repeat(" ", m.Settings().TabStop)
if x < len(l) {
buf.SetLine(y, l[:x]+tabs+l[x:])
@ -275,12 +275,12 @@ func (a InsertDeletePreviousWord) Execute(m Model) tea.Cmd {
buf := m.ActiveBuffer()
x, y := win.Cursor.Col, win.Cursor.Line
line := buf.Lines[y]
line := buf.Line(y)
// At start of line: merge with previous line (same as backspace)
if x == 0 {
if y > 0 {
prevLine := buf.Lines[y-1]
prevLine := buf.Line(y - 1)
newX := len(prevLine)
buf.SetLine(y-1, prevLine+line)
buf.DeleteLine(y)

View File

@ -2,6 +2,7 @@ package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
tea "github.com/charmbracelet/bubbletea"
)
@ -38,7 +39,12 @@ type Model interface {
CommandCursor() int
SetCommandCursor(cur int)
CommandOutput() *core.CommandOutput
// DO NOT FORGET TO CALL SetMode()
SetCommandOutput(out *core.CommandOutput)
CommandHistory() []string
SetCommandHistory(history []string)
CommandHistoryCursor() int
SetCommandHistoryCursor(cur int)
// ==================================================
// Editor-wide State
@ -48,6 +54,8 @@ type Model interface {
Settings() core.EditorSettings
SetSettings(s core.EditorSettings)
Styles() style.Styles
SetStyles(s style.Styles)
// ==================================================
// Registers
@ -56,6 +64,12 @@ type Model interface {
GetRegister(name rune) (core.Register, bool)
SetRegister(name rune, t core.RegisterType, cnt []string) error
UpdateDefaultRegister(t core.RegisterType, cnt []string)
// Dot operator - accumulate keys for repeat
SetLastChangeKeys(keys []string)
LastChangeKeys() []string
ClearLastChangeKeys()
HandleKey(key string) tea.Cmd
}
// Action is the base interface - anything executable
@ -100,3 +114,9 @@ type Resolvable interface {
Motion
Resolve(m Model) Motion
}
type TextObject interface {
// GetRange calculates both endpoints for the text object
// modifier: "i" (inner) or "a" (around)
GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType)
}

View File

@ -60,3 +60,27 @@ func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
m.SetMode(core.VisualBlockMode)
return nil
}
// TODO: Implement count?
type Undo struct{}
func (a Undo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack.CanUndo() {
buf.Undo(win)
}
return nil
}
// TODO: Implement count?
type Redo struct{}
func (a Redo) Execute(m Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack.CanRedo() {
buf.Redo(win)
}
return nil
}

141
internal/action/mock.go Normal file
View File

@ -0,0 +1,141 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
tea "github.com/charmbracelet/bubbletea"
)
// MockModel is a shared test implementation of the Model interface.
// Used by test files across multiple packages to avoid duplication.
// All fields are exported to allow direct manipulation in tests.
type MockModel struct {
WindowsList []*core.Window
ActiveWindowVal *core.Window
BuffersList []*core.Buffer
SettingsVal core.EditorSettings
ModeVal core.Mode
RegistersMap map[rune]core.Register
InsertKeysList []string
CommandVal string
CommandCursorVal int
CommandOutputVal *core.CommandOutput
CommandHistoryList []string
CommandHistoryCur int
LastFindVal core.LastFindCommand
StylesVal style.Styles
LastChangeKeysList []string
}
// NewMockModel creates a mock with an empty buffer and 24x80 window.
func NewMockModel() *MockModel {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
WithWidth(80).
Build()
return &MockModel{
WindowsList: []*core.Window{&win},
ActiveWindowVal: &win,
BuffersList: []*core.Buffer{&buf},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// NewMockModelWithBuffer creates a mock with a custom buffer.
func NewMockModelWithBuffer(buf *core.Buffer) *MockModel {
win := core.NewWindowBuilder().
WithBuffer(buf).
WithHeight(24).
WithWidth(80).
Build()
return &MockModel{
WindowsList: []*core.Window{&win},
ActiveWindowVal: &win,
BuffersList: []*core.Buffer{buf},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// NewMockModelWithWindow creates a mock with a custom window.
func NewMockModelWithWindow(win *core.Window) *MockModel {
return &MockModel{
WindowsList: []*core.Window{win},
ActiveWindowVal: win,
BuffersList: []*core.Buffer{win.Buffer},
SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(),
}
}
// ==================================================
// Model Interface Implementation
// ==================================================
// Core Data Access
func (m *MockModel) Windows() []*core.Window { return m.WindowsList }
func (m *MockModel) ActiveWindow() *core.Window { return m.ActiveWindowVal }
func (m *MockModel) Buffers() []*core.Buffer { return m.BuffersList }
func (m *MockModel) SetBuffers(bufs []*core.Buffer) { m.BuffersList = bufs }
func (m *MockModel) ActiveBuffer() *core.Buffer { return m.ActiveWindowVal.Buffer }
// Insert Mode State
func (m *MockModel) InsertKeys() []string { return m.InsertKeysList }
func (m *MockModel) SetInsertKeys(keys []string) { m.InsertKeysList = keys }
func (m *MockModel) SetInsertRecording(count int, a Action) {}
func (m *MockModel) ExitInsertMode() {}
func (m *MockModel) SetLastFind(char string, forward, inclusive bool) {
m.LastFindVal = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive}
}
func (m *MockModel) GetLastFind() *core.LastFindCommand { return &m.LastFindVal }
// Command Mode State
func (m *MockModel) Command() string { return m.CommandVal }
func (m *MockModel) SetCommand(cmd string) { m.CommandVal = cmd }
func (m *MockModel) CommandCursor() int { return m.CommandCursorVal }
func (m *MockModel) SetCommandCursor(cur int) { m.CommandCursorVal = cur }
func (m *MockModel) CommandOutput() *core.CommandOutput { return m.CommandOutputVal }
func (m *MockModel) SetCommandOutput(out *core.CommandOutput) { m.CommandOutputVal = out }
func (m *MockModel) CommandHistory() []string { return m.CommandHistoryList }
func (m *MockModel) SetCommandHistory(history []string) { m.CommandHistoryList = history }
func (m *MockModel) CommandHistoryCursor() int { return m.CommandHistoryCur }
func (m *MockModel) SetCommandHistoryCursor(cur int) { m.CommandHistoryCur = cur }
// Editor-wide State
func (m *MockModel) Mode() core.Mode { return m.ModeVal }
func (m *MockModel) SetMode(mode core.Mode) { m.ModeVal = mode }
func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
func (m *MockModel) Styles() style.Styles { return m.StylesVal }
func (m *MockModel) SetStyles(s style.Styles) { m.StylesVal = s }
// Registers
func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap }
func (m *MockModel) GetRegister(name rune) (core.Register, bool) {
reg, ok := m.RegistersMap[name]
return reg, ok
}
func (m *MockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error {
m.RegistersMap[name] = core.Register{Type: t, Content: cnt}
return nil
}
func (m *MockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.RegistersMap['"'] = core.Register{Type: t, Content: cnt}
}
// Dot operator
func (m *MockModel) SetLastChangeKeys(keys []string) { m.LastChangeKeysList = keys }
func (m *MockModel) LastChangeKeys() []string { return m.LastChangeKeysList }
func (m *MockModel) ClearLastChangeKeys() { m.LastChangeKeysList = []string{} }
func (m *MockModel) HandleKey(key string) tea.Cmd { return nil }

View File

@ -57,29 +57,56 @@ func (a Paste) Execute(m Model) tea.Cmd {
{
lines := reg.Content
// Shouldn't happen, just a check
if len(lines) != 1 {
out := core.CommandOutput{
Lines: []string{"Charwise register should only have a single line of content."},
Inline: true,
IsError: true,
}
m.SetCommandOutput(&out)
if len(lines) == 0 {
break
}
x := win.Cursor.Col
y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y]
// Catch edge cases, end of line, start of blank line
curLine := buf.Line(y)
insertAt := min(x+1, len(curLine))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt))
if len(lines) == 1 {
// Single-line charwise paste
cnt := strings.Repeat(lines[0], max(1, a.Count))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt))
} else {
// Multi-line charwise paste (e.g., from vi{ yank)
suffix := curLine[insertAt:] // Save the part after cursor
// For count > 1, we paste the content multiple times
// Each paste continues from where the previous one ended
var content strings.Builder
for i := 0; i < a.Count; i++ {
for j, line := range lines {
if j > 0 {
content.WriteString("\n")
}
content.WriteString(line)
}
}
// Split the pasted content into lines
pastedLines := strings.Split(content.String(), "\n")
// First line: append to current line
buf.SetLine(y, curLine[:insertAt]+pastedLines[0])
// Middle lines: insert as new lines
for i := 1; i < len(pastedLines); i++ {
buf.InsertLine(y+i, pastedLines[i])
}
// Last line: append the suffix
lastLineIdx := y + len(pastedLines) - 1
buf.SetLine(lastLineIdx, buf.Line(lastLineIdx)+suffix)
// Set cursor to end of last pasted content (before suffix)
win.SetCursorLine(lastLineIdx)
win.SetCursorCol(len(buf.Line(lastLineIdx)) - len(suffix) - 1)
}
}
default:
out := core.CommandOutput{
@ -142,29 +169,56 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
{
lines := reg.Content
// Shouldn't happen, just a check
if len(lines) != 1 {
out := core.CommandOutput{
Lines: []string{"Charwise register should only have a single line of content."},
Inline: true,
IsError: true,
}
m.SetCommandOutput(&out)
if len(lines) == 0 {
break
}
x := win.Cursor.Col
y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y]
// Catch edge cases, end of line, start of blank line
curLine := buf.Line(y)
insertAt := min(x, len(curLine))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt))
if len(lines) == 1 {
// Single-line charwise paste before cursor
cnt := strings.Repeat(lines[0], max(1, a.Count))
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
buf.SetLine(y, newLine)
win.SetCursorCol(x + len(cnt))
} else {
// Multi-line charwise paste before cursor
suffix := curLine[insertAt:] // Save the part after cursor
// For count > 1, we paste the content multiple times
// Each paste continues from where the previous one ended
var content strings.Builder
for i := 0; i < a.Count; i++ {
for j, line := range lines {
if j > 0 {
content.WriteString("\n")
}
content.WriteString(line)
}
}
// Split the pasted content into lines
pastedLines := strings.Split(content.String(), "\n")
// First line: insert at cursor position
buf.SetLine(y, curLine[:insertAt]+pastedLines[0])
// Middle lines: insert as new lines
for i := 1; i < len(pastedLines); i++ {
buf.InsertLine(y+i, pastedLines[i])
}
// Last line: append the suffix
lastLineIdx := y + len(pastedLines) - 1
buf.SetLine(lastLineIdx, buf.Line(lastLineIdx)+suffix)
// Set cursor to end of last pasted content (before suffix)
win.SetCursorLine(lastLineIdx)
win.SetCursorCol(len(buf.Line(lastLineIdx)) - len(suffix) - 1)
}
}
default:
out := core.CommandOutput{
@ -186,9 +240,11 @@ func (a PasteBefore) WithCount(n int) Action {
return PasteBefore{Count: n}
}
// VisualPaste implements Action (p in visual mode) - replaces selection with register content
// VisualPaste implements Action (p/p in visual mode) - replaces selection with
// register content when Replace flag is set
type VisualPaste struct {
Count int
Count int
Replace bool
}
// VisualPaste.Execute: Replaces visual selection with register content (p in visual mode).
@ -212,11 +268,11 @@ func (a VisualPaste) Execute(m Model) tea.Cmd {
switch mode {
case core.VisualMode:
visualCharPaste(m, reg, start, end)
visualCharPaste(m, reg, start, end, a.Replace)
case core.VisualBlockMode:
visualBlockPaste(m, reg, start, end)
visualBlockPaste(m, reg, start, end, a.Replace)
case core.VisualLineMode:
visualLinePaste(m, reg, start, end)
visualLinePaste(m, reg, start, end, a.Replace)
}
// Exit visual mode
@ -241,7 +297,7 @@ func normalizeSelection(m Model) (core.Position, core.Position) {
}
// visualCharPaste: Handles paste operation in visual (character) mode.
func visualCharPaste(m Model, reg core.Register, start, end core.Position) {
func visualCharPaste(m Model, reg core.Register, start, end core.Position, replace bool) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
@ -257,7 +313,7 @@ func visualCharPaste(m Model, reg core.Register, start, end core.Position) {
} else if reg.Type == core.CharwiseRegister {
// Charwise paste: insert text at cursor position
if len(reg.Content) == 1 {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + reg.Content[0] + line[insertAt:]
buf.SetLine(start.Line, newLine)
@ -272,7 +328,7 @@ func visualCharPaste(m Model, reg core.Register, start, end core.Position) {
for i, content := range reg.Content {
if i == 0 {
// First line: insert at start position
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
insertAt := min(start.Col, len(line))
newLine := line[:insertAt] + content
if len(reg.Content) == 1 {
@ -290,11 +346,13 @@ func visualCharPaste(m Model, reg core.Register, start, end core.Position) {
}
// Update register with deleted text
m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText})
if replace {
m.UpdateDefaultRegister(core.CharwiseRegister, []string{deletedText})
}
}
// visualBlockPaste: Handles paste operation in visual block mode.
func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
func visualBlockPaste(m Model, reg core.Register, start, end core.Position, replace bool) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
@ -304,7 +362,7 @@ func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
// Extract deleted text (for register)
var deletedLines []string
for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y]
line := buf.Line(y)
if startCol < len(line) {
ec := min(endCol+1, len(line))
deletedLines = append(deletedLines, line[startCol:ec])
@ -315,7 +373,7 @@ func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
// Delete the block selection
for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y]
line := buf.Line(y)
if startCol >= len(line) {
continue
}
@ -331,7 +389,7 @@ func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
}
for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y]
line := buf.Line(y)
insertAt := min(startCol, len(line))
// Pad with spaces if needed
for len(line) < insertAt {
@ -346,18 +404,20 @@ func visualBlockPaste(m Model, reg core.Register, start, end core.Position) {
win.SetCursorCol(startCol)
// Update register with deleted block text (joined)
m.UpdateDefaultRegister(core.CharwiseRegister, []string{strings.Join(deletedLines, "\n")})
if replace {
m.UpdateDefaultRegister(core.CharwiseRegister, []string{strings.Join(deletedLines, "\n")})
}
}
// visualLinePaste: Handles paste operation in visual line mode.
func visualLinePaste(m Model, reg core.Register, start, end core.Position) {
func visualLinePaste(m Model, reg core.Register, start, end core.Position, replace bool) {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
// Extract deleted lines (for register)
var deletedLines []string
for y := start.Line; y <= end.Line; y++ {
deletedLines = append(deletedLines, buf.Lines[y])
deletedLines = append(deletedLines, buf.Line(y))
}
// Delete the selected lines (from end to start to preserve indices)
@ -397,7 +457,9 @@ func visualLinePaste(m Model, reg core.Register, start, end core.Position) {
win.SetCursorCol(0)
// Update register with deleted lines
m.UpdateDefaultRegister(core.LinewiseRegister, deletedLines)
if replace {
m.UpdateDefaultRegister(core.LinewiseRegister, deletedLines)
}
}
// extractCharSelection: Extracts text from a character selection range.
@ -405,7 +467,7 @@ func extractCharSelection(m Model, start, end core.Position) string {
buf := m.ActiveBuffer()
if start.Line == end.Line {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
endCol := min(end.Col+1, len(line))
startCol := min(start.Col, len(line))
if startCol >= endCol {
@ -418,7 +480,7 @@ func extractCharSelection(m Model, start, end core.Position) string {
var result strings.Builder
// First line: from start.Col to end
firstLine := buf.Lines[start.Line]
firstLine := buf.Line(start.Line)
if start.Col < len(firstLine) {
result.WriteString(firstLine[start.Col:])
}
@ -426,12 +488,12 @@ func extractCharSelection(m Model, start, end core.Position) string {
// Middle lines: entire lines
for y := start.Line + 1; y < end.Line; y++ {
result.WriteString(buf.Lines[y])
result.WriteString(buf.Line(y))
result.WriteString("\n")
}
// Last line: from beginning to end.Col
lastLine := buf.Lines[end.Line]
lastLine := buf.Line(end.Line)
endCol := min(end.Col+1, len(lastLine))
result.WriteString(lastLine[:endCol])
@ -444,12 +506,12 @@ func deleteCharSelectionForPaste(m Model, start, end core.Position) {
buf := m.ActiveBuffer()
if start.Line == end.Line {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
endCol := min(end.Col+1, len(line))
buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else {
startLine := buf.Lines[start.Line]
endLine := buf.Lines[end.Line]
startLine := buf.Line(start.Line)
endLine := buf.Line(end.Line)
prefix := ""
if start.Col < len(startLine) {
@ -477,5 +539,5 @@ var _ Repeatable = VisualPaste{}
// VisualPaste.WithCount: Returns a new VisualPaste with the given count.
func (a VisualPaste) WithCount(n int) Action {
return VisualPaste{Count: n}
return VisualPaste{Count: n, Replace: a.Replace}
}

41
internal/action/repeat.go Normal file
View File

@ -0,0 +1,41 @@
package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// Repeat implements Action (.) - repeat last input
type Repeat struct {
Count int
}
func (a Repeat) Execute(m Model) tea.Cmd {
keys := m.LastChangeKeys()
if len(keys) == 1 && keys[0] == "." {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Cannot repeat '.'"},
Inline: true,
IsError: true,
})
return nil
}
var cmds []tea.Cmd
for _, key := range keys {
cmd := m.HandleKey(key)
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
// Ensure Repeat implements Repeatable
var _ Repeatable = Repeat{}
// Repeat.WithCount: Returns a new Repeat with the given count.
func (a Repeat) WithCount(n int) Action {
return Repeat{Count: n}
}

129
internal/action/replace.go Normal file
View File

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

View File

@ -6,11 +6,14 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea"
)
@ -307,6 +310,27 @@ func cmdRegisters(m action.Model, args []string, force bool) tea.Cmd {
return nil
}
// --------------------------------------------------
// History Commands
// --------------------------------------------------
func cmdHistory(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
history := m.CommandHistory()
reversed := slices.Clone(history)
slices.Reverse(reversed)
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":history",
Lines: reversed,
Inline: false,
IsError: false,
})
return nil
}
// --------------------------------------------------
// Buffer Commands
// --------------------------------------------------
@ -854,3 +878,84 @@ func parseSetOption(m action.Model, opt string) error {
return nil
}
// --------------------------------------------------
// Colorscheme Commands
// --------------------------------------------------
func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
_ = force
// No args, just print the current scheme
if len(args) == 0 {
s := m.Styles().ChromaStyle
if s == nil {
return nil
}
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{s.Name},
Inline: true,
IsError: false,
})
return nil
}
// Args given, set the scheme
name := strings.Join(args, " ")
chromaStyle := styles.Registry[name]
if chromaStyle == nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)},
Inline: true,
IsError: true,
})
return nil
}
m.SetStyles(style.ChromaStyles(chromaStyle))
return nil
}
func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
colors := styles.Names()
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":colorschemes",
Lines: colors,
Inline: false,
IsError: false,
})
return nil
}
func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
lines := m.ActiveBuffer().UndoStack.List()
// For now, display an error when empty
if len(lines) == 0 {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{"Undo stack is empty"},
Inline: true,
IsError: true,
})
return nil
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":undo",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}

File diff suppressed because it is too large Load Diff

View File

@ -63,7 +63,7 @@ func writeBuffer(m action.Model, buf *core.Buffer, args []string, force bool) (t
// Using a bufio.Writer because its more efficient
writer := bufio.NewWriter(file)
for _, line := range buf.Lines {
n, err := writer.WriteString(line + "\n")
n, err := writer.WriteString(line.String() + "\n")
if err != nil {
return nil, err
}

View File

@ -162,6 +162,13 @@ func (r *Registry) registerDefaults() {
Handler: cmdRegisters,
})
// History commands
r.Register(Command{
Name: "history",
ShortForm: "his",
Handler: cmdHistory,
})
// Buffer commands
r.Register(Command{
Name: "buffers",
@ -217,4 +224,24 @@ func (r *Registry) registerDefaults() {
ShortForm: "e",
Handler: cmdEdit,
})
// Color scheme commands
r.Register(Command{
Name: "colorscheme",
ShortForm: "colo",
Handler: cmdColorscheme,
})
r.Register(Command{
Name: "colorschemes",
ShortForm: "colorschemes",
Handler: cmdListColorschemes,
})
// Undo stack commands
r.Register(Command{
Name: "undo",
ShortForm: "u",
Handler: cmdUndoList,
})
}

View File

@ -20,7 +20,7 @@ type Buffer struct {
// File data
Filename string
Filetype string
Lines []string
Lines []*GapBuffer // Changed from []string to []*GapBuffer
// Flags (not used yet)
Modified bool
@ -29,7 +29,7 @@ type Buffer struct {
ReadOnly bool
// Options BufferOptions
// UndoTree TODO: This will be big
UndoStack *UndoStack
}
// ==================================================
@ -42,14 +42,18 @@ func (b *Buffer) Line(idx int) string {
if idx < 0 || idx >= len(b.Lines) {
return ""
}
return b.Lines[idx]
return b.Lines[idx].String()
}
// Buffer.SetLine: Set the content of the line at an index. Does nothing if the
// index is out of bounds. This function sets the modified flag.
func (b *Buffer) SetLine(idx int, content string) {
if idx >= 0 && idx < len(b.Lines) {
b.Lines[idx] = content
// Record set line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordSetLine(idx, b.Lines[idx].String(), content)
}
b.Lines[idx].Set(content)
}
b.Modified = true
}
@ -64,7 +68,14 @@ func (b *Buffer) InsertLine(idx int, content string) {
if idx > len(b.Lines) {
idx = len(b.Lines)
}
b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...)
// Record insert line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordInsertLine(idx, content)
}
newLine := NewGapBuffer(content)
b.Lines = append(b.Lines[:idx], append([]*GapBuffer{newLine}, b.Lines[idx:]...)...)
b.Modified = true
}
@ -72,6 +83,10 @@ func (b *Buffer) InsertLine(idx int, content string) {
// of bounds. This function sets the modified flag.
func (b *Buffer) DeleteLine(idx int) {
if idx >= 0 && idx < len(b.Lines) {
// Record delete line in undo stack
if b.UndoStack != nil {
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx].String())
}
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
}
b.Modified = true
@ -82,6 +97,101 @@ func (b *Buffer) LineCount() int {
return len(b.Lines)
}
// ==================================================
// Undo Stack
// ==================================================
func (b *Buffer) Undo(w *Window) bool {
if b.UndoStack == nil {
return false
}
block := b.UndoStack.Undo()
if block == nil {
return false
}
// Apply changes in REVERSE order
for i := len(block.Changes) - 1; i >= 0; i-- {
change := block.Changes[i]
// Temporarily disable recording while we undo
wasRecording := b.UndoStack.recording
b.UndoStack.recording = false
switch change.Type {
case SetLineChange:
// Restore old data
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines[change.Line].Set(change.OldData)
}
case InsertLineChange:
// Remove the inserted line
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
}
case DeleteLineChange:
// Re-insert the deleted line
if change.Line <= len(b.Lines) {
newLine := NewGapBuffer(change.OldData)
b.Lines = append(b.Lines[:change.Line], append([]*GapBuffer{newLine}, b.Lines[change.Line:]...)...)
}
}
b.UndoStack.recording = wasRecording
}
// Restore cursor position
w.SetCursorLine(block.OldCursor.Line)
w.SetCursorCol(block.OldCursor.Col)
return true
}
func (b *Buffer) Redo(w *Window) bool {
if b.UndoStack == nil {
return false
}
block := b.UndoStack.Redo()
if block == nil {
return false
}
// Apply changes in FORWARD order
for _, change := range block.Changes {
// Temporarily disable recording while we redo
wasRecording := b.UndoStack.recording
b.UndoStack.recording = false
switch change.Type {
case SetLineChange:
// Apply new data
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines[change.Line].Set(change.NewData)
}
case InsertLineChange:
// Re-insert the line
if change.Line <= len(b.Lines) {
newLine := NewGapBuffer(change.NewData)
b.Lines = append(b.Lines[:change.Line], append([]*GapBuffer{newLine}, b.Lines[change.Line:]...)...)
}
case DeleteLineChange:
// Re-delete the line
if change.Line >= 0 && change.Line < len(b.Lines) {
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
}
}
b.UndoStack.recording = wasRecording
}
// Restore cursor position
w.SetCursorLine(block.NewCursor.Line)
w.SetCursorCol(block.NewCursor.Col)
return true
}
// ==================================================
// Setters
// ==================================================
@ -101,7 +211,10 @@ func (b *Buffer) SetFiletype(filetype string) {
// Buffer.SetLines: Replace all lines in the buffer with the provided lines.
// This is useful when loading a file or resetting buffer content.
func (b *Buffer) SetLines(lines []string) {
b.Lines = lines
b.Lines = make([]*GapBuffer, len(lines))
for i, line := range lines {
b.Lines[i] = NewGapBuffer(line)
}
}
// Buffer.SetModified: Set the modified flag for this buffer. A modified buffer

View File

@ -12,15 +12,16 @@ type BufferBuilder struct {
func NewBufferBuilder() *BufferBuilder {
return &BufferBuilder{
buffer: Buffer{
Id: 0, // This is set when built
Type: ScatchBuffer, // Default buffer type
Filename: "",
Filetype: "",
Lines: []string{""},
Modified: false,
Loaded: false,
Listed: false,
ReadOnly: false,
Id: 0, // This is set when built
Type: ScatchBuffer, // Default buffer type
Filename: "",
Filetype: "",
Lines: []*GapBuffer{NewEmptyGapBuffer()},
Modified: false,
Loaded: false,
Listed: false,
ReadOnly: false,
UndoStack: NewUndoStack(), // Empty undo stack
},
}
}
@ -39,7 +40,10 @@ func (b *BufferBuilder) WithFiletype(filetype string) *BufferBuilder {
// BufferBuilder.WithLines: Attaches a lines to the buffer that is being built.
func (b *BufferBuilder) WithLines(lines []string) *BufferBuilder {
b.buffer.Lines = lines
b.buffer.Lines = make([]*GapBuffer, len(lines))
for i, line := range lines {
b.buffer.Lines[i] = NewGapBuffer(line)
}
return b
}

View File

@ -1,8 +1,11 @@
package core
import "strings"
import (
"strings"
)
const CommandOutputExitMessage = "Press ENTER to continue"
const CommandOutputScrollMessage = "Use j/k to scroll"
type CommandOutput struct {
Title string
@ -37,3 +40,39 @@ func (c *CommandOutput) Height() int {
func (c *CommandOutput) IsActive() bool {
return len(c.Lines) > 0
}
// maxOutputWindowHeight: Calculates the max height of the output window. This is simply
// just 3/4 of the terminal height. This allows the title to always be shown, but also
// allows the user to not totally lose mental context when viewing a large output.
func maxOutputWindowHeight(termHeight int) int {
return int(float64(termHeight) * 0.75)
}
// CommandOutput.Viewport: Returns a list of the lines in the current viewport, depends on
// the height of the terminal. This function should be in place of the Lines property.
func (c *CommandOutput) Viewport(height int) []string {
start := c.ScrollOffset
end := maxOutputWindowHeight(height) + start
// Clamp end to available lines
if end > len(c.Lines) {
end = len(c.Lines)
}
return c.Lines[start:end]
}
// CommandOutput.ScrollDown: Manages the scrolling down logic and handles bounds checks.
// This function depends on the height on the terminal.
func (c *CommandOutput) ScrollDown(height int) {
if (c.ScrollOffset + maxOutputWindowHeight(height)) < len(c.Lines) {
c.ScrollOffset++
}
}
// CommandOutput.ScrollUp: Manages the scrolling up logic and handles bounds checks.
func (c *CommandOutput) ScrollUp() {
if c.ScrollOffset > 0 {
c.ScrollOffset--
}
}

View File

@ -0,0 +1,588 @@
package core
import "testing"
// TestCommandOutputHeight tests the Height() method for various configurations
func TestCommandOutputHeight(t *testing.T) {
t.Run("inline mode returns 1", func(t *testing.T) {
co := &CommandOutput{
Title: "Test Title",
Lines: []string{"line1", "line2", "line3"},
Inline: true,
}
if got := co.Height(); got != 1 {
t.Errorf("Height() = %d, want 1 for inline mode", got)
}
})
t.Run("empty output with no title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
Inline: false,
}
// 0 lines + 0 title + 2 padding = 2
want := 2
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Test",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 1 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line without title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("multiple lines with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Output",
Lines: []string{"line1", "line2", "line3", "line4", "line5"},
Inline: false,
}
// 5 lines + 1 title + 2 padding = 8
want := 8
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("whitespace-only title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: " ",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title (whitespace) + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("empty string title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: "",
Lines: []string{"line1", "line2"},
Inline: false,
}
// 2 lines + 0 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
}
// TestCommandOutputIsActive tests the IsActive() method
func TestCommandOutputIsActive(t *testing.T) {
t.Run("active with lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when lines present")
}
})
t.Run("inactive with no lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
}
if co.IsActive() {
t.Error("IsActive() = true, want false when no lines")
}
})
t.Run("inactive with nil lines", func(t *testing.T) {
co := &CommandOutput{
Lines: nil,
}
if co.IsActive() {
t.Error("IsActive() = true, want false when lines is nil")
}
})
t.Run("active with multiple lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when multiple lines present")
}
})
}
// TestMaxOutputWindowHeight tests the maxOutputWindowHeight helper function
func TestMaxOutputWindowHeight(t *testing.T) {
tests := []struct {
name string
termHeight int
want int
}{
{"100 height terminal", 100, 75},
{"80 height terminal", 80, 60},
{"40 height terminal", 40, 30},
{"24 height terminal", 24, 18},
{"20 height terminal", 20, 15},
{"10 height terminal", 10, 7},
{"1 height terminal", 1, 0},
{"0 height terminal", 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := maxOutputWindowHeight(tt.termHeight)
if got != tt.want {
t.Errorf("maxOutputWindowHeight(%d) = %d, want %d", tt.termHeight, got, tt.want)
}
})
}
}
// TestCommandOutputViewport tests the Viewport() method
func TestCommandOutputViewport(t *testing.T) {
t.Run("viewport at start with small content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75, viewport shows all 3 lines
viewport := co.Viewport(100)
if len(viewport) != 3 {
t.Errorf("Viewport() returned %d lines, want 3", len(viewport))
}
want := []string{"line1", "line2", "line3"}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
t.Run("viewport at start with large content", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
viewport := co.Viewport(100)
if len(viewport) != 75 {
t.Errorf("Viewport() returned %d lines, want 75", len(viewport))
}
// Should start with first line
if viewport[0] != "A" {
t.Errorf("Viewport()[0] = %q, want 'A'", viewport[0])
}
})
t.Run("viewport scrolled down", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 3,
}
// Terminal height 20 → max window = 15
viewport := co.Viewport(20)
// Should start at offset 3 (line "D")
if viewport[0] != "D" {
t.Errorf("Viewport()[0] = %q, want 'D' (offset 3)", viewport[0])
}
})
t.Run("viewport with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('0' + (i % 10)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (40 lines) → max window = 30
viewport := co.Viewport(40)
if len(viewport) != 30 {
t.Errorf("Viewport(40) returned %d lines, want 30", len(viewport))
}
// Large terminal (200 lines) → max window = 150, but only 100 lines available
viewport = co.Viewport(200)
if len(viewport) != 100 {
t.Errorf("Viewport(200) returned %d lines, want 100 (all available)", len(viewport))
}
})
t.Run("viewport at maximum scroll", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 2, // Showing lines from index 2 onwards
}
// Terminal height 20 → max window = 15 (but only 3 lines available from offset 2)
viewport := co.Viewport(20)
want := []string{"C", "D", "E"}
if len(viewport) != len(want) {
t.Errorf("Viewport() returned %d lines, want %d", len(viewport), len(want))
}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
}
// TestCommandOutputScrollDown tests the ScrollDown() method
func TestCommandOutputScrollDown(t *testing.T) {
t.Run("scroll down with space available", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
// Can scroll since 100 lines > 75 viewport
co.ScrollDown(100)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d after ScrollDown, want 1", co.ScrollOffset)
}
})
t.Run("scroll down multiple times", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(100)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 ScrollDown calls, want 5", co.ScrollOffset)
}
})
t.Run("cannot scroll past end of content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
// Terminal height 20 → max window = 15
// 5 lines fit entirely in viewport, should not scroll
co.ScrollDown(20)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not scroll when content fits)", co.ScrollOffset)
}
})
t.Run("scroll down to maximum", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 0,
}
// Terminal height 16 → max window = 12
// 10 lines total, can scroll down until offset + 12 >= 10
// Max offset = 10 - 12 = -2, but we can't go negative
// Actually: can scroll while (offset + 12) < 10
// So max offset before stopping = 10 - 12 = can't scroll at all? Let me recalculate
// offset=0: show lines 0-11 (but only 10 exist) → shows all 10
// Can't scroll since viewport > content
// Let's use different numbers: 20 lines, window of 12
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co.Lines = lines
// Max scroll: offset + 12 < 20 → offset < 8 → max offset = 7
// But the function allows offset until (offset + 12) < 20
// So when offset = 8, offset + 12 = 20, not < 20, stops
for i := 0; i < 20; i++ {
co.ScrollDown(16)
}
// Maximum offset should be 8 (showing lines 8-19, which is 12 lines)
want := 8
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to max, want %d", co.ScrollOffset, want)
}
})
t.Run("scroll down at maximum has no effect", func(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 8, // Already at max for height 16 (window 12)
}
co.ScrollDown(16)
if co.ScrollOffset != 8 {
t.Errorf("ScrollOffset = %d, want 8 (should not scroll past end)", co.ScrollOffset)
}
})
t.Run("scroll with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (height 40 → window 30)
co.ScrollDown(40)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d for height 40, want 1", co.ScrollOffset)
}
// Reset
co.ScrollOffset = 0
// Large terminal (height 200 → window 150)
co.ScrollDown(200)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d for height 200, want 0 (content fits entirely)", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollUp tests the ScrollUp() method
func TestCommandOutputScrollUp(t *testing.T) {
t.Run("scroll up from offset", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
co.ScrollUp()
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 2", co.ScrollOffset)
}
})
t.Run("scroll up multiple times", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 5,
}
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after 3 ScrollUp calls, want 2", co.ScrollOffset)
}
})
t.Run("cannot scroll up past zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not go negative)", co.ScrollOffset)
}
})
t.Run("scroll up to zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
// Scroll up 3 times to reach 0
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
// Try scrolling up one more time
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling past top, want 0", co.ScrollOffset)
}
})
t.Run("scroll up from large offset", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 50,
}
co.ScrollUp()
if co.ScrollOffset != 49 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 49", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollingIntegration tests combined scrolling behavior
func TestCommandOutputScrollingIntegration(t *testing.T) {
t.Run("scroll down and up", func(t *testing.T) {
lines := make([]string, 50)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 40 // max window = 30
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(termHeight)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 down, want 5", co.ScrollOffset)
}
// Scroll up 2 times
for i := 0; i < 2; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 3 {
t.Errorf("ScrollOffset = %d after 2 up, want 3", co.ScrollOffset)
}
// Scroll back to top
for i := 0; i < 10; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
})
t.Run("scroll to bottom and back to top", func(t *testing.T) {
lines := make([]string, 30)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 28 // max window = 21
// Scroll to bottom (max offset = 30 - 21 = 9)
for i := 0; i < 20; i++ {
co.ScrollDown(termHeight)
}
want := 9
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to bottom, want %d", co.ScrollOffset, want)
}
// Verify viewport shows last lines
viewport := co.Viewport(termHeight)
if len(viewport) != 21 {
t.Errorf("Viewport length = %d, want 21", len(viewport))
}
if viewport[len(viewport)-1] != string(rune('A'+29)) {
t.Errorf("Last viewport line = %q, want %q", viewport[len(viewport)-1], string(rune('A'+29)))
}
// Scroll back to top
for i := 0; i < 20; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling back to top, want 0", co.ScrollOffset)
}
})
}

180
internal/core/gap_buffer.go Normal file
View File

@ -0,0 +1,180 @@
package core
// GapBuffer represents a single line of text using the gap buffer data structure.
// It maintains a gap (empty space) in the buffer where the cursor is positioned,
// making insertions and deletions at the cursor position very efficient (O(1)).
type GapBuffer struct {
buffer []rune // The underlying buffer containing text and gap
gapStart int // Index where the gap starts
gapEnd int // Index where the gap ends (exclusive)
}
// NewGapBuffer: creates a new gap buffer with the given initial content.
// The gap is positioned at the end of the content.
func NewGapBuffer(content string) *GapBuffer {
runes := []rune(content)
initialCapacity := len(runes) + 16 // Extra space for the gap
buffer := make([]rune, initialCapacity)
copy(buffer, runes)
return &GapBuffer{
buffer: buffer,
gapStart: len(runes),
gapEnd: initialCapacity,
}
}
// NewEmptyGapBuffer: Creates a new empty gap buffer with default capacity.
func NewEmptyGapBuffer() *GapBuffer {
initialCapacity := 16
return &GapBuffer{
buffer: make([]rune, initialCapacity),
gapStart: 0,
gapEnd: initialCapacity,
}
}
// GapBuffer.String: Converts the gap buffer to a string, excluding the gap.
func (gb *GapBuffer) String() string {
result := make([]rune, 0, gb.Len())
result = append(result, gb.buffer[:gb.gapStart]...)
result = append(result, gb.buffer[gb.gapEnd:]...)
return string(result)
}
// GapBuffer.Len: Returns the length of the text (excluding the gap).
func (gb *GapBuffer) Len() int {
return len(gb.buffer) - (gb.gapEnd - gb.gapStart)
}
// GapBuffer.GapSize: Returns the current size of the gap.
func (gb *GapBuffer) GapSize() int {
return gb.gapEnd - gb.gapStart
}
// GapBuffer.Insert: Inserts a string at the specified position.
// This moves the gap to the position first, then inserts the text.
func (gb *GapBuffer) Insert(pos int, text string) {
if pos < 0 || pos > gb.Len() {
return // Invalid position
}
runes := []rune(text)
if len(runes) == 0 {
return
}
// Move gap to insertion position
gb.moveGap(pos)
// Ensure gap is large enough
if gb.GapSize() < len(runes) {
gb.grow(len(runes))
}
// Insert runes at gap start
copy(gb.buffer[gb.gapStart:], runes)
gb.gapStart += len(runes)
}
// GapBuffer.Delete: Deletes count runes starting at position pos.
func (gb *GapBuffer) Delete(pos, count int) {
if pos < 0 || pos >= gb.Len() || count <= 0 {
return
}
// Clamp count to not exceed buffer length
if pos+count > gb.Len() {
count = gb.Len() - pos
}
// Move gap to deletion position
gb.moveGap(pos)
// Expand gap to absorb deleted characters
gb.gapEnd += count
}
// GapBuffer.RuneAt: Returns the rune at the specified position.
func (gb *GapBuffer) RuneAt(pos int) rune {
if pos < 0 || pos >= gb.Len() {
return 0
}
if pos < gb.gapStart {
return gb.buffer[pos]
}
return gb.buffer[pos+gb.GapSize()]
}
// GapBuffer.Substring: Returns a substring from start to end (exclusive).
func (gb *GapBuffer) Substring(start, end int) string {
if start < 0 {
start = 0
}
if end > gb.Len() {
end = gb.Len()
}
if start >= end {
return ""
}
result := make([]rune, 0, end-start)
for i := start; i < end; i++ {
result = append(result, gb.RuneAt(i))
}
return string(result)
}
// GapBuffer.moveGap: Moves the gap to the specified position.
func (gb *GapBuffer) moveGap(pos int) {
if pos < gb.gapStart {
// Move gap left: shift text from [pos, gapStart) to [gapEnd-delta, gapEnd)
delta := gb.gapStart - pos
copy(gb.buffer[gb.gapEnd-delta:gb.gapEnd], gb.buffer[pos:gb.gapStart])
gb.gapStart = pos
gb.gapEnd -= delta
} else if pos > gb.gapStart {
// Move gap right: shift text from [gapEnd, gapEnd+delta) to [gapStart, gapStart+delta)
delta := pos - gb.gapStart
copy(gb.buffer[gb.gapStart:gb.gapStart+delta], gb.buffer[gb.gapEnd:gb.gapEnd+delta])
gb.gapStart += delta
gb.gapEnd += delta
}
}
// GapBuffer.grow: Expands the buffer to accommodate at least minGapSize additional characters.
func (gb *GapBuffer) grow(minGapSize int) {
oldLen := len(gb.buffer)
newGapSize := minGapSize * 2 // Double the required size for future insertions
newLen := oldLen + newGapSize - gb.GapSize()
newBuffer := make([]rune, newLen)
// Copy text before gap
copy(newBuffer, gb.buffer[:gb.gapStart])
// Copy text after gap to new position
newGapEnd := newLen - (oldLen - gb.gapEnd)
copy(newBuffer[newGapEnd:], gb.buffer[gb.gapEnd:])
gb.buffer = newBuffer
gb.gapEnd = newGapEnd
}
// GapBuffer.Set: Replaces the entire content of the gap buffer.
func (gb *GapBuffer) Set(content string) {
runes := []rune(content)
capacity := len(runes) + 16
gb.buffer = make([]rune, capacity)
copy(gb.buffer, runes)
gb.gapStart = len(runes)
gb.gapEnd = capacity
}
// GapBuffer.Clear: Removes all content from the gap buffer.
func (gb *GapBuffer) Clear() {
gb.gapStart = 0
gb.gapEnd = len(gb.buffer)
}

View File

@ -0,0 +1,455 @@
package core
import "testing"
func TestGapBufferString(t *testing.T) {
t.Run("gapBuffer.String() on empty buffer returns \"\"", func(t *testing.T) {
buf := NewEmptyGapBuffer()
str := buf.String()
if str != "" {
t.Fatalf("buf.String() expected '', got '%s'", str)
}
})
t.Run("gapBuffer.String() on string returns the string", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
str := buf.String()
if str != "Hello world" {
t.Fatalf("buf.String() expected 'Hello world', got '%s'", str)
}
})
t.Run("gapBuffer.String() after moving gap returns the string", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.moveGap(6)
str := buf.String()
if str != "Hello world" {
t.Fatalf("buf.String() expected 'Hello world', got '%s'", str)
}
})
t.Run("gapBuffer.String() after growing gap returns the string", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.grow(16)
str := buf.String()
if str != "Hello world" {
t.Fatalf("buf.String() expected 'Hello world', got '%s'", str)
}
})
t.Run("gapBuffer.String() handles unicode characters", func(t *testing.T) {
buf := NewGapBuffer("Hello 世界 🌍")
str := buf.String()
if str != "Hello 世界 🌍" {
t.Fatalf("buf.String() expected 'Hello 世界 🌍', got '%s'", str)
}
})
}
func TestGapBufferLen(t *testing.T) {
t.Run("empty buffer has length 0", func(t *testing.T) {
buf := NewEmptyGapBuffer()
if buf.Len() != 0 {
t.Fatalf("expected length 0, got %d", buf.Len())
}
})
t.Run("buffer with content returns correct length", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.Len() != 5 {
t.Fatalf("expected length 5, got %d", buf.Len())
}
})
t.Run("length with unicode characters", func(t *testing.T) {
buf := NewGapBuffer("Hi 🌍")
if buf.Len() != 4 {
t.Fatalf("expected length 4, got %d", buf.Len())
}
})
}
func TestGapBufferInsert(t *testing.T) {
t.Run("insert at beginning", func(t *testing.T) {
buf := NewGapBuffer("world")
buf.Insert(0, "Hello ")
if buf.String() != "Hello world" {
t.Fatalf("expected 'Hello world', got '%s'", buf.String())
}
})
t.Run("insert at end", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Insert(5, " world")
if buf.String() != "Hello world" {
t.Fatalf("expected 'Hello world', got '%s'", buf.String())
}
})
t.Run("insert in middle", func(t *testing.T) {
buf := NewGapBuffer("Helloworld")
buf.Insert(5, " ")
if buf.String() != "Hello world" {
t.Fatalf("expected 'Hello world', got '%s'", buf.String())
}
})
t.Run("insert into empty buffer", func(t *testing.T) {
buf := NewEmptyGapBuffer()
buf.Insert(0, "Hello")
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("insert empty string does nothing", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Insert(2, "")
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("insert at invalid position does nothing", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Insert(-1, "X")
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
buf.Insert(100, "X")
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("insert unicode characters", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Insert(5, " 世界")
if buf.String() != "Hello 世界" {
t.Fatalf("expected 'Hello 世界', got '%s'", buf.String())
}
})
t.Run("multiple consecutive insertions", func(t *testing.T) {
buf := NewEmptyGapBuffer()
buf.Insert(0, "a")
buf.Insert(1, "b")
buf.Insert(2, "c")
if buf.String() != "abc" {
t.Fatalf("expected 'abc', got '%s'", buf.String())
}
})
t.Run("insert large text triggers grow", func(t *testing.T) {
buf := NewGapBuffer("Hello")
longText := "Lorem ipsum dolor sit amet, consectetur adipiscing elit"
buf.Insert(5, longText)
expected := "Hello" + longText
if buf.String() != expected {
t.Fatalf("expected '%s', got '%s'", expected, buf.String())
}
})
}
func TestGapBufferDelete(t *testing.T) {
t.Run("delete from beginning", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.Delete(0, 6)
if buf.String() != "world" {
t.Fatalf("expected 'world', got '%s'", buf.String())
}
})
t.Run("delete from end", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.Delete(5, 6)
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("delete from middle", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.Delete(5, 1)
if buf.String() != "Helloworld" {
t.Fatalf("expected 'Helloworld', got '%s'", buf.String())
}
})
t.Run("delete single character", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Delete(1, 1)
if buf.String() != "Hllo" {
t.Fatalf("expected 'Hllo', got '%s'", buf.String())
}
})
t.Run("delete all content", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Delete(0, 5)
if buf.String() != "" {
t.Fatalf("expected '', got '%s'", buf.String())
}
})
t.Run("delete with count exceeding length", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Delete(2, 100)
if buf.String() != "He" {
t.Fatalf("expected 'He', got '%s'", buf.String())
}
})
t.Run("delete at invalid position does nothing", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Delete(-1, 2)
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
buf.Delete(100, 2)
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("delete with zero count does nothing", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Delete(2, 0)
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("delete unicode characters", func(t *testing.T) {
buf := NewGapBuffer("Hello 世界")
buf.Delete(6, 2)
if buf.String() != "Hello " {
t.Fatalf("expected 'Hello ', got '%s'", buf.String())
}
})
}
func TestGapBufferRuneAt(t *testing.T) {
t.Run("get rune at valid position", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.RuneAt(0) != 'H' {
t.Fatalf("expected 'H', got '%c'", buf.RuneAt(0))
}
if buf.RuneAt(4) != 'o' {
t.Fatalf("expected 'o', got '%c'", buf.RuneAt(4))
}
})
t.Run("get rune before gap", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.moveGap(6)
if buf.RuneAt(5) != ' ' {
t.Fatalf("expected ' ', got '%c'", buf.RuneAt(5))
}
})
t.Run("get rune after gap", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.moveGap(6)
if buf.RuneAt(6) != 'w' {
t.Fatalf("expected 'w', got '%c'", buf.RuneAt(6))
}
})
t.Run("get rune at invalid position returns 0", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.RuneAt(-1) != 0 {
t.Fatalf("expected 0, got '%c'", buf.RuneAt(-1))
}
if buf.RuneAt(100) != 0 {
t.Fatalf("expected 0, got '%c'", buf.RuneAt(100))
}
})
t.Run("get unicode rune", func(t *testing.T) {
buf := NewGapBuffer("🌍世界")
if buf.RuneAt(0) != '🌍' {
t.Fatalf("expected '🌍', got '%c'", buf.RuneAt(0))
}
if buf.RuneAt(1) != '世' {
t.Fatalf("expected '世', got '%c'", buf.RuneAt(1))
}
})
}
func TestGapBufferSubstring(t *testing.T) {
t.Run("substring from middle", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
if buf.Substring(0, 5) != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.Substring(0, 5))
}
if buf.Substring(6, 11) != "world" {
t.Fatalf("expected 'world', got '%s'", buf.Substring(6, 11))
}
})
t.Run("substring entire content", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.Substring(0, 5) != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.Substring(0, 5))
}
})
t.Run("substring with start >= end returns empty", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.Substring(3, 3) != "" {
t.Fatalf("expected '', got '%s'", buf.Substring(3, 3))
}
if buf.Substring(3, 2) != "" {
t.Fatalf("expected '', got '%s'", buf.Substring(3, 2))
}
})
t.Run("substring clamps to buffer bounds", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.Substring(-5, 3) != "Hel" {
t.Fatalf("expected 'Hel', got '%s'", buf.Substring(-5, 3))
}
if buf.Substring(2, 100) != "llo" {
t.Fatalf("expected 'llo', got '%s'", buf.Substring(2, 100))
}
})
t.Run("substring with gap in middle", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.moveGap(6)
if buf.Substring(0, 11) != "Hello world" {
t.Fatalf("expected 'Hello world', got '%s'", buf.Substring(0, 11))
}
})
t.Run("substring with unicode", func(t *testing.T) {
buf := NewGapBuffer("Hi 世界")
if buf.Substring(3, 5) != "世界" {
t.Fatalf("expected '世界', got '%s'", buf.Substring(3, 5))
}
})
}
func TestGapBufferSet(t *testing.T) {
t.Run("set replaces content", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Set("Goodbye")
if buf.String() != "Goodbye" {
t.Fatalf("expected 'Goodbye', got '%s'", buf.String())
}
})
t.Run("set to empty string", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Set("")
if buf.String() != "" {
t.Fatalf("expected '', got '%s'", buf.String())
}
})
t.Run("set on empty buffer", func(t *testing.T) {
buf := NewEmptyGapBuffer()
buf.Set("Hello")
if buf.String() != "Hello" {
t.Fatalf("expected 'Hello', got '%s'", buf.String())
}
})
t.Run("set resets gap position", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.moveGap(2)
buf.Set("World")
if buf.String() != "World" {
t.Fatalf("expected 'World', got '%s'", buf.String())
}
if buf.gapStart != 5 {
t.Fatalf("expected gap start at 5, got %d", buf.gapStart)
}
})
}
func TestGapBufferClear(t *testing.T) {
t.Run("clear removes all content", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.Clear()
if buf.String() != "" {
t.Fatalf("expected '', got '%s'", buf.String())
}
})
t.Run("clear on empty buffer", func(t *testing.T) {
buf := NewEmptyGapBuffer()
buf.Clear()
if buf.String() != "" {
t.Fatalf("expected '', got '%s'", buf.String())
}
})
t.Run("clear resets gap to start", func(t *testing.T) {
buf := NewGapBuffer("Hello")
buf.Clear()
if buf.gapStart != 0 {
t.Fatalf("expected gap start at 0, got %d", buf.gapStart)
}
if buf.gapEnd != len(buf.buffer) {
t.Fatalf("expected gap end at %d, got %d", len(buf.buffer), buf.gapEnd)
}
})
}
func TestGapBufferGapSize(t *testing.T) {
t.Run("gap size on new buffer", func(t *testing.T) {
buf := NewGapBuffer("Hello")
if buf.GapSize() != 16 {
t.Fatalf("expected gap size 16, got %d", buf.GapSize())
}
})
t.Run("gap size on empty buffer", func(t *testing.T) {
buf := NewEmptyGapBuffer()
if buf.GapSize() != 16 {
t.Fatalf("expected gap size 16, got %d", buf.GapSize())
}
})
t.Run("gap size decreases after insert", func(t *testing.T) {
buf := NewGapBuffer("Hello")
initialGapSize := buf.GapSize()
buf.Insert(5, "XX")
if buf.GapSize() != initialGapSize-2 {
t.Fatalf("expected gap size %d, got %d", initialGapSize-2, buf.GapSize())
}
})
}
func TestGapBufferComplex(t *testing.T) {
t.Run("sequence of operations", func(t *testing.T) {
buf := NewEmptyGapBuffer()
buf.Insert(0, "Hello")
buf.Insert(5, " world")
buf.Delete(5, 1)
buf.Insert(5, ", ")
if buf.String() != "Hello, world" {
t.Fatalf("expected 'Hello, world', got '%s'", buf.String())
}
})
t.Run("insert and delete at different positions", func(t *testing.T) {
buf := NewGapBuffer("abcdefgh")
buf.Delete(2, 4)
buf.Insert(2, "XX")
if buf.String() != "abXXgh" {
t.Fatalf("expected 'abXXgh', got '%s'", buf.String())
}
})
t.Run("editing with gap movement", func(t *testing.T) {
buf := NewGapBuffer("Hello world")
buf.Insert(0, ">> ")
buf.Insert(buf.Len(), " <<")
if buf.String() != ">> Hello world <<" {
t.Fatalf("expected '>> Hello world <<', got '%s'", buf.String())
}
})
}

View File

@ -11,13 +11,15 @@ 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:
case NormalMode, WaitingMode:
return "NORMAL"
case InsertMode:
return "INSERT"
@ -29,6 +31,8 @@ func (m Mode) ToString() string {
return "V-LINE"
case VisualBlockMode:
return "V-BLOCK"
case ReplaceMode:
return "REPLACE"
default:
return "-----"
}

View File

@ -82,7 +82,8 @@ func addSpecialRegisters(reg map[rune]Register) {
// Small delete? Expression?
// Last inserted text (readonly)
// VIM: Last inserted text (readonly)
// GIM: Content stored for the '.' operator (for debugging)
reg['.'] = emptyRegister()
// Current file name (readonly)

180
internal/core/undo.go Normal file
View File

@ -0,0 +1,180 @@
package core
import (
"fmt"
"slices"
)
type ChangeType string
const (
SetLineChange ChangeType = "SetLine"
InsertLineChange ChangeType = "InsertLine"
DeleteLineChange ChangeType = "DeleteLine"
)
type Change struct {
Type ChangeType
Line int
OldData string
NewData string
}
type ChangeBlock struct {
Changes []Change
OldCursor Position // Before OP
NewCursor Position // After OP
}
type UndoStack struct {
undoStack []ChangeBlock
redoStack []ChangeBlock
current []Change
recording bool
oldCursor Position
}
func NewUndoStack() *UndoStack {
return &UndoStack{
undoStack: []ChangeBlock{},
redoStack: []ChangeBlock{},
current: []Change{},
recording: false,
oldCursor: Position{},
}
}
func (u *UndoStack) BeginBlock(cursor Position) {
u.current = []Change{}
u.recording = true
u.oldCursor = cursor
}
func (u *UndoStack) EndBlock(cursor Position) {
// If not recording or nothing changed, we can exit safely
if !u.recording || len(u.current) == 0 {
return
}
block := ChangeBlock{
Changes: u.current,
OldCursor: u.oldCursor,
NewCursor: cursor,
}
u.undoStack = append(u.undoStack, block)
u.redoStack = []ChangeBlock{} // Reset old changes, can no longer redo
u.recording = false
u.current = []Change{}
}
func (u *UndoStack) RecordSetLine(line int, oldData, newData string) {
if !u.recording {
return
}
change := Change{
Type: SetLineChange,
Line: line,
OldData: oldData,
NewData: newData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) RecordInsertLine(line int, newData string) {
if !u.recording {
return
}
change := Change{
Type: InsertLineChange,
Line: line,
NewData: newData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) RecordDeleteLine(line int, oldData string) {
if !u.recording {
return
}
change := Change{
Type: DeleteLineChange,
Line: line,
OldData: oldData,
}
u.current = append(u.current, change)
}
func (u *UndoStack) Undo() *ChangeBlock {
if len(u.undoStack) == 0 {
return nil
}
// Pop from undo stack
size := len(u.undoStack)
block := u.undoStack[size-1]
u.undoStack = u.undoStack[:size-1]
// Push to redo stack
u.redoStack = append(u.redoStack, block)
return &block
}
func (u *UndoStack) Redo() *ChangeBlock {
if len(u.redoStack) == 0 {
return nil
}
// Pop from redo stack
size := len(u.redoStack)
block := u.redoStack[size-1]
u.redoStack = u.redoStack[:size-1]
// Push to undo stack
u.undoStack = append(u.undoStack, block)
return &block
}
func (u *UndoStack) CanUndo() bool {
return len(u.undoStack) > 0
}
func (u *UndoStack) CanRedo() bool {
return len(u.redoStack) > 0
}
func (u *UndoStack) Recording() bool {
return u.recording
}
func (u *UndoStack) List() []string {
var lines []string
stack := slices.Clone(u.undoStack)
slices.Reverse(stack)
for _, b := range stack {
lines = append(lines, fmt.Sprintf(
"block (%d:%d) -> (%d:%d)",
b.OldCursor.Line,
b.OldCursor.Col,
b.NewCursor.Line,
b.NewCursor.Col,
))
for _, c := range b.Changes {
lines = append(lines, fmt.Sprintf(
"\t%q #%d (%s) -> (%s)",
c.Type,
c.Line,
c.OldData,
c.NewData,
))
}
}
return lines
}

View File

@ -60,7 +60,7 @@ func (w *Window) ClampCursor() {
}
// Clamp column to valid range [0, lineLen]
lineLen := len(w.Buffer.Lines[w.Cursor.Line])
lineLen := w.Buffer.Lines[w.Cursor.Line].Len()
if w.Cursor.Col < 0 {
w.Cursor.Col = 0
} else if lineLen == 0 {

View File

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

View File

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

View File

@ -13,8 +13,8 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -24,8 +24,8 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -35,8 +35,8 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("lines[0] = %q, want 'hell'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("lines[0] = %q, want 'hell'", m.ActiveBuffer().Lines[0].String())
}
})
@ -46,8 +46,8 @@ func TestDeleteChar(t *testing.T) {
sendKeys(tm, "x", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -59,8 +59,8 @@ func TestDeleteCharWithCount(t *testing.T) {
sendKeys(tm, "3", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -70,8 +70,8 @@ func TestDeleteCharWithCount(t *testing.T) {
sendKeys(tm, "1", "0", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -81,8 +81,8 @@ func TestDeleteCharWithCount(t *testing.T) {
sendKeys(tm, "2", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -94,8 +94,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -108,8 +108,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -119,8 +119,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0].String())
}
})
@ -130,8 +130,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ab c" {
t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ab c" {
t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0].String())
}
})
@ -144,8 +144,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
@ -155,8 +155,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x", "x", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lo" {
t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -166,8 +166,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ab" {
t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ab" {
t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0].String())
}
})
@ -177,8 +177,8 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "5", "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -188,8 +188,276 @@ func TestDeleteCharEdgeCases(t *testing.T) {
sendKeys(tm, "x")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "abde" {
t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "abde" {
t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestDeleteCharBackward(t *testing.T) {
t.Run("test 'X' deletes character before cursor", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' in middle of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' at end of line", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'XX' deletes two characters backward", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "X", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestDeleteCharBackwardWithCount(t *testing.T) {
t.Run("test '3X' deletes three characters backward", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "3", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '10X' with overflow", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "1", "0", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "o" {
t.Errorf("lines[0] = %q, want 'o'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '2X' from middle", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "2", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hlo" {
t.Errorf("lines[0] = %q, want 'hlo'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestDeleteCharBackwardEdgeCases(t *testing.T) {
t.Run("test 'X' at start of line does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'X' on empty line does nothing", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'X' on single character line from end", func(t *testing.T) {
lines := []string{"a"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' at second char deletes first", func(t *testing.T) {
lines := []string{"ab"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "b" {
t.Errorf("Line(0) = %q, want 'b'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' with whitespace", func(t *testing.T) {
lines := []string{"a b c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ab c" {
t.Errorf("Line(0) = %q, want 'ab c'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' preserves other lines", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 1, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'X' multiple times deletes multiple chars backward", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "X", "X", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "lo" {
t.Errorf("Line(0) = %q, want 'lo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' on line with tabs", func(t *testing.T) {
lines := []string{"a\tb"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ab" {
t.Errorf("Line(0) = %q, want 'ab'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test '5X' with only 3 chars available before cursor", func(t *testing.T) {
lines := []string{"abc"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "5", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "c" {
t.Errorf("Line(0) = %q, want 'c'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' in middle preserves surrounding chars", func(t *testing.T) {
lines := []string{"abcde"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "abde" {
t.Errorf("Line(0) = %q, want 'abde'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' cursor position after delete", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
// Cursor should move back one position after deleting char before it
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test '3X' cursor position after delete", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "3", "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ho" {
t.Errorf("Line(0) = %q, want 'ho'", m.ActiveBuffer().Lines[0].String())
}
// Cursor should be at position 1 after deleting 3 chars backward from position 4
if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'X' at start of second line does nothing", func(t *testing.T) {
lines := []string{"hello", "world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 1})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test 'X' on whitespace-only line", func(t *testing.T) {
lines := []string{" "}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " " {
t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'X' from end of line deletes last char", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "X")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("Line(0) = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
}
})
}
@ -201,8 +469,8 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -212,8 +480,8 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -223,8 +491,8 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0].String())
}
})
@ -246,8 +514,8 @@ func TestDeleteToEndOfLine(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -260,11 +528,11 @@ func TestDeleteToEndOfLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %q, want '3'", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "hi" {
t.Errorf("Line(1) = %q, want 'hi'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "hi" {
t.Errorf("Line(1) = %q, want 'hi'", m.ActiveBuffer().Lines[1].String())
}
})
@ -277,8 +545,8 @@ func TestDeleteToEndOfLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %q, want '1'", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -290,8 +558,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -304,8 +572,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
})
@ -315,11 +583,11 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2].String())
}
})
@ -329,8 +597,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "he" {
t.Errorf("Line(0) = %q, want 'he'", m.ActiveBuffer().Lines[0].String())
}
// Cursor should clamp to last char
if m.ActiveWindow().Cursor.Col != 1 {
@ -344,8 +612,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -355,8 +623,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != " " {
t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " " {
t.Errorf("Line(0) = %q, want ' '", m.ActiveBuffer().Lines[0].String())
}
})
@ -366,8 +634,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -377,8 +645,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "a" {
t.Errorf("Line(0) = %q, want 'a'", m.ActiveBuffer().Lines[0].String())
}
})
@ -388,8 +656,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
@ -399,8 +667,8 @@ func TestDeleteToEndOfLineEdgeCases(t *testing.T) {
sendKeys(tm, "D")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "first" {
t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "first" {
t.Errorf("Line(0) = %q, want 'first'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())

View File

@ -25,8 +25,8 @@ func TestEnterInsert(t *testing.T) {
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -36,8 +36,8 @@ func TestEnterInsert(t *testing.T) {
sendKeys(tm, "i", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -70,8 +70,8 @@ func TestEnterInsertAfter(t *testing.T) {
sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hXello" {
t.Errorf("lines[0] = %q, want 'hXello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hXello" {
t.Errorf("lines[0] = %q, want 'hXello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -81,8 +81,8 @@ func TestEnterInsertAfter(t *testing.T) {
sendKeys(tm, "a", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helXlo" {
t.Errorf("lines[0] = %q, want 'helXlo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helXlo" {
t.Errorf("lines[0] = %q, want 'helXlo'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -94,8 +94,8 @@ func TestEnterInsertLineStart(t *testing.T) {
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -105,8 +105,8 @@ func TestEnterInsertLineStart(t *testing.T) {
sendKeys(tm, "I", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -118,8 +118,8 @@ func TestEnterInsertLineEnd(t *testing.T) {
sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0].String())
}
})
@ -129,8 +129,8 @@ func TestEnterInsertLineEnd(t *testing.T) {
sendKeys(tm, "A", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -147,8 +147,8 @@ func TestOpenLineBelow(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1].String())
}
})
@ -161,8 +161,8 @@ func TestOpenLineBelow(t *testing.T) {
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2].String())
}
})
@ -175,8 +175,8 @@ func TestOpenLineBelow(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[2] != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "new" {
t.Errorf("lines[2] = %q, want 'new'", m.ActiveBuffer().Lines[2].String())
}
})
@ -206,8 +206,8 @@ func TestOpenLineBelowWithCount(t *testing.T) {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
}
for i := 1; i <= 3; i++ {
if m.ActiveBuffer().Lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i])
if m.ActiveBuffer().Lines[i].String() != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i].String())
}
}
})
@ -221,11 +221,11 @@ func TestOpenLineBelowWithCount(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "ab" {
t.Errorf("lines[1] = %q, want 'ab'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "ab" {
t.Errorf("lines[1] = %q, want 'ab'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "ab" {
t.Errorf("lines[2] = %q, want 'ab'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "ab" {
t.Errorf("lines[2] = %q, want 'ab'", m.ActiveBuffer().Lines[2].String())
}
})
}
@ -240,8 +240,8 @@ func TestOpenLineAbove(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "new" {
t.Errorf("lines[1] = %q, want 'new'", m.ActiveBuffer().Lines[1].String())
}
})
@ -254,8 +254,8 @@ func TestOpenLineAbove(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("len(lines) = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "new" {
t.Errorf("lines[0] = %q, want 'new'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "new" {
t.Errorf("lines[0] = %q, want 'new'", m.ActiveBuffer().Lines[0].String())
}
})
@ -282,8 +282,8 @@ func TestOpenLineAboveWithCount(t *testing.T) {
t.Errorf("len(lines) = %d, want 4", m.ActiveBuffer().LineCount())
}
for i := 0; i < 3; i++ {
if m.ActiveBuffer().Lines[i] != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i])
if m.ActiveBuffer().Lines[i].String() != "x" {
t.Errorf("lines[%d] = %q, want 'x'", i, m.ActiveBuffer().Lines[i].String())
}
}
})
@ -301,11 +301,11 @@ func TestInsertModeEnter(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != " world" {
t.Errorf("lines[1] = %q, want ' world'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != " world" {
t.Errorf("lines[1] = %q, want ' world'", m.ActiveBuffer().Lines[1].String())
}
})
@ -318,11 +318,11 @@ func TestInsertModeEnter(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("lines[1] = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("lines[1] = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
})
@ -335,11 +335,11 @@ func TestInsertModeEnter(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("len(lines) = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "hello" {
t.Errorf("lines[1] = %q, want 'hello'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "hello" {
t.Errorf("lines[1] = %q, want 'hello'", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -351,8 +351,8 @@ func TestInsertModeBackspace(t *testing.T) {
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("lines[0] = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -365,8 +365,8 @@ func TestInsertModeBackspace(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -376,8 +376,8 @@ func TestInsertModeBackspace(t *testing.T) {
sendKeys(tm, "i", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -387,8 +387,8 @@ func TestInsertModeBackspace(t *testing.T) {
sendKeys(tm, "i", "backspace", "backspace", "backspace", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "he" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "he" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -400,8 +400,8 @@ func TestInsertModeDelete(t *testing.T) {
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "word" {
t.Errorf("lines[0] = %q, want 'word'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "word" {
t.Errorf("lines[0] = %q, want 'word'", m.ActiveBuffer().Lines[0].String())
}
})
@ -414,8 +414,8 @@ func TestInsertModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -428,8 +428,8 @@ func TestInsertModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("len(lines) = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -439,8 +439,8 @@ func TestInsertModeDelete(t *testing.T) {
sendKeys(tm, "i", "delete", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -450,8 +450,8 @@ func TestInsertModeDelete(t *testing.T) {
sendKeys(tm, "i", "delete", "delete", "delete", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ho" {
t.Errorf("lines[0] = %q, want 'he'", m.ActiveBuffer().Lines[0].String())
}
})
@ -464,8 +464,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -475,8 +475,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "right", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -486,11 +486,11 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
@ -500,11 +500,11 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1].String())
}
})
@ -514,8 +514,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "left", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "Xhello" {
t.Errorf("lines[0] = %q, want 'Xhello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -525,8 +525,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "a", "right", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloX" {
t.Errorf("lines[0] = %q, want 'helloX'", m.ActiveBuffer().Lines[0].String())
}
})
@ -536,8 +536,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -547,8 +547,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heXllo" {
t.Errorf("lines[0] = %q, want 'heXllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -558,8 +558,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "up", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hiX" {
t.Errorf("lines[0] = %q, want 'hiX'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hiX" {
t.Errorf("lines[0] = %q, want 'hiX'", m.ActiveBuffer().Lines[0].String())
}
})
@ -569,8 +569,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "hiX" {
t.Errorf("lines[1] = %q, want 'hiX'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "hiX" {
t.Errorf("lines[1] = %q, want 'hiX'", m.ActiveBuffer().Lines[1].String())
}
})
@ -580,8 +580,8 @@ func TestInsertModeArrowKeys(t *testing.T) {
sendKeys(tm, "i", "right", "right", "down", "X", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[1] != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "woXrld" {
t.Errorf("lines[1] = %q, want 'woXrld'", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -593,8 +593,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("CursorX() = %d, want '5'", m.ActiveWindow().Cursor.Col)
@ -607,8 +607,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
@ -621,8 +621,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello wo" {
t.Errorf("lines[0] = %q, want 'hello wo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello wo" {
t.Errorf("lines[0] = %q, want 'hello wo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 7 {
t.Errorf("CursorX() = %d, want '7'", m.ActiveWindow().Cursor.Col)
@ -655,8 +655,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want '1'", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
@ -672,8 +672,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -686,8 +686,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "i", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lo" {
t.Errorf("lines[0] = %q, want 'lo'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -700,8 +700,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "..." {
t.Errorf("lines[0] = %q, want '...'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "..." {
t.Errorf("lines[0] = %q, want '...'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
@ -714,8 +714,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello\t" {
t.Errorf("lines[0] = %q, want 'hello\\t'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello\t" {
t.Errorf("lines[0] = %q, want 'hello\\t'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("CursorX() = %d, want 5", m.ActiveWindow().Cursor.Col)
@ -731,8 +731,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helloworld" {
t.Errorf("lines[0] = %q, want 'helloworld'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want 4", m.ActiveWindow().Cursor.Col)
@ -748,8 +748,8 @@ func TestInsertModeDeletePreviousWord(t *testing.T) {
sendKeys(tm, "a", "ctrl+w", "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)

View File

@ -176,7 +176,7 @@ func TestMoveToLineEnd(t *testing.T) {
sendKeys(tm, "$")
m := getFinalModel(t, tm)
want := len(lines[0])
want := len(lines[0]) - 1
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])
want := len(lines[0]) - 1
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])
want := len(lines[0]) - 1
if m.ActiveWindow().Cursor.Col != want {
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, want)
}
@ -633,8 +633,8 @@ func TestMoveToColumnWithOperator(t *testing.T) {
// Deletes from column 1 to current position (exclusive), so "hello" deleted
// Result depends on inclusive/exclusive behavior
// In Vim: d| from col 5 deletes chars 0-4, leaving " world"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -646,8 +646,8 @@ func TestMoveToColumnWithOperator(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes from cursor (0) to column 5 (index 4), so "hell" deleted
// Result: "o world"
if m.ActiveBuffer().Lines[0] != "o world" {
t.Errorf("Line(0) = %q, want 'o world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "o world" {
t.Errorf("Line(0) = %q, want 'o world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -720,8 +720,8 @@ func TestMoveToColumnInVisualMode(t *testing.T) {
m := getFinalModel(t, tm)
// Visual selection from 0 to 4 inclusive, delete "hello"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
}

View File

@ -928,8 +928,8 @@ func TestMoveForwardWORDWithOperator(t *testing.T) {
m := getFinalModel(t, tm)
// Should delete "hello.world " (including trailing space)
if m.ActiveBuffer().Lines[0] != "next" {
t.Errorf("Line(0) = %q, want 'next'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "next" {
t.Errorf("Line(0) = %q, want 'next'", m.ActiveBuffer().Lines[0].String())
}
})
@ -946,11 +946,11 @@ func TestMoveForwardWORDWithOperator(t *testing.T) {
sendKeys(tm2, "d", "w")
m2 := getFinalModel(t, tm2)
if m1.ActiveBuffer().Lines[0] != "next" {
t.Errorf("dW: Line(0) = %q, want 'next'", m1.ActiveBuffer().Lines[0])
if m1.ActiveBuffer().Lines[0].String() != "next" {
t.Errorf("dW: Line(0) = %q, want 'next'", m1.ActiveBuffer().Lines[0].String())
}
if m2.ActiveBuffer().Lines[0] != ".world next" {
t.Errorf("dw: Line(0) = %q, want '.world next'", m2.ActiveBuffer().Lines[0])
if m2.ActiveBuffer().Lines[0].String() != ".world next" {
t.Errorf("dw: Line(0) = %q, want '.world next'", m2.ActiveBuffer().Lines[0].String())
}
})
@ -960,8 +960,8 @@ func TestMoveForwardWORDWithOperator(t *testing.T) {
sendKeys(tm, "d", "2", "W")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "three four" {
t.Errorf("Line(0) = %q, want 'three four'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "three four" {
t.Errorf("Line(0) = %q, want 'three four'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1028,8 +1028,8 @@ func TestMoveForwardWORDInVisualMode(t *testing.T) {
m := getFinalModel(t, tm)
// Visual selection from 0 to 12, delete "hello.world "
if m.ActiveBuffer().Lines[0] != "ext" {
t.Errorf("Line(0) = %q, want 'ext'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ext" {
t.Errorf("Line(0) = %q, want 'ext'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1557,8 +1557,8 @@ func TestMoveForwardWORDEndWithOperator(t *testing.T) {
m := getFinalModel(t, tm)
// Should delete "hello.world" leaving " next"
if m.ActiveBuffer().Lines[0] != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1570,8 +1570,8 @@ func TestMoveForwardWORDEndWithOperator(t *testing.T) {
m1 := getFinalModel(t, tm1)
// 'de' should delete "hello" leaving ".world next"
if m1.ActiveBuffer().Lines[0] != ".world next" {
t.Errorf("'de': Line(0) = %q, want '.world next'", m1.ActiveBuffer().Lines[0])
if m1.ActiveBuffer().Lines[0].String() != ".world next" {
t.Errorf("'de': Line(0) = %q, want '.world next'", m1.ActiveBuffer().Lines[0].String())
}
// Now test 'dE'
@ -1580,8 +1580,8 @@ func TestMoveForwardWORDEndWithOperator(t *testing.T) {
m2 := getFinalModel(t, tm2)
// 'dE' should delete "hello.world" leaving " next"
if m2.ActiveBuffer().Lines[0] != " next" {
t.Errorf("'dE': Line(0) = %q, want ' next'", m2.ActiveBuffer().Lines[0])
if m2.ActiveBuffer().Lines[0].String() != " next" {
t.Errorf("'dE': Line(0) = %q, want ' next'", m2.ActiveBuffer().Lines[0].String())
}
})
@ -1592,8 +1592,8 @@ func TestMoveForwardWORDEndWithOperator(t *testing.T) {
m := getFinalModel(t, tm)
// Should delete "one.a two.b" leaving " three"
if m.ActiveBuffer().Lines[0] != " three" {
t.Errorf("Line(0) = %q, want ' three'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " three" {
t.Errorf("Line(0) = %q, want ' three'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1653,8 +1653,8 @@ func TestMoveForwardWORDEndInVisualMode(t *testing.T) {
m := getFinalModel(t, tm)
// Should delete "hello.world" leaving " next"
if m.ActiveBuffer().Lines[0] != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1685,3 +1685,816 @@ 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)
}
})
}

View File

@ -26,11 +26,11 @@ func TestChangeLine(t *testing.T) {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
// First line should be empty (ready for insert)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("Line(1) = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
@ -45,14 +45,14 @@ func TestChangeLine(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line three" {
t.Errorf("Line(2) = %q, want 'line three'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line three" {
t.Errorf("Line(2) = %q, want 'line three'", m.ActiveBuffer().Lines[2].String())
}
})
@ -67,11 +67,11 @@ func TestChangeLine(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
})
@ -109,8 +109,8 @@ func TestChangeLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -148,11 +148,11 @@ func TestChangeLineWithCount(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1].String())
}
})
@ -171,14 +171,14 @@ func TestChangeLineWithCount(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "one" {
t.Errorf("Line(0) = %q, want 'one'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "one" {
t.Errorf("Line(0) = %q, want 'one'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "five" {
t.Errorf("Line(2) = %q, want 'five'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "five" {
t.Errorf("Line(2) = %q, want 'five'", m.ActiveBuffer().Lines[2].String())
}
})
@ -196,8 +196,8 @@ func TestChangeLineWithCount(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -235,8 +235,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "ello world" {
t.Errorf("Line(0) = %q, want 'ello world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello world" {
t.Errorf("Line(0) = %q, want 'ello world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -251,8 +251,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "llo world" {
t.Errorf("Line(0) = %q, want 'llo world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo world" {
t.Errorf("Line(0) = %q, want 'llo world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -267,8 +267,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hell world" {
t.Errorf("Line(0) = %q, want 'hell world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell world" {
t.Errorf("Line(0) = %q, want 'hell world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -283,8 +283,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -299,8 +299,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -317,8 +317,8 @@ func TestChangeWithHorizontalMotion(t *testing.T) {
}
// ^ is exclusive motion, so position 8 (space) is not included
// Delete positions 3-7 ("hello"), leaving " " + " world" = " world"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -339,8 +339,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -355,8 +355,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "heworld" {
t.Errorf("Line(0) = %q, want 'heworld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heworld" {
t.Errorf("Line(0) = %q, want 'heworld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -371,8 +371,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -387,8 +387,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -403,8 +403,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "three four" {
t.Errorf("Line(0) = %q, want 'three four'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "three four" {
t.Errorf("Line(0) = %q, want 'three four'", m.ActiveBuffer().Lines[0].String())
}
})
@ -419,8 +419,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "next" {
t.Errorf("Line(0) = %q, want 'next'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "next" {
t.Errorf("Line(0) = %q, want 'next'", m.ActiveBuffer().Lines[0].String())
}
})
@ -435,8 +435,8 @@ func TestChangeWithWordMotion(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " next" {
t.Errorf("Line(0) = %q, want ' next'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -461,11 +461,11 @@ func TestChangeWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1].String())
}
})
@ -484,11 +484,11 @@ func TestChangeWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line three" {
t.Errorf("Line(1) = %q, want 'line three'", m.ActiveBuffer().Lines[1].String())
}
})
@ -507,11 +507,11 @@ func TestChangeWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "four" {
t.Errorf("Line(1) = %q, want 'four'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "four" {
t.Errorf("Line(1) = %q, want 'four'", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -536,8 +536,8 @@ func TestChangeWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -556,8 +556,8 @@ func TestChangeWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -576,11 +576,11 @@ func TestChangeWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -601,8 +601,8 @@ func TestChangeToEndOfLine(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -617,8 +617,8 @@ func TestChangeToEndOfLine(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -634,8 +634,8 @@ func TestChangeToEndOfLine(t *testing.T) {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
// Should delete last char
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0].String())
}
})
@ -673,8 +673,8 @@ func TestSubstituteCharacter(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("Line(0) = %q, want 'ello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("Line(0) = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -689,8 +689,8 @@ func TestSubstituteCharacter(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -705,8 +705,8 @@ func TestSubstituteCharacter(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("Line(0) = %q, want 'hell'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -727,8 +727,8 @@ func TestSubstituteLine(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -740,14 +740,14 @@ func TestSubstituteLine(t *testing.T) {
sendKeys(tm, "S")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line three" {
t.Errorf("Line(2) = %q, want 'line three'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line three" {
t.Errorf("Line(2) = %q, want 'line three'", m.ActiveBuffer().Lines[2].String())
}
})
@ -766,11 +766,11 @@ func TestSubstituteLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "three" {
t.Errorf("Line(1) = %q, want 'three'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "three" {
t.Errorf("Line(1) = %q, want 'three'", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -791,8 +791,8 @@ func TestVisualModeChange(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "ello world" {
t.Errorf("Line(0) = %q, want 'ello world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello world" {
t.Errorf("Line(0) = %q, want 'ello world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -807,8 +807,8 @@ func TestVisualModeChange(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -823,8 +823,8 @@ func TestVisualModeChange(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -878,8 +878,8 @@ func TestVisualLineModeChange(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
})
@ -898,14 +898,14 @@ func TestVisualLineModeChange(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line one" {
t.Errorf("Line(0) = %q, want 'line one'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "" {
t.Errorf("Line(1) = %q, want ''", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line four" {
t.Errorf("Line(2) = %q, want 'line four'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line four" {
t.Errorf("Line(2) = %q, want 'line four'", m.ActiveBuffer().Lines[2].String())
}
})
@ -946,8 +946,8 @@ func TestChangeEdgeCases(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -963,8 +963,8 @@ func TestChangeEdgeCases(t *testing.T) {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
// cw on last word should change to end of line
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -979,8 +979,8 @@ func TestChangeEdgeCases(t *testing.T) {
if m.Mode() != core.InsertMode {
t.Errorf("Mode() = %v, want InsertMode", m.Mode())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})

View File

@ -24,8 +24,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %s, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %s, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -44,11 +44,11 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want '1'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "testing" {
t.Errorf("Line(1) = %s, want 'testing'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "testing" {
t.Errorf("Line(1) = %s, want 'testing'", m.ActiveBuffer().Lines[1].String())
}
})
@ -67,8 +67,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -87,8 +87,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %s, want 'world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %s, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -107,12 +107,12 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want '1'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "another line" {
t.Errorf("Line(1) = %s, want 'another line'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "another line" {
t.Errorf("Line(1) = %s, want 'another line'", m.ActiveBuffer().Lines[1].String())
}
})
@ -131,8 +131,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -151,8 +151,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want '0'", m.ActiveWindow().Cursor.Line)
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %s, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -176,8 +176,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -190,8 +190,8 @@ func TestDeleteLine(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
@ -210,8 +210,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("Line(0) = %s, want 'ello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("Line(0) = %s, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -224,8 +224,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want '2'", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("Line(0) = %s, want 'helo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("Line(0) = %s, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -238,8 +238,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("CursorX() = %d, want '4'", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %s, want 'hell'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("Line(0) = %s, want 'hell'", m.ActiveBuffer().Lines[0].String())
}
})
@ -252,8 +252,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want '0'", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %s, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -267,8 +267,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "hllo" {
t.Errorf("Line(0) = %q, want 'hllo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hllo" {
t.Errorf("Line(0) = %q, want 'hllo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -282,8 +282,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
if m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0] != "helo" {
t.Errorf("Line(0) = %q, want 'helo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "helo" {
t.Errorf("Line(0) = %q, want 'helo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -293,8 +293,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
sendKeys(tm, "2", "d", "l")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -304,8 +304,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
sendKeys(tm, "d", "2", "l")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("Line(0) = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
@ -315,8 +315,8 @@ func TestDeleteOperatorWithHorozontalMotion(t *testing.T) {
sendKeys(tm, "2", "d", "h")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heo" {
t.Errorf("Line(0) = %q, want 'heo'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heo" {
t.Errorf("Line(0) = %q, want 'heo'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -331,11 +331,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
@ -351,8 +351,8 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
@ -370,11 +370,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1].String())
}
})
@ -387,11 +387,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1].String())
}
})
@ -404,11 +404,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1].String())
}
})
@ -421,11 +421,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 4" {
t.Errorf("Line(1) = %q, want 'line 4'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("CursorY() = %d, want 1", m.ActiveWindow().Cursor.Line)
@ -443,11 +443,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.ActiveBuffer().Lines[1].String())
}
})
@ -460,8 +460,8 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
@ -477,11 +477,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1].String())
}
})
@ -494,11 +494,11 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1].String())
}
})
@ -512,8 +512,8 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
})
@ -527,8 +527,8 @@ func TestDeleteOperatorWithVerticalMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -541,8 +541,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "d", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -555,8 +555,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "d", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "heworld" {
t.Errorf("Line(0) = %q, want \"heworld\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "heworld" {
t.Errorf("Line(0) = %q, want \"heworld\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
@ -569,8 +569,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "d", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0].String())
}
})
@ -580,8 +580,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "2", "d", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "three four" {
t.Errorf("Line(0) = %q, want \"three four\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "three four" {
t.Errorf("Line(0) = %q, want \"three four\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -591,8 +591,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "d", "2", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "three four" {
t.Errorf("Line(0) = %q, want \"three four\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "three four" {
t.Errorf("Line(0) = %q, want \"three four\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -603,8 +603,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// 'w' motion stops at punctuation, so it should delete "hello"
if m.ActiveBuffer().Lines[0] != ", world" {
t.Errorf("Line(0) = %q, want \", world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != ", world" {
t.Errorf("Line(0) = %q, want \", world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -616,8 +616,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// 'e' is inclusive - deletes "hello" (cols 0-4 inclusive), leaves " world"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want \" world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want \" world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -631,8 +631,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From 'l' (col 2) to 'o' (col 4) inclusive → deletes "llo"
if m.ActiveBuffer().Lines[0] != "he world" {
t.Errorf("Line(0) = %q, want \"he world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "he world" {
t.Errorf("Line(0) = %q, want \"he world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -643,8 +643,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From 'o' of "hello" (col 4) to 'd' of "world" (col 10) inclusive
if m.ActiveBuffer().Lines[0] != "hell" {
t.Errorf("Line(0) = %q, want \"hell\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hell" {
t.Errorf("Line(0) = %q, want \"hell\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -655,8 +655,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes "one" and " two" (to end of second word inclusive)
if m.ActiveBuffer().Lines[0] != " three four" {
t.Errorf("Line(0) = %q, want \" three four\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " three four" {
t.Errorf("Line(0) = %q, want \" three four\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -667,8 +667,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
sendKeys(tm, "d", "b")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -679,8 +679,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// cursor at 'r' of "world", db should delete back to start of "world"
if m.ActiveBuffer().Lines[0] != "hello rld" {
t.Errorf("Line(0) = %q, want \"hello rld\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello rld" {
t.Errorf("Line(0) = %q, want \"hello rld\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -691,8 +691,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// cursor at 'w', db should delete "hello " back
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -703,8 +703,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// cursor at 'f' of "four", 2db should delete "two three "
if m.ActiveBuffer().Lines[0] != "one four" {
t.Errorf("Line(0) = %q, want \"one four\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "one four" {
t.Errorf("Line(0) = %q, want \"one four\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -715,8 +715,8 @@ func TestDeleteOperatorWithWordMotion(t *testing.T) {
m := getFinalModel(t, tm)
// cursor at 'd' (last char), db should delete back to start of "world"
if m.ActiveBuffer().Lines[0] != "hello d" {
t.Errorf("Line(0) = %q, want \"hello d\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello d" {
t.Errorf("Line(0) = %q, want \"hello d\"", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -729,8 +729,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "0")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -740,8 +740,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "0")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -754,8 +754,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "0")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "d" {
t.Errorf("Line(0) = %q, want \"d\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "d" {
t.Errorf("Line(0) = %q, want \"d\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -765,8 +765,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "0")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "lo world" {
t.Errorf("Line(0) = %q, want \"lo world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lo world" {
t.Errorf("Line(0) = %q, want \"lo world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -777,8 +777,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "$")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -788,8 +788,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "$")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0].String())
}
})
@ -799,8 +799,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "$")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worl" {
t.Errorf("Line(0) = %q, want \"hello worl\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello worl" {
t.Errorf("Line(0) = %q, want \"hello worl\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -810,11 +810,11 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "$")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want \"hello \"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "second line" {
t.Errorf("Line(1) = %q, want \"second line\"", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "second line" {
t.Errorf("Line(1) = %q, want \"second line\"", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
@ -827,8 +827,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
sendKeys(tm, "d", "$")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want \"\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
@ -843,8 +843,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From col 0 to first non-whitespace (col 3, 'h') - deletes leading spaces
if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("Line(0) = %q, want \"hello world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -858,8 +858,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From col 1 to first non-whitespace (col 3, 'h') - deletes cols 1-2
if m.ActiveBuffer().Lines[0] != " hello world" {
t.Errorf("Line(0) = %q, want \" hello world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " hello world" {
t.Errorf("Line(0) = %q, want \" hello world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
@ -874,8 +874,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From col 6 ('l') back to col 3 ('h')
// Should delete "hel" leaving " lo world"
if m.ActiveBuffer().Lines[0] != " lo world" {
t.Errorf("Line(0) = %q, want \" lo world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " lo world" {
t.Errorf("Line(0) = %q, want \" lo world\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -887,8 +887,8 @@ func TestDeleteOperatorWithLinePositionMotion(t *testing.T) {
m := getFinalModel(t, tm)
// From col 5 (' ') back to col 0 ('h')
// Should delete "hello" leaving " world"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want \" world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want \" world\"", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -903,11 +903,11 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1].String())
}
})
@ -920,8 +920,8 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -934,11 +934,11 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1].String())
}
})
@ -962,11 +962,11 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 4" {
t.Errorf("Line(0) = %q, want 'line 4'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 4" {
t.Errorf("Line(0) = %q, want 'line 4'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 5" {
t.Errorf("Line(1) = %q, want 'line 5'", m.ActiveBuffer().Lines[1].String())
}
})
@ -979,8 +979,8 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -993,11 +993,11 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 2 {
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 3" {
t.Errorf("Line(1) = %q, want 'line 3'", m.ActiveBuffer().Lines[1].String())
}
})
@ -1021,8 +1021,8 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
@ -1035,8 +1035,8 @@ func TestDeleteOperatorWithJumpMotion(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,581 @@
package editor
import (
"testing"
)
// ==================================================
// P0: Basic Recording Tests
// ==================================================
func TestDotOperatorRecording(t *testing.T) {
t.Run("records simple delete", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 1 || keys[0] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys)
}
// Also verify . register
reg, ok := m.GetRegister('.')
if !ok {
t.Fatal("dot register not found")
}
if reg.Content[0] != "x" {
t.Errorf("dot register = %q, want \"x\"", reg.Content[0])
}
})
t.Run("records operator motion", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "d", "w")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys)
}
})
t.Run("records double press operator", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello", "world"}))
sendKeys(tm, "d", "d")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys)
}
})
t.Run("records visual operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "V", "j", "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 3 || keys[0] != "V" || keys[1] != "j" || keys[2] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"V\", \"j\", \"x\"]", keys)
}
})
t.Run("records insert mode", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "i", "X", "Y", "Z", "esc")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
if len(keys) != 5 || keys[0] != "i" || keys[1] != "X" || keys[2] != "Y" || keys[3] != "Z" || keys[4] != "esc" {
t.Errorf("LastChangeKeys() = %v, want [\"i\", \"X\", \"Y\", \"Z\", \"esc\"]", keys)
}
})
}
// ==================================================
// P0: Non-Recording Tests
// ==================================================
func TestDotOperatorNonRecording(t *testing.T) {
t.Run("does not record pure motions", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"}))
sendKeys(tm, "k", "k", "k")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Pure motions should result in empty recording
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
t.Run("does not record dot operator itself", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x", ".", ".")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should still be just ["x"], not ["x", ".", "."]
if len(keys) != 1 || keys[0] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"x\"]", keys)
}
})
t.Run("does not record command mode entry", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, ":")
sendKeys(tm, "esc") // Exit command mode
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Command mode entry should not record
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
t.Run("does not record visual mode entry without action", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "v", "esc")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Just entering and exiting visual mode should not record
if len(keys) != 0 {
t.Errorf("LastChangeKeys() = %v, want []", keys)
}
})
}
// ==================================================
// P0: Basic Replay Tests
// ==================================================
func TestDotOperatorReplay(t *testing.T) {
t.Run("repeats simple delete", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x") // "ello"
sendKeys(tm, ".") // "llo"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "llo" {
t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats operator motion", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"one two three"}))
sendKeys(tm, "d", "w") // "two three"
sendKeys(tm, ".") // "three"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "three" {
t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats double press operator", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "d", "d") // Delete first line -> "line2", "line3"
sendKeys(tm, ".") // Delete next line -> "line3"
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "line3" {
t.Errorf("buffer = %q, want \"line3\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Recording Replacement Tests
// ==================================================
func TestDotOperatorRecordingReplacement(t *testing.T) {
t.Run("new action replaces old recording", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "x") // Record ["x"]
sendKeys(tm, "l", "l") // Motions clear recording buffer but don't save
sendKeys(tm, "d", "w") // Record ["d", "w"]
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should be the latest action ["d", "w"], not ["x"]
if len(keys) != 2 || keys[0] != "d" || keys[1] != "w" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"w\"]", keys)
}
})
t.Run("motions clear recording without saving", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3", "line4"}))
sendKeys(tm, "x") // Record ["x"]
sendKeys(tm, "j", "j", "j") // Motions don't overwrite saved recording
sendKeys(tm, "d", "d") // New action replaces
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should be ["d", "d"] from the last modifying action
if len(keys) != 2 || keys[0] != "d" || keys[1] != "d" {
t.Errorf("LastChangeKeys() = %v, want [\"d\", \"d\"]", keys)
}
})
}
// ==================================================
// P1: Visual Mode Tests
// ==================================================
func TestDotOperatorVisualMode(t *testing.T) {
t.Run("repeats visual line operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"a", "b", "c", "d", "e"}))
sendKeys(tm, "V", "j", "x") // Delete lines 0-1 -> "c", "d", "e"
// Cursor should be at line 0 after deletion
sendKeys(tm, ".") // Repeat -> delete next 2 lines -> "e"
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "e" {
t.Errorf("buffer = %q, want \"e\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Insert Mode Tests
// ==================================================
func TestDotOperatorInsertMode(t *testing.T) {
t.Run("repeats insert mode operation", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "i", "X", "Y", "Z", "esc") // "XYZhello", cursor at col 2 (on Z)
// Move cursor after XYZ (to col 3, between Z and h)
sendKeys(tm, "l")
sendKeys(tm, ".") // Should insert XYZ again at col 3
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "XYZXYZhello" {
t.Errorf("buffer = %q, want \"XYZXYZhello\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P1: Multiple Repeat Tests
// ==================================================
func TestDotOperatorMultipleRepeats(t *testing.T) {
t.Run("dot can be used multiple times", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world foo bar"}))
sendKeys(tm, "d", "w") // "world foo bar"
sendKeys(tm, ".") // "foo bar"
sendKeys(tm, ".") // "bar"
sendKeys(tm, ".") // ""
m := getFinalModel(t, tm)
line := m.ActiveBuffer().Line(0)
// After deleting 4 words, should be empty or just whitespace
if line != "" && line != " " {
t.Errorf("buffer = %q, want empty or space", line)
}
})
}
// ==================================================
// P2: Edge Cases
// ==================================================
func TestDotOperatorEdgeCases(t *testing.T) {
t.Run("repeat at start of file", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "x") // "ello"
sendKeys(tm, ".") // "llo"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "llo" {
t.Errorf("buffer = %q, want \"llo\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeat after undo", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "x") // "ello world"
sendKeys(tm, "u") // Undo -> "hello world"
sendKeys(tm, ".") // Repeat should still work -> "ello world"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "ello world" {
t.Errorf("buffer = %q, want \"ello world\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeat with no recorded change", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, ".") // Dot with nothing recorded
m := getFinalModel(t, tm)
// Should not crash, buffer should be unchanged
if m.ActiveBuffer().Line(0) != "hello" {
t.Errorf("buffer = %q, want \"hello\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P2: Integration with Counts
// ==================================================
func TestDotOperatorWithCounts(t *testing.T) {
t.Run("recording includes count", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "3", "x")
m := getFinalModel(t, tm)
keys := m.LastChangeKeys()
// Should record both the count and the action
if len(keys) != 2 || keys[0] != "3" || keys[1] != "x" {
t.Errorf("LastChangeKeys() = %v, want [\"3\", \"x\"]", keys)
}
})
t.Run("repeat preserves count", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "3", "x") // Delete 3 chars -> "lo"
sendKeys(tm, ".") // Should delete 3 more (or try to)
m := getFinalModel(t, tm)
// After deleting 3 + 3 = 6 chars, should be empty or have no chars left
if m.ActiveBuffer().Line(0) != "" {
t.Errorf("buffer = %q, want empty", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// P2: Complex Sequences
// ==================================================
func TestDotOperatorComplexSequences(t *testing.T) {
t.Run("complex sequence of operations", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "x") // Delete 'l' from line1 -> ["ine1", "line2", "line3"]
sendKeys(tm, "j", "j") // Move to line 2
sendKeys(tm, "d", "d") // Delete line 2 (line3) -> ["ine1", "line2"], cursor at line 1
sendKeys(tm, "k") // Move up to line 0
sendKeys(tm, ".") // Repeat dd - deletes line 0 (ine1) -> ["line2"]
m := getFinalModel(t, tm)
// After the sequence, dd was recorded and repeated at line 0, deleting it
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "line2" {
t.Errorf("buffer = %q, want \"line2\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// Additional Coverage: Change Operator
// ==================================================
func TestDotOperatorChangeOperator(t *testing.T) {
t.Run("repeats change word", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"one two three"}))
sendKeys(tm, "c", "w", "X", "esc") // Change "one " to "X" -> "Xtwo three"
sendKeys(tm, "w") // Move to "two"
sendKeys(tm, ".") // Repeat cw -> change "two " to "X" -> "Xtwo X"
m := getFinalModel(t, tm)
// cw deletes word and space after, result is "Xtwo X" + trailing content
if m.ActiveBuffer().Line(0) != "Xtwo Xthree" && m.ActiveBuffer().Line(0) != "Xtwo X" {
t.Errorf("buffer = %q, want \"Xtwo X\" or \"Xtwo Xthree\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats change line", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2", "line3"}))
sendKeys(tm, "c", "c", "N", "E", "W", "esc") // Change line -> "NEW"
sendKeys(tm, "j") // Move to next line
sendKeys(tm, ".") // Repeat cc
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "NEW" || m.ActiveBuffer().Line(1) != "NEW" {
t.Errorf("lines = [%q, %q], want [\"NEW\", \"NEW\"]",
m.ActiveBuffer().Line(0), m.ActiveBuffer().Line(1))
}
})
}
// ==================================================
// Additional Coverage: Paste Operations
// ==================================================
func TestDotOperatorPaste(t *testing.T) {
t.Run("repeats paste", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello", "world"}))
sendKeys(tm, "y", "y") // Yank line
sendKeys(tm, "p") // Paste -> "hello", "hello", "world"
sendKeys(tm, ".") // Repeat paste -> "hello", "hello", "hello", "world"
m := getFinalModel(t, tm)
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(0) != "hello" || m.ActiveBuffer().Line(1) != "hello" || m.ActiveBuffer().Line(2) != "hello" {
t.Errorf("first 3 lines should be \"hello\"")
}
})
}
// ==================================================
// Additional Coverage: Append Mode
// ==================================================
func TestDotOperatorAppendMode(t *testing.T) {
t.Run("repeats append", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "a", "X", "Y", "esc") // Append XY after 'h' -> "hXYello", cursor at Y
sendKeys(tm, "l") // Move right one char
sendKeys(tm, ".") // Repeat append at new position
m := getFinalModel(t, tm)
// Result will depend on exact cursor behavior after 'a' mode
if m.ActiveBuffer().Line(0) != "hXYeXYllo" {
t.Errorf("buffer = %q, want \"hXYeXYllo\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats append at end of line", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello"}))
sendKeys(tm, "A", "!", "esc") // Append at end -> "hello!"
sendKeys(tm, "j") // Move to another line (if exists) or stay
sendKeys(tm, ".") // Repeat A!
m := getFinalModel(t, tm)
// Should append at end of current line
if m.ActiveBuffer().Line(0) != "hello!!" {
t.Errorf("buffer = %q, want \"hello!!\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// Additional Coverage: Visual Character Mode
// ==================================================
func TestDotOperatorVisualCharMode(t *testing.T) {
t.Run("repeats visual char delete", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world"}))
sendKeys(tm, "v", "l", "l", "x") // Select "hel" and delete -> "lo world"
sendKeys(tm, ".") // Repeat -> delete "lo " -> "world"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "world" {
t.Errorf("buffer = %q, want \"world\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// Additional Coverage: Text Objects
// ==================================================
func TestDotOperatorTextObjects(t *testing.T) {
t.Run("repeats delete inner word", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"one two three"}))
sendKeys(tm, "d", "i", "w") // Delete "one" -> " two three"
sendKeys(tm, "w") // Move to "two"
sendKeys(tm, ".") // Repeat diw -> delete "two"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != " three" {
t.Errorf("buffer = %q, want \" three\"", m.ActiveBuffer().Line(0))
}
})
t.Run("repeats delete a word", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"one two three"}))
sendKeys(tm, "d", "a", "w") // Delete "one " -> "two three"
sendKeys(tm, ".") // Repeat daw -> delete "two "
m := getFinalModel(t, tm)
if m.ActiveBuffer().Line(0) != "three" {
t.Errorf("buffer = %q, want \"three\"", m.ActiveBuffer().Line(0))
}
})
}
// ==================================================
// Additional Coverage: Open Line
// ==================================================
func TestDotOperatorOpenLine(t *testing.T) {
t.Run("repeats open line below", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2"}))
sendKeys(tm, "o", "N", "E", "W", "esc") // Open below and insert "NEW"
sendKeys(tm, "j") // Move down
sendKeys(tm, ".") // Repeat
m := getFinalModel(t, tm)
// Should have: line1, NEW, line2, NEW
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(1) != "NEW" || m.ActiveBuffer().Line(3) != "NEW" {
t.Errorf("lines[1] = %q, lines[3] = %q, both want \"NEW\"",
m.ActiveBuffer().Line(1), m.ActiveBuffer().Line(3))
}
})
t.Run("repeats open line above", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"line1", "line2"}))
sendKeys(tm, "j") // Move to line2
sendKeys(tm, "O", "T", "O", "P", "esc") // Open above and insert "TOP"
sendKeys(tm, "j", "j") // Move down past inserted line
sendKeys(tm, ".") // Repeat
m := getFinalModel(t, tm)
// Should have: line1, TOP, TOP, line2
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Line(1) != "TOP" || m.ActiveBuffer().Line(2) != "TOP" {
t.Errorf("lines[1] = %q, lines[2] = %q, both want \"TOP\"",
m.ActiveBuffer().Line(1), m.ActiveBuffer().Line(2))
}
})
}
// ==================================================
// Additional Coverage: Character Find Motions
// ==================================================
func TestDotOperatorCharMotions(t *testing.T) {
t.Run("repeats delete to char", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello world foo"}))
sendKeys(tm, "d", "f", "o") // Delete from 'h' until and including first 'o' -> "llo world foo"
sendKeys(tm, "w") // Move to next word
sendKeys(tm, ".") // Repeat dfo
m := getFinalModel(t, tm)
// After dfo from start: "llo world foo"
// After w: cursor somewhere in the line
// After . (repeat dfo): delete until next 'o'
// Actual result will depend on word motion and where 'o' is found
line := m.ActiveBuffer().Line(0)
if len(line) == 0 {
t.Errorf("buffer should not be empty after two dfo operations")
}
if line != " rld foo" {
t.Errorf("line is '%s', but expected ' rld foo'", line)
}
})
t.Run("repeats change until char", func(t *testing.T) {
tm := newTestModel(t, WithLines([]string{"hello;world;end"}))
sendKeys(tm, "c", "t", ";", "X", "esc") // Change "hello" (until ;) to "X" -> "X;world;end"
sendKeys(tm, "f", ";") // Move to first ';'
sendKeys(tm, "l") // Move past ';' to 'w'
sendKeys(tm, ".") // Repeat ct; from 'w'
m := getFinalModel(t, tm)
// After ct; from 'w': changes "world" (until next ;) to "X" -> result varies
// Accept the actual implementation behavior
line := m.ActiveBuffer().Line(0)
if len(line) < 3 {
t.Errorf("buffer = %q, seems too short", line)
}
if line != "Xo;Xd;end" {
t.Errorf("line is '%s', but expected 'Xo;Xd;end'", line)
}
})
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,988 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ============================================================================
// Word Text Object Tests (iw/aw)
// ============================================================================
func TestTextObjectInnerWord(t *testing.T) {
t.Run("test 'viw' selects inner word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "i", "w")
m := getFinalModel(t, tm)
if !m.Mode().IsVisualMode() {
t.Errorf("Expected visual mode")
}
// Should select "hello" (cols 0-4)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'diw' deletes inner word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "i", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'ciw' changes inner word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "c", "i", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
if m.Mode() != core.InsertMode {
t.Errorf("Expected insert mode after ciw")
}
})
t.Run("test 'yiw' yanks inner word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "y", "i", "w")
m := getFinalModel(t, tm)
reg, ok := m.GetRegister('"')
if !ok {
t.Fatalf("Default register not found")
}
if len(reg.Content) != 1 || reg.Content[0] != "hello" {
t.Errorf("register content = %v, want ['hello']", reg.Content)
}
})
t.Run("test 'viw' at start of word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "v", "i", "w")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'viw' at end of word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
sendKeys(tm, "v", "i", "w")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'viw' on underscore word", func(t *testing.T) {
lines := []string{"hello_world test"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
sendKeys(tm, "v", "i", "w")
m := getFinalModel(t, tm)
// Should select "hello_world" (cols 0-10)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=10",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'viw' on punctuation", func(t *testing.T) {
lines := []string{"foo-bar baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "w")
m := getFinalModel(t, tm)
// Should select just "-" (col 3)
if m.ActiveWindow().Anchor.Col != 3 || m.ActiveWindow().Cursor.Col != 3 {
t.Errorf("anchor=%d, cursor=%d, want anchor=3, cursor=3",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
}
func TestTextObjectAroundWord(t *testing.T) {
t.Run("test 'vaw' selects around word with trailing space", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "a", "w")
m := getFinalModel(t, tm)
// Should select "hello " (cols 0-5, includes trailing space)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'daw' deletes around word", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "a", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'daw' on last word (no trailing space)", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
sendKeys(tm, "d", "a", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// WORD Text Object Tests (iW/aW)
// ============================================================================
func TestTextObjectInnerWORD(t *testing.T) {
t.Run("test 'viW' selects inner WORD", func(t *testing.T) {
lines := []string{"foo-bar baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "i", "W")
m := getFinalModel(t, tm)
// Should select "foo-bar" (cols 0-6)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=6",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'diW' deletes inner WORD", func(t *testing.T) {
lines := []string{"foo-bar.baz test"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "d", "i", "W")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " test" {
t.Errorf("lines[0] = %q, want ' test'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectAroundWORD(t *testing.T) {
t.Run("test 'vaW' selects around WORD with trailing space", func(t *testing.T) {
lines := []string{"foo-bar baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "v", "a", "W")
m := getFinalModel(t, tm)
// Should select "foo-bar " (cols 0-7, includes trailing space)
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 7 {
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=7",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'daW' deletes around WORD", func(t *testing.T) {
lines := []string{"foo-bar baz"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "a", "W")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "baz" {
t.Errorf("lines[0] = %q, want 'baz'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// Delimiter Text Object Tests (i</a<, i(/a(, etc.)
// ============================================================================
func TestTextObjectAngleBrackets(t *testing.T) {
t.Run("test 'vi<' selects inner angle brackets", func(t *testing.T) {
lines := []string{"<hello>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "<")
m := getFinalModel(t, tm)
// Should select "hello" (cols 1-5)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'vi>' works same as 'vi<'", func(t *testing.T) {
lines := []string{"<hello>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", ">")
m := getFinalModel(t, tm)
// Should select "hello" (cols 1-5)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di<' deletes inner angle brackets", func(t *testing.T) {
lines := []string{"<hello>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", "<")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "<>" {
t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da<' deletes around angle brackets", func(t *testing.T) {
lines := []string{"<hello>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", "<")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'vi<' on empty brackets does nothing", func(t *testing.T) {
lines := []string{"<>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "<")
m := getFinalModel(t, tm)
// Should remain unchanged
if m.ActiveBuffer().Lines[0].String() != "<>" {
t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'vi<' in nested brackets", func(t *testing.T) {
lines := []string{"<foo<bar>>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "v", "i", "<")
m := getFinalModel(t, tm)
// Should select "bar" (cols 5-7, the innermost pair)
if m.ActiveWindow().Anchor.Col != 5 || m.ActiveWindow().Cursor.Col != 7 {
t.Errorf("anchor=%d, cursor=%d, want anchor=5, cursor=7",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
}
func TestTextObjectParentheses(t *testing.T) {
t.Run("test 'vi(' selects inner parentheses", func(t *testing.T) {
lines := []string{"(hello)"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "(")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'vi)' works same as 'vi('", func(t *testing.T) {
lines := []string{"(hello)"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", ")")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di(' deletes inner parentheses", func(t *testing.T) {
lines := []string{"func(hello)"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "func()" {
t.Errorf("lines[0] = %q, want 'func()'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da(' deletes around parentheses", func(t *testing.T) {
lines := []string{"func(hello)"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
sendKeys(tm, "d", "a", "(")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "func" {
t.Errorf("lines[0] = %q, want 'func'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'vi(' on empty parens does nothing", func(t *testing.T) {
lines := []string{"()"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "()" {
t.Errorf("lines[0] = %q, want '()'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectBraces(t *testing.T) {
t.Run("test 'vi{' selects inner braces", func(t *testing.T) {
lines := []string{"{hello}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "{")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di{' deletes inner braces", func(t *testing.T) {
lines := []string{"{hello}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "{}" {
t.Errorf("lines[0] = %q, want '{}'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da{' deletes around braces", func(t *testing.T) {
lines := []string{"{hello}"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", "{")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectBrackets(t *testing.T) {
t.Run("test 'vi[' selects inner brackets", func(t *testing.T) {
lines := []string{"[hello]"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "[")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di[' deletes inner brackets", func(t *testing.T) {
lines := []string{"[hello]"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", "[")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "[]" {
t.Errorf("lines[0] = %q, want '[]'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da[' deletes around brackets", func(t *testing.T) {
lines := []string{"[hello]"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", "[")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectDoubleQuotes(t *testing.T) {
t.Run("test 'vi\"' selects inner double quotes", func(t *testing.T) {
lines := []string{`"hello"`}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", `"`)
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di\"' deletes inner double quotes", func(t *testing.T) {
lines := []string{`"hello"`}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", `"`)
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != `""` {
t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da\"' deletes around double quotes", func(t *testing.T) {
lines := []string{`"hello"`}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", `"`)
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'vi\"' on empty quotes does nothing", func(t *testing.T) {
lines := []string{`""`}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", `"`)
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != `""` {
t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectSingleQuotes(t *testing.T) {
t.Run("test 'vi'' selects inner single quotes", func(t *testing.T) {
lines := []string{"'hello'"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "'")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di'' deletes inner single quotes", func(t *testing.T) {
lines := []string{"'hello'"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", "'")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "''" {
t.Errorf("lines[0] = %q, want \"''\"", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da'' deletes around single quotes", func(t *testing.T) {
lines := []string{"'hello'"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", "'")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestTextObjectBackticks(t *testing.T) {
t.Run("test 'vi`' selects inner backticks", func(t *testing.T) {
lines := []string{"`hello`"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "v", "i", "`")
m := getFinalModel(t, tm)
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
}
})
t.Run("test 'di`' deletes inner backticks", func(t *testing.T) {
lines := []string{"`hello`"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "i", "`")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "``" {
t.Errorf("lines[0] = %q, want '``'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'da`' deletes around backticks", func(t *testing.T) {
lines := []string{"`hello`"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "d", "a", "`")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// Edge Cases and Complex Scenarios
// ============================================================================
func TestTextObjectEdgeCases(t *testing.T) {
t.Run("test 'diw' on single character word", func(t *testing.T) {
lines := []string{"a b c"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != " b c" {
t.Errorf("lines[0] = %q, want ' b c'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'ci<' then type replacement", func(t *testing.T) {
lines := []string{"<hello>"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
sendKeys(tm, "c", "i", "<")
sendKeyString(tm, "world")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "<world>" {
t.Errorf("lines[0] = %q, want '<world>'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test 'yi(' then paste", func(t *testing.T) {
lines := []string{"func(arg)", "test"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "y", "i", "(")
sendKeys(tm, "j", "p")
m := getFinalModel(t, tm)
// 'p' pastes after cursor, so "arg" is pasted after 't' -> "testarg"
if m.ActiveBuffer().Lines[1].String() != "testarg" {
t.Errorf("lines[1] = %q, want 'testarg'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("test text object cursor after delimiters does nothing", func(t *testing.T) {
lines := []string{"before (hello) after"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Should remain unchanged since cursor is not inside parens
if m.ActiveBuffer().Lines[0].String() != "before (hello) after" {
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test text object cursor before delimiters selects inside", func(t *testing.T) {
lines := []string{"before (hello) after"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "before () after" {
t.Errorf("lines[0] = %q, want 'before () after'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test text object cursor before delimiters with 'a' modifier", func(t *testing.T) {
lines := []string{"before (hello) after"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "a", "(")
m := getFinalModel(t, tm)
// 'a' should delete including the delimiters
if m.ActiveBuffer().Lines[0].String() != "before after" {
t.Errorf("lines[0] = %q, want 'before after'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test text object cursor on opening delimiter", func(t *testing.T) {
lines := []string{"text (hello) more"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Cursor on '(' at position 5, should still select inside
if m.ActiveBuffer().Lines[0].String() != "text () more" {
t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test text object cursor on closing delimiter", func(t *testing.T) {
lines := []string{"text (hello) more"}
// "text (hello) more"
// 01234567891011 <- ')' is at position 11
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 11, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Cursor on ')', should still select inside
if m.ActiveBuffer().Lines[0].String() != "text () more" {
t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple delimiter pairs - cursor before first", func(t *testing.T) {
lines := []string{"(foo) bar (baz)"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Should select the first pair it finds
if m.ActiveBuffer().Lines[0].String() != "() bar (baz)" {
t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple delimiter pairs - cursor between pairs", func(t *testing.T) {
lines := []string{"(foo) bar (baz)"}
// Cursor on 'b' in "bar"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Should search forward and find the second pair
if m.ActiveBuffer().Lines[0].String() != "(foo) bar ()" {
t.Errorf("lines[0] = %q, want '(foo) bar ()'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple delimiter pairs - cursor inside first", func(t *testing.T) {
lines := []string{"(foo) bar (baz)"}
// Cursor on 'o' in "foo"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
// Should select the first pair since cursor is inside it
if m.ActiveBuffer().Lines[0].String() != "() bar (baz)" {
t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple quoted strings - cursor before first", func(t *testing.T) {
lines := []string{`foo "bar" baz "qux"`}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", `"`)
m := getFinalModel(t, tm)
// Should find and select first quoted string
if m.ActiveBuffer().Lines[0].String() != `foo "" baz "qux"` {
t.Errorf("lines[0] = %q, want 'foo \"\" baz \"qux\"'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("test multiple quoted strings - cursor between pairs", func(t *testing.T) {
lines := []string{`"foo" bar "baz"`}
// Cursor on 'b' in "bar"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "d", "i", `"`)
m := getFinalModel(t, tm)
// Should search forward and find second string
if m.ActiveBuffer().Lines[0].String() != `"foo" bar ""` {
t.Errorf("lines[0] = %q, want '\"foo\" bar \"\"'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// Multi-line Delimiter Tests
// ============================================================================
func TestTextObjectMultiLineDelimiters(t *testing.T) {
t.Run("test 'di{' on multi-line braces", func(t *testing.T) {
lines := []string{
"func test() {",
" body",
"}",
}
// Cursor on "body"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"func test() {",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test 'da{' on multi-line braces", func(t *testing.T) {
lines := []string{
"func test() {",
" body",
"}",
}
// Cursor on "body"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1})
sendKeys(tm, "d", "a", "{")
m := getFinalModel(t, tm)
expected := []string{
"func test() ",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test 'vi(' on multi-line parentheses", func(t *testing.T) {
lines := []string{
"function(",
" arg1,",
" arg2",
")",
}
// Cursor on "arg1"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1})
sendKeys(tm, "v", "i", "(")
m := getFinalModel(t, tm)
// Should select from after '(' to before ')'
// Line 0, col 9 (after '(') to line 3, col -1 (before ')')
// But since we're in visual mode, check the anchor and cursor
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("anchor.Line=%d, cursor.Line=%d, want anchor.Line=0, cursor.Line=2",
m.ActiveWindow().Anchor.Line, m.ActiveWindow().Cursor.Line)
}
// Anchor should be at col 9 (after '('), cursor at end of line 2
if m.ActiveWindow().Anchor.Col != 9 {
t.Errorf("anchor.Col=%d, want 9", m.ActiveWindow().Anchor.Col)
}
})
t.Run("test 'di(' on multi-line parentheses", func(t *testing.T) {
lines := []string{
"function(",
" arg1,",
" arg2",
")",
}
// Cursor on "arg1"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
expected := []string{
"function(",
")",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test nested multi-line braces - cursor in outer", func(t *testing.T) {
lines := []string{
"outer {",
" inner {",
" content",
" }",
" more",
"}",
}
// Cursor on "more" (inside outer, outside inner)
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 4})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"outer {",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test nested multi-line braces - cursor in inner", func(t *testing.T) {
lines := []string{
"outer {",
" inner {",
" content",
" }",
" more",
"}",
}
// Cursor on "content" (inside inner block)
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 8, Line: 2})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"outer {",
" inner {",
" }",
" more",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test nested multi-line braces with multiple nesting levels", func(t *testing.T) {
lines := []string{
"level1 {",
" level2 {",
" level3 {",
" target",
" }",
" }",
"}",
}
// Cursor on "target"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 3})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"level1 {",
" level2 {",
" level3 {",
" }",
" }",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test multi-line delimiters - cursor on opening line", func(t *testing.T) {
lines := []string{
"function(arg) {",
" body",
"}",
}
// Cursor on opening line, after '{'
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 14, Line: 0})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"function(arg) {",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test multi-line delimiters - cursor on closing line", func(t *testing.T) {
lines := []string{
"function(arg) {",
" body",
"}",
}
// Cursor on closing line, before '}'
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"function(arg) {",
"}",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test multi-line delimiters - cursor before delimiters searches forward", func(t *testing.T) {
lines := []string{
"before",
"function(arg) {",
" body",
"}",
"after",
}
// Cursor on "before"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
sendKeys(tm, "d", "i", "{")
m := getFinalModel(t, tm)
expected := []string{
"before",
"function(arg) {",
"}",
"after",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
t.Run("test nested parentheses across lines", func(t *testing.T) {
lines := []string{
"outer(",
" inner(",
" content",
" ),",
" more",
")",
}
// Cursor on "content"
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 8, Line: 2})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
expected := []string{
"outer(",
" inner(",
" ),",
" more",
")",
}
if !slicesEqual(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("lines = %v, want %v", bufferLinesToStrings(m.ActiveBuffer()), expected)
}
})
}
// Helper function to get buffer lines as strings
func bufferLinesToStrings(buf *core.Buffer) []string {
result := make([]string, buf.LineCount())
for i := 0; i < buf.LineCount(); i++ {
result[i] = buf.Line(i)
}
return result
}
// Helper function to compare slices
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,992 @@
package editor
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// equalStringSlices compares two string slices for equality
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ============================================================================
// BASIC UNDO/REDO TESTS
// ============================================================================
func TestUndoBasicOperations(t *testing.T) {
t.Run("undo single character delete with x", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'h'
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
// Verify undo stack is empty
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected undo stack to be empty after undoing all changes")
}
})
t.Run("undo and redo single character delete", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'h'
sendKeys(tm, "u") // Undo
sendKeys(tm, "ctrl+r") // Redo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
// Verify redo stack is empty
if m.ActiveBuffer().UndoStack.CanRedo() {
t.Error("Expected redo stack to be empty after redoing all changes")
}
})
t.Run("undo multiple x operations creates separate undo blocks", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'h' -> "ello"
sendKeys(tm, "x") // Delete 'e' -> "llo"
sendKeys(tm, "x") // Delete first 'l' -> "lo"
sendKeys(tm, "u") // Undo last x -> "llo"
sendKeys(tm, "u") // Undo second x -> "ello"
sendKeys(tm, "u") // Undo first x -> "hello"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("After 3 undos: lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
// Verify undo stack is empty
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected undo stack to be empty after undoing all changes")
}
})
t.Run("undo single X (delete backward)", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "X") // Delete 'e'
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
}
func TestUndoCursorRestoration(t *testing.T) {
t.Run("undo restores cursor position after x", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "x") // Delete 'w' at position 6
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
}
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("redo restores cursor position after operation", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete at position 0
sendKeys(tm, "u") // Undo
sendKeys(tm, "ctrl+r") // Redo
m := getFinalModel(t, tm)
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col)
}
})
}
// ============================================================================
// INSERT MODE UNDO TESTS
// ============================================================================
func TestUndoInsertMode(t *testing.T) {
t.Run("insert mode groups all characters into one undo", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i") // Enter insert mode
sendKeyString(tm, "hello") // Type 5 characters
sendKeys(tm, "esc") // Exit insert mode
sendKeys(tm, "u") // Undo once
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
// Verify only one undo was needed
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected undo stack to be empty after single undo of insert session")
}
})
t.Run("multiple insert sessions create separate undo blocks", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
// First insert session
sendKeys(tm, "i")
sendKeyString(tm, "hello")
sendKeys(tm, "esc")
// Second insert session
sendKeys(tm, "a") // Append
sendKeyString(tm, " world")
sendKeys(tm, "esc")
// Undo second session
sendKeys(tm, "u")
// Undo first session
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("After 2 undos: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
// Verify undo stack is empty
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected undo stack to be empty after undoing all changes")
}
})
t.Run("insert with newlines groups everything into one undo", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i")
sendKeyString(tm, "line1")
sendKeys(tm, "enter")
sendKeyString(tm, "line2")
sendKeys(tm, "enter")
sendKeyString(tm, "line3")
sendKeys(tm, "esc")
// Single undo should remove everything
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 1 || m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("After undo: got %d lines with content %q, want 1 empty line",
len(m.ActiveBuffer().Lines), m.ActiveBuffer().Lines)
}
})
t.Run("insert mode with backspace is grouped into one undo", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i")
sendKeyString(tm, "hello")
sendKeys(tm, "backspace", "backspace") // Delete "lo"
sendKeyString(tm, "y") // Type "y"
sendKeys(tm, "esc")
// Single undo should remove entire insert session
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// OPERATOR UNDO TESTS (dd, cc, etc.)
// ============================================================================
func TestUndoDeleteOperator(t *testing.T) {
t.Run("dd creates one undo block", func(t *testing.T) {
lines := []string{"line1", "line2", "line3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "d") // Delete line1
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 3 {
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("3dd creates one undo block for all 3 lines", func(t *testing.T) {
lines := []string{"line1", "line2", "line3", "line4"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "3", "d", "d") // Delete 3 lines
sendKeys(tm, "u") // Undo once
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 4 {
t.Errorf("line count = %d, want 4", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[2].String() != "line3" {
t.Errorf("lines[2] = %q, want 'line3'", m.ActiveBuffer().Lines[2].String())
}
})
t.Run("dw (delete word) creates one undo block", func(t *testing.T) {
lines := []string{"hello world foo"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "w") // Delete "hello "
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("D (delete to end of line) undoes correctly", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "D") // Delete "world"
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 6 {
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
}
})
}
func TestUndoChangeOperator(t *testing.T) {
t.Run("cc (change line) undoes correctly", func(t *testing.T) {
lines := []string{"original line", "line2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "c", "c") // Change line (deletes and enters insert)
sendKeyString(tm, "new line") // Type new content
sendKeys(tm, "esc") // Exit insert mode
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "original line" {
t.Errorf("lines[0] = %q, want 'original line'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("cw (change word) undoes correctly", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "c", "w") // Change word
sendKeyString(tm, "hi") // Type replacement
sendKeys(tm, "esc")
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("s (substitute char) undoes correctly", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "s") // Substitute character
sendKeyString(tm, "H") // Type replacement
sendKeys(tm, "esc")
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("S (substitute line) undoes correctly", func(t *testing.T) {
lines := []string{"original", "line2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "S") // Substitute line
sendKeyString(tm, "replaced") // Type replacement
sendKeys(tm, "esc")
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "original" {
t.Errorf("lines[0] = %q, want 'original'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// VISUAL MODE UNDO TESTS
// ============================================================================
func TestUndoVisualMode(t *testing.T) {
t.Run("visual char mode delete undoes correctly", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v") // Enter visual char mode
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
sendKeys(tm, "d") // Delete selection
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("visual line mode delete undoes correctly", func(t *testing.T) {
lines := []string{"line1", "line2", "line3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V") // Enter visual line mode
sendKeys(tm, "j") // Select 2 lines
sendKeys(tm, "d") // Delete
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 3 {
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("visual block mode delete undoes correctly", func(t *testing.T) {
lines := []string{"hello", "world", "test"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+v") // Enter visual block mode
sendKeys(tm, "j", "j") // Select 3 lines
sendKeys(tm, "l", "l") // Select 3 columns
sendKeys(tm, "d") // Delete block
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1].String() != "world" {
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1].String())
}
})
t.Run("visual char mode change undoes correctly", func(t *testing.T) {
lines := []string{"hello world"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "v") // Visual char mode
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
sendKeys(tm, "c") // Change
sendKeyString(tm, "hi") // Type replacement
sendKeys(tm, "esc")
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// TEXT OBJECT UNDO TESTS
// ============================================================================
func TestUndoTextObjects(t *testing.T) {
t.Run("diw (delete inner word) undoes correctly", func(t *testing.T) {
lines := []string{"hello world foo"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
sendKeys(tm, "d", "i", "w") // Delete inner word
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("daw (delete a word) undoes correctly", func(t *testing.T) {
lines := []string{"hello world foo"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
sendKeys(tm, "d", "a", "w") // Delete a word
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello world foo" {
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("ci( changes inside parens undoes correctly", func(t *testing.T) {
lines := []string{"before (hello) after"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 9, Line: 0})
sendKeys(tm, "c", "i", "(") // Change inside parens
sendKeyString(tm, "world") // Type replacement
sendKeys(tm, "esc")
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "before (hello) after" {
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// UNDO/REDO SEQUENCE TESTS
// ============================================================================
func TestUndoRedoSequences(t *testing.T) {
t.Run("undo then redo multiple times", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'h' -> "ello"
sendKeys(tm, "x") // Delete 'e' -> "llo"
// Undo twice
sendKeys(tm, "u", "u")
// Redo twice
sendKeys(tm, "ctrl+r", "ctrl+r")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("After 2 redos: lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("new change after undo clears redo stack", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'h' -> "ello"
sendKeys(tm, "x") // Delete 'e' -> "llo"
sendKeys(tm, "u") // Undo -> "ello"
sendKeys(tm, "x") // New change -> "llo"
m := getFinalModel(t, tm)
// Verify redo is not possible
if m.ActiveBuffer().UndoStack.CanRedo() {
t.Error("Expected redo stack to be cleared after new change")
}
// Verify content
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("complex sequence: operations, undo, redo, more operations", func(t *testing.T) {
lines := []string{"line1", "line2", "line3"}
tm := newTestModelWithLines(t, lines)
// Do operations
sendKeys(tm, "d", "d") // Delete line1
sendKeys(tm, "d", "d") // Delete line2
// Undo once
sendKeys(tm, "u")
// Redo
sendKeys(tm, "ctrl+r")
// New operation
sendKeys(tm, "i")
sendKeyString(tm, "new")
sendKeys(tm, "esc")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "newline3" {
t.Errorf("After insert: lines[0] = %q, want 'newline3'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// EDGE CASE TESTS
// ============================================================================
func TestUndoEdgeCases(t *testing.T) {
t.Run("undo on empty undo stack does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "u") // Undo when nothing to undo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("redo on empty redo stack does nothing", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "ctrl+r") // Redo when nothing to redo
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("undo after exhausting redo stack", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete
sendKeys(tm, "u") // Undo
sendKeys(tm, "ctrl+r") // Redo
sendKeys(tm, "ctrl+r") // Try redo again (should do nothing)
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("undo operation that left buffer empty", func(t *testing.T) {
lines := []string{"only line"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "d") // Delete only line (buffer should have empty line)
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 1 {
t.Errorf("line count = %d, want 1", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "only line" {
t.Errorf("lines[0] = %q, want 'only line'", m.ActiveBuffer().Lines[0].String())
}
})
}
// ============================================================================
// MULTI-LINE OPERATION TESTS
// ============================================================================
func TestUndoMultiLineOperations(t *testing.T) {
t.Run("undo multi-line delete from visual mode", func(t *testing.T) {
lines := []string{"line1", "line2", "line3", "line4", "line5"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "V") // Visual line mode
sendKeys(tm, "j", "j") // Select 3 lines
sendKeys(tm, "d") // Delete
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 5 {
t.Errorf("line count = %d, want 5", len(m.ActiveBuffer().Lines))
}
for i := 0; i < 5; i++ {
expected := "line" + string(rune('1'+i))
if m.ActiveBuffer().Lines[i].String() != expected {
t.Errorf("lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), expected)
}
}
})
t.Run("undo delete spanning multiple lines with motion", func(t *testing.T) {
lines := []string{"line1", "line2", "line3"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "j") // Delete current line and line below
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 3 {
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("undo o (open line below) operation", func(t *testing.T) {
lines := []string{"line1", "line2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "o") // Open line below
sendKeyString(tm, "new line") // Type content
sendKeys(tm, "esc") // Exit insert
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 2 {
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[0].String() != "line1" {
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0].String())
}
})
t.Run("undo O (open line above) operation", func(t *testing.T) {
lines := []string{"line1", "line2"}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
sendKeys(tm, "O") // Open line above
sendKeyString(tm, "new line") // Type content
sendKeys(tm, "esc") // Exit insert
sendKeys(tm, "u") // Undo
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 2 {
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
}
if m.ActiveBuffer().Lines[1].String() != "line2" {
t.Errorf("lines[1] = %q, want 'line2'", m.ActiveBuffer().Lines[1].String())
}
})
}
// ============================================================================
// UNDO STACK INSPECTION TESTS
// ============================================================================
func TestUndoStackStructure(t *testing.T) {
t.Run("verify undo stack has correct number of blocks", func(t *testing.T) {
lines := []string{"hello"}
tm := newTestModelWithLines(t, lines)
// Perform 3 separate operations
sendKeys(tm, "x") // Op 1
sendKeys(tm, "x") // Op 2
sendKeys(tm, "x") // Op 3
m := getFinalModel(t, tm)
// Should have 3 undo blocks
undoCount := 0
for m.ActiveBuffer().UndoStack.CanUndo() {
m.ActiveBuffer().UndoStack.Undo()
undoCount++
}
if undoCount != 3 {
t.Errorf("undo block count = %d, want 3", undoCount)
}
})
t.Run("verify insert mode creates single block with multiple changes", func(t *testing.T) {
lines := []string{""}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "i")
sendKeyString(tm, "hello")
sendKeys(tm, "esc")
// Verify single undo removes everything
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected empty undo stack after single undo")
}
})
t.Run("verify dd creates single block with correct change types", func(t *testing.T) {
lines := []string{"line1", "line2"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "d", "d")
// Verify undo restores correctly
sendKeys(tm, "u")
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 2 {
t.Errorf("line count after undo = %d, want 2", len(m.ActiveBuffer().Lines))
}
// Verify undo stack is empty after undo
if m.ActiveBuffer().UndoStack.CanUndo() {
t.Error("Expected empty undo stack after undoing all changes")
}
})
}
// ============================================================================
// COMPLEX SCENARIO TESTS
// ============================================================================
func TestUndoComplexScenarios(t *testing.T) {
t.Run("realistic editing session with multiple undo/redo", func(t *testing.T) {
lines := []string{"func main() {", "}", ""}
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
// Insert a line
sendKeys(tm, "O")
sendKeyString(tm, "\tfmt.Println(\"hello\")")
sendKeys(tm, "esc")
// Delete a word
sendKeys(tm, "d", "i", "w")
// Undo delete
sendKeys(tm, "u")
// Undo insert
sendKeys(tm, "u")
// Redo both
sendKeys(tm, "ctrl+r", "ctrl+r")
m := getFinalModel(t, tm)
if len(m.ActiveBuffer().Lines) != 4 {
t.Errorf("After 2 redos: line count = %d, want 4", len(m.ActiveBuffer().Lines))
}
})
t.Run("alternating operations and undos", func(t *testing.T) {
lines := []string{"abc"}
tm := newTestModelWithLines(t, lines)
sendKeys(tm, "x") // Delete 'a' -> "bc"
sendKeys(tm, "u") // Undo -> "abc"
sendKeys(tm, "$") // Move to end
sendKeys(tm, "x") // Delete 'c' -> "ab"
sendKeys(tm, "u") // Undo -> "abc"
sendKeys(tm, "0") // Move to start
sendKeys(tm, "x") // Delete 'a' -> "bc"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0].String() != "bc" {
t.Errorf("lines[0] = %q, want 'bc'", m.ActiveBuffer().Lines[0].String())
}
})
}
// =================================================================
// PASTE OPERATIONS TESTS
// =================================================================
func TestUndoPasteOperations(t *testing.T) {
t.Run("basic p (paste after) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2"})
// Yank first line and paste after second line
sendKeys(tm, "y", "y") // yank current line (line1)
sendKeys(tm, "j") // move to line2
sendKeys(tm, "p") // paste after line2
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"line1", "line2"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i].String() != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
}
}
// Cursor should be back at line2
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
})
t.Run("basic P (paste before) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2"})
// Yank first line and paste before second line
sendKeys(tm, "y", "y") // yank current line (line1)
sendKeys(tm, "j") // move to line2
sendKeys(tm, "P") // paste before line2
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"line1", "line2"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i].String() != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
}
}
// Cursor should be back at line2
if m.ActiveWindow().Cursor.Line != 1 {
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
}
})
t.Run("charwise paste undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello world"})
// Yank "hello" and paste after "world"
sendKeys(tm, "y", "w") // yank word "hello"
sendKeys(tm, "$") // move to end
sendKeys(tm, "p") // paste after cursor
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"hello world"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("visual mode paste undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"hello world", "foo bar"})
// Yank "hello" then select "world" and paste over it
sendKeys(tm, "y", "w") // yank "hello"
sendKeys(tm, "w") // move to "world"
sendKeys(tm, "v", "e") // select "world"
sendKeys(tm, "p") // paste over selection
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"hello world", "foo bar"}
if len(m.ActiveBuffer().Lines) != len(expected) {
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
}
for i, exp := range expected {
if m.ActiveBuffer().Lines[i].String() != exp {
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i].String(), exp)
}
}
})
t.Run("multiple paste operations undo separately", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"base"})
sendKeys(tm, "y", "y") // yank "base"
sendKeys(tm, "p") // paste: "base\nbase"
sendKeys(tm, "p") // paste: "base\nbase\nbase"
sendKeys(tm, "u") // undo last paste: "base\nbase"
sendKeys(tm, "u") // undo first paste: "base"
m := getFinalModel(t, tm)
expected := []string{"base"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("paste with count undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"test"})
sendKeys(tm, "y", "y") // yank "test"
sendKeyString(tm, "3p") // paste 3 times
sendKeys(tm, "u") // undo (should undo all 3 pastes as one block)
m := getFinalModel(t, tm)
expected := []string{"test"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
}
// =================================================================
// COMPLEX COUNT OPERATIONS TESTS
// =================================================================
func TestUndoComplexCountOperations(t *testing.T) {
t.Run("5dd undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"1", "2", "3", "4", "5", "6", "7"})
sendKeys(tm, "j", "j") // move to line 3
sendKeyString(tm, "5dd") // delete 5 lines (3,4,5,6,7)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"1", "2", "3", "4", "5", "6", "7"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at line 3 (index 2)
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
}
})
t.Run("3cw (change 3 words) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"one two three four five"})
sendKeys(tm, "3", "c", "w") // change 3 words
sendKeys(tm, "CHANGED") // type replacement
sendKeys(tm, "esc") // exit insert mode
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"one two three four five"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("10x (delete 10 chars) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"abcdefghijklmnopqrstuvwxyz"})
sendKeys(tm, "5", "|") // move to column 5 (f)
sendKeyString(tm, "10x") // delete 10 chars (fghijklmno)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"abcdefghijklmnopqrstuvwxyz"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at column 4 (index of 'e', 0-based)
if m.ActiveWindow().Cursor.Col != 4 {
t.Errorf("Cursor.Col = %d, want 4", m.ActiveWindow().Cursor.Col)
}
})
t.Run("2cc (change 2 lines) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"line1", "line2", "line3", "line4"})
sendKeys(tm, "j") // move to line2
sendKeys(tm, "2", "c", "c") // change 2 lines (line2, line3)
sendKeys(tm, "NEW", "LINE") // type replacement
sendKeys(tm, "esc") // exit insert mode
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"line1", "line2", "line3", "line4"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("4diw (delete 4 words) undo", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"word1 word2 word3 word4 word5"})
sendKeys(tm, "w") // move to word2
sendKeyString(tm, "4diw") // delete 4 words (word2, word3, word4, word5)
sendKeys(tm, "u") // undo
m := getFinalModel(t, tm)
expected := []string{"word1 word2 word3 word4 word5"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
})
t.Run("complex count with paste: 3p after 2yy", func(t *testing.T) {
tm := newTestModelWithLines(t, []string{"A", "B", "C", "D"})
sendKeyString(tm, "2yy") // yank 2 lines (A, B)
sendKeys(tm, "j", "j") // move to line C
sendKeyString(tm, "3p") // paste 3 times
sendKeys(tm, "u") // undo paste
m := getFinalModel(t, tm)
expected := []string{"A", "B", "C", "D"}
if !equalStringSlices(bufferLinesToStrings(m.ActiveBuffer()), expected) {
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
}
// Cursor should be back at line C (index 2)
if m.ActiveWindow().Cursor.Line != 2 {
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
}
})
}

View File

@ -125,8 +125,8 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "v", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ello" {
t.Errorf("Line(0) = %q, want \"ello\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ello" {
t.Errorf("Line(0) = %q, want \"ello\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -139,8 +139,8 @@ func TestVisualModeDelete(t *testing.T) {
sendKeys(tm, "v", "l", "l", "l", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "o world" {
t.Errorf("Line(0) = %q, want \"o world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "o world" {
t.Errorf("Line(0) = %q, want \"o world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -154,8 +154,8 @@ func TestVisualModeDelete(t *testing.T) {
// anchor=3, cursor=1 → normalized start=1, end=3 → delete "ell" → "ho"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 1 {
t.Errorf("CursorX() = %d, want 1", m.ActiveWindow().Cursor.Col)
@ -172,8 +172,8 @@ func TestVisualModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "held" {
t.Errorf("Line(0) = %q, want \"held\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "held" {
t.Errorf("Line(0) = %q, want \"held\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Col != 2 {
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
@ -192,8 +192,8 @@ func TestVisualModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "world" {
t.Errorf("Line(0) = %q, want \"world\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
@ -209,8 +209,8 @@ func TestVisualModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "testing" {
t.Errorf("Line(0) = %q, want \"testing\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "testing" {
t.Errorf("Line(0) = %q, want \"testing\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveWindow().Cursor.Line != 0 {
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
@ -227,8 +227,8 @@ func TestVisualModeDelete(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello" {
t.Errorf("Line(0) = %q, want \"hello\"", m.ActiveBuffer().Lines[0].String())
}
})
@ -241,11 +241,11 @@ func TestVisualModeDelete(t *testing.T) {
// "hello"[:0]+"hello"[2:] = "llo"
// "world"[:0]+"world"[2:] = "rld"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "llo" {
t.Errorf("Line(0) = %q, want \"llo\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "llo" {
t.Errorf("Line(0) = %q, want \"llo\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "rld" {
t.Errorf("Line(1) = %q, want \"rld\"", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "rld" {
t.Errorf("Line(1) = %q, want \"rld\"", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveWindow().Cursor.Col != 0 {
t.Errorf("CursorX() = %d, want 0", m.ActiveWindow().Cursor.Col)
@ -264,11 +264,11 @@ func TestVisualModeDelete(t *testing.T) {
// "hello"[:1]+"hello"[4:] = "h"+"o" = "ho"
// "world"[:1]+"world"[4:] = "w"+"d" = "wd"
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "ho" {
t.Errorf("Line(0) = %q, want \"ho\"", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "wd" {
t.Errorf("Line(1) = %q, want \"wd\"", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "wd" {
t.Errorf("Line(1) = %q, want \"wd\"", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -302,8 +302,8 @@ func TestVisualModeWordMotions(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes from 0 to 6 inclusive = "hello w", leaves "orld"
if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -333,8 +333,8 @@ func TestVisualModeWordMotions(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes "hello"
if m.ActiveBuffer().Lines[0] != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != " world" {
t.Errorf("Line(0) = %q, want ' world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -364,8 +364,8 @@ func TestVisualModeWordMotions(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes from "h" (0) to "w" (6) inclusive
if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -398,9 +398,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveWindow().Anchor.Col != 0 {
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
}
// $ moves past end of line
if m.ActiveWindow().Cursor.Col != 11 {
t.Errorf("CursorX() = %d, want 11", m.ActiveWindow().Cursor.Col)
if m.ActiveWindow().Cursor.Col != 10 {
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
}
})
@ -412,8 +411,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
sendKeys(tm, "v", "$", "d")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello " {
t.Errorf("Line(0) = %q, want 'hello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -442,8 +441,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
m := getFinalModel(t, tm)
// Deletes from 'h' (0) to 'w' (6) inclusive
if m.ActiveBuffer().Lines[0] != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "orld" {
t.Errorf("Line(0) = %q, want 'orld'", m.ActiveBuffer().Lines[0].String())
}
})
@ -493,8 +492,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0].String())
}
})
@ -527,8 +526,8 @@ func TestVisualModeJumpMotions(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "lin 3" {
t.Errorf("Line(0) = %q, want 'lin 3'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -564,8 +563,8 @@ func TestVisualLineModeJumpMotions(t *testing.T) {
if m.ActiveBuffer().LineCount() != 1 {
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "" {
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
}
})

View File

@ -46,14 +46,14 @@ func TestYankLineBasic(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 1" {
t.Errorf("Line(0) = %q, want 'line 1'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 2" {
t.Errorf("Line(1) = %q, want 'line 2'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2].String())
}
})
@ -583,8 +583,8 @@ func TestYankWithCharwiseMotions(t *testing.T) {
sendKeys(tm, "y", "w")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -659,8 +659,8 @@ func TestYankVisualCharwise(t *testing.T) {
sendKeys(tm, "v", "l", "l", "l", "l", "y")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello world" {
t.Errorf("Line(0) = %q, want 'hello world'", m.ActiveBuffer().Lines[0].String())
}
})
}
@ -904,8 +904,8 @@ func TestYankRegisterBehavior(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[1] != "to copy" {
t.Errorf("Line(1) = %q, want 'to copy'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "to copy" {
t.Errorf("Line(1) = %q, want 'to copy'", m.ActiveBuffer().Lines[1].String())
}
})
}
@ -1053,8 +1053,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
sendKeys(tm, "v", "l", "l", "l", "l", "y", "$", "p")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1067,8 +1067,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
sendKeys(tm, "v", "$", "y", "0", "P")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "worldhello world" {
t.Errorf("Line(0) = %q, want 'worldhello world'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "worldhello world" {
t.Errorf("Line(0) = %q, want 'worldhello world'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1084,8 +1084,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[2] != "line 1" {
t.Errorf("Line(2) = %q, want 'line 1'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line 1" {
t.Errorf("Line(2) = %q, want 'line 1'", m.ActiveBuffer().Lines[2].String())
}
})
@ -1101,11 +1101,11 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 6 {
t.Errorf("LineCount() = %d, want 6", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[4] != "line 1" {
t.Errorf("Line(4) = %q, want 'line 1'", m.ActiveBuffer().Lines[4])
if m.ActiveBuffer().Lines[4].String() != "line 1" {
t.Errorf("Line(4) = %q, want 'line 1'", m.ActiveBuffer().Lines[4].String())
}
if m.ActiveBuffer().Lines[5] != "line 2" {
t.Errorf("Line(5) = %q, want 'line 2'", m.ActiveBuffer().Lines[5])
if m.ActiveBuffer().Lines[5].String() != "line 2" {
t.Errorf("Line(5) = %q, want 'line 2'", m.ActiveBuffer().Lines[5].String())
}
})
@ -1121,11 +1121,11 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 4 {
t.Errorf("LineCount() = %d, want 4", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 3" {
t.Errorf("Line(0) = %q, want 'line 3'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1].String())
}
})
@ -1140,14 +1140,14 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "original" {
t.Errorf("Line(1) = %q, want 'original'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "original" {
t.Errorf("Line(1) = %q, want 'original'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2].String())
}
})
@ -1162,14 +1162,14 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "original" {
t.Errorf("Line(0) = %q, want 'original'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "other" {
t.Errorf("Line(1) = %q, want 'other'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "other" {
t.Errorf("Line(1) = %q, want 'other'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "other" {
t.Errorf("Line(2) = %q, want 'other'", m.ActiveBuffer().Lines[2].String())
}
})
@ -1182,8 +1182,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
sendKeys(tm, "y", "w", "$", "p")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello " {
t.Errorf("Line(0) = %q, want 'hello worldhello '", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello worldhello " {
t.Errorf("Line(0) = %q, want 'hello worldhello '", m.ActiveBuffer().Lines[0].String())
}
})
@ -1196,8 +1196,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
sendKeys(tm, "y", "e", "$", "p")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "hello worldhello" {
t.Errorf("Line(0) = %q, want 'hello worldhello'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1210,8 +1210,8 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
sendKeys(tm, "v", "l", "l", "y", "$", "p")
m := getFinalModel(t, tm)
if m.ActiveBuffer().Lines[0] != "abcdefghcde" {
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "abcdefghcde" {
t.Errorf("Line(0) = %q, want 'abcdefghcde'", m.ActiveBuffer().Lines[0].String())
}
})
@ -1246,14 +1246,14 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
if m.ActiveBuffer().LineCount() != 3 {
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
}
if m.ActiveBuffer().Lines[0] != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0])
if m.ActiveBuffer().Lines[0].String() != "line 2" {
t.Errorf("Line(0) = %q, want 'line 2'", m.ActiveBuffer().Lines[0].String())
}
if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line 3" {
t.Errorf("Line(2) = %q, want 'line 3'", m.ActiveBuffer().Lines[2].String())
}
})
@ -1270,17 +1270,17 @@ func TestVisualYankPasteRoundTrip(t *testing.T) {
t.Errorf("LineCount() = %d, want 7", m.ActiveBuffer().LineCount())
}
// Original + 2 copies of 2 lines = 3 + 4 = 7
if m.ActiveBuffer().Lines[1] != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1])
if m.ActiveBuffer().Lines[1].String() != "line 1" {
t.Errorf("Line(1) = %q, want 'line 1'", m.ActiveBuffer().Lines[1].String())
}
if m.ActiveBuffer().Lines[2] != "line 2" {
t.Errorf("Line(2) = %q, want 'line 2'", m.ActiveBuffer().Lines[2])
if m.ActiveBuffer().Lines[2].String() != "line 2" {
t.Errorf("Line(2) = %q, want 'line 2'", m.ActiveBuffer().Lines[2].String())
}
if m.ActiveBuffer().Lines[3] != "line 1" {
t.Errorf("Line(3) = %q, want 'line 1'", m.ActiveBuffer().Lines[3])
if m.ActiveBuffer().Lines[3].String() != "line 1" {
t.Errorf("Line(3) = %q, want 'line 1'", m.ActiveBuffer().Lines[3].String())
}
if m.ActiveBuffer().Lines[4] != "line 2" {
t.Errorf("Line(4) = %q, want 'line 2'", m.ActiveBuffer().Lines[4])
if m.ActiveBuffer().Lines[4].String() != "line 2" {
t.Errorf("Line(4) = %q, want 'line 2'", m.ActiveBuffer().Lines[4].String())
}
})
}

View File

@ -37,9 +37,11 @@ type Model struct {
lastFind core.LastFindCommand
// Command line state
command string
commandCursor int
commandOutput *core.CommandOutput
command string
commandCursor int
commandOutput *core.CommandOutput
commandHistory []string
commandHistoryCursor int
// Global settings
settings core.EditorSettings
@ -49,6 +51,9 @@ type Model struct {
// Visual styles
styles style.Styles
// Dot operator state
lastChangeKeys []string
}
// Model.Init: Initialize the model and start any commands that may need to run. Required
@ -118,6 +123,25 @@ func (m *Model) GetLastFind() *core.LastFindCommand {
return &m.lastFind
}
// Does update the '.' register
func (m *Model) SetLastChangeKeys(keys []string) {
m.lastChangeKeys = keys
m.SetRegister('.', core.CharwiseRegister, []string{strings.Join(keys, "")})
}
func (m *Model) LastChangeKeys() []string {
return m.lastChangeKeys
}
func (m *Model) ClearLastChangeKeys() {
m.lastChangeKeys = []string{}
}
func (m *Model) HandleKey(key string) tea.Cmd {
return m.input.Handle(m, key)
}
func (m *Model) ExitInsertMode() {
win := m.ActiveWindow()
if m.insertCount > 1 {
@ -279,6 +303,22 @@ func (m *Model) SetCommandOutput(out *core.CommandOutput) {
m.commandOutput = out
}
func (m *Model) CommandHistory() []string {
return m.commandHistory
}
func (m *Model) SetCommandHistory(history []string) {
m.commandHistory = history
}
func (m *Model) CommandHistoryCursor() int {
return m.commandHistoryCursor
}
func (m *Model) SetCommandHistoryCursor(cur int) {
m.commandHistoryCursor = cur
}
// ==================================================
// Editor-wide State
// ==================================================

View File

@ -4,13 +4,17 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/alecthomas/chroma/v2/styles"
)
type ModelBuilder struct {
model Model
}
// NewModelBuilder: Builds and returns a new model, using the default color scheme (kanagawa-wave).
func NewModelBuilder() *ModelBuilder {
chromaStyle := styles.Get("kanagawa-wave")
return &ModelBuilder{
model: Model{
buffers: []*core.Buffer{},
@ -28,7 +32,7 @@ func NewModelBuilder() *ModelBuilder {
commandOutput: nil,
settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(),
styles: style.DefaultStyles(),
styles: style.ChromaStyles(chromaStyle),
},
}
}

View File

@ -2,6 +2,7 @@ package editor
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/motion"
tea "github.com/charmbracelet/bubbletea"
)
@ -49,6 +50,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@ -60,9 +72,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: Any vim action should exit also
// Simple override for command output mode for now
if m.Mode() == core.CommandOutputMode {
if msg.Type == tea.KeyEnter {
// 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())

View File

@ -2,6 +2,7 @@ package editor
import (
"fmt"
"strconv"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
@ -22,6 +23,10 @@ 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())
@ -33,7 +38,7 @@ func (m Model) View() string {
// TODO: This is not idea, but it works for now
cmd := m.CommandOutput()
if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 {
view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth)
view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth, m.termHeight)
}
return view
@ -49,17 +54,23 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo
start := w.ScrollY
end := w.ScrollY + w.ViewportHeight()
// Chroma stuff
lexer := style.GetLexer(buf)
// Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() {
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum)
styleMap := styles.MakeStyleMap(lexer, buf.Line(lineNum))
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap)
view.WriteString(line)
} else {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), w.Width))
}
view.WriteRune('\n')
}
// Draw status line
statusBar := drawStatusBar(w, mode)
statusBar := drawStatusBar(w, mode, styles)
view.WriteString(statusBar + "\n")
return view.String()
@ -67,13 +78,9 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo
// drawLine: Renders a single line with syntax highlighting, cursor, and visual selection.
// Handles gutter, cursor rendering, and visual mode highlighting.
func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int) string {
runes := []rune(line)
curStyle := styles.CursorStyle(mode)
visStyle := styles.VisualHighlight
func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int, styleMap []lipgloss.Style) string {
var view strings.Builder
runes := []rune(line)
// Draw gutter first
gutter := drawGutter(w, styles, options, lineNumber)
@ -84,24 +91,33 @@ func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode
// Current char is cursor
if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
if col < len(runes) {
view.WriteString(curStyle.Render(string(runes[col])))
cur := styles.CursorStyle(mode, styleMap[col])
view.WriteString(cur.Render(string(runes[col])))
} else {
view.WriteString(curStyle.Render(" "))
view.WriteString(styles.DefaultCursorStyle(mode).Render(" "))
}
// Not cursor, but not end
} else if col < len(runes) {
s := styleMap[col]
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(string(runes[col])))
vis := styles.VisualHighlightWithTextColor(s)
view.WriteString(vis.Render(string(runes[col])))
} else {
view.WriteRune(runes[col])
view.WriteString(s.Render(string(runes[col])))
}
// Allow highlight on blank lines or chars
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
view.WriteString(visStyle.Render(" "))
view.WriteString(styles.VisualHighlight.Render(" "))
}
}
// Pad remainder of line to window width with background color
dif := w.Width - lipgloss.Width(view.String())
if dif > 0 {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), dif))
}
return view.String()
}
@ -153,9 +169,9 @@ func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, cu
// drawStatusBar: Renders the status bar with mode and cursor position,
// padding the middle with spaces to fill the terminal width.
func drawStatusBar(w *core.Window, mode core.Mode) string {
left := leftBar(w, mode)
right := rightBar(w, mode)
func drawStatusBar(w *core.Window, mode core.Mode, styles style.Styles) string {
left := leftBar(w, mode, styles)
right := rightBar(w, mode, styles)
diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right))
@ -164,12 +180,12 @@ func drawStatusBar(w *core.Window, mode core.Mode) string {
return ""
}
middle := strings.Repeat(" ", diff)
middle := strings.Repeat(styles.BackgroundStyle.Render(" "), diff)
return left + middle + right
}
// leftBar: Returns the left side of the status bar showing the current mode.
func leftBar(w *core.Window, mode core.Mode) string {
func leftBar(w *core.Window, mode core.Mode, styles style.Styles) string {
buf := w.Buffer
var flags []string
@ -185,12 +201,13 @@ func leftBar(w *core.Window, mode core.Mode) string {
flagStr = "(" + strings.Join(flags, "") + ")"
}
return fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr)
bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr)
return styles.LineStyle.Render(bar)
}
// rightBar: Returns the right side of the status bar showing cursor position
// and selection count in visual mode.
func rightBar(w *core.Window, mode core.Mode) (bar string) {
func rightBar(w *core.Window, mode core.Mode, styles style.Styles) (bar string) {
if mode.IsVisualMode() {
lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1
bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount)
@ -198,41 +215,44 @@ func rightBar(w *core.Window, mode core.Mode) (bar string) {
bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1)
}
buf := w.Buffer
bar = fmt.Sprintf("%s %s", buf.Filetype, bar)
bar = styles.LineStyle.Render(fmt.Sprintf("%s %s", buf.Filetype, bar))
return
}
// drawCommandBar: Renders the command line showing command input, errors, or
// output depending on the current mode and state.
func drawCommandBar(m Model) string {
styles := m.Styles()
// Compute left bar (command side)
var leftBar string
if m.Mode() == core.CommandMode {
leftBar = ":"
leftBar = styles.LineStyle.Render(":")
cmd := []rune(m.Command())
cur := m.CommandCursor()
for i, r := range cmd {
if i == cur {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(string(r))
leftBar += styles.DefaultCursorStyle(m.Mode()).Render(string(r))
} else {
leftBar += string(r)
leftBar += styles.LineStyle.Render(string(r))
}
}
// Cursor at end of command
if cur >= len(cmd) {
leftBar += m.Styles().CursorStyle(m.Mode()).Render(" ")
leftBar += styles.DefaultCursorStyle(m.Mode()).Render(" ")
}
// bar = fmt.Sprintf("%s %d", bar, cur)
} else if out := m.CommandOutput(); out != nil && len(out.Lines) > 0 && out.Inline {
// TODO: This is not perfect, temporary
text := strings.Join(out.Lines, " ")
if out.IsError {
leftBar = m.Styles().CommandError.Render(text)
leftBar = styles.CommandError.Render(text)
} else {
leftBar = text
leftBar = styles.LineStyle.Render(text)
}
} else if strings.TrimSpace(m.Command()) != "" {
leftBar = fmt.Sprintf(":%s", m.Command())
content := fmt.Sprintf(":%s", m.Command())
leftBar = styles.LineStyle.Render(content) //
}
// Compute right bar
@ -240,12 +260,13 @@ func drawCommandBar(m Model) string {
var rightBar string
if len(m.input.Pending()) > 0 {
width := 10 // Size of the block to display
rightBar = fmt.Sprintf("%-*s", width, m.input.Pending())
content := fmt.Sprintf("%-*s", width, m.input.Pending())
rightBar = styles.LineStyle.Render(content)
}
dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar))
bar := leftBar + strings.Repeat(" ", max(0, dif)) + rightBar
bar := leftBar + strings.Repeat(styles.BackgroundStyle.Render(" "), max(0, dif)) + rightBar
return bar
}
@ -296,7 +317,7 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
// overlayCommandOutputWindow: Draw the overlay of the command output window. This will override
// (overlay) the displayed content, so it should be used only when needed.
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int) string {
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int, termHeight int) string {
// Safety check
if cmd == nil {
return view
@ -310,17 +331,30 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
overlay = append(overlay, styles.CommandOutputBorder.Render(strings.Repeat(" ", termWidth)))
if strings.TrimSpace(cmd.Title) != "" {
overlay = append(overlay, cmd.Title)
title := styles.LineStyle.Render(cmd.Title)
overlay = append(overlay, title)
}
for _, l := range cmd.Lines {
overlay = append(overlay, strings.ReplaceAll(l, "\n", "\\n"))
viewLines := cmd.Viewport(termHeight)
for _, l := range viewLines {
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
overlay = append(overlay, content)
}
overlay = append(overlay, styles.CommandContinueMessage.Render(core.CommandOutputExitMessage))
msg := core.CommandOutputExitMessage
if len(cmd.Lines) > len(cmd.Viewport(termHeight)) {
msg += ". " + core.CommandOutputScrollMessage
}
overlay = append(overlay, styles.CommandContinueMessage.Render(msg))
// NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(),
// which would cause Lipgloss to embed newlines internally and corrupt the line count.
// If block-level styles are ever added, this approach must be replaced.
// Add background color to end of each line
for i, l := range overlay {
dif := termWidth - lipgloss.Width(l)
overlay[i] += styles.BackgroundStyle.Render(strings.Repeat(" ", dif))
}
// Remove 'h' lines from back of view and append overlay
h := len(overlay)
final := lines[:max(0, len(lines)-h)]

View File

@ -1,8 +1,11 @@
package input
import (
"slices"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/operator"
tea "github.com/charmbracelet/bubbletea"
)
@ -14,7 +17,8 @@ const (
StateCount
StateOperatorPending
StateMotionCount
StateWaitingForChar // Waiting for character argument (f/t/F/T)
StateWaitingForChar // Waiting for character argument (f/t/F/T)
StateWaitingForTextObject // Waiting for text object (after i/a)
)
// Handler: Manages input processing with a state machine for vim-style commands.
@ -28,11 +32,16 @@ type Handler struct {
buffer string // for display (what user has typed)
pending string // partial key sequence (e.g., "g" waiting for second key)
charMotionType string // which char motion is waiting: "f", "t", "F", or "T"
modifier string // which modifier used for text object: "i" or "a"
// Dot operator - accumulate keys for current operation
recordingKeys []string
// Keymaps
normalKeymap *Keymap
visualKeymap *Keymap
insertKeymap *Keymap
replaceKeymap *Keymap
commandKeymap *Keymap
currentKeymap *Keymap
@ -45,6 +54,7 @@ func NewHandler() *Handler {
normalKeymap: NewNormalKeymap(),
visualKeymap: NewVisualKeymap(),
insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(),
currentKeymap: nil,
}
@ -53,10 +63,29 @@ func NewHandler() *Handler {
// Handler.Handle: Main entry point for processing a keypress. Routes to appropriate
// handler based on current mode and state.
func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
ignoreKeys := []string{".", "u", "ctrl+r"}
// Record key for dot operator (except in insert/command mode which handle separately)
if m.Mode() != core.InsertMode && m.Mode() != core.CommandMode && !slices.Contains(ignoreKeys, key) {
h.recordingKeys = append(h.recordingKeys, key)
}
// ESC always resets everything
if key == "esc" {
h.Reset()
// If insert mode, keep the escape
if m.Mode() == core.InsertMode {
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
}
h.recordingKeys = []string{} // Clear recording on ESC
h.Reset()
if m.Mode() == core.InsertMode || m.Mode() == core.ReplaceMode {
// Before exiting insert mode, end the block in the undo stack
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
m.ExitInsertMode()
} else {
m.SetMode(core.NormalMode)
@ -68,6 +97,8 @@ 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)
}
@ -77,6 +108,22 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
return h.handleCharMotion(m, key)
}
if h.state == StateWaitingForTextObject {
return h.handleTextObjectKey(m, key)
}
// i/a after operator or in visual mode = text object modifier
if (key == "i" || key == "a") && h.pending == "" {
if h.state == StateOperatorPending ||
h.state == StateMotionCount ||
m.Mode().IsVisualMode() {
h.modifier = key
h.state = StateWaitingForTextObject
h.buffer += key
return nil
}
}
// Try to accumulate count (only if no pending sequence)
if h.pending == "" && h.tryAccumulateCount(key) {
return nil
@ -129,6 +176,9 @@ 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
@ -139,6 +189,8 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string)
return h.handleInitial(m, kind, binding, key)
case StateOperatorPending, StateMotionCount:
return h.handleAfterOperator(m, kind, binding, key)
case StateWaitingForTextObject:
return h.handleTextObject(m, kind, binding, key)
}
h.Reset()
return nil
@ -160,7 +212,13 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if res, ok := mot.(action.Resolvable); ok {
mot = res.Resolve(m)
}
cmd := mot.Execute(m)
cmd := h.executeMotion(m, mot)
// Only clear recording for pure motions in normal mode
// In visual mode, motions are part of building the selection
if !m.Mode().IsVisualMode() {
h.recordingKeys = []string{}
}
h.Reset()
return cmd
@ -174,12 +232,12 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if m.Mode() == core.VisualLineMode {
mtype = core.Linewise
}
cmd := op.Operate(m, start, end, mtype)
cmd := h.executeOperator(m, op, start, end, mtype)
// Only reset to normal mode if operator didn't enter insert mode
if m.Mode() != core.InsertMode {
m.SetMode(core.NormalMode)
}
h.Reset()
h.RecordAndReset(m)
return cmd
}
// In normal mode, wait for a motion to define the range
@ -193,8 +251,13 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
if r, ok := act.(action.Repeatable); ok {
act = r.WithCount(count)
}
cmd := act.Execute(m)
h.Reset()
cmd := h.executeAction(m, act)
// Only record if we're not entering visual mode (visual ops record when they complete)
if m.Mode().IsVisualMode() {
h.Reset() // In visual mode now, don't save yet
} else {
h.RecordAndReset(m)
}
return cmd
}
@ -212,14 +275,19 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
if kind == "operator" && key == h.operatorKey {
// Only call DoublePress if the operator supports it
if dp, ok := h.operator.(action.DoublePresser); ok {
cmd := dp.DoublePress(m, count)
h.Reset()
cmd := h.executeDoublePress(m, dp, count)
h.RecordAndReset(m)
return cmd
}
h.Reset()
return nil
}
// Do not quit when we see a/i (allow for text objects)
if kind == "modifier" {
return nil
}
// Motion after operator
if kind == "motion" {
mot := binding.(action.Motion)
@ -233,10 +301,10 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
}
// Get range and motion type
start := win.Cursor
mot.Execute(m)
h.executeMotion(m, mot)
end := win.Cursor
cmd := h.operator.Operate(m, start, end, mot.Type())
h.Reset()
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.RecordAndReset(m)
return cmd
}
@ -301,26 +369,91 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
// Apply count if supported
if r, ok := mot.(action.Repeatable); ok {
mot = r.WithCount(count).(action.Motion)
result := r.WithCount(count)
// WithCount returns Action, but char motions still implement Motion
if m, ok := result.(action.Motion); ok {
mot = m
}
}
// If operator pending (e.g., "df{char}"), get range and operate
if h.operator != nil {
win := m.ActiveWindow()
start := win.Cursor
mot.Execute(m)
h.executeMotion(m, mot)
end := win.Cursor
cmd := h.operator.Operate(m, start, end, mot.Type())
h.Reset()
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
h.RecordAndReset(m)
return cmd
}
// Otherwise just execute the motion
cmd := mot.Execute(m)
h.Reset()
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()
}
return cmd
}
// Handler.handleTextObject: Handles input when waiting for text object after i/a.
// Processes text objects like 'w', ')', '"', etc. and applies pending operator if any.
func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key string) tea.Cmd {
// Not sure what count is fore
// count := h.effectiveCount()
if kind != "text_object" {
// Invalid - expected a text object
h.Reset()
return nil
}
textObj := binding.(action.TextObject)
win := m.ActiveWindow()
// Calculate the region
start, end, mtype := textObj.GetRange(m, win.Cursor, h.modifier)
// If we have an operator pending (e.g., "diw")
if h.operator != nil {
cmd := h.executeOperator(m, h.operator, start, end, mtype)
h.RecordAndReset(m)
return cmd
}
// In visual mode (e.g., "viw")
if m.Mode().IsVisualMode() {
// Set anchor and cursor to define the selection
win.Anchor = start
win.Cursor = end
h.Reset()
return nil
}
// Shouldn't reach here - text object without operator or visual mode
h.Reset()
return nil
}
// Handler.handleTextObjectKey: Handles the key press when waiting for a text object.
// Looks up the text object directly (bypassing normal motion lookup).
func (h *Handler) handleTextObjectKey(m action.Model, key string) tea.Cmd {
// Look up text object directly
textObj, ok := h.currentKeymap.textObjects[key]
if !ok {
// Not a valid text object
h.Reset()
return nil
}
// Call the existing handleTextObject with the found text object
return h.handleTextObject(m, "text_object", textObj, key)
}
// Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns
// true if successful, false if the key is not a digit or is an invalid count.
func (h *Handler) tryAccumulateCount(key string) bool {
@ -370,6 +503,7 @@ func (h *Handler) effectiveCount() int {
}
// Handler.Reset: Clears all handler state including counts, operators, and buffers.
// Does NOT clear recordingKeys - those accumulate across an operation.
func (h *Handler) Reset() {
h.state = StateReady
h.count1 = 0
@ -379,6 +513,29 @@ func (h *Handler) Reset() {
h.buffer = ""
h.pending = ""
h.charMotionType = ""
h.modifier = ""
// NOTE: recordingKeys is NOT cleared here - it accumulates across the operation
}
func (h *Handler) RecordAndReset(m action.Model) {
// Save the recorded keys to the model for dot operator
// Filter out mode-switch keys that don't modify the buffer
ignoreStates := []string{":", "v", "V", "."}
if len(h.recordingKeys) > 0 {
// Check if the entire sequence is just a mode switch
shouldRecord := true
if len(h.recordingKeys) == 1 && slices.Contains(ignoreStates, h.recordingKeys[0]) {
shouldRecord = false
}
if shouldRecord {
m.SetLastChangeKeys(h.recordingKeys)
}
}
h.recordingKeys = []string{} // Clear recording after saving
h.Reset()
}
// Handler.Pending: Returns the accumulated input buffer for display.
@ -388,9 +545,21 @@ func (h *Handler) Pending() string {
// Handler.handleInsertKey: Processes a keypress in insert mode, recording it
// for count replay and executing it as an action or character insertion.
//
// This function does not make use of the execute abstractions, to prevent each
// key inserted from creating a new block in the undo stack.
func (h *Handler) handleInsertKey(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.insertKeymap.Lookup(key)
@ -405,8 +574,35 @@ 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.
// it as an action or inserting it into the command line. This does not record
// anything into the undo stack.
func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
kind, binding := h.commandKeymap.Lookup(key)
switch kind {
@ -431,3 +627,83 @@ func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {
}
return c, a
}
func (h *Handler) executeAction(m action.Model, act action.Action) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
cmd := act.Execute(m)
// If the action one that includes insert mode, we should not end the block, we want to
// include the text from the insert mode in the block.
_, O := act.(action.OpenLineAbove)
_, o := act.(action.OpenLineBelow)
_, s := act.(action.SubstituteChar)
_, S := act.(action.SubstituteLine)
_, C := act.(action.ChangeToEndOfLine)
if o || O || s || S || C {
return nil
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return cmd
}
func (h *Handler) executeMotion(m action.Model, mot action.Motion) tea.Cmd {
// These do not change the buffer, so no need to record anything
return mot.Execute(m)
}
func (h *Handler) executeOperator(m action.Model, op action.Operator, start, end core.Position, mtype core.MotionType) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
cmd := op.Operate(m, start, end, mtype)
// If operator is one that enters insert mode, we do not want to end the block.
_, c := op.(operator.ChangeOperator)
if c {
return cmd
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return cmd
}
func (h *Handler) executeDoublePress(m action.Model, dp action.DoublePresser, count int) tea.Cmd {
buf := m.ActiveBuffer()
win := m.ActiveWindow()
if buf.UndoStack != nil {
buf.UndoStack.BeginBlock(win.Cursor)
}
cmd := dp.DoublePress(m, count)
// If operator being double pressed is one that enters insert mode, we do not
// want to end the block.
_, c := dp.(operator.ChangeOperator)
if c {
return cmd
}
if buf.UndoStack != nil {
buf.UndoStack.EndBlock(win.Cursor)
}
return cmd
}

View File

@ -5,14 +5,17 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/command"
"git.gophernest.net/azpect/TextEditor/internal/motion"
"git.gophernest.net/azpect/TextEditor/internal/operator"
"git.gophernest.net/azpect/TextEditor/internal/textobject"
)
// Keymap: Maps key sequences to motions, operators, and actions.
type Keymap struct {
motions map[string]action.Motion
operators map[string]action.Operator
actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
charMotions map[string]action.Motion // motions that need character argument: f/t/F/T
actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
charMotions map[string]action.Motion // motions that need character argument: f/t/F/T
modifiers map[string]any // modifiers for text objects: i/a
textObjects map[string]action.TextObject // motions that need text objects: i.e., 'viw'
}
// NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings.
@ -35,10 +38,15 @@ func NewNormalKeymap() *Keymap {
"e": motion.MoveForwardWordEnd{Count: 1},
"E": motion.MoveForwardWORDEnd{Count: 1},
"b": motion.MoveBackwardWord{Count: 1},
"ctrl+u": motion.ScrollUpHalfPage{},
"ctrl+d": motion.ScrollDownHalfPage{},
"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},
",": action.RepeatFind{Count: 1, Reverse: true},
},
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
@ -55,6 +63,7 @@ func NewNormalKeymap() *Keymap {
"o": action.OpenLineBelow{},
"O": action.OpenLineAbove{},
"x": action.DeleteChar{Count: 1},
"X": action.DeletePrevChar{Count: 1},
":": action.EnterComandMode{},
"v": action.EnterVisualMode{},
"V": action.EnterVisualLineMode{},
@ -65,12 +74,39 @@ func NewNormalKeymap() *Keymap {
"S": action.SubstituteLine{Count: 1},
"p": action.Paste{Count: 1},
"P": action.PasteBefore{Count: 1},
"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,
"a": nil,
},
textObjects: map[string]action.TextObject{
"w": textobject.Word{},
"W": textobject.WORD{},
// TODO: 's' and 'p'
"{": textobject.Delimiter{Char: '{'},
"}": textobject.Delimiter{Char: '}'},
"(": textobject.Delimiter{Char: '('},
")": textobject.Delimiter{Char: ')'},
"[": textobject.Delimiter{Char: '['},
"]": textobject.Delimiter{Char: ']'},
"<": textobject.Delimiter{Char: '<'},
">": textobject.Delimiter{Char: '>'},
"\"": textobject.Delimiter{Char: '"'},
"'": textobject.Delimiter{Char: '\''},
"`": textobject.Delimiter{Char: '`'},
"b": textobject.Delimiter{Char: '('},
"B": textobject.Delimiter{Char: '{'},
},
}
}
@ -79,31 +115,46 @@ 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},
"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},
// TODO: O and o. These are fun ones! Should be simple too
},
operators: map[string]action.Operator{
"d": operator.DeleteOperator{},
"x": operator.DeleteOperator{},
"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},
"p": action.VisualPaste{Count: 1, Replace: true},
"P": action.VisualPaste{Count: 1, Replace: false},
".": action.Repeat{Count: 1},
// ":": action.EnterComandMode{}, // Different OP
},
charMotions: map[string]action.Motion{
@ -112,6 +163,28 @@ func NewVisualKeymap() *Keymap {
"t": action.FindChar{Forward: true, Inclusive: false},
"T": action.FindChar{Forward: false, Inclusive: false},
},
modifiers: map[string]any{
"i": nil,
"a": nil,
},
textObjects: map[string]action.TextObject{
"w": textobject.Word{},
"W": textobject.WORD{},
// TODO: 's' and 'p'
"{": textobject.Delimiter{Char: '{'},
"}": textobject.Delimiter{Char: '}'},
"(": textobject.Delimiter{Char: '('},
")": textobject.Delimiter{Char: ')'},
"[": textobject.Delimiter{Char: '['},
"]": textobject.Delimiter{Char: ']'},
"<": textobject.Delimiter{Char: '<'},
">": textobject.Delimiter{Char: '>'},
"\"": textobject.Delimiter{Char: '"'},
"'": textobject.Delimiter{Char: '\''},
"`": textobject.Delimiter{Char: '`'},
"b": textobject.Delimiter{Char: '('},
"B": textobject.Delimiter{Char: '{'},
},
}
}
@ -133,7 +206,26 @@ 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.
@ -142,6 +234,8 @@ func NewCommandKeymap() *Keymap {
motions: map[string]action.Motion{
"left": motion.MoveCommandLeft{},
"right": motion.MoveCommandRight{},
"up": motion.MoveCommandHistoryUp{},
"down": motion.MoveCommandHistoryDown{},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
@ -169,6 +263,12 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
if cm, ok := km.charMotions[key]; ok {
return "char_motion", cm
}
if mo, ok := km.modifiers[key]; ok {
return "modifier", mo
}
if to, ok := km.textObjects[key]; ok {
return "text_object", to
}
return "", nil
}
@ -194,6 +294,16 @@ func (km *Keymap) HasPrefix(prefix string) bool {
return true
}
}
for key := range km.modifiers {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
for key := range km.textObjects {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
return true
}
}
return false
}

View File

@ -80,7 +80,7 @@ type MoveRight struct {
func (a MoveRight) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
lineLen := len(buf.Lines[win.Cursor.Line])
lineLen := buf.Lines[win.Cursor.Line].Len()
for i := 0; i < a.Count && win.Cursor.Col <= lineLen; i++ {
win.SetCursorCol(win.Cursor.Col + 1)
}

View File

@ -31,3 +31,54 @@ func (a MoveCommandRight) Execute(m action.Model) tea.Cmd {
// MoveCommandRight.Type: Returns CharwiseExclusive for command line motion.
func (a MoveCommandRight) Type() core.MotionType { return core.CharwiseExclusive }
// ==================================================
// Command History Motions
// ==================================================
// MoveCommandHistoryUp implements Motion - moves cursor right in command line.
type MoveCommandHistoryUp struct{}
// MoveCommandHistoryUp.Execute: Moves the command history cursor up (if possible).
func (a MoveCommandHistoryUp) Execute(m action.Model) tea.Cmd {
cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1)
history := m.CommandHistory()
if cur < len(history) {
cmd := history[cur]
m.SetCommand(cmd)
m.SetCommandCursor(len(cmd))
// Only go up if we can
m.SetCommandHistoryCursor(cur + 1)
}
return nil
}
// MoveCommandHistoryUp.Type: Returns Linewise for command line motion.
func (a MoveCommandHistoryUp) Type() core.MotionType { return core.Linewise }
// MoveCommandHistoryDown implements Motion - moves cursor right in command line.
type MoveCommandHistoryDown struct{}
// MoveCommandHistoryDown.Execute: Moves the command history cursor down (if possible).
func (a MoveCommandHistoryDown) Execute(m action.Model) tea.Cmd {
cur := m.CommandHistoryCursor() // always +1 (bascially indexed at 1)
history := m.CommandHistory()
if cur > 1 {
cmd := history[cur-2]
m.SetCommand(cmd)
m.SetCommandCursor(len(cmd))
} else {
m.SetCommand("") // BUG: We should probably keep the original in state
m.SetCommandCursor(0)
}
m.SetCommandHistoryCursor(max(0, cur-1))
return nil
}
// MoveCommandHistoryDown.Type: Returns Linewise for command line motion.
func (a MoveCommandHistoryDown) Type() core.MotionType { return core.Linewise }

View File

@ -0,0 +1,376 @@
package motion
import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// ==================================================
// MoveCommandHistoryUp Tests
// ==================================================
func TestMoveCommandHistoryUp(t *testing.T) {
t.Run("navigate up from start", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Should load first command in history
if m.Command() != "cmd3" {
t.Errorf("Command = %q, want %q", m.Command(), "cmd3")
}
// Cursor should move to end of command
if m.CommandCursor() != len("cmd3") {
t.Errorf("CommandCursor = %d, want %d", m.CommandCursor(), len("cmd3"))
}
// History cursor should advance
if m.CommandHistoryCursor() != 1 {
t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor())
}
})
t.Run("navigate up multiple times", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
// First up
action.Execute(m)
if m.Command() != "cmd3" {
t.Errorf("After 1st up: Command = %q, want 'cmd3'", m.Command())
}
// Second up
action.Execute(m)
if m.Command() != "cmd2" {
t.Errorf("After 2nd up: Command = %q, want 'cmd2'", m.Command())
}
// Third up
action.Execute(m)
if m.Command() != "cmd1" {
t.Errorf("After 3rd up: Command = %q, want 'cmd1'", m.Command())
}
if m.CommandHistoryCursor() != 3 {
t.Errorf("CommandHistoryCursor = %d, want 3", m.CommandHistoryCursor())
}
})
t.Run("cannot navigate past end of history", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 3, // Already at end
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Should not change command (still showing cmd1)
if m.Command() != "" {
t.Errorf("Command = %q, want empty (should not go past end)", m.Command())
}
// Cursor should not advance
if m.CommandHistoryCursor() != 3 {
t.Errorf("CommandHistoryCursor = %d, want 3 (should not advance)", m.CommandHistoryCursor())
}
})
t.Run("empty history does nothing", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "current",
CommandHistoryList: []string{},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
// Command should not change
if m.Command() != "current" {
t.Errorf("Command = %q, want 'current' (no history to navigate)", m.Command())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("command cursor moves to end", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandCursorVal: 0,
CommandHistoryList: []string{"long command here"},
CommandHistoryCur: 0,
}
action := MoveCommandHistoryUp{}
action.Execute(m)
expectedLen := len("long command here")
if m.CommandCursor() != expectedLen {
t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen)
}
})
}
// ==================================================
// MoveCommandHistoryDown Tests
// ==================================================
func TestMoveCommandHistoryDown(t *testing.T) {
t.Run("navigate down from middle of history", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "cmd2",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 2, // At cmd2
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should load cmd3 (more recent)
if m.Command() != "cmd3" {
t.Errorf("Command = %q, want 'cmd3'", m.Command())
}
// Cursor should be at 1
if m.CommandHistoryCursor() != 1 {
t.Errorf("CommandHistoryCursor = %d, want 1", m.CommandHistoryCursor())
}
})
t.Run("navigate down to empty command", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "cmd3",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 1, // At first command
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should clear command (back to current input)
if m.Command() != "" {
t.Errorf("Command = %q, want empty (back to current)", m.Command())
}
if m.CommandCursor() != 0 {
t.Errorf("CommandCursor = %d, want 0", m.CommandCursor())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("navigate down from cursor 0 does nothing", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0, // Already at bottom
}
action := MoveCommandHistoryDown{}
action.Execute(m)
// Should clear command
if m.Command() != "" {
t.Errorf("Command = %q, want empty", m.Command())
}
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0", m.CommandHistoryCursor())
}
})
t.Run("command cursor moves to end", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandCursorVal: 0,
CommandHistoryList: []string{"cmd3", "long command here"},
CommandHistoryCur: 2,
}
action := MoveCommandHistoryDown{}
action.Execute(m)
expectedLen := len("cmd3")
if m.CommandCursor() != expectedLen {
t.Errorf("CommandCursor = %d, want %d (end of command)", m.CommandCursor(), expectedLen)
}
})
}
// ==================================================
// Integration Tests
// ==================================================
func TestCommandHistoryIntegration(t *testing.T) {
t.Run("up down up sequence", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"},
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
downAction := MoveCommandHistoryDown{}
// Up twice
upAction.Execute(m) // cursor=1, cmd=cmd3
upAction.Execute(m) // cursor=2, cmd=cmd2
// Down once
downAction.Execute(m) // cursor=1, cmd=cmd3
// Up again
upAction.Execute(m) // cursor=2, cmd=cmd2
if m.Command() != "cmd2" {
t.Errorf("Command = %q, want 'cmd2'", m.Command())
}
if m.CommandHistoryCursor() != 2 {
t.Errorf("CommandHistoryCursor = %d, want 2", m.CommandHistoryCursor())
}
})
t.Run("navigate up past end does not crash", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd1", "cmd2"},
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate to end
upAction.Execute(m) // cursor = 1, cmd = cmd1
upAction.Execute(m) // cursor = 2, cmd = cmd2
// Try to go past end
upAction.Execute(m) // Should do nothing
if m.CommandHistoryCursor() != 2 {
t.Errorf("CommandHistoryCursor = %d, want 2 (should stay at end)", m.CommandHistoryCursor())
}
})
t.Run("navigate down past bottom does not crash", func(t *testing.T) {
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: []string{"cmd1"},
CommandHistoryCur: 0,
}
downAction := MoveCommandHistoryDown{}
// Try to go down from bottom
downAction.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor())
}
// Try again
downAction.Execute(m)
if m.CommandHistoryCursor() != 0 {
t.Errorf("CommandHistoryCursor = %d, want 0 (should stay at bottom)", m.CommandHistoryCursor())
}
})
}
// ==================================================
// Long History Tests
// ==================================================
func TestCommandHistoryWithLongHistory(t *testing.T) {
t.Run("navigate through 20 commands", func(t *testing.T) {
history := make([]string, 20)
for i := 0; i < 20; i++ {
history[i] = string(rune('A' + i))
}
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: history,
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate to 10th command
for i := 0; i < 10; i++ {
upAction.Execute(m)
}
want := string(rune('A' + 9))
if m.Command() != want {
t.Errorf("Command after 10 ups = %q, want %q", m.Command(), want)
}
if m.CommandHistoryCursor() != 10 {
t.Errorf("CommandHistoryCursor = %d, want 10", m.CommandHistoryCursor())
}
})
t.Run("navigate to very end of long history", func(t *testing.T) {
history := make([]string, 50)
for i := 0; i < 50; i++ {
history[i] = string(rune('0' + (i % 10)))
}
m := &action.MockModel{
ModeVal: core.CommandMode,
CommandVal: "",
CommandHistoryList: history,
CommandHistoryCur: 0,
}
upAction := MoveCommandHistoryUp{}
// Navigate all the way to the end
for i := 0; i < 100; i++ { // Try to go past end
upAction.Execute(m)
}
// Should be at position 50 (end of 50-item array)
if m.CommandHistoryCursor() != 50 {
t.Errorf("CommandHistoryCursor = %d, want 50 (at end)", m.CommandHistoryCursor())
}
// Should be showing last command
want := string(rune('0' + 49%10))
if m.Command() != want {
t.Errorf("Command = %q, want %q (last in history)", m.Command(), want)
}
})
}

View File

@ -50,7 +50,7 @@ type MoveToLineEnd struct{}
func (a MoveToLineEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
win.SetCursorCol(len(buf.Lines[win.Cursor.Line]))
win.SetCursorCol(buf.Lines[win.Cursor.Line].Len() - 1)
return nil
}
@ -65,7 +65,7 @@ func (a MoveToLineContentStart) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
x := 0
for x < len(line) {
ch := line[x]
@ -96,7 +96,7 @@ func (a MoveToColumn) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
line := buf.Lines[win.Cursor.Line]
line := buf.Line(win.Cursor.Line)
col := min(a.Count-1, len(line)-1)
win.SetCursorCol(col)
@ -111,21 +111,23 @@ func (a MoveToColumn) WithCount(n int) action.Action {
// TODO: Count for these, maybe?
// ScrollDownHalfPage implements Motion (ctrl+d) - linewise
type ScrollDownHalfPage struct{}
// ScrollDownPage implements Motion (ctrl+d) - linewise
type ScrollDownPage struct {
Divisor int
}
// ScrollDownHalfPage.Execute: Scrolls down half a page while maintaining the
// cursor's relative position in the viewport.
func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
func (a ScrollDownPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
viewportHeight := win.ViewportHeight()
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / 2
scroll := viewportHeight / a.Divisor
scrollOff := win.Options.ScrollOff
// Current relative position in viewport
@ -152,22 +154,24 @@ func (a ScrollDownHalfPage) Execute(m action.Model) tea.Cmd {
return nil
}
func (a ScrollDownHalfPage) Type() core.MotionType { return core.Linewise }
func (a ScrollDownPage) Type() core.MotionType { return core.Linewise }
// ScrollUpHalfPage implements Motion (ctrl+u) - linewise
type ScrollUpHalfPage struct{}
// ScrollUpPage implements Motion (ctrl+u) - linewise
type ScrollUpPage struct {
Divisor int
}
// ScrollUpHalfPage.Execute: Scrolls up half a page while maintaining the
// cursor's relative position in the viewport.
func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
func (a ScrollUpPage) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
viewportHeight := win.Height - 2
viewportHeight := win.ViewportHeight()
if viewportHeight <= 0 {
return nil
}
scroll := viewportHeight / 2
scroll := viewportHeight / a.Divisor
scrollOff := win.Options.ScrollOff
// Current relative position in viewport
@ -193,4 +197,4 @@ func (a ScrollUpHalfPage) Execute(m action.Model) tea.Cmd {
return nil
}
func (a ScrollUpHalfPage) Type() core.MotionType { return core.Linewise }
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }

View File

@ -24,7 +24,7 @@ func isWordPunctuation(c byte) bool {
// nextWordStart: Finds the start of the next word from position (x,y), handling
// word boundaries and line crossing.
func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
line := buf.Line(y)
// Skip current class
if x < len(line) {
@ -59,7 +59,7 @@ func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
// Move to first char of next line
y++
line = buf.Lines[y]
line = buf.Line(y)
x = 0
// If the first char of the new line is no whitespace, stay here!
@ -74,7 +74,7 @@ func nextWordStart(buf *core.Buffer, x, y int) (int, int) {
// nextWORDStart: Finds the start of the next WORD from position (x,y), treating
// all non-whitespace as a single class.
func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
line := buf.Line(y)
// Skip current WORD (all non-whitespace is one class for W)
for x < len(line) && line[x] != ' ' && line[x] != '\t' {
@ -100,7 +100,7 @@ func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
// Move to first char of next line
y++
line = buf.Lines[y]
line = buf.Line(y)
x = 0
// If the first char of the new line is no whitespace, stay here!
@ -115,7 +115,7 @@ func nextWORDStart(buf *core.Buffer, x, y int) (int, int) {
// nextWordEnd: Finds the end of the next word from position (x,y), respecting
// word character classes.
func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
line := buf.Line(y)
// Advance once to avoid being stuck on the current end
x++
@ -128,7 +128,7 @@ func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
// Otherwise, move to next line
y++
x = 0
line = buf.Lines[y]
line = buf.Line(y)
}
// Skip whitespace and cross lines if needed
@ -150,7 +150,7 @@ func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
// Move to first char of next line
y++
line = buf.Lines[y]
line = buf.Line(y)
x = 0
}
@ -174,7 +174,7 @@ func nextWordEnd(buf *core.Buffer, x, y int) (int, int) {
// nextWORDEnd: Finds the end of the next WORD from position (x,y), treating
// all non-whitespace as a single class.
func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
line := buf.Line(y)
// Advance once to avoid being stuck on the current end
x++
@ -187,7 +187,7 @@ func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
// Otherwise, move to next line
y++
x = 0
line = buf.Lines[y]
line = buf.Line(y)
}
// Skip whitespace and cross lines if needed
@ -209,7 +209,7 @@ func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
// Move to first char of next line
y++
line = buf.Lines[y]
line = buf.Line(y)
x = 0
}
@ -224,7 +224,7 @@ func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) {
// prevWordStart: Finds the start of the previous word from position (x,y),
// moving backward through character classes.
func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Lines[y]
line := buf.Line(y)
// Back one to avoid being stuck on the current start
x--
@ -233,7 +233,7 @@ func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Lines[y]
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y // landed on an empty line
@ -252,7 +252,7 @@ func prevWordStart(buf *core.Buffer, x, y int) (int, int) {
return 0, 0
}
y--
line = buf.Lines[y]
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
@ -412,3 +412,290 @@ func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive
func (a MoveBackwardWord) WithCount(n int) action.Action {
return MoveBackwardWord{Count: n}
}
// prevWORDStart: Finds the start of the previous WORD from position (x,y),
// treating all non-whitespace as a single class.
func prevWORDStart(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
// Back one to avoid being stuck on the current start
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y // landed on an empty line
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Skip to the start of the WORD (all non-whitespace is one class)
for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' {
x--
}
return x, y
}
// prevWordEnd: Finds the end of the previous word from position (x,y),
// respecting word character classes.
func prevWordEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
origY := y
// Back one to avoid being stuck on the current end
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
// Don't return early for empty line - we'll handle it in whitespace skip
}
// Skip backward through current word class if we're on one
// BUT: if we crossed lines in the "back one" step, we're already at the end of a word
if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' {
if isWordChar(line[x]) {
// Skip word characters
for x >= 0 && isWordChar(line[x]) {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
} else {
// Skip punctuation
for x >= 0 && isWordPunctuation(line[x]) {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char, this is our word end!
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Now x,y is at the start of the target word. Move forward to its end.
if x >= 0 {
if isWordChar(line[x]) {
for x+1 < len(line) && isWordChar(line[x+1]) {
x++
}
} else if isWordPunctuation(line[x]) {
for x+1 < len(line) && isWordPunctuation(line[x+1]) {
x++
}
}
}
return x, y
}
// prevWORDEnd: Finds the end of the previous WORD from position (x,y),
// treating all non-whitespace as a single class.
func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) {
line := buf.Line(y)
origY := y
// Back one to avoid being stuck on the current end
x--
if x < 0 {
if y == 0 {
return 0, 0 // beginning of file, stay put
}
y--
line = buf.Line(y)
x = len(line) - 1
// Don't return early for empty line - we'll handle it in whitespace skip
}
// Skip backward through current WORD if we're on one
// BUT: if we crossed lines in the "back one" step, we're already at the end of a WORD
if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' {
for x >= 0 && line[x] != ' ' && line[x] != '\t' {
x--
if x < 0 {
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if x < 0 {
return 0, y
}
}
}
}
// Skip whitespace backward, crossing lines if needed
for {
for x >= 0 && (line[x] == ' ' || line[x] == '\t') {
x--
}
if x >= 0 {
break // landed on a non-whitespace char, this is our WORD end!
}
if y == 0 {
return 0, 0
}
y--
line = buf.Line(y)
x = len(line) - 1
if len(line) == 0 {
return 0, y // empty line acts as a word boundary
}
}
// Now x,y is at the start of the target WORD. Move forward to its end.
if x >= 0 {
for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' {
x++
}
}
return x, y
}
// MoveBackwardWORD implements Motion (B) - charwise
type MoveBackwardWORD struct {
Count int
}
// MoveBackwardWORD.Execute: Moves the cursor backward by Count WORDs (B motion).
func (a MoveBackwardWORD) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWORDStart(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// MoveBackwardWORD.Type: Returns CharwiseExclusive for backward WORD motion.
func (a MoveBackwardWORD) Type() core.MotionType { return core.CharwiseExclusive }
// MoveBackwardWORD.WithCount: Returns a new MoveBackwardWORD with the given count.
func (a MoveBackwardWORD) WithCount(n int) action.Action {
return MoveBackwardWORD{Count: n}
}
// MoveBackwardWordEnd implements Motion (ge) - charwise
type MoveBackwardWordEnd struct {
Count int
}
// MoveBackwardWordEnd.Execute: Moves the cursor to the end of the previous word (ge motion).
func (a MoveBackwardWordEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWordEnd(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// MoveBackwardWordEnd.Type: Returns CharwiseInclusive for backward word-end motion.
func (a MoveBackwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveBackwardWordEnd.WithCount: Returns a new MoveBackwardWordEnd with the given count.
func (a MoveBackwardWordEnd) WithCount(n int) action.Action {
return MoveBackwardWordEnd{Count: n}
}
// MoveBackwardWORDEnd implements Motion (gE) - charwise
type MoveBackwardWORDEnd struct {
Count int
}
// MoveBackwardWORDEnd.Execute: Moves the cursor to the end of the previous WORD (gE motion).
func (a MoveBackwardWORDEnd) Execute(m action.Model) tea.Cmd {
win := m.ActiveWindow()
buf := m.ActiveBuffer()
x := win.Cursor.Col
y := win.Cursor.Line
for i := 0; i < a.Count; i++ {
x, y = prevWORDEnd(buf, x, y)
}
win.SetCursorCol(x)
win.SetCursorLine(y)
return nil
}
// MoveBackwardWORDEnd.Type: Returns CharwiseInclusive for backward WORD-end motion.
func (a MoveBackwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive }
// MoveBackwardWORDEnd.WithCount: Returns a new MoveBackwardWORDEnd with the given count.
func (a MoveBackwardWORDEnd) WithCount(n int) action.Action {
return MoveBackwardWORDEnd{Count: n}
}

View File

@ -68,18 +68,18 @@ func changeCharSelection(m action.Model, start, end core.Position) {
var deletedText string
if start.Line == end.Line {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
endCol := min(end.Col+1, len(line))
deletedText = line[start.Col:endCol]
buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else {
startLine := buf.Lines[start.Line]
endLine := buf.Lines[end.Line]
startLine := buf.Line(start.Line)
endLine := buf.Line(end.Line)
// Extract deleted text
deletedText = startLine[start.Col:] + "\n"
for y := start.Line + 1; y < end.Line; y++ {
deletedText += buf.Lines[y] + "\n"
deletedText += buf.Line(y) + "\n"
}
endCol := min(end.Col+1, len(endLine))
deletedText += endLine[:endCol]
@ -113,7 +113,7 @@ func changeLineSelection(m action.Model, start, end core.Position) {
var lines []string
for i := end.Line; i >= start.Line; i-- {
lines = append([]string{buf.Lines[i]}, lines...)
lines = append([]string{buf.Line(i)}, lines...)
buf.DeleteLine(i)
}
@ -138,7 +138,7 @@ func changeBlockSelection(m action.Model, start, end core.Position) {
endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y]
line := buf.Line(y)
if startCol >= len(line) {
continue
}
@ -168,7 +168,7 @@ func (o ChangeOperator) DoublePress(m action.Model, count int) tea.Cmd {
// Collect lines to delete (always delete at startY since lines shift up)
for range opCount {
lines = append(lines, buf.Lines[startY])
lines = append(lines, buf.Line(startY))
buf.DeleteLine(startY)
}

View File

@ -39,7 +39,7 @@ func (o DeleteOperator) DoublePress(m action.Model, count int) tea.Cmd {
for range opCount {
y := win.Cursor.Line
lines = append(lines, buf.Lines[y])
lines = append(lines, buf.Line(y))
buf.DeleteLine(y)
@ -99,12 +99,12 @@ func deleteCharSelection(m action.Model, start, end core.Position) {
buf := m.ActiveBuffer()
if start.Line == end.Line {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
endCol := min(end.Col+1, len(line))
buf.SetLine(start.Line, line[:start.Col]+line[endCol:])
} else {
startLine := buf.Lines[start.Line]
endLine := buf.Lines[end.Line]
startLine := buf.Line(start.Line)
endLine := buf.Line(end.Line)
prefix := startLine[:start.Col]
suffix := ""
@ -131,7 +131,7 @@ func deleteLineSelection(m action.Model, start, end core.Position) {
var lines []string
for i := end.Line; i >= start.Line; i-- {
lines = append(lines, buf.Lines[i])
lines = append(lines, buf.Line(i))
buf.DeleteLine(i)
}
@ -159,7 +159,7 @@ func deleteBlockSelection(m action.Model, start, end core.Position) {
endCol := max(start.Col, end.Col)
for y := start.Line; y <= end.Line; y++ {
line := buf.Lines[y]
line := buf.Line(y)
if startCol >= len(line) {
continue
}

View File

@ -30,8 +30,14 @@ func (o YankOperator) Operate(m action.Model, start, end core.Position, mtype co
})
}
win.SetCursorCol(start.Col)
win.SetCursorLine(start.Line)
// 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)
return nil
}
@ -51,7 +57,7 @@ func (o YankOperator) DoublePress(m action.Model, count int) tea.Cmd {
var lines []string
for i := range opCount {
lines = append(lines, buf.Lines[y+i])
lines = append(lines, buf.Line(y+i))
}
// Put her in the register!
@ -66,17 +72,7 @@ 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.Lines[start.Line]
line := buf.Line(start.Line)
startX := min(start.Col, end.Col)
endX := max(start.Col, end.Col)
@ -91,22 +87,19 @@ func yankNormalMode(m action.Model, start, end core.Position, mtype core.MotionT
cnt := line[startX:endX]
m.UpdateDefaultRegister(core.CharwiseRegister, []string{cnt})
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
// }
win := m.ActiveWindow()
win.SetCursorCol(startX)
win.SetCursorLine(start.Line)
case mtype == core.Linewise:
// 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)
cnt := buf.Lines[startY : endY+1]
var cnt []string
for i := startY; i <= endY; i++ {
cnt = append(cnt, buf.Line(i))
}
m.UpdateDefaultRegister(core.LinewiseRegister, cnt)
}
}
@ -122,7 +115,7 @@ func yankVisualMode(m action.Model, start, end core.Position) {
// Single line selection
if start.Line == end.Line {
line := buf.Lines[start.Line]
line := buf.Line(start.Line)
endCol := min(end.Col+1, len(line)) // +1 because visual selection is inclusive
startCol := min(start.Col, len(line))
cnt := line[startCol:endCol]
@ -134,17 +127,17 @@ func yankVisualMode(m action.Model, start, end core.Position) {
var content []string
// First line: from start.Col to end of line
firstLine := buf.Lines[start.Line]
firstLine := buf.Line(start.Line)
startCol := min(start.Col, len(firstLine))
content = append(content, firstLine[startCol:])
// Middle lines: entire lines
for y := start.Line + 1; y < end.Line; y++ {
content = append(content, buf.Lines[y])
content = append(content, buf.Line(y))
}
// Last line: from beginning to end.Col (inclusive)
lastLine := buf.Lines[end.Line]
lastLine := buf.Line(end.Line)
endCol := min(end.Col+1, len(lastLine))
content = append(content, lastLine[:endCol])
@ -169,7 +162,10 @@ func yankVisualLineMode(m action.Model, start, end core.Position) {
startY := min(start.Line, end.Line)
endY := max(start.Line, end.Line)
cnt := buf.Lines[startY : endY+1]
var cnt []string
for i := startY; i <= endY; i++ {
cnt = append(cnt, buf.Line(i))
}
m.UpdateDefaultRegister(core.LinewiseRegister, cnt)
}
@ -187,7 +183,7 @@ func yankVisualBlockMode(m action.Model, start, end core.Position) {
var content []string
for y := startY; y <= endY; y++ {
line := buf.Lines[y]
line := buf.Line(y)
// Handle lines shorter than the block selection
if startX >= len(line) {

View File

@ -46,6 +46,7 @@ func (p *ProgramBuilder) FileProgram(filename string) *ProgramBuilder {
WithType(core.FileBuffer).
WithFilename(filename).
WithFiletype(ext).
Listed().
Build()
win := core.NewWindowBuilder().

205
internal/style/style.go Normal file → Executable file
View File

@ -1,7 +1,11 @@
package style
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/charmbracelet/lipgloss"
)
@ -11,6 +15,7 @@ type Styles struct {
CursorNormal lipgloss.Style
CursorInsert lipgloss.Style
CursorCommand lipgloss.Style
CursorReplace lipgloss.Style
// Gutter (line numbers)
Gutter lipgloss.Style
@ -28,14 +33,22 @@ type Styles struct {
CommandError lipgloss.Style
CommandOutputBorder lipgloss.Style
CommandContinueMessage lipgloss.Style
// General Styles
LineStyle lipgloss.Style // This is a simple background with no text coloring
BackgroundStyle lipgloss.Style // This is just the background
// Chroma data
ChromaStyle *chroma.Style
}
// DefaultStyles returns the default editor color scheme.
// DefaultStyles: Returns the default editor color scheme.
func DefaultStyles() Styles {
return Styles{
CursorNormal: lipgloss.NewStyle().Reverse(true),
CursorInsert: lipgloss.NewStyle().Underline(true),
CursorCommand: lipgloss.NewStyle().Reverse(true),
CursorReplace: lipgloss.NewStyle().Underline(true),
Gutter: lipgloss.NewStyle().
Background(lipgloss.Color("236")).
@ -67,17 +80,203 @@ func DefaultStyles() Styles {
CommandContinueMessage: lipgloss.NewStyle().
Foreground(lipgloss.Color("#546fba")),
ChromaStyle: nil,
}
}
// CursorStyle returns the appropriate cursor style for the given mode.
func (s Styles) CursorStyle(mode core.Mode) lipgloss.Style {
func ChromaStyles(chromaStyle *chroma.Style) Styles {
bgString := chromaStyle.Get(chroma.Background).Background.String()
lineNumbers := chromaStyle.Get(chroma.LineTableTD)
lineHighlight := chromaStyle.Get(chroma.LineHighlight)
return Styles{
CursorNormal: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Reverse(true),
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()),
).
Foreground(lipgloss.Color(lineNumbers.Colour.String())),
GutterCurrentLine: lipgloss.NewStyle().
Background(lipgloss.Color(
darkenColor(lineNumbers.Background, 0.9).String()),
).
Foreground(lipgloss.Color(lineNumbers.Colour.String())),
VisualHighlight: lipgloss.NewStyle().
Background(lipgloss.Color(lineHighlight.Background.String())).
Foreground(lipgloss.Color(lineHighlight.Colour.String())),
VisualAnchor: lipgloss.NewStyle().
Background(lipgloss.Color(lineHighlight.Background.String())).
Foreground(lipgloss.Color(lineHighlight.Colour.String())),
StatusBar: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("243")),
StatusBarActive: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("230")),
CommandError: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("#e3203a")),
CommandOutputBorder: lipgloss.NewStyle().
Background(
lipgloss.Color(
darkenColor(
chromaStyle.Get(chroma.Background).Background, 0.5).
String(),
),
),
CommandContinueMessage: lipgloss.NewStyle().
Background(lipgloss.Color(bgString)).
Foreground(lipgloss.Color("#546fba")),
LineStyle: lipgloss.NewStyle().
Foreground(lipgloss.Color(chromaStyle.Get(chroma.Line).Colour.String())).
Background(lipgloss.Color(bgString)),
BackgroundStyle: lipgloss.NewStyle().Background(lipgloss.Color(bgString)),
ChromaStyle: chromaStyle,
}
}
// Styles.DefaultCursorStyle: Returns the appropriate cursor style for the given mode.
func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return s.CursorInsert
case core.CommandMode:
return s.CursorCommand
case core.ReplaceMode:
return s.CursorReplace
default:
return s.CursorNormal
}
}
// Styles.CursorStyle: Returns a cursor style derived from a chroma style. This function should preferred
// over the DefaultCursorStyle, but in cases where there is no style to apply, the DefaultCursorStyle
// will always work.
func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style {
switch mode {
case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode:
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()).
Foreground(style.GetForeground()).
Underline(true)
}
}
// Styles.VisualHighlightWithTextColor: Works analogously to CursorStyle vs DefaultCursorStyle. When a
// style is available, this function should be used, so the text color will be rendered in front
// of the background. Otherwise, the VisualHighlight property will always work.
func (s Styles) VisualHighlightWithTextColor(style lipgloss.Style) lipgloss.Style {
return lipgloss.NewStyle().
Background(s.VisualHighlight.GetBackground()).
Foreground(style.GetForeground())
}
// Styles.MakeStyleMap: Generates a style map for a single line. A style map is a mapping from
// column a lipgloss style. Cursor styles are not handled by this map, but they can be derived
// by inverting the background and foreground (and rolling back to the default).
func (s Styles) MakeStyleMap(lexer chroma.Lexer, line string) []lipgloss.Style {
m := make([]lipgloss.Style, len(line))
if s.ChromaStyle == nil {
return m
}
iter, err := lexer.Tokenise(nil, line)
if err != nil {
panic(err)
}
col := 0
for _, token := range iter.Tokens() {
entry := s.ChromaStyle.Get(token.Type)
s := lipgloss.NewStyle().
Background(lipgloss.Color(entry.Background.String())).
Foreground(lipgloss.Color(entry.Colour.String()))
for _, char := range token.Value {
if char == '\n' {
continue
}
if col < len(m) {
m[col] = s
}
col++
}
}
return m
}
// darkenColor: Uses a factor (0.0 to 1.0) to darken a color using its opacity.
func darkenColor(c chroma.Colour, factor float64) chroma.Colour {
r := uint8(float64(c.Red()) * factor)
g := uint8(float64(c.Green()) * factor)
b := uint8(float64(c.Blue()) * factor)
return chroma.NewColour(r, g, b)
}
// GetLexer: Uses buffer meta data or content to pick a lexer for use in applying
// highlights.
func GetLexer(buf *core.Buffer) chroma.Lexer {
var lexer chroma.Lexer
if buf.Filetype != "" {
lexer = lexers.Get(strings.TrimPrefix(buf.Filetype, "."))
}
if lexer == nil && buf.Filename != "" {
lexer = lexers.Match(buf.Filename)
}
if lexer == nil && len(buf.Lines) > 0 {
// Get first few lines for content analysis
var content strings.Builder
for i := 0; i < min(len(buf.Lines), 10); i++ {
content.WriteString(buf.Lines[i].String() + "\n")
}
lexer = lexers.Analyse(content.String())
}
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer) // Merge tokens together
return lexer
}

View File

@ -0,0 +1,524 @@
package textobject
import (
"fmt"
"slices"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// Map opposite char
var DirectionalDelimiterMap map[rune]rune = map[rune]rune{
'(': ')',
'[': ']',
'{': '}',
'<': '>',
}
var singleDelimiterList []rune = []rune{'"', '\'', '`'}
func getStartDelimiterFromEnd(d rune) (rune, bool) {
if slices.Contains(singleDelimiterList, d) {
return d, true
}
for start, end := range DirectionalDelimiterMap {
if end == d {
return start, true
}
}
return ' ', false
}
func getEndDelimiterFromStart(d rune) (rune, bool) {
if slices.Contains(singleDelimiterList, d) {
return d, true
}
end, found := DirectionalDelimiterMap[d]
return end, found
}
// Delimiter implements text object for words (iw/aw)
type Delimiter struct {
Char rune
}
func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
buf := m.ActiveBuffer()
// Determine which is a starting delimiter and which ends
_, isStartingDelimiter := DirectionalDelimiterMap[to.Char]
var (
startDelim rune
endDelim rune
startFound bool = true
endFound bool = true
)
if isStartingDelimiter {
startDelim = to.Char
endDelim, endFound = getEndDelimiterFromStart(to.Char)
} else {
endDelim = to.Char
startDelim, startFound = getStartDelimiterFromEnd(to.Char)
}
if !endFound || !startFound {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("Could not find delimiters from '%c'", to.Char)},
Inline: true,
IsError: false,
})
return cursor, cursor, core.CharwiseExclusive
}
// Convert buffer lines to strings for delimiter finding
var lines []string
for i := 0; i < buf.LineCount(); i++ {
lines = append(lines, buf.Line(i))
}
// Use multi-line delimiter pair finding
start, end, found := findMultiLineDelimiterPair(lines, startDelim, endDelim, cursor, modifier == "a")
if !found {
return cursor, cursor, core.CharwiseExclusive
}
// Check if positions are valid
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
return cursor, cursor, core.CharwiseExclusive
}
return start, end, core.CharwiseInclusive
}
func findDelimiterStart(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) {
for i := col; i >= 0; i-- {
if rune(line[i]) == delimiter {
if includeDelimiter {
return core.Position{Line: 0, Col: i}, true
}
return core.Position{Line: 0, Col: i + 1}, true
}
}
return core.Position{Line: 0, Col: 0}, false
}
func findDelimiterEnd(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) {
for i := col; i < len(line); i++ {
if rune(line[i]) == delimiter {
if includeDelimiter {
return core.Position{Line: 0, Col: i}, true
}
return core.Position{Line: 0, Col: i - 1}, true
}
}
return core.Position{Line: 0, Col: 0}, false
}
// findDelimiterPair tries to find a matching pair of delimiters.
// First it tries to find delimiters around the cursor.
// If that fails, it searches forward for the next pair.
func findDelimiterPair(line string, startDelim, endDelim rune, cursorCol int, includeDelimiters bool) (core.Position, core.Position, bool) {
// First, try to find delimiters around cursor
start, sOk := findDelimiterStart(line, startDelim, cursorCol, includeDelimiters)
end, eOk := findDelimiterEnd(line, endDelim, cursorCol, includeDelimiters)
if sOk && eOk {
// Verify this is actually a valid pair by checking there are no
// unmatched delimiters between start and end
var startDelimPos, endDelimPos int
if includeDelimiters {
startDelimPos = start.Col
endDelimPos = end.Col
} else {
startDelimPos = start.Col - 1
endDelimPos = end.Col + 1
}
// For proper pair validation, check if this is the nearest matching pair
// by ensuring the end delimiter we found is the first one after the start
if isValidPair(line, startDelim, endDelim, startDelimPos, endDelimPos) {
// Cursor should be at or between the delimiters
if startDelimPos <= cursorCol && cursorCol <= endDelimPos {
return start, end, true
}
}
}
// Not inside delimiters, search forward for next pair
startDelimPos, foundStart := findNextDelimiter(line, startDelim, cursorCol)
if !foundStart {
return core.Position{}, core.Position{}, false
}
endDelimPos, foundEnd := findNextDelimiter(line, endDelim, startDelimPos+1)
if !foundEnd {
return core.Position{}, core.Position{}, false
}
// Validate this pair as well
if !isValidPair(line, startDelim, endDelim, startDelimPos, endDelimPos) {
return core.Position{}, core.Position{}, false
}
// Calculate start and end positions based on modifier
if includeDelimiters {
start = core.Position{Line: 0, Col: startDelimPos}
end = core.Position{Line: 0, Col: endDelimPos}
} else {
start = core.Position{Line: 0, Col: startDelimPos + 1}
end = core.Position{Line: 0, Col: endDelimPos - 1}
}
return start, end, true
}
// isValidPair checks if the delimiters at startPos and endPos form a valid matching pair.
// For same-delimiter pairs (quotes), it checks they form an opening/closing pair.
// For directional pairs (parens, brackets), it ensures the end is the matching closer for the start.
func isValidPair(line string, startDelim, endDelim rune, startPos, endPos int) bool {
if startPos >= endPos {
return false
}
// For quote-like delimiters where start and end are the same
if startDelim == endDelim {
// For quotes, we need to determine if startPos is an opening quote and endPos is a closing quote
// We do this by counting quotes before each position
// An opening quote has an even number of quotes before it (0, 2, 4, ...)
// A closing quote has an odd number of quotes before it (1, 3, 5, ...)
quotesBeforeStart := 0
for i := 0; i < startPos; i++ {
if rune(line[i]) == startDelim {
quotesBeforeStart++
}
}
quotesBeforeEnd := quotesBeforeStart + 1 // We know there's at least the startPos quote
for i := startPos + 1; i < endPos; i++ {
if rune(line[i]) == startDelim {
quotesBeforeEnd++
}
}
// startPos should be an opening quote (even number before it)
// endPos should be a closing quote (odd number before it)
// AND there should be no quotes between them for a simple pair
startIsOpening := quotesBeforeStart%2 == 0
endIsClosing := quotesBeforeEnd%2 == 1
noQuotesBetween := quotesBeforeEnd == quotesBeforeStart+1
return startIsOpening && endIsClosing && noQuotesBetween
}
// For directional delimiters, check that endPos has the first unmatched closing delimiter
// Simple approach: ensure there's no end delimiter between startPos and endPos that would
// close an earlier start delimiter
nestLevel := 0
for i := startPos + 1; i < endPos; i++ {
if rune(line[i]) == startDelim {
nestLevel++
} else if rune(line[i]) == endDelim {
if nestLevel > 0 {
nestLevel--
} else {
// Found an unmatched end delimiter before our endPos
return false
}
}
}
// The endPos should close the startPos
return nestLevel == 0
}
// findNextDelimiter searches forward from startCol for the next occurrence of delimiter
func findNextDelimiter(line string, delimiter rune, startCol int) (int, bool) {
for i := startCol; i < len(line); i++ {
if rune(line[i]) == delimiter {
return i, true
}
}
return 0, false
}
// ============================================================================
// Multi-line Delimiter Functions
// ============================================================================
// findMultiLineDelimiterPair finds a matching pair of delimiters across multiple lines.
// It handles proper nesting for directional delimiters (parens, brackets, braces).
func findMultiLineDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) {
// First, try to find delimiters around the cursor position (innermost pair)
start, end, found := findEnclosingDelimiterPair(lines, startDelim, endDelim, cursor, includeDelimiters)
if found {
return start, end, true
}
// Not inside delimiters, search forward for the next pair
start, end, found = findNextDelimiterPair(lines, startDelim, endDelim, cursor, includeDelimiters)
return start, end, found
}
// findEnclosingDelimiterPair finds the innermost delimiter pair that encloses the cursor.
func findEnclosingDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) {
// Search backward from cursor to find opening delimiter
startPos, foundStart := searchBackwardForDelimiter(lines, startDelim, endDelim, cursor)
if !foundStart {
return core.Position{}, core.Position{}, false
}
// Search forward from the opening delimiter to find matching closing delimiter
endPos, foundEnd := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, startPos)
if !foundEnd {
return core.Position{}, core.Position{}, false
}
// Check if cursor is between these delimiters
if !isCursorBetween(cursor, startPos, endPos) {
return core.Position{}, core.Position{}, false
}
// Adjust positions based on includeDelimiters
start, end, ok := adjustDelimiterPositions(lines, startPos, endPos, includeDelimiters)
return start, end, ok
}
// findNextDelimiterPair searches forward from cursor for the next delimiter pair.
func findNextDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) {
// Search forward from cursor for opening delimiter
startPos, foundStart := searchForwardSimple(lines, startDelim, cursor)
if !foundStart {
return core.Position{}, core.Position{}, false
}
// Search forward from opening for matching closing delimiter
endPos, foundEnd := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, startPos)
if !foundEnd {
return core.Position{}, core.Position{}, false
}
// Adjust positions based on includeDelimiters
start, end, ok := adjustDelimiterPositions(lines, startPos, endPos, includeDelimiters)
return start, end, ok
}
// searchBackwardForDelimiter searches backward from cursor to find the nearest opening delimiter
// that could enclose the cursor position.
func searchBackwardForDelimiter(lines []string, startDelim, endDelim rune, cursor core.Position) (core.Position, bool) {
// For quote-like delimiters, only search current line
if startDelim == endDelim {
return searchBackwardForDelimiterSingleLine(lines[cursor.Line], startDelim, endDelim, cursor.Col, cursor.Line)
}
// Start from cursor position and go backward
line := cursor.Line
col := cursor.Col
// Search current line from cursor backwards
for i := col; i >= 0; i-- {
if i < len(lines[line]) && rune(lines[line][i]) == startDelim {
// Found a potential start delimiter, verify it could enclose cursor
pos := core.Position{Line: line, Col: i}
// Check if this delimiter's matching pair is after the cursor
endPos, found := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, pos)
if found && isCursorBetween(cursor, pos, endPos) {
return pos, true
}
}
}
// Search previous lines
for line = cursor.Line - 1; line >= 0; line-- {
for i := len(lines[line]) - 1; i >= 0; i-- {
if rune(lines[line][i]) == startDelim {
pos := core.Position{Line: line, Col: i}
endPos, found := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, pos)
if found && isCursorBetween(cursor, pos, endPos) {
return pos, true
}
}
}
}
return core.Position{}, false
}
// searchBackwardForDelimiterSingleLine searches backward on a single line (for quotes).
func searchBackwardForDelimiterSingleLine(line string, startDelim, endDelim rune, col int, lineNum int) (core.Position, bool) {
for i := col; i >= 0; i-- {
if i < len(line) && rune(line[i]) == startDelim {
// For quotes, verify it's an opening quote by checking if it has a matching closing quote
// Count quotes before this position
quotesBefore := 0
for j := 0; j < i; j++ {
if rune(line[j]) == startDelim {
quotesBefore++
}
}
// If even number of quotes before, this is an opening quote
if quotesBefore%2 == 0 {
// Find matching closing quote
for j := i + 1; j < len(line); j++ {
if rune(line[j]) == endDelim {
// Check if cursor is between i and j
if col > i && col < j {
return core.Position{Line: lineNum, Col: i}, true
}
break
}
}
}
}
}
return core.Position{}, false
}
// searchForwardForMatchingDelimiter searches forward from startPos to find the matching closing delimiter.
// It properly handles nesting for directional delimiters.
func searchForwardForMatchingDelimiter(lines []string, startDelim, endDelim rune, startPos core.Position) (core.Position, bool) {
nestLevel := 0
line := startPos.Line
startCol := startPos.Col + 1 // Start after the opening delimiter
// For quote-like delimiters (same start and end)
if startDelim == endDelim {
// Simple search for next occurrence on same line
if line < len(lines) {
for i := startCol; i < len(lines[line]); i++ {
if rune(lines[line][i]) == endDelim {
return core.Position{Line: line, Col: i}, true
}
}
}
return core.Position{}, false
}
// For directional delimiters with nesting
// Search current line from startCol
for i := startCol; i < len(lines[line]); i++ {
ch := rune(lines[line][i])
if ch == startDelim {
nestLevel++
} else if ch == endDelim {
if nestLevel == 0 {
return core.Position{Line: line, Col: i}, true
}
nestLevel--
}
}
// Search subsequent lines
for line = startPos.Line + 1; line < len(lines); line++ {
for i := 0; i < len(lines[line]); i++ {
ch := rune(lines[line][i])
if ch == startDelim {
nestLevel++
} else if ch == endDelim {
if nestLevel == 0 {
return core.Position{Line: line, Col: i}, true
}
nestLevel--
}
}
}
return core.Position{}, false
}
// searchForwardSimple searches forward from cursor for the next occurrence of delimiter.
func searchForwardSimple(lines []string, delimiter rune, cursor core.Position) (core.Position, bool) {
// Search current line from cursor
for i := cursor.Col; i < len(lines[cursor.Line]); i++ {
if rune(lines[cursor.Line][i]) == delimiter {
return core.Position{Line: cursor.Line, Col: i}, true
}
}
// Search subsequent lines
for line := cursor.Line + 1; line < len(lines); line++ {
for i := 0; i < len(lines[line]); i++ {
if rune(lines[line][i]) == delimiter {
return core.Position{Line: line, Col: i}, true
}
}
}
return core.Position{}, false
}
// isCursorBetween checks if cursor is between start and end positions.
func isCursorBetween(cursor, start, end core.Position) bool {
// Check if cursor is after or at start
if cursor.Line < start.Line || (cursor.Line == start.Line && cursor.Col < start.Col) {
return false
}
// Check if cursor is before or at end
if cursor.Line > end.Line || (cursor.Line == end.Line && cursor.Col > end.Col) {
return false
}
return true
}
// hasOnlyWhitespaceBefore checks if there is only whitespace before the character at col.
func hasOnlyWhitespaceBefore(line string, col int) bool {
for i := 0; i < col; i++ {
if !isWhitespace(rune(line[i])) {
return false
}
}
return true
}
// isWhitespace checks if a rune is whitespace.
func isWhitespace(r rune) bool {
return r == ' ' || r == '\t'
}
// adjustDelimiterPositions adjusts the delimiter positions based on whether to include delimiters.
func adjustDelimiterPositions(lines []string, startPos, endPos core.Position, includeDelimiters bool) (core.Position, core.Position, bool) {
if includeDelimiters {
// Include the delimiters themselves
return startPos, endPos, true
}
// Exclude the delimiters - start after opening, end before closing
start := core.Position{Line: startPos.Line, Col: startPos.Col + 1}
end := core.Position{Line: endPos.Line, Col: endPos.Col - 1}
// Handle special cases
if startPos.Line == endPos.Line {
// Same line - check if there's content between delimiters
if startPos.Col+1 > endPos.Col-1 {
// Empty delimiters like "()" - return positions that will be detected as invalid
return start, end, true
}
} else {
// Multi-line case
// If start position is beyond the line (delimiter was last char on its line)
if start.Col >= len(lines[start.Line]) {
// Position at end of line to include the newline in deletion
start.Col = len(lines[start.Line])
}
// If end position is negative (delimiter was first char on its line) OR
// delimiter has only whitespace before it on its line
if end.Col < 0 || hasOnlyWhitespaceBefore(lines[endPos.Line], endPos.Col) {
// Position at end of previous line to include that line's newline
if end.Line > 0 {
end.Line--
end.Col = len(lines[end.Line])
}
}
}
return start, end, true
}

205
internal/textobject/word.go Normal file
View File

@ -0,0 +1,205 @@
package textobject
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
)
// Word implements text object for words (iw/aw)
type Word struct{}
func (to Word) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
buf := m.ActiveBuffer()
line := buf.Line(cursor.Line)
// Find word boundaries
start := findWordStart(line, cursor.Col)
end := findWordEnd(line, cursor.Col, modifier == "a")
// Word object's don't span lines
start.Line = cursor.Line
end.Line = cursor.Line
return start, end, core.CharwiseInclusive
}
// Word implements text object for WORDs (iW/aW)
type WORD struct{}
func (to WORD) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
buf := m.ActiveBuffer()
line := buf.Line(cursor.Line)
// Find word boundaries
start := findWORDStart(line, cursor.Col)
end := findWORDEnd(line, cursor.Col, modifier == "a")
// Word object's don't span lines
start.Line = cursor.Line
end.Line = cursor.Line
return start, end, core.CharwiseInclusive
}
// isWordChar: Returns true if the character is a word character (alphanumeric
// or underscore). COPIED FROM internal/motion/word.go
func isWordChar(c byte) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_'
}
func findWordStart(line string, col int) core.Position {
if col >= len(line) || col < 0 {
return core.Position{Line: 0, Col: 0}
}
curChar := line[col]
// Don't start on whitespace (shouldn't happen for text objects)
if curChar == ' ' || curChar == '\t' {
return core.Position{Line: 0, Col: col}
}
// Determine if we're on word char or punctuation
onWordChar := isWordChar(curChar)
// Move backwards while in the same character class
i := col
for i > 0 {
prevChar := line[i-1]
// Stop at whitespace
if prevChar == ' ' || prevChar == '\t' {
break
}
// Stop if character class changes
if isWordChar(prevChar) != onWordChar {
break
}
i--
}
return core.Position{Line: 0, Col: i}
}
func findWordEnd(line string, col int, includeWhitespace bool) core.Position {
if col >= len(line) || col < 0 {
return core.Position{Line: 0, Col: 0}
}
curChar := line[col]
// Don't start on whitespace
if curChar == ' ' || curChar == '\t' {
return core.Position{Line: 0, Col: col}
}
// Determine if we're on word char or punctuation
onWordChar := isWordChar(curChar)
// Move forward while in the same character class
i := col
for i < len(line) {
c := line[i]
// Stop at whitespace
if c == ' ' || c == '\t' {
break
}
// Stop if character class changes
if isWordChar(c) != onWordChar {
break
}
i++
}
// i is now one past the end, so back up
i--
// If including whitespace, skip trailing spaces/tabs
if includeWhitespace {
i++ // Move forward to whitespace
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
i++
}
i-- // Back to last whitespace
}
return core.Position{Line: 0, Col: i}
}
func findWORDStart(line string, col int) core.Position {
if col >= len(line) || col < 0 {
return core.Position{Line: 0, Col: 0}
}
curChar := line[col]
// Don't start on whitespace (shouldn't happen for text objects)
if curChar == ' ' || curChar == '\t' {
return core.Position{Line: 0, Col: col}
}
// For WORD, all non-whitespace is one class
// Just move backwards until we hit whitespace or start of line
i := col
for i > 0 {
prevChar := line[i-1]
// Stop at whitespace
if prevChar == ' ' || prevChar == '\t' {
break
}
i--
}
return core.Position{Line: 0, Col: i}
}
func findWORDEnd(line string, col int, includeWhitespace bool) core.Position {
if col >= len(line) || col < 0 {
return core.Position{Line: 0, Col: 0}
}
curChar := line[col]
// Don't start on whitespace
if curChar == ' ' || curChar == '\t' {
return core.Position{Line: 0, Col: col}
}
// For WORD, all non-whitespace is one class
// Move forward until we hit whitespace or end of line
i := col
for i < len(line) {
c := line[i]
// Stop at whitespace
if c == ' ' || c == '\t' {
break
}
i++
}
// i is now one past the end, so back up
i--
// If including whitespace, skip trailing spaces/tabs
if includeWhitespace {
i++ // Move forward to whitespace
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
i++
}
i-- // Back to last whitespace
}
return core.Position{Line: 0, Col: i}
}

43
internal/theme/theme.go Normal file
View File

@ -0,0 +1,43 @@
package theme
import (
"embed"
"fmt"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/styles"
)
//go:embed themes/*
var themeFS embed.FS
// RegisterAll: Registers all XML theme files embedded in the themes/ directory
// with chroma's style registry. After calling this, styles.Get() will recognize
// any theme defined in those files.
func RegisterAll() error {
entries, err := themeFS.ReadDir("themes")
if err != nil {
return fmt.Errorf("failed to read embedded themes directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
f, err := themeFS.Open("themes/" + entry.Name())
if err != nil {
return fmt.Errorf("failed to open theme %s: %w", entry.Name(), err)
}
style, err := chroma.NewXMLStyle(f)
f.Close()
if err != nil {
return fmt.Errorf("failed to parse theme %s: %w", entry.Name(), err)
}
styles.Register(style)
}
return nil
}

View File

@ -0,0 +1,83 @@
<style name="kanagawa-dragon">
<entry type="Background" style="bg:#181616 #c5c9c5" />
<entry type="CodeLine" style="#c5c9c5" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#c5c9c5" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#393836" />
<entry type="LineNumbersTable" style="#625e5a" />
<entry type="LineNumbers" style="#625e5a" />
<entry type="Keyword" style="#8992a7" />
<entry type="KeywordReserved" style="#8992a7" />
<entry type="KeywordPseudo" style="#8992a7" />
<entry type="KeywordConstant" style="#b6927b" />
<entry type="KeywordDeclaration" style="#8992a7" />
<entry type="KeywordNamespace" style="#c4b28a" />
<entry type="KeywordType" style="#8ea4a2" />
<entry type="Name" style="#c5c9c5" />
<entry type="NameClass" style="#8ea4a2" />
<entry type="NameConstant" style="#b6927b" />
<entry type="NameDecorator" style="bold #b6927b" />
<entry type="NameEntity" style="#c4b28a" />
<entry type="NameException" style="#b6927b" />
<entry type="NameFunction" style="#8ba4b0" />
<entry type="NameFunctionMagic" style="#8ba4b0" />
<entry type="NameLabel" style="#949fb5" />
<entry type="NameNamespace" style="#c4b28a" />
<entry type="NameProperty" style="#c4b28a" />
<entry type="NameTag" style="#8ba4b0" />
<entry type="NameVariable" style="#c5c9c5" />
<entry type="NameVariableClass" style="#c5c9c5" />
<entry type="NameVariableGlobal" style="#c5c9c5" />
<entry type="NameVariableInstance" style="#c5c9c5" />
<entry type="NameVariableMagic" style="#c5c9c5" />
<entry type="NameAttribute" style="#c4b28a" />
<entry type="NameBuiltin" style="#c4746e" />
<entry type="NameBuiltinPseudo" style="#c4746e" />
<entry type="NameOther" style="#c5c9c5" />
<entry type="Literal" style="#c5c9c5" />
<entry type="LiteralDate" style="#c5c9c5" />
<entry type="LiteralString" style="#8a9a7b" />
<entry type="LiteralStringChar" style="#8a9a7b" />
<entry type="LiteralStringSingle" style="#8a9a7b" />
<entry type="LiteralStringDouble" style="#8a9a7b" />
<entry type="LiteralStringBacktick" style="#8a9a7b" />
<entry type="LiteralStringOther" style="#8a9a7b" />
<entry type="LiteralStringSymbol" style="#8a9a7b" />
<entry type="LiteralStringInterpol" style="#949fb5" />
<entry type="LiteralStringAffix" style="#c4746e" />
<entry type="LiteralStringDelimiter" style="#949fb5" />
<entry type="LiteralStringEscape" style="#c4746e" />
<entry type="LiteralStringRegex" style="#c4746e" />
<entry type="LiteralStringDoc" style="#737c73" />
<entry type="LiteralStringHeredoc" style="#737c73" />
<entry type="LiteralNumber" style="#a292a3" />
<entry type="LiteralNumberBin" style="#a292a3" />
<entry type="LiteralNumberHex" style="#a292a3" />
<entry type="LiteralNumberInteger" style="#a292a3" />
<entry type="LiteralNumberFloat" style="#a292a3" />
<entry type="LiteralNumberIntegerLong" style="#a292a3" />
<entry type="LiteralNumberOct" style="#a292a3" />
<entry type="Operator" style="bold #c4746e" />
<entry type="OperatorWord" style="bold #c4746e" />
<entry type="Comment" style="italic #737c73" />
<entry type="CommentSingle" style="italic #737c73" />
<entry type="CommentMultiline" style="italic #737c73" />
<entry type="CommentSpecial" style="italic #737c73" />
<entry type="CommentHashbang" style="italic #737c73" />
<entry type="CommentPreproc" style="italic #c4746e" />
<entry type="CommentPreprocFile" style="bold #c4746e" />
<entry type="Generic" style="#c5c9c5" />
<entry type="GenericInserted" style="bg:#2b3328 #76946a" />
<entry type="GenericDeleted" style="bg:#43242b #c34043" />
<entry type="GenericEmph" style="italic #c5c9c5" />
<entry type="GenericStrong" style="bold #c5c9c5" />
<entry type="GenericUnderline" style="underline #c5c9c5" />
<entry type="GenericHeading" style="bold #8ba4b0" />
<entry type="GenericSubheading" style="bold #8ba4b0" />
<entry type="GenericOutput" style="#c5c9c5" />
<entry type="GenericPrompt" style="#c5c9c5" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

View File

@ -0,0 +1,83 @@
<style name="kanagawa-lotus">
<entry type="Background" style="bg:#f2ecbc #545464" />
<entry type="CodeLine" style="#545464" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#545464" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#e4d794" />
<entry type="LineNumbersTable" style="#a09cac" />
<entry type="LineNumbers" style="#a09cac" />
<entry type="Keyword" style="#624c83" />
<entry type="KeywordReserved" style="#624c83" />
<entry type="KeywordPseudo" style="#624c83" />
<entry type="KeywordConstant" style="#cc6d00" />
<entry type="KeywordDeclaration" style="#624c83" />
<entry type="KeywordNamespace" style="#77713f" />
<entry type="KeywordType" style="#597b75" />
<entry type="Name" style="#545464" />
<entry type="NameClass" style="#597b75" />
<entry type="NameConstant" style="#cc6d00" />
<entry type="NameDecorator" style="bold #cc6d00" />
<entry type="NameEntity" style="#77713f" />
<entry type="NameException" style="#cc6d00" />
<entry type="NameFunction" style="#4d699b" />
<entry type="NameFunctionMagic" style="#4d699b" />
<entry type="NameLabel" style="#6693bf" />
<entry type="NameNamespace" style="#77713f" />
<entry type="NameProperty" style="#77713f" />
<entry type="NameTag" style="#4d699b" />
<entry type="NameVariable" style="#545464" />
<entry type="NameVariableClass" style="#545464" />
<entry type="NameVariableGlobal" style="#545464" />
<entry type="NameVariableInstance" style="#545464" />
<entry type="NameVariableMagic" style="#545464" />
<entry type="NameAttribute" style="#77713f" />
<entry type="NameBuiltin" style="#c84053" />
<entry type="NameBuiltinPseudo" style="#c84053" />
<entry type="NameOther" style="#545464" />
<entry type="Literal" style="#545464" />
<entry type="LiteralDate" style="#545464" />
<entry type="LiteralString" style="#6f894e" />
<entry type="LiteralStringChar" style="#6f894e" />
<entry type="LiteralStringSingle" style="#6f894e" />
<entry type="LiteralStringDouble" style="#6f894e" />
<entry type="LiteralStringBacktick" style="#6f894e" />
<entry type="LiteralStringOther" style="#6f894e" />
<entry type="LiteralStringSymbol" style="#6f894e" />
<entry type="LiteralStringInterpol" style="#6693bf" />
<entry type="LiteralStringAffix" style="#c84053" />
<entry type="LiteralStringDelimiter" style="#6693bf" />
<entry type="LiteralStringEscape" style="#836f4a" />
<entry type="LiteralStringRegex" style="#836f4a" />
<entry type="LiteralStringDoc" style="#8a8980" />
<entry type="LiteralStringHeredoc" style="#8a8980" />
<entry type="LiteralNumber" style="#b35b79" />
<entry type="LiteralNumberBin" style="#b35b79" />
<entry type="LiteralNumberHex" style="#b35b79" />
<entry type="LiteralNumberInteger" style="#b35b79" />
<entry type="LiteralNumberFloat" style="#b35b79" />
<entry type="LiteralNumberIntegerLong" style="#b35b79" />
<entry type="LiteralNumberOct" style="#b35b79" />
<entry type="Operator" style="bold #836f4a" />
<entry type="OperatorWord" style="bold #836f4a" />
<entry type="Comment" style="italic #8a8980" />
<entry type="CommentSingle" style="italic #8a8980" />
<entry type="CommentMultiline" style="italic #8a8980" />
<entry type="CommentSpecial" style="italic #8a8980" />
<entry type="CommentHashbang" style="italic #8a8980" />
<entry type="CommentPreproc" style="italic #c84053" />
<entry type="CommentPreprocFile" style="bold #c84053" />
<entry type="Generic" style="#545464" />
<entry type="GenericInserted" style="bg:#b7d0ae #6e915f" />
<entry type="GenericDeleted" style="bg:#d9a594 #d7474b" />
<entry type="GenericEmph" style="italic #545464" />
<entry type="GenericStrong" style="bold #545464" />
<entry type="GenericUnderline" style="underline #545464" />
<entry type="GenericHeading" style="bold #4d699b" />
<entry type="GenericSubheading" style="bold #4d699b" />
<entry type="GenericOutput" style="#545464" />
<entry type="GenericPrompt" style="#545464" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

View File

@ -0,0 +1,83 @@
<style name="kanagawa-wave">
<entry type="Background" style="bg:#1f1f28 #dcd7ba" />
<entry type="CodeLine" style="#dcd7ba" />
<entry type="Error" style="#e82424" />
<entry type="Other" style="#dcd7ba" />
<entry type="LineTableTD" style="" />
<entry type="LineTable" style="" />
<entry type="LineHighlight" style="bg:#363646" />
<entry type="LineNumbersTable" style="#54546d" />
<entry type="LineNumbers" style="#54546d" />
<entry type="Keyword" style="#957fb8" />
<entry type="KeywordReserved" style="#957fb8" />
<entry type="KeywordPseudo" style="#957fb8" />
<entry type="KeywordConstant" style="#ffa066" />
<entry type="KeywordDeclaration" style="#957fb8" />
<entry type="KeywordNamespace" style="#e6c384" />
<entry type="KeywordType" style="#7aa89f" />
<entry type="Name" style="#dcd7ba" />
<entry type="NameClass" style="#7aa89f" />
<entry type="NameConstant" style="#ffa066" />
<entry type="NameDecorator" style="bold #ffa066" />
<entry type="NameEntity" style="#e6c384" />
<entry type="NameException" style="#ffa066" />
<entry type="NameFunction" style="#7e9cd8" />
<entry type="NameFunctionMagic" style="#7e9cd8" />
<entry type="NameLabel" style="#7fb4ca" />
<entry type="NameNamespace" style="#e6c384" />
<entry type="NameProperty" style="#e6c384" />
<entry type="NameTag" style="#7e9cd8" />
<entry type="NameVariable" style="#dcd7ba" />
<entry type="NameVariableClass" style="#dcd7ba" />
<entry type="NameVariableGlobal" style="#dcd7ba" />
<entry type="NameVariableInstance" style="#dcd7ba" />
<entry type="NameVariableMagic" style="#dcd7ba" />
<entry type="NameAttribute" style="#e6c384" />
<entry type="NameBuiltin" style="#e46876" />
<entry type="NameBuiltinPseudo" style="#e46876" />
<entry type="NameOther" style="#dcd7ba" />
<entry type="Literal" style="#dcd7ba" />
<entry type="LiteralDate" style="#dcd7ba" />
<entry type="LiteralString" style="#98bb6c" />
<entry type="LiteralStringChar" style="#98bb6c" />
<entry type="LiteralStringSingle" style="#98bb6c" />
<entry type="LiteralStringDouble" style="#98bb6c" />
<entry type="LiteralStringBacktick" style="#98bb6c" />
<entry type="LiteralStringOther" style="#98bb6c" />
<entry type="LiteralStringSymbol" style="#98bb6c" />
<entry type="LiteralStringInterpol" style="#7fb4ca" />
<entry type="LiteralStringAffix" style="#ff5d62" />
<entry type="LiteralStringDelimiter" style="#7fb4ca" />
<entry type="LiteralStringEscape" style="#c0a36e" />
<entry type="LiteralStringRegex" style="#c0a36e" />
<entry type="LiteralStringDoc" style="#727169" />
<entry type="LiteralStringHeredoc" style="#727169" />
<entry type="LiteralNumber" style="#d27e99" />
<entry type="LiteralNumberBin" style="#d27e99" />
<entry type="LiteralNumberHex" style="#d27e99" />
<entry type="LiteralNumberInteger" style="#d27e99" />
<entry type="LiteralNumberFloat" style="#d27e99" />
<entry type="LiteralNumberIntegerLong" style="#d27e99" />
<entry type="LiteralNumberOct" style="#d27e99" />
<entry type="Operator" style="bold #c0a36e" />
<entry type="OperatorWord" style="bold #c0a36e" />
<entry type="Comment" style="italic #727169" />
<entry type="CommentSingle" style="italic #727169" />
<entry type="CommentMultiline" style="italic #727169" />
<entry type="CommentSpecial" style="italic #727169" />
<entry type="CommentHashbang" style="italic #727169" />
<entry type="CommentPreproc" style="italic #e46876" />
<entry type="CommentPreprocFile" style="bold #e46876" />
<entry type="Generic" style="#dcd7ba" />
<entry type="GenericInserted" style="bg:#2b3328 #76946a" />
<entry type="GenericDeleted" style="bg:#43242b #c34043" />
<entry type="GenericEmph" style="italic #dcd7ba" />
<entry type="GenericStrong" style="bold #dcd7ba" />
<entry type="GenericUnderline" style="underline #dcd7ba" />
<entry type="GenericHeading" style="bold #7e9cd8" />
<entry type="GenericSubheading" style="bold #7e9cd8" />
<entry type="GenericOutput" style="#dcd7ba" />
<entry type="GenericPrompt" style="#dcd7ba" />
<entry type="GenericError" style="#e82424" />
<entry type="GenericTraceback" style="#e82424" />
</style>

25
qodo.md Normal file
View File

@ -0,0 +1,25 @@
### 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."`