Skip to content

Commit 9c45ef3

Browse files
authored
Merge pull request #113 from Maxteabag/feature/vim-count-prefixf
feat: add vim count prefix support for motions and operators
2 parents ac15fbe + b7a438c commit 9c45ef3

File tree

10 files changed

+650
-52
lines changed

10 files changed

+650
-52
lines changed

sqlit/core/input_context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ class InputContext:
3333
last_result_is_error: bool
3434
has_results: bool
3535
stacked_result_count: int = 0
36+
count_buffer: str = ""

sqlit/domains/query/ui/mixins/query_editing_cursor.py

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,46 @@ class QueryEditingCursorMixin:
99
"""Cursor movement and navigation for the query editor."""
1010

1111
def _move_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = None) -> None:
12-
"""Move cursor using a vim motion."""
12+
"""Move cursor using a vim motion, with optional count prefix support."""
1313
from sqlit.domains.query.editing import MOTIONS
1414

1515
motion_func = MOTIONS.get(motion_key)
1616
if not motion_func:
1717
return
1818

19+
# Get count prefix (if any)
20+
count = self._get_and_clear_count() or 1
21+
1922
text = self.query_input.text
2023
row, col = self.query_input.cursor_location
21-
result = motion_func(text, row, col, char)
22-
self.query_input.cursor_location = (result.position.row, result.position.col)
24+
25+
# Apply motion `count` times
26+
for _ in range(count):
27+
result = motion_func(text, row, col, char)
28+
new_row, new_col = result.position.row, result.position.col
29+
# Stop if motion didn't move (hit boundary)
30+
if (new_row, new_col) == (row, col):
31+
break
32+
row, col = new_row, new_col
33+
34+
self.query_input.cursor_location = (row, col)
2335

2436
def action_g_leader_key(self: QueryMixinHost) -> None:
2537
"""Show the g motion leader menu."""
2638
self._start_leader_pending("g")
2739

2840
def action_g_first_line(self: QueryMixinHost) -> None:
29-
"""Go to first line (gg)."""
41+
"""Go to first line (gg), or to line N with count prefix (e.g., 3gg)."""
3042
self._clear_leader_pending()
31-
self.query_input.cursor_location = (0, 0)
43+
count = self._get_and_clear_count()
44+
if count is not None:
45+
lines = self.query_input.text.split("\n")
46+
num_lines = len(lines)
47+
target_row = min(count - 1, num_lines - 1)
48+
target_row = max(0, target_row)
49+
self.query_input.cursor_location = (target_row, 0)
50+
else:
51+
self.query_input.cursor_location = (0, 0)
3252

3353
def action_g_word_end_back(self: QueryMixinHost) -> None:
3454
"""Go to end of previous word (ge)."""
@@ -56,32 +76,20 @@ def action_g_execute_query_atomic(self: QueryMixinHost) -> None:
5676
self.action_execute_query_atomic()
5777

5878
def action_cursor_left(self: QueryMixinHost) -> None:
59-
"""Move cursor left (h in normal mode)."""
60-
row, col = self.query_input.cursor_location
61-
self.query_input.cursor_location = (row, max(0, col - 1))
79+
"""Move cursor left (h in normal mode), with count support."""
80+
self._move_with_motion("h")
6281

6382
def action_cursor_right(self: QueryMixinHost) -> None:
64-
"""Move cursor right (l in normal mode)."""
65-
lines = self.query_input.text.split("\n")
66-
row, col = self.query_input.cursor_location
67-
line_len = len(lines[row]) if row < len(lines) else 0
68-
self.query_input.cursor_location = (row, min(col + 1, line_len))
83+
"""Move cursor right (l in normal mode), with count support."""
84+
self._move_with_motion("l")
6985

7086
def action_cursor_up(self: QueryMixinHost) -> None:
71-
"""Move cursor up (k in normal mode)."""
72-
lines = self.query_input.text.split("\n")
73-
row, col = self.query_input.cursor_location
74-
new_row = max(0, row - 1)
75-
new_col = min(col, len(lines[new_row]) if new_row < len(lines) else 0)
76-
self.query_input.cursor_location = (new_row, new_col)
87+
"""Move cursor up (k in normal mode), with count support."""
88+
self._move_with_motion("k")
7789

7890
def action_cursor_down(self: QueryMixinHost) -> None:
79-
"""Move cursor down (j in normal mode)."""
80-
lines = self.query_input.text.split("\n")
81-
row, col = self.query_input.cursor_location
82-
new_row = min(row + 1, len(lines) - 1)
83-
new_col = min(col, len(lines[new_row]) if new_row < len(lines) else 0)
84-
self.query_input.cursor_location = (new_row, new_col)
91+
"""Move cursor down (j in normal mode), with count support."""
92+
self._move_with_motion("j")
8593

