diff --git a/FEATURES.md b/FEATURES.md index 261c4b2..6172d30 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -27,9 +27,9 @@ ### File Movement - [x] `G` - Move to bottom 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 -- [ ] `M` - Move to middle of screen -- [ ] `L` - Move to bottom of screen +- [x] `H` - Move to top of screen +- [x] `M` - Move to middle of screen +- [x] `L` - Move to bottom of screen ### Scroll - [x] `ctrl+u` - Scroll up half page @@ -105,8 +105,8 @@ - [x] `x` - Delete character under cursor - [x] `D` - Delete to end of line - [x] `X` - Delete character before cursor -- [ ] `J` - Join lines -- [ ] `gJ` - Join lines without space +- [x] `J` - Join lines +- [x] `gJ` - Join lines without space ### Yank/Paste - [x] `p` - Paste after cursor diff --git a/V0.1.md b/V0.1.md index 46f09e3..898bc0d 100644 --- a/V0.1.md +++ b/V0.1.md @@ -10,8 +10,8 @@ - [ ] Search (/, ?, n, N) with highlighting - [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS) - [ ] % (matching bracket) -- [ ] J (join lines) -- [ ] H/M/L (screen movement) +- [x] J (join lines) +- [x] H/M/L (screen movement) - [ ] Status line (mode, filename, position, modified flag) ## Should Have (Makes it Usable) diff --git a/internal/editor/integration_motion_screen_test.go b/internal/editor/integration_motion_screen_test.go new file mode 100644 index 0000000..9ad2f20 --- /dev/null +++ b/internal/editor/integration_motion_screen_test.go @@ -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) + } +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 355f332..a1577e1 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -26,6 +26,9 @@ func NewNormalKeymap() *Keymap { "k": motion.MoveUp{Count: 1}, "h": motion.MoveLeft{Count: 1}, "l": motion.MoveRight{Count: 1}, + "H": motion.MoveToScreenTop{Count: 1}, + "M": motion.MoveToScreenMiddle{}, + "L": motion.MoveToScreenBottom{Count: 1}, "G": motion.MoveToBottom{}, "gg": motion.MoveToTop{}, "0": motion.MoveToLineStart{}, @@ -121,6 +124,9 @@ func NewVisualKeymap() *Keymap { "k": motion.MoveUp{Count: 1}, "h": motion.MoveLeft{Count: 1}, "l": motion.MoveRight{Count: 1}, + "H": motion.MoveToScreenTop{Count: 1}, + "M": motion.MoveToScreenMiddle{}, + "L": motion.MoveToScreenBottom{Count: 1}, "G": motion.MoveToBottom{}, "gg": motion.MoveToTop{}, "0": motion.MoveToLineStart{}, diff --git a/internal/motion/jump.go b/internal/motion/jump.go index fc811f9..4d8518e 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -6,6 +6,30 @@ import ( 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 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 } + +// 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} +}