feat: implemented the % motion! tested as well
All checks were successful
Run Test Suite / test (push) Successful in 42s
All checks were successful
Run Test Suite / test (push) Successful in 42s
This commit is contained in:
parent
b808e75a38
commit
1aa1954d35
@ -57,7 +57,7 @@
|
||||
- [ ] `#` - Search word under cursor backward
|
||||
|
||||
### Other Movement
|
||||
- [ ] `%` - Jump to matching bracket
|
||||
- [x] `%` - Jump to matching bracket
|
||||
- [ ] `{` - Jump to previous paragraph
|
||||
- [ ] `}` - Jump to next paragraph
|
||||
- [ ] `(` - Jump to previous sentence
|
||||
|
||||
@ -725,3 +725,378 @@ func TestMoveToColumnInVisualMode(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJumpToMatchingDelimiter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lines []string
|
||||
start core.Position
|
||||
expected core.Position
|
||||
}{
|
||||
{
|
||||
name: "opening paren jumps forward to matching paren",
|
||||
lines: []string{"a(b(c)d)e"},
|
||||
start: core.Position{Line: 0, Col: 1},
|
||||
expected: core.Position{Line: 0, Col: 7},
|
||||
},
|
||||
{
|
||||
name: "closing paren jumps backward to matching opening paren",
|
||||
lines: []string{"a(b(c)d)e"},
|
||||
start: core.Position{Line: 0, Col: 7},
|
||||
expected: core.Position{Line: 0, Col: 1},
|
||||
},
|
||||
{
|
||||
name: "opening square bracket jumps forward with nesting",
|
||||
lines: []string{"x[ab[c]d]y"},
|
||||
start: core.Position{Line: 0, Col: 1},
|
||||
expected: core.Position{Line: 0, Col: 8},
|
||||
},
|
||||
{
|
||||
name: "closing square bracket jumps backward with nesting",
|
||||
lines: []string{"x[ab[c]d]y"},
|
||||
start: core.Position{Line: 0, Col: 8},
|
||||
expected: core.Position{Line: 0, Col: 1},
|
||||
},
|
||||
{
|
||||
name: "opening brace jumps forward across lines",
|
||||
lines: []string{"if (ok) {", " call()", "}"},
|
||||
start: core.Position{Line: 0, Col: 8},
|
||||
expected: core.Position{Line: 2, Col: 0},
|
||||
},
|
||||
{
|
||||
name: "closing brace jumps backward across lines",
|
||||
lines: []string{"if (ok) {", " call()", "}"},
|
||||
start: core.Position{Line: 2, Col: 0},
|
||||
expected: core.Position{Line: 0, Col: 8},
|
||||
},
|
||||
{
|
||||
name: "searches forward on current line when not on delimiter",
|
||||
lines: []string{"xx (a(b)c) yy"},
|
||||
start: core.Position{Line: 0, Col: 0},
|
||||
expected: core.Position{Line: 0, Col: 9},
|
||||
},
|
||||
{
|
||||
name: "no delimiter at or after cursor does nothing",
|
||||
lines: []string{"xx (a(b)c) yy"},
|
||||
start: core.Position{Line: 0, Col: 10},
|
||||
expected: core.Position{Line: 0, Col: 10},
|
||||
},
|
||||
{
|
||||
name: "unmatched opening delimiter does nothing",
|
||||
lines: []string{"x (abc"},
|
||||
start: core.Position{Line: 0, Col: 2},
|
||||
expected: core.Position{Line: 0, Col: 2},
|
||||
},
|
||||
{
|
||||
name: "unmatched closing delimiter does nothing",
|
||||
lines: []string{"abc)"},
|
||||
start: core.Position{Line: 0, Col: 3},
|
||||
expected: core.Position{Line: 0, Col: 3},
|
||||
},
|
||||
{
|
||||
name: "backward matching across lines handles nested delimiters",
|
||||
lines: []string{"if (a +", " (b * c)", ")"},
|
||||
start: core.Position{Line: 2, Col: 0},
|
||||
expected: core.Position{Line: 0, Col: 3},
|
||||
},
|
||||
{
|
||||
name: "forward matching across lines handles nested delimiters",
|
||||
lines: []string{"if (a +", " (b * c)", ")"},
|
||||
start: core.Position{Line: 0, Col: 3},
|
||||
expected: core.Position{Line: 2, Col: 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, tt.lines, tt.start)
|
||||
sendKeys(tm, "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveWindow().Cursor.Line != tt.expected.Line {
|
||||
t.Errorf("CursorY() = %d, want %d", m.ActiveWindow().Cursor.Line, tt.expected.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != tt.expected.Col {
|
||||
t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, tt.expected.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJumpToMatchingDelimiterInVisualMode(t *testing.T) {
|
||||
t.Run("test 'v%' selects to matching delimiter", func(t *testing.T) {
|
||||
lines := []string{"foo(bar)baz"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
|
||||
sendKeys(tm, "v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 3 {
|
||||
t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 7 {
|
||||
t.Errorf("CursorX() = %d, want 7", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v%' from closing delimiter selects backward", func(t *testing.T) {
|
||||
lines := []string{"foo(bar)baz"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 7})
|
||||
sendKeys(tm, "v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 7 {
|
||||
t.Errorf("AnchorX() = %d, want 7", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v%' searches forward on current line before selecting", func(t *testing.T) {
|
||||
lines := []string{"xx (a(b)c) yy"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 0})
|
||||
sendKeys(tm, "v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 9 {
|
||||
t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v%' with no delimiter after cursor keeps selection in place", func(t *testing.T) {
|
||||
lines := []string{"xx (a(b)c) yy"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
|
||||
sendKeys(tm, "v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 10 {
|
||||
t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 10 {
|
||||
t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) {
|
||||
lines := []string{"x (abc"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 2})
|
||||
sendKeys(tm, "v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 2 {
|
||||
t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v%d' deletes the matched delimiter range", func(t *testing.T) {
|
||||
lines := []string{"foo(bar)baz"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
|
||||
sendKeys(tm, "v", "%", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.NormalMode {
|
||||
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0].String() != "foobaz" {
|
||||
t.Errorf("Line(0) = %q, want 'foobaz'", m.ActiveBuffer().Lines[0].String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'V%' spans matching lines", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
|
||||
sendKeys(tm, "V", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualLineMode {
|
||||
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 0 {
|
||||
t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'V%' from closing delimiter selects backward across lines", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0})
|
||||
sendKeys(tm, "V", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualLineMode {
|
||||
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 2 {
|
||||
t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'V%' with no delimiter after cursor keeps selection in place", func(t *testing.T) {
|
||||
lines := []string{"xx (a(b)c) yy"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
|
||||
sendKeys(tm, "V", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualLineMode {
|
||||
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 {
|
||||
t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 {
|
||||
t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'V%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) {
|
||||
lines := []string{"if (abc"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3})
|
||||
sendKeys(tm, "V", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualLineMode {
|
||||
t.Errorf("Mode() = %v, want VisualLineMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 3 {
|
||||
t.Errorf("anchor = (%d,%d), want (0,3)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("cursor = (%d,%d), want (0,3)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'V%d' deletes linewise matched range", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
|
||||
sendKeys(tm, "V", "%", "d")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.NormalMode {
|
||||
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
|
||||
}
|
||||
if m.ActiveBuffer().LineCount() != 1 {
|
||||
t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount())
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0].String() != "" {
|
||||
t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+v%' updates block selection to matching delimiter", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
|
||||
sendKeys(tm, "ctrl+v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualBlockMode {
|
||||
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 8 {
|
||||
t.Errorf("anchor = (%d,%d), want (0,8)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 2 || m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("cursor = (%d,%d), want (2,0)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+v%' from closing delimiter selects backward", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0})
|
||||
sendKeys(tm, "ctrl+v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualBlockMode {
|
||||
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 2 || m.ActiveWindow().Anchor.Col != 0 {
|
||||
t.Errorf("anchor = (%d,%d), want (2,0)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 8 {
|
||||
t.Errorf("cursor = (%d,%d), want (0,8)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+v%' with no delimiter after cursor keeps block selection in place", func(t *testing.T) {
|
||||
lines := []string{"xx (a(b)c) yy"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10})
|
||||
sendKeys(tm, "ctrl+v", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualBlockMode {
|
||||
t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 {
|
||||
t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 {
|
||||
t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'ctrl+v%y' yanks block and exits visual mode", func(t *testing.T) {
|
||||
lines := []string{"if (ok) {", " call()", "}"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8})
|
||||
sendKeys(tm, "ctrl+v", "%", "y")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.NormalMode {
|
||||
t.Errorf("Mode() = %v, want NormalMode", m.Mode())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJumpToMatchingDelimiterIgnoresCount(t *testing.T) {
|
||||
t.Run("test '5%' in normal mode still performs delimiter matching", func(t *testing.T) {
|
||||
lines := []string{"a(b)c"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1})
|
||||
sendKeys(tm, "5", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test 'v5%' in visual mode still performs delimiter matching", func(t *testing.T) {
|
||||
lines := []string{"a(b)c"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1})
|
||||
sendKeys(tm, "v", "5", "%")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.Mode() != core.VisualMode {
|
||||
t.Errorf("Mode() = %v, want VisualMode", m.Mode())
|
||||
}
|
||||
if m.ActiveWindow().Anchor.Col != 1 {
|
||||
t.Errorf("AnchorX() = %d, want 1", m.ActiveWindow().Anchor.Col)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@ func NewNormalKeymap() *Keymap {
|
||||
"ctrl+f": motion.ScrollDownPage{Divisor: 1},
|
||||
";": action.RepeatFind{Count: 1, Reverse: false},
|
||||
",": action.RepeatFind{Count: 1, Reverse: true},
|
||||
"%": motion.JumpToMatchingDelimiter{},
|
||||
},
|
||||
operators: map[string]action.Operator{
|
||||
"d": operator.DeleteOperator{},
|
||||
@ -148,6 +149,7 @@ func NewVisualKeymap() *Keymap {
|
||||
"ctrl+f": motion.ScrollDownPage{Divisor: 1},
|
||||
";": action.RepeatFind{Count: 1, Reverse: false},
|
||||
",": action.RepeatFind{Count: 1, Reverse: true},
|
||||
"%": motion.JumpToMatchingDelimiter{},
|
||||
// TODO: O and o. These are fun ones! Should be simple too
|
||||
},
|
||||
operators: map[string]action.Operator{
|
||||
|
||||
@ -315,7 +315,7 @@ func TestCommandHistoryIntegration(t *testing.T) {
|
||||
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++ {
|
||||
for i := range 20 {
|
||||
history[i] = string(rune('A' + i))
|
||||
}
|
||||
|
||||
@ -329,7 +329,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
|
||||
upAction := MoveCommandHistoryUp{}
|
||||
|
||||
// Navigate to 10th command
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
upAction.Execute(m)
|
||||
}
|
||||
|
||||
@ -344,7 +344,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
|
||||
|
||||
t.Run("navigate to very end of long history", func(t *testing.T) {
|
||||
history := make([]string, 50)
|
||||
for i := 0; i < 50; i++ {
|
||||
for i := range 50 {
|
||||
history[i] = string(rune('0' + (i % 10)))
|
||||
}
|
||||
|
||||
@ -358,7 +358,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
|
||||
upAction := MoveCommandHistoryUp{}
|
||||
|
||||
// Navigate all the way to the end
|
||||
for i := 0; i < 100; i++ { // Try to go past end
|
||||
for range 100 { // Try to go past end
|
||||
upAction.Execute(m)
|
||||
}
|
||||
|
||||
|
||||
113
internal/motion/delimiter_utils.go
Normal file
113
internal/motion/delimiter_utils.go
Normal file
@ -0,0 +1,113 @@
|
||||
package motion
|
||||
|
||||
import "git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
|
||||
// Delimiter matching helpers operate on byte-indexed columns, which matches the
|
||||
// rest of the editor cursor model.
|
||||
|
||||
func isDelimiter(ch byte) bool {
|
||||
switch ch {
|
||||
case '{', '}', '[', ']', '(', ')':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isOpeningDelimiter(ch byte) bool {
|
||||
return ch == '{' || ch == '[' || ch == '('
|
||||
}
|
||||
|
||||
func getOppositeDelimiter(ch byte) (byte, bool) {
|
||||
switch ch {
|
||||
case '{':
|
||||
return '}', true
|
||||
case '}':
|
||||
return '{', true
|
||||
case '[':
|
||||
return ']', true
|
||||
case ']':
|
||||
return '[', true
|
||||
case '(':
|
||||
return ')', true
|
||||
case ')':
|
||||
return '(', true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func findDelimiterOnLine(line string, startCol int) (int, byte, bool) {
|
||||
if len(line) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
col := max(0, startCol)
|
||||
for col < len(line) {
|
||||
ch := line[col]
|
||||
if isDelimiter(ch) {
|
||||
return col, ch, true
|
||||
}
|
||||
col++
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func findMatchingForward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) {
|
||||
depth := 0
|
||||
|
||||
for y := line; y < buf.LineCount(); y++ {
|
||||
text := buf.Line(y)
|
||||
xStart := 0
|
||||
if y == line {
|
||||
xStart = col + 1
|
||||
}
|
||||
|
||||
for x := xStart; x < len(text); x++ {
|
||||
ch := text[x]
|
||||
if ch == startDelim {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == matchDelim {
|
||||
if depth == 0 {
|
||||
return core.Position{Line: y, Col: x}, true
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return core.Position{}, false
|
||||
}
|
||||
|
||||
func findMatchingBackward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) {
|
||||
depth := 0
|
||||
|
||||
for y := line; y >= 0; y-- {
|
||||
text := buf.Line(y)
|
||||
xStart := len(text) - 1
|
||||
if y == line {
|
||||
xStart = col - 1
|
||||
}
|
||||
|
||||
for x := xStart; x >= 0; x-- {
|
||||
ch := text[x]
|
||||
if ch == startDelim {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == matchDelim {
|
||||
if depth == 0 {
|
||||
return core.Position{Line: y, Col: x}, true
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return core.Position{}, false
|
||||
}
|
||||
@ -22,10 +22,7 @@ func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) {
|
||||
|
||||
start := win.ScrollY
|
||||
end := start + win.ViewportHeight() - 1
|
||||
end = min(end, buf.LineCount()-1)
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
end = max(min(end, buf.LineCount()-1), start)
|
||||
|
||||
return start, end
|
||||
}
|
||||
@ -293,3 +290,43 @@ func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise }
|
||||
func (a MoveToScreenBottom) WithCount(n int) action.Action {
|
||||
return MoveToScreenBottom{Count: n}
|
||||
}
|
||||
|
||||
// Used for the % motion, not countable
|
||||
type JumpToMatchingDelimiter struct{}
|
||||
|
||||
func (a JumpToMatchingDelimiter) Execute(m action.Model) tea.Cmd {
|
||||
buf := m.ActiveBuffer()
|
||||
if buf.LineCount() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
win := m.ActiveWindow()
|
||||
lineIdx := win.Cursor.Line
|
||||
line := buf.Line(lineIdx)
|
||||
|
||||
col, startDelim, found := findDelimiterOnLine(line, win.Cursor.Col)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
matchDelim, ok := getOppositeDelimiter(startDelim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var target core.Position
|
||||
if isOpeningDelimiter(startDelim) {
|
||||
target, found = findMatchingForward(buf, lineIdx, col, startDelim, matchDelim)
|
||||
} else {
|
||||
target, found = findMatchingBackward(buf, lineIdx, col, startDelim, matchDelim)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
win.SetCursorPos(target.Line, target.Col)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a JumpToMatchingDelimiter) Type() core.MotionType { return core.CharwiseInclusive }
|
||||
|
||||
@ -194,7 +194,7 @@ func isValidPair(line string, startDelim, endDelim rune, startPos, endPos int) b
|
||||
// A closing quote has an odd number of quotes before it (1, 3, 5, ...)
|
||||
|
||||
quotesBeforeStart := 0
|
||||
for i := 0; i < startPos; i++ {
|
||||
for i := range startPos {
|
||||
if rune(line[i]) == startDelim {
|
||||
quotesBeforeStart++
|
||||
}
|
||||
@ -470,7 +470,7 @@ func isCursorBetween(cursor, start, end core.Position) bool {
|
||||
|
||||
// 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++ {
|
||||
for i := range col {
|
||||
if !isWhitespace(rune(line[i])) {
|
||||
return false
|
||||
}
|
||||
|
||||
4
v0.1.md
4
v0.1.md
@ -9,7 +9,7 @@
|
||||
- [x] Buffer switching
|
||||
- [ ] Search (/, ?, n, N) with highlighting
|
||||
- [x] Syntax highlighting via treesitter
|
||||
- [ ] % (matching bracket)
|
||||
- [x] % (matching bracket)
|
||||
- [x] J (join lines)
|
||||
- [x] H/M/L (screen movement)
|
||||
- [ ] Status line (mode, filename, position, modified flag)
|
||||
@ -18,7 +18,6 @@
|
||||
## 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~
|
||||
- [x] Horizontal scroll
|
||||
@ -40,3 +39,4 @@
|
||||
- Macros → v0.2
|
||||
- Git integration → v0.3
|
||||
- Plugin system → v1.0
|
||||
- Configuration file -> v0.3
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user