8694
def action_cursor_word_forward(self: QueryMixinHost) -> None:
8795
"""Move cursor to next word (w)."""
@@ -108,8 +116,18 @@ def action_cursor_line_end(self: QueryMixinHost) -> None:
108116
self._move_with_motion("$")
109117

110118
def action_cursor_last_line(self: QueryMixinHost) -> None:
111-
"""Move cursor to last line (G)."""
112-
self._move_with_motion("G")
119+
"""Move cursor to last line (G), or to line N with count prefix (e.g., 25G)."""
120+
count = self._get_and_clear_count()
121+
if count is not None:
122+
# Go to specific line (1-indexed)
123+
lines = self.query_input.text.split("\n")
124+
num_lines = len(lines)
125+
target_row = min(count - 1, num_lines - 1) # Convert to 0-indexed, clamp
126+
target_row = max(0, target_row)
127+
self.query_input.cursor_location = (target_row, 0)
128+
else:
129+
# Go to last line
130+
self._move_with_motion("G")
113131

114132
def action_cursor_matching_bracket(self: QueryMixinHost) -> None:
115133
"""Move cursor to matching bracket (%)."""

sqlit/domains/query/ui/mixins/query_editing_operators.py

Lines changed: 109 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,14 @@ class QueryEditingOperatorsMixin:
1010
"""Delete/yank/change operator actions for the query editor."""
1111

1212
def action_delete_line(self: QueryMixinHost) -> None:
13-
"""Delete the current line in the query editor."""
13+
"""Delete the current line (dd), with count support for multi-line delete."""
1414
self._clear_leader_pending()
15-
result = edit_delete.delete_line(
16-
self.query_input.text,
17-
*self.query_input.cursor_location,
18-
)
19-
self._apply_edit_result(result)
15+
self._delete_with_motion("_") # _ is the current line motion
2016

2117
def action_delete_word(self: QueryMixinHost) -> None:
22-
"""Delete forward word starting at cursor."""
18+
"""Delete forward word (dw), with count support."""
2319
self._clear_leader_pending()
24-
result = edit_delete.delete_word(
25-
self.query_input.text,
26-
*self.query_input.cursor_location,
27-
)
28-
self._apply_edit_result(result)
20+
self._delete_with_motion("w")
2921

3022
def action_delete_word_back(self: QueryMixinHost) -> None:
3123
"""Delete word backwards from cursor."""
@@ -200,24 +192,57 @@ def handle_result(obj_char: str | None) -> None:
200192
self.push_screen(TextObjectMenuScreen(mode, operator="delete"), handle_result)
201193

202194
def _delete_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = None) -> None:
203-
"""Execute delete with a motion."""
204-
from sqlit.domains.query.editing import MOTIONS, operator_delete
195+
"""Execute delete with a motion, with optional count prefix support."""
196+
from sqlit.domains.query.editing import MOTIONS, MotionType, Position, Range, operator_delete
205197

206198
motion_func = MOTIONS.get(motion_key)
207199
if not motion_func:
208200
return
209201

202+
# Get count prefix (if any)
203+
count = self._get_and_clear_count() or 1
204+
210205
text = self.query_input.text
211206
row, col = self.query_input.cursor_location
207+
lines = text.split("\n")
212208

213209
result = motion_func(text, row, col, char)
214210
if not result.range:
215211
return
216212

213+
final_range = result.range
214+
215+
# Handle count for line motions (e.g., 3dd deletes 3 lines)
216+
if motion_key == "_" and count > 1:
217+
# _ is the current line motion; expand to cover `count` lines
218+
start_row = row
219+
end_row = min(row + count - 1, len(lines) - 1)
220+
end_col = len(lines[end_row]) if end_row < len(lines) else 0
221+
final_range = Range(
222+
Position(start_row, 0),
223+
Position(end_row, end_col),
224+
MotionType.LINEWISE,
225+
)
226+
elif count > 1:
227+
# For other motions, iterate to expand range
228+
end_row, end_col = result.position.row, result.position.col
229+
for _ in range(count - 1):
230+
next_result = motion_func(text, end_row, end_col, char)
231+
if (next_result.position.row, next_result.position.col) == (end_row, end_col):
232+
break # Motion didn't move
233+
end_row, end_col = next_result.position.row, next_result.position.col
234+
# Rebuild range from original position to final position
235+
final_range = Range(
236+
result.range.start,
237+
Position(end_row, end_col),
238+
result.range.motion_type,
239+
result.range.inclusive,
240+
)
241+
217242
# Push undo state before delete
218243
self._push_undo_state()
219244

220-
op_result = operator_delete(text, result.range)
245+
op_result = operator_delete(text, final_range)
221246
self.query_input.text = op_result.text
222247
self.query_input.cursor_location = (op_result.row, op_result.col)
223248

@@ -389,27 +414,58 @@ def handle_result(obj_char: str | None) -> None:
389414
self.push_screen(TextObjectMenuScreen(mode, operator="yank"), handle_result)
390415

391416
def _yank_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = None) -> None:
392-
"""Execute yank with a motion."""
393-
from sqlit.domains.query.editing import MOTIONS, operator_yank
417+
"""Execute yank with a motion, with optional count prefix support."""
418+
from sqlit.domains.query.editing import MOTIONS, MotionType, Position, Range, operator_yank
394419

395420
motion_func = MOTIONS.get(motion_key)
396421
if not motion_func:
397422
return
398423

424+
# Get count prefix (if any)
425+
count = self._get_and_clear_count() or 1
426+
399427
text = self.query_input.text
400428
row, col = self.query_input.cursor_location
429+
lines = text.split("\n")
401430

402431
result = motion_func(text, row, col, char)
403432
if not result.range:
404433
return
405434

406-
op_result = operator_yank(text, result.range)
435+
final_range = result.range
436+
437+
# Handle count for line motions (e.g., 3yy yanks 3 lines)
438+
if motion_key == "_" and count > 1:
439+
start_row = row
440+
end_row = min(row + count - 1, len(lines) - 1)
441+
end_col = len(lines[end_row]) if end_row < len(lines) else 0
442+
final_range = Range(
443+
Position(start_row, 0),
444+
Position(end_row, end_col),
445+
MotionType.LINEWISE,
446+
)
447+
elif count > 1:
448+
# For other motions, iterate to expand range
449+
end_row, end_col = result.position.row, result.position.col
450+
for _ in range(count - 1):
451+
next_result = motion_func(text, end_row, end_col, char)
452+
if (next_result.position.row, next_result.position.col) == (end_row, end_col):
453+
break
454+
end_row, end_col = next_result.position.row, next_result.position.col
455+
final_range = Range(
456+
result.range.start,
457+
Position(end_row, end_col),
458+
result.range.motion_type,
459+
result.range.inclusive,
460+
)
461+
462+
op_result = operator_yank(text, final_range)
407463

408464
# Copy yanked text to system clipboard
409465
if op_result.yanked:
410466
self._copy_text(op_result.yanked)
411467
# Flash the yanked range
412-
ordered = result.range.ordered()
468+
ordered = final_range.ordered()
413469
self._flash_yank_range(
414470
ordered.start.row, ordered.start.col,
415471
ordered.end.row, ordered.end.col,
@@ -590,24 +646,55 @@ def handle_result(obj_char: str | None) -> None:
590646
self.push_screen(TextObjectMenuScreen(mode, operator="change"), handle_result)
591647

592648
def _change_with_motion(self: QueryMixinHost, motion_key: str, char: str | None = None) -> None:
593-
"""Execute change with a motion (delete + enter insert mode)."""
594-
from sqlit.domains.query.editing import MOTIONS, operator_change
649+
"""Execute change with a motion, with optional count prefix support."""
650+
from sqlit.domains.query.editing import MOTIONS, MotionType, Position, Range, operator_change
595651

596652
motion_func = MOTIONS.get(motion_key)
597653
if not motion_func:
598654
return
599655

656+
# Get count prefix (if any)
657+
count = self._get_and_clear_count() or 1
658+
600659
text = self.query_input.text
601660
row, col = self.query_input.cursor_location
661+
lines = text.split("\n")
602662

603663
result = motion_func(text, row, col, char)
604664
if not result.range:
605665
return
606666

667+
final_range = result.range
668+
669+
# Handle count for line motions (e.g., 3cc changes 3 lines)
670+
if motion_key == "_" and count > 1:
671+
start_row = row
672+
end_row = min(row + count - 1, len(lines) - 1)
673+
end_col = len(lines[end_row]) if end_row < len(lines) else 0
674+
final_range = Range(
675+
Position(start_row, 0),
676+
Position(end_row, end_col),
677+
MotionType.LINEWISE,
678+
)
679+
elif count > 1:
680+
# For other motions, iterate to expand range
681+
end_row, end_col = result.position.row, result.position.col
682+
for _ in range(count - 1):
683+
next_result = motion_func(text, end_row, end_col, char)
684+
if (next_result.position.row, next_result.position.col) == (end_row, end_col):
685+
break
686+
end_row, end_col = next_result.position.row, next_result.position.col
687+
final_range = Range(
688+
result.range.start,
689+
Position(end_row, end_col),
690+
result.range.motion_type,
691+
result.range.inclusive,
692+
)
693+
607694
# Push undo state before change
608695
self._push_undo_state()
609696

610-
op_result = operator_change(text, result.range)
697+
op_result = operator_change(text, final_range)
611698
self.query_input.text = op_result.text
612699
self.query_input.cursor_location = (op_result.row, op_result.col)
613700

0 commit comments

Comments
 (0)