side-vim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
side_vim/motions.py ADDED
@@ -0,0 +1,428 @@
1
+ """Vim motions on plain text: where the cursor goes, and what an operator covers.
2
+
3
+ No Qt here. The text is a sequence of lines without their newlines, and each line is a
4
+ sequence of characters as the user sees them (grapheme clusters: a family emoji, a
5
+ flag or a letter plus an accent is one character). A plain str works as a line too.
6
+ Working on whole clusters means no command can ever cut one in half.
7
+
8
+ A position is (line, col), both 0-based, col counted in those characters. While a
9
+ motion runs, a position may sit on the end of a line (col == len(line)), the spot that
10
+ stands for the newline, just as vim's cursor may sit on the NUL at the end of a line.
11
+
12
+ The word motions follow vim's own step-by-step logic (fwd_word, end_word and bck_word
13
+ in vim's textobject.c), so that the odd cases, like `cw` and `dw` at the end of a line,
14
+ come out the same as in vim.
15
+
16
+ The column j and k aim for is a screen column, like vim's: a tab takes up to
17
+ `tabstop` cells, CJK characters and emoji take 2.
18
+ """
19
+
20
+ import sys
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass
23
+ from enum import Enum
24
+
25
+ from side_vim.unicode import cell_width, char_class
26
+
27
+ # The column `$` wants: "stay at the end of the line" while moving with j and k.
28
+ MAXCOL = sys.maxsize
29
+
30
+ MOTIONS = frozenset({"h", "j", "k", "l", "w", "b", "e", "W", "B", "E", "0", "^", "$", "gg", "G"})
31
+
32
+ BLANKS = (" ", "\t")
33
+
34
+ # One line: a str, or a sequence of grapheme clusters.
35
+ type Line = Sequence[str]
36
+
37
+
38
+ class Kind(Enum):
39
+ """How an operator treats the text between the cursor and where the motion went."""
40
+
41
+ EXCLUSIVE = "exclusive" # up to, not including, the end
42
+ INCLUSIVE = "inclusive" # up to and including the end
43
+ LINEWISE = "linewise" # whole lines
44
+
45
+
46
+ @dataclass(frozen=True, order=True)
47
+ class Pos:
48
+ line: int
49
+ col: int
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Move:
54
+ """Where a motion took the cursor."""
55
+
56
+ pos: Pos
57
+ kind: Kind
58
+ want: int # the screen column j and k aim for from now on
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Range:
63
+ """The text an operator acts on.
64
+
65
+ Linewise: every line from start.line to end.line, both included; the columns don't
66
+ matter. Otherwise: from start up to, not including, end.
67
+ """
68
+
69
+ start: Pos
70
+ end: Pos
71
+ linewise: bool
72
+
73
+
74
+ class _Cursor:
75
+ """A position that steps through the text like vim's inc_cursor and dec_cursor."""
76
+
77
+ def __init__(self, lines: Sequence[Line], pos: Pos, bigword: bool = False) -> None:
78
+ self.lines: Sequence[Line] = lines
79
+ self.line: int = pos.line
80
+ self.col: int = pos.col
81
+ self.bigword: bool = bigword
82
+
83
+ @property
84
+ def pos(self) -> Pos:
85
+ return Pos(self.line, self.col)
86
+
87
+ def char(self) -> str:
88
+ text = self.lines[self.line]
89
+ return text[self.col] if self.col < len(text) else ""
90
+
91
+ def cls(self) -> int:
92
+ return char_class(self.char(), self.bigword)
93
+
94
+ def line_empty(self) -> bool:
95
+ return not self.lines[self.line]
96
+
97
+ def inc(self) -> int:
98
+ """Step forward. 0: moved in the line, 2: onto the line end, 1: to the next line,
99
+ -1: at the end of the text, didn't move."""
100
+ text = self.lines[self.line]
101
+ if self.col < len(text):
102
+ self.col += 1
103
+ return 0 if self.col < len(text) else 2
104
+ if self.line < len(self.lines) - 1:
105
+ self.line += 1
106
+ self.col = 0
107
+ return 1
108
+ return -1
109
+
110
+ def dec(self) -> int:
111
+ """Step back. 0: moved in the line, 1: to the end of the line before,
112
+ -1: at the start of the text, didn't move."""
113
+ if self.col > 0:
114
+ self.col -= 1
115
+ return 0
116
+ if self.line > 0:
117
+ self.line -= 1
118
+ self.col = len(self.lines[self.line])
119
+ return 1
120
+ return -1
121
+
122
+ def skip_class(self, cls: int, forward: bool) -> bool:
123
+ """Move while on characters of class cls. True if the text ran out."""
124
+ while self.cls() == cls:
125
+ if (self.inc() if forward else self.dec()) == -1:
126
+ return True
127
+ return False
128
+
129
+
130
+ def _fwd_word(cur: _Cursor, count: int, eol: bool) -> bool:
131
+ """vim's fwd_word. eol: an operator is pending, so stop at the end of the line on the
132
+ last word. Returns False when the cursor started on the last character of the text."""
133
+ while count > 0:
134
+ count -= 1
135
+ sclass = cur.cls()
136
+ last_line = cur.line == len(cur.lines) - 1
137
+ i = cur.inc()
138
+ if i == -1 or (i >= 1 and last_line):
139
+ return False
140
+ if i >= 1 and eol and count == 0:
141
+ return True
142
+ # one character past the end of the current word, if any
143
+ if sclass != 0:
144
+ while cur.cls() == sclass:
145
+ i = cur.inc()
146
+ if i == -1 or (i >= 1 and eol and count == 0):
147
+ return True
148
+ # then on to the next non-blank, stopping at an empty line
149
+ while cur.cls() == 0:
150
+ if cur.col == 0 and cur.line_empty():
151
+ break
152
+ i = cur.inc()
153
+ if i == -1 or (i >= 1 and eol and count == 0):
154
+ return True
155
+ return True
156
+
157
+
158
+ def _end_word(cur: _Cursor, count: int, stop: bool) -> bool:
159
+ """vim's end_word, with empty=False. stop: if already on the end of a word, stay
160
+ there for the first count (how `cw` works)."""
161
+ while count > 0:
162
+ count -= 1
163
+ sclass = cur.cls()
164
+ if cur.inc() == -1:
165
+ return False
166
+ if cur.cls() == sclass and sclass != 0:
167
+ # in the middle of a word: go to its end
168
+ if cur.skip_class(sclass, forward=True):
169
+ return False
170
+ elif not stop or sclass == 0:
171
+ # at the end of a word: skip blanks, then go to the end of the next word
172
+ while cur.cls() == 0:
173
+ if cur.inc() == -1:
174
+ return False
175
+ if cur.skip_class(cur.cls(), forward=True):
176
+ return False
177
+ _ = cur.dec() # one step too far
178
+ stop = False
179
+ return True
180
+
181
+
182
+ def _bck_word(cur: _Cursor, count: int) -> bool:
183
+ """vim's bck_word with stop=False. False when already at the start of the text."""
184
+ while count > 0:
185
+ count -= 1
186
+ if cur.dec() == -1:
187
+ return False
188
+ # skip blanks before the word, stopping at an empty line
189
+ empty_line = False
190
+ while cur.cls() == 0:
191
+ if cur.col == 0 and cur.line_empty():
192
+ empty_line = True
193
+ break
194
+ if cur.dec() == -1:
195
+ return True
196
+ if empty_line:
197
+ continue
198
+ # back to the start of this word
199
+ if cur.skip_class(cur.cls(), forward=False):
200
+ return True
201
+ _ = cur.inc() # one step too far
202
+ return True
203
+
204
+
205
+ def first_non_blank(text: Line) -> int:
206
+ """The column of the first non-blank, or of the last character if the line is all
207
+ blanks (vim's beginline with BL_WHITE | BL_FIX)."""
208
+ col = 0
209
+ while col < len(text) - 1 and text[col] in BLANKS:
210
+ col += 1
211
+ return col
212
+
213
+
214
+ def _width(ch: str, vcol: int, tabstop: int) -> int:
215
+ """Screen cells a character takes when it starts at screen column vcol."""
216
+ if ch == "\t":
217
+ return tabstop - vcol % tabstop
218
+ return cell_width(ch)
219
+
220
+
221
+ def screen_col(text: Line, col: int, tabstop: int = 8) -> int:
222
+ """The screen column vim shows the cursor in: the first cell of a character, but the
223
+ last cell of a tab."""
224
+ vcol = 0
225
+ for ch in text[:col]:
226
+ vcol += _width(ch, vcol, tabstop)
227
+ if col < len(text) and text[col] == "\t":
228
+ vcol += _width("\t", vcol, tabstop) - 1
229
+ return vcol
230
+
231
+
232
+ def column_for(text: Line, want: int, visual: bool = False, tabstop: int = 8) -> int:
233
+ """The column j and k land on for a wanted screen column (vim's coladvance). In
234
+ Visual mode the cursor may sit on the line end."""
235
+ if want >= MAXCOL:
236
+ return max(len(text) - 1 + visual, 0)
237
+ vcol = col = 0
238
+ while vcol <= want and col < len(text):
239
+ vcol += _width(text[col], vcol, tabstop)
240
+ col += 1
241
+ if vcol > want or not visual:
242
+ col -= 1
243
+ return max(col, 0)
244
+
245
+
246
+ def move(
247
+ lines: Sequence[Line],
248
+ pos: Pos,
249
+ motion: str,
250
+ count: int | None = None,
251
+ *,
252
+ want: int | None = None,
253
+ op: str | None = None,
254
+ visual: bool = False,
255
+ tabstop: int = 8,
256
+ ) -> Move | None:
257
+ """Run a motion. None means vim would refuse it (beep), and a pending operator is
258
+ cancelled.
259
+
260
+ count: None when no count was typed (it matters for G and gg).
261
+ want: the screen column j and k aim for; defaults to the cursor's.
262
+ op: the pending operator ("d", "c" or "y"), or None.
263
+ visual: Visual mode, where the cursor may sit on the line end.
264
+ tabstop: how many screen columns a tab stop is apart.
265
+ """
266
+ n = count or 1
267
+ text = lines[pos.line]
268
+
269
+ def moved(to: Pos, kind: Kind) -> Move:
270
+ return Move(to, kind, screen_col(lines[to.line], to.col, tabstop))
271
+
272
+ if motion == "h":
273
+ if pos.col == 0:
274
+ # vim beeps, but a pending operator still runs, on nothing (`ch` inserts)
275
+ return moved(pos, Kind.EXCLUSIVE) if op and not visual else None
276
+ return moved(Pos(pos.line, max(pos.col - n, 0)), Kind.EXCLUSIVE)
277
+
278
+ if motion == "l":
279
+ last = len(text) if visual else len(text) - 1
280
+ if pos.col >= last:
281
+ # at the end already: an operator still takes the last character, or
282
+ # runs on nothing on an empty line
283
+ if op and not visual:
284
+ return moved(pos, Kind.INCLUSIVE if text else Kind.EXCLUSIVE)
285
+ return None
286
+ # running out of line with an operator pending takes the last character too
287
+ kind = Kind.INCLUSIVE if op and not visual and pos.col + n > last else Kind.EXCLUSIVE
288
+ return moved(Pos(pos.line, min(pos.col + n, last)), kind)
289
+
290
+ if motion in ("j", "k"):
291
+ if motion == "j":
292
+ if pos.line >= len(lines) - 1:
293
+ return None
294
+ line = min(pos.line + n, len(lines) - 1)
295
+ else:
296
+ if pos.line == 0:
297
+ return None
298
+ line = max(pos.line - n, 0)
299
+ if want is None:
300
+ want = screen_col(text, pos.col, tabstop)
301
+ col = column_for(lines[line], want, visual, tabstop)
302
+ return Move(Pos(line, col), Kind.LINEWISE, want)
303
+
304
+ if motion == "0":
305
+ return moved(Pos(pos.line, 0), Kind.EXCLUSIVE)
306
+
307
+ if motion == "^":
308
+ return moved(Pos(pos.line, first_non_blank(text)), Kind.EXCLUSIVE)
309
+
310
+ if motion == "$":
311
+ if n > 1 and pos.line >= len(lines) - 1:
312
+ return None
313
+ line = min(pos.line + n - 1, len(lines) - 1)
314
+ col = column_for(lines[line], MAXCOL, visual, tabstop)
315
+ return Move(Pos(line, col), Kind.INCLUSIVE, MAXCOL)
316
+
317
+ if motion in ("gg", "G"):
318
+ if count is None:
319
+ line = 0 if motion == "gg" else len(lines) - 1
320
+ else:
321
+ line = min(max(count, 1), len(lines)) - 1
322
+ return moved(Pos(line, first_non_blank(lines[line])), Kind.LINEWISE)
323
+
324
+ if motion in ("w", "W", "e", "E"):
325
+ to, kind = _word_motion(lines, pos, motion, n, op, visual)
326
+ return moved(to, kind)
327
+
328
+ if motion in ("b", "B"):
329
+ cur = _Cursor(lines, pos, bigword=motion == "B")
330
+ if not _bck_word(cur, n):
331
+ return None
332
+ return moved(cur.pos, Kind.EXCLUSIVE)
333
+
334
+ raise ValueError(f"unknown motion: {motion!r}")
335
+
336
+
337
+ def _word_motion(
338
+ lines: Sequence[Line], pos: Pos, motion: str, n: int, op: str | None, visual: bool
339
+ ) -> tuple[Pos, Kind]:
340
+ """w W e E, like vim's nv_wordcmd."""
341
+ cur = _Cursor(lines, pos, bigword=motion in ("W", "E"))
342
+ word_end = motion in ("e", "E")
343
+ inclusive = word_end
344
+ stop = False
345
+ # "cw" on a word works like "ce": the blanks after the word stay
346
+ if not word_end and op == "c" and cur.char() not in ("", *BLANKS):
347
+ inclusive = word_end = stop = True
348
+ _ = _end_word(cur, n, stop) if word_end else _fwd_word(cur, n, eol=op is not None)
349
+ # Don't leave the cursor on the line end, unless it didn't move forward.
350
+ if pos < cur.pos and cur.col > 0 and cur.char() == "" and not visual:
351
+ cur.col -= 1
352
+ inclusive = True
353
+ return cur.pos, Kind.INCLUSIVE if inclusive else Kind.EXCLUSIVE
354
+
355
+
356
+ def _in_indent(lines: Sequence[Line], pos: Pos) -> bool:
357
+ """True if only blanks come before pos in its line."""
358
+ return all(ch in BLANKS for ch in lines[pos.line][: pos.col])
359
+
360
+
361
+ def operator_range(lines: Sequence[Line], start: Pos, moved: Move, op: str) -> Range:
362
+ """What an operator covers, from the cursor at start to where the motion went.
363
+
364
+ Follows vim's rules: an exclusive motion that ends in column 0 of a later line
365
+ stops at the end of the line before, and turns linewise if it started in the
366
+ indent. A multi-line delete turns linewise if it started in the indent and only
367
+ blanks follow its end.
368
+ """
369
+ a, b = sorted((start, moved.pos))
370
+ if moved.kind is Kind.LINEWISE:
371
+ return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
372
+
373
+ inclusive = moved.kind is Kind.INCLUSIVE
374
+ if not inclusive and b.col == 0 and b.line > a.line:
375
+ b = Pos(b.line - 1, len(lines[b.line - 1]))
376
+ if _in_indent(lines, a):
377
+ return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
378
+ if b.col:
379
+ b = Pos(b.line, b.col - 1)
380
+ inclusive = True
381
+
382
+ end_text = lines[b.line]
383
+ if op == "d" and b.line > a.line:
384
+ rest = end_text[b.col + (inclusive if b.col < len(end_text) else 0) :]
385
+ if all(ch in BLANKS for ch in rest) and _in_indent(lines, a):
386
+ return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
387
+
388
+ end = Pos(b.line, min(b.col + inclusive, len(end_text)))
389
+ return Range(a, end, linewise=False)
390
+
391
+
392
+ def line_range(lines: Sequence[Line], pos: Pos, count: int | None = None) -> Range | None:
393
+ """The lines `dd`, `yy` and `cc` cover. None if the count asks for lines below the
394
+ last one and the cursor is already on it."""
395
+ n = count or 1
396
+ if n > 1 and pos.line >= len(lines) - 1:
397
+ return None
398
+ last = min(pos.line + n - 1, len(lines) - 1)
399
+ return Range(Pos(pos.line, 0), Pos(last, 0), linewise=True)
400
+
401
+
402
+ def visual_range(lines: Sequence[Line], anchor: Pos, cursor: Pos, linewise: bool) -> Range:
403
+ """The text a Visual selection covers, both ends included (vim's 'selection' set to
404
+ inclusive). A selection that ends on the line end takes the line break, unless it's
405
+ the last line."""
406
+ a, b = sorted((anchor, cursor))
407
+ if linewise:
408
+ return Range(Pos(a.line, 0), Pos(b.line, 0), linewise=True)
409
+ text = lines[b.line]
410
+ if b.col < len(text):
411
+ end = Pos(b.line, b.col + 1)
412
+ elif b.line < len(lines) - 1:
413
+ end = Pos(b.line + 1, 0)
414
+ else:
415
+ end = Pos(b.line, len(text))
416
+ return Range(a, end, linewise=False)
417
+
418
+
419
+ def range_text(lines: Sequence[Line], r: Range) -> str:
420
+ """The text a range covers. Linewise text ends with a newline, like vim's register."""
421
+ if r.linewise:
422
+ return "".join("".join(lines[i]) + "\n" for i in range(r.start.line, r.end.line + 1))
423
+ if r.start.line == r.end.line:
424
+ return "".join(lines[r.start.line][r.start.col : r.end.col])
425
+ parts = ["".join(lines[r.start.line][r.start.col :])]
426
+ parts.extend("".join(lines[i]) for i in range(r.start.line + 1, r.end.line))
427
+ parts.append("".join(lines[r.end.line][: r.end.col]))
428
+ return "\n".join(parts)
side_vim/notation.py ADDED
@@ -0,0 +1,8 @@
1
+ """Read vim key notation, shared by the test driver and the recorder."""
2
+
3
+ import re
4
+
5
+
6
+ def tokens(keys: str) -> list[str]:
7
+ """Split "3w<Esc>ix" into "3", "w", "<Esc>", "i", "x"."""
8
+ return re.findall(r"<[^<>]+>|.", keys, re.S)
side_vim/parser.py ADDED
@@ -0,0 +1,185 @@
1
+ """Turn vim key tokens into commands. No Qt here.
2
+
3
+ Keys come in one token at a time: a single character ("w", "3", "G"), or a name in
4
+ angle brackets ("<C-r>", "<Left>"). Esc never reaches the parser; the editor side
5
+ handles it.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from side_vim.motions import MOTIONS
11
+ from side_vim.scroll import SCROLL_KEYS
12
+
13
+ # Keys that are other names for a motion.
14
+ ALIASES = {
15
+ "<Left>": "h",
16
+ "<Right>": "l",
17
+ "<Up>": "k",
18
+ "<Down>": "j",
19
+ "<Home>": "0",
20
+ "<End>": "$",
21
+ }
22
+
23
+ # The commands, other than motions, each with True if it changes the text. A read-only
24
+ # editor skips those. Every other list of keys is made from these tables.
25
+
26
+ # Operators take a motion in Normal mode (`dw`) and act on the selection in Visual mode.
27
+ OPERATORS = {"d": True, "c": True, "y": False}
28
+
29
+ # Keys vim treats as an operator with a fixed motion.
30
+ SHORTHANDS = {"x": ("d", "l"), "D": ("d", "$"), "C": ("c", "$")}
31
+
32
+ COMMON_ACTIONS = {
33
+ "n": False,
34
+ "N": False,
35
+ "/": False,
36
+ "?": False,
37
+ } | dict.fromkeys(SCROLL_KEYS, False)
38
+
39
+ # Commands that take a count but no motion, in Normal mode.
40
+ NORMAL_ACTIONS = {
41
+ "p": True,
42
+ "P": True,
43
+ "J": True,
44
+ "u": True,
45
+ "<C-r>": True,
46
+ "i": True,
47
+ "a": True,
48
+ "I": True,
49
+ "A": True,
50
+ "o": True,
51
+ "O": True,
52
+ "v": False,
53
+ "V": False,
54
+ } | COMMON_ACTIONS
55
+
56
+ # Commands that act on the selection or the mode, in Visual mode. `x` is `d` here.
57
+ VISUAL_ACTIONS = OPERATORS | {"p": True, "o": False, "v": False, "V": False} | COMMON_ACTIONS
58
+
59
+ # The Normal mode commands that start Insert mode.
60
+ INSERT_COMMANDS = frozenset({"i", "a", "I", "A", "o", "O"})
61
+
62
+ # The Ctrl keys vim takes in Normal and Visual mode. Other Ctrl keys go to the app.
63
+ CTRL_KEYS = frozenset(key for key in NORMAL_ACTIONS | VISUAL_ACTIONS if key.startswith("<C-"))
64
+
65
+ # The motion name for `dd`, `cc` and `yy`: the current line and count-1 lines below.
66
+ LINE = "line"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Command:
71
+ """One complete command.
72
+
73
+ name: "move" for a plain motion, an operator ("d", "c", "y"), or one of the
74
+ actions above ("p", "<C-r>", "i", ...).
75
+ count: the count typed, or None if there was none. For an operator, the two
76
+ counts are multiplied (`2d3w` is `d` with 6).
77
+ motion: for "move" and for operators in Normal mode, the motion ("w", "gg", or
78
+ LINE for `dd`). None for actions and for operators in Visual mode.
79
+ """
80
+
81
+ name: str
82
+ count: int | None = None
83
+ motion: str | None = None
84
+
85
+
86
+ def changes_text(command: Command, visual: bool) -> bool:
87
+ """True if the command changes the text, in Visual mode or in Normal mode."""
88
+ if command.name == "move":
89
+ return False
90
+ if command.name in OPERATORS:
91
+ return OPERATORS[command.name]
92
+ return (VISUAL_ACTIONS if visual else NORMAL_ACTIONS)[command.name]
93
+
94
+
95
+ class _Pending:
96
+ pass
97
+
98
+
99
+ PENDING = _Pending()
100
+
101
+
102
+ class Parser:
103
+ """Collects keys until they make a command."""
104
+
105
+ def __init__(self) -> None:
106
+ self._keys: list[str] = []
107
+
108
+ @property
109
+ def pending(self) -> str:
110
+ """The keys typed so far for the next command, like vim's showcmd."""
111
+ return "".join(self._keys)
112
+
113
+ def reset(self) -> None:
114
+ self._keys.clear()
115
+
116
+ def feed(self, key: str, visual: bool = False) -> Command | None:
117
+ """Add a key. Returns the command once it's complete, otherwise None. Keys that
118
+ can't make a command are dropped, and pending becomes empty."""
119
+ self._keys.append(key)
120
+ result = _parse(self._keys, visual)
121
+ if isinstance(result, _Pending):
122
+ return None
123
+ self._keys.clear()
124
+ return result
125
+
126
+
127
+ def _read_count(keys: list[str], i: int) -> tuple[int | None, int]:
128
+ """Read a count starting at keys[i]. A count can't start with 0, since 0 is a
129
+ motion."""
130
+ if i >= len(keys) or keys[i] not in "123456789" or len(keys[i]) != 1:
131
+ return None, i
132
+ digits = ""
133
+ while i < len(keys) and len(keys[i]) == 1 and keys[i] in "0123456789":
134
+ digits += keys[i]
135
+ i += 1
136
+ return int(digits), i
137
+
138
+
139
+ def _read_motion(keys: list[str], i: int) -> str | _Pending | None:
140
+ """Read a motion that must end the keys. None if keys[i:] isn't one."""
141
+ if i == len(keys):
142
+ return PENDING
143
+ key = ALIASES.get(keys[i], keys[i])
144
+ if key == "g":
145
+ if i + 1 == len(keys):
146
+ return PENDING
147
+ return "gg" if keys[i + 1] == "g" and i + 2 == len(keys) else None
148
+ if key in MOTIONS and i + 1 == len(keys):
149
+ return key
150
+ return None
151
+
152
+
153
+ def _parse(keys: list[str], visual: bool) -> Command | _Pending | None:
154
+ count, i = _read_count(keys, 0)
155
+ if i == len(keys):
156
+ return PENDING
157
+ key = keys[i]
158
+
159
+ motion = _read_motion(keys, i)
160
+ if motion is not None:
161
+ return motion if isinstance(motion, _Pending) else Command("move", count, motion)
162
+
163
+ if visual:
164
+ key = "d" if key == "x" else key
165
+ if key in VISUAL_ACTIONS and i + 1 == len(keys):
166
+ return Command(key, count)
167
+ return None
168
+
169
+ if key in SHORTHANDS and i + 1 == len(keys):
170
+ op, fixed = SHORTHANDS[key]
171
+ return Command(op, count, fixed)
172
+
173
+ if key in OPERATORS:
174
+ count2, j = _read_count(keys, i + 1)
175
+ total = None if count is None and count2 is None else (count or 1) * (count2 or 1)
176
+ if j < len(keys) and keys[j] == key and j + 1 == len(keys):
177
+ return Command(key, total, LINE)
178
+ motion = _read_motion(keys, j)
179
+ if motion is None or isinstance(motion, _Pending):
180
+ return motion
181
+ return Command(key, total, motion)
182
+
183
+ if key in NORMAL_ACTIONS and i + 1 == len(keys):
184
+ return Command(key, count)
185
+ return None