feat: implemented the % motion! tested as well
All checks were successful
Run Test Suite / test (push) Successful in 42s

This commit is contained in:
Hayden Hargreaves 2026-04-09 09:32:25 -07:00
parent b808e75a38
commit 1aa1954d35
8 changed files with 598 additions and 71 deletions

View File

@ -57,7 +57,7 @@
- [ ] `#` - Search word under cursor backward - [ ] `#` - Search word under cursor backward
### Other Movement ### Other Movement
- [ ] `%` - Jump to matching bracket - [x] `%` - Jump to matching bracket
- [ ] `{` - Jump to previous paragraph - [ ] `{` - Jump to previous paragraph
- [ ] `}` - Jump to next paragraph - [ ] `}` - Jump to next paragraph
- [ ] `(` - Jump to previous sentence - [ ] `(` - Jump to previous sentence

View File

@ -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)
}
})
}

View File

@ -50,6 +50,7 @@ func NewNormalKeymap() *Keymap {
"ctrl+f": motion.ScrollDownPage{Divisor: 1}, "ctrl+f": motion.ScrollDownPage{Divisor: 1},
";": action.RepeatFind{Count: 1, Reverse: false}, ";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true}, ",": action.RepeatFind{Count: 1, Reverse: true},
"%": motion.JumpToMatchingDelimiter{},
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{
"d": operator.DeleteOperator{}, "d": operator.DeleteOperator{},
@ -148,6 +149,7 @@ func NewVisualKeymap() *Keymap {
"ctrl+f": motion.ScrollDownPage{Divisor: 1}, "ctrl+f": motion.ScrollDownPage{Divisor: 1},
";": action.RepeatFind{Count: 1, Reverse: false}, ";": action.RepeatFind{Count: 1, Reverse: false},
",": action.RepeatFind{Count: 1, Reverse: true}, ",": action.RepeatFind{Count: 1, Reverse: true},
"%": motion.JumpToMatchingDelimiter{},
// TODO: O and o. These are fun ones! Should be simple too // TODO: O and o. These are fun ones! Should be simple too
}, },
operators: map[string]action.Operator{ operators: map[string]action.Operator{

View File

@ -315,7 +315,7 @@ func TestCommandHistoryIntegration(t *testing.T) {
func TestCommandHistoryWithLongHistory(t *testing.T) { func TestCommandHistoryWithLongHistory(t *testing.T) {
t.Run("navigate through 20 commands", func(t *testing.T) { t.Run("navigate through 20 commands", func(t *testing.T) {
history := make([]string, 20) history := make([]string, 20)
for i := 0; i < 20; i++ { for i := range 20 {
history[i] = string(rune('A' + i)) history[i] = string(rune('A' + i))
} }
@ -329,7 +329,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
upAction := MoveCommandHistoryUp{} upAction := MoveCommandHistoryUp{}
// Navigate to 10th command // Navigate to 10th command
for i := 0; i < 10; i++ { for range 10 {
upAction.Execute(m) 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) { t.Run("navigate to very end of long history", func(t *testing.T) {
history := make([]string, 50) history := make([]string, 50)
for i := 0; i < 50; i++ { for i := range 50 {
history[i] = string(rune('0' + (i % 10))) history[i] = string(rune('0' + (i % 10)))
} }
@ -358,7 +358,7 @@ func TestCommandHistoryWithLongHistory(t *testing.T) {
upAction := MoveCommandHistoryUp{} upAction := MoveCommandHistoryUp{}
// Navigate all the way to the end // 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) upAction.Execute(m)
} }

View 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
}

View File

@ -22,10 +22,7 @@ func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) {
start := win.ScrollY start := win.ScrollY
end := start + win.ViewportHeight() - 1 end := start + win.ViewportHeight() - 1
end = min(end, buf.LineCount()-1) end = max(min(end, buf.LineCount()-1), start)
if end < start {
end = start
}
return start, end return start, end
} }
@ -293,3 +290,43 @@ func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise }
func (a MoveToScreenBottom) WithCount(n int) action.Action { func (a MoveToScreenBottom) WithCount(n int) action.Action {
return MoveToScreenBottom{Count: n} 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 }

View File

@ -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, ...) // A closing quote has an odd number of quotes before it (1, 3, 5, ...)
quotesBeforeStart := 0 quotesBeforeStart := 0
for i := 0; i < startPos; i++ { for i := range startPos {
if rune(line[i]) == startDelim { if rune(line[i]) == startDelim {
quotesBeforeStart++ 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. // hasOnlyWhitespaceBefore checks if there is only whitespace before the character at col.
func hasOnlyWhitespaceBefore(line string, col int) bool { func hasOnlyWhitespaceBefore(line string, col int) bool {
for i := 0; i < col; i++ { for i := range col {
if !isWhitespace(rune(line[i])) { if !isWhitespace(rune(line[i])) {
return false return false
} }

View File

@ -9,7 +9,7 @@
- [x] Buffer switching - [x] Buffer switching
- [ ] Search (/, ?, n, N) with highlighting - [ ] Search (/, ?, n, N) with highlighting
- [x] Syntax highlighting via treesitter - [x] Syntax highlighting via treesitter
- [ ] % (matching bracket) - [x] % (matching bracket)
- [x] J (join lines) - [x] J (join lines)
- [x] 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)
@ -18,7 +18,6 @@
## Should Have (Makes it Usable) ## Should Have (Makes it Usable)
- [ ] :substitute (%s/old/new/g) - at least basic version - [ ] :substitute (%s/old/new/g) - at least basic version
- [ ] Better command-mode autocomplete/hints - [ ] Better command-mode autocomplete/hints
- [ ] Configuration file support (~/.gimrc or similar)
- [ ] ~Persistent undo history~ - [ ] ~Persistent undo history~
- [ ] ~Line wrapping display option~ - [ ] ~Line wrapping display option~
- [x] Horizontal scroll - [x] Horizontal scroll
@ -40,3 +39,4 @@
- Macros → v0.2 - Macros → v0.2
- Git integration → v0.3 - Git integration → v0.3
- Plugin system → v1.0 - Plugin system → v1.0
- Configuration file -> v0.3