feat: implemtned H, M, L screen actions, tested
All checks were successful
Run Test Suite / test (push) Successful in 17s
All checks were successful
Run Test Suite / test (push) Successful in 17s
This commit is contained in:
parent
f17573edd2
commit
7a7472fd12
10
FEATURES.md
10
FEATURES.md
@ -27,9 +27,9 @@
|
|||||||
### File Movement
|
### File Movement
|
||||||
- [x] `G` - Move to bottom of file (or line N with count)
|
- [x] `G` - Move to bottom of file (or line N with count)
|
||||||
- [x] `gg` - Move to top of file (or line N with count)
|
- [x] `gg` - Move to top of file (or line N with count)
|
||||||
- [ ] `H` - Move to top of screen
|
- [x] `H` - Move to top of screen
|
||||||
- [ ] `M` - Move to middle of screen
|
- [x] `M` - Move to middle of screen
|
||||||
- [ ] `L` - Move to bottom of screen
|
- [x] `L` - Move to bottom of screen
|
||||||
|
|
||||||
### Scroll
|
### Scroll
|
||||||
- [x] `ctrl+u` - Scroll up half page
|
- [x] `ctrl+u` - Scroll up half page
|
||||||
@ -105,8 +105,8 @@
|
|||||||
- [x] `x` - Delete character under cursor
|
- [x] `x` - Delete character under cursor
|
||||||
- [x] `D` - Delete to end of line
|
- [x] `D` - Delete to end of line
|
||||||
- [x] `X` - Delete character before cursor
|
- [x] `X` - Delete character before cursor
|
||||||
- [ ] `J` - Join lines
|
- [x] `J` - Join lines
|
||||||
- [ ] `gJ` - Join lines without space
|
- [x] `gJ` - Join lines without space
|
||||||
|
|
||||||
### Yank/Paste
|
### Yank/Paste
|
||||||
- [x] `p` - Paste after cursor
|
- [x] `p` - Paste after cursor
|
||||||
|
|||||||
4
V0.1.md
4
V0.1.md
@ -10,8 +10,8 @@
|
|||||||
- [ ] Search (/, ?, n, N) with highlighting
|
- [ ] Search (/, ?, n, N) with highlighting
|
||||||
- [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS)
|
- [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS)
|
||||||
- [ ] % (matching bracket)
|
- [ ] % (matching bracket)
|
||||||
- [ ] J (join lines)
|
- [x] J (join lines)
|
||||||
- [ ] H/M/L (screen movement)
|
- [x] H/M/L (screen movement)
|
||||||
- [ ] Status line (mode, filename, position, modified flag)
|
- [ ] Status line (mode, filename, position, modified flag)
|
||||||
|
|
||||||
## Should Have (Makes it Usable)
|
## Should Have (Makes it Usable)
|
||||||
|
|||||||
162
internal/editor/integration_motion_screen_test.go
Normal file
162
internal/editor/integration_motion_screen_test.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScreenTopMotion(t *testing.T) {
|
||||||
|
t.Run("H moves to the top visible line first non-blank", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[82] = " top"
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "H")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 82, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("3H moves to third visible line from top", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[84] = " third"
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "3", "H")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 84, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("H count clamps to bottom visible line", func(t *testing.T) {
|
||||||
|
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "9", "9", "9", "H")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 99, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenMiddleMotion(t *testing.T) {
|
||||||
|
t.Run("M moves to middle visible line first non-blank", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[90] = "\tmiddle"
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "M")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 90, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("count before M is ignored", func(t *testing.T) {
|
||||||
|
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "9", "M")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 90, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenBottomMotion(t *testing.T) {
|
||||||
|
t.Run("L moves to bottom visible line first non-blank", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[99] = " bottom"
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "L")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 99, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("3L moves to third visible line from bottom", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[97] = " above"
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "3", "L")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 97, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("L count clamps to top visible line", func(t *testing.T) {
|
||||||
|
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "9", "9", "9", "L")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 82, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenMotionsEdgeCases(t *testing.T) {
|
||||||
|
t.Run("small file: H and L clamp to file bounds", func(t *testing.T) {
|
||||||
|
lines := []string{" one", "two", "three", "four", "five"}
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "L")
|
||||||
|
sendKeys(tm, "H")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 0, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("M on blank middle line lands at column 0", func(t *testing.T) {
|
||||||
|
lines := screenMotionLines(100)
|
||||||
|
lines[90] = ""
|
||||||
|
|
||||||
|
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "M")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
assertCursorPos(t, m, 90, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenMotionsIntegration(t *testing.T) {
|
||||||
|
t.Run("dH treats H as a linewise motion", func(t *testing.T) {
|
||||||
|
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "d", "H")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().LineCount() != 82 {
|
||||||
|
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Line(81) != "line 81" {
|
||||||
|
t.Errorf("Line(81) = %q, want %q", m.ActiveBuffer().Line(81), "line 81")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("H works as a visual line motion", func(t *testing.T) {
|
||||||
|
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
||||||
|
sendKeys(tm, "G", "V", "H", "d")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().LineCount() != 82 {
|
||||||
|
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
|
||||||
|
}
|
||||||
|
if m.Mode() != core.NormalMode {
|
||||||
|
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenMotionLines(n int) []string {
|
||||||
|
lines := make([]string, n)
|
||||||
|
for i := range n {
|
||||||
|
lines[i] = fmt.Sprintf("line %d", i)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCursorPos(t *testing.T, m *Model, wantLine, wantCol int) {
|
||||||
|
t.Helper()
|
||||||
|
if m.ActiveWindow().Cursor.Line != wantLine {
|
||||||
|
t.Errorf("Cursor.Line = %d, want %d", m.ActiveWindow().Cursor.Line, wantLine)
|
||||||
|
}
|
||||||
|
if m.ActiveWindow().Cursor.Col != wantCol {
|
||||||
|
t.Errorf("Cursor.Col = %d, want %d", m.ActiveWindow().Cursor.Col, wantCol)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,9 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"k": motion.MoveUp{Count: 1},
|
"k": motion.MoveUp{Count: 1},
|
||||||
"h": motion.MoveLeft{Count: 1},
|
"h": motion.MoveLeft{Count: 1},
|
||||||
"l": motion.MoveRight{Count: 1},
|
"l": motion.MoveRight{Count: 1},
|
||||||
|
"H": motion.MoveToScreenTop{Count: 1},
|
||||||
|
"M": motion.MoveToScreenMiddle{},
|
||||||
|
"L": motion.MoveToScreenBottom{Count: 1},
|
||||||
"G": motion.MoveToBottom{},
|
"G": motion.MoveToBottom{},
|
||||||
"gg": motion.MoveToTop{},
|
"gg": motion.MoveToTop{},
|
||||||
"0": motion.MoveToLineStart{},
|
"0": motion.MoveToLineStart{},
|
||||||
@ -121,6 +124,9 @@ func NewVisualKeymap() *Keymap {
|
|||||||
"k": motion.MoveUp{Count: 1},
|
"k": motion.MoveUp{Count: 1},
|
||||||
"h": motion.MoveLeft{Count: 1},
|
"h": motion.MoveLeft{Count: 1},
|
||||||
"l": motion.MoveRight{Count: 1},
|
"l": motion.MoveRight{Count: 1},
|
||||||
|
"H": motion.MoveToScreenTop{Count: 1},
|
||||||
|
"M": motion.MoveToScreenMiddle{},
|
||||||
|
"L": motion.MoveToScreenBottom{Count: 1},
|
||||||
"G": motion.MoveToBottom{},
|
"G": motion.MoveToBottom{},
|
||||||
"gg": motion.MoveToTop{},
|
"gg": motion.MoveToTop{},
|
||||||
"0": motion.MoveToLineStart{},
|
"0": motion.MoveToLineStart{},
|
||||||
|
|||||||
@ -6,6 +6,30 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func firstNonBlankCol(line string) int {
|
||||||
|
for i := 0; i < len(line); i++ {
|
||||||
|
if line[i] != ' ' && line[i] != '\t' {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) {
|
||||||
|
if buf.LineCount() == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
start := win.ScrollY
|
||||||
|
end := start + win.ViewportHeight() - 1
|
||||||
|
end = min(end, buf.LineCount()-1)
|
||||||
|
if end < start {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
// MoveToTop implements Motion (gg) - linewise
|
// MoveToTop implements Motion (gg) - linewise
|
||||||
type MoveToTop struct{}
|
type MoveToTop struct{}
|
||||||
|
|
||||||
@ -198,3 +222,74 @@ func (a ScrollUpPage) Execute(m action.Model) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
|
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
|
||||||
|
|
||||||
|
// MoveToScreenTop implements Motion (H) - linewise
|
||||||
|
type MoveToScreenTop struct {
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToScreenTop.Execute: Moves the cursor to the count-th line from the top
|
||||||
|
// of the visible window and places it on the first non-blank character.
|
||||||
|
func (a MoveToScreenTop) Execute(m action.Model) tea.Cmd {
|
||||||
|
win := m.ActiveWindow()
|
||||||
|
buf := m.ActiveBuffer()
|
||||||
|
|
||||||
|
start, end := visibleLineBounds(win, buf)
|
||||||
|
count := max(1, a.Count)
|
||||||
|
targetLine := min(start+count-1, end)
|
||||||
|
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
||||||
|
|
||||||
|
win.SetCursorPos(targetLine, targetCol)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a MoveToScreenTop) Type() core.MotionType { return core.Linewise }
|
||||||
|
|
||||||
|
func (a MoveToScreenTop) WithCount(n int) action.Action {
|
||||||
|
return MoveToScreenTop{Count: n}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToScreenMiddle implements Motion (M) - linewise
|
||||||
|
type MoveToScreenMiddle struct{}
|
||||||
|
|
||||||
|
// MoveToScreenMiddle.Execute: Moves the cursor to the middle visible line and
|
||||||
|
// places it on the first non-blank character.
|
||||||
|
func (a MoveToScreenMiddle) Execute(m action.Model) tea.Cmd {
|
||||||
|
win := m.ActiveWindow()
|
||||||
|
buf := m.ActiveBuffer()
|
||||||
|
|
||||||
|
start, end := visibleLineBounds(win, buf)
|
||||||
|
targetLine := start + (end-start)/2
|
||||||
|
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
||||||
|
|
||||||
|
win.SetCursorPos(targetLine, targetCol)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a MoveToScreenMiddle) Type() core.MotionType { return core.Linewise }
|
||||||
|
|
||||||
|
// MoveToScreenBottom implements Motion (L) - linewise
|
||||||
|
type MoveToScreenBottom struct {
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveToScreenBottom.Execute: Moves the cursor to the count-th line from the
|
||||||
|
// bottom of the visible window and places it on the first non-blank character.
|
||||||
|
func (a MoveToScreenBottom) Execute(m action.Model) tea.Cmd {
|
||||||
|
win := m.ActiveWindow()
|
||||||
|
buf := m.ActiveBuffer()
|
||||||
|
|
||||||
|
start, end := visibleLineBounds(win, buf)
|
||||||
|
count := max(1, a.Count)
|
||||||
|
targetLine := max(end-count+1, start)
|
||||||
|
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
||||||
|
|
||||||
|
win.SetCursorPos(targetLine, targetCol)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise }
|
||||||
|
|
||||||
|
func (a MoveToScreenBottom) WithCount(n int) action.Action {
|
||||||
|
return MoveToScreenBottom{Count: n}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user