Skip to content

Commit beca8d7

Browse files
committed
Implement vi mode and basic motions in pyrepl
1 parent 6d45cd8 commit beca8d7

File tree

8 files changed

+759
-18
lines changed

8 files changed

+759
-18
lines changed

Lib/_pyrepl/commands.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import os
2424
import time
2525

26+
from .types import ViMode
27+
2628
# Categories of actions:
2729
# killing
2830
# yanking
@@ -325,10 +327,19 @@ def do(self) -> None:
325327
b = r.buffer
326328
for _ in range(r.get_arg()):
327329
p = r.pos + 1
328-
if p <= len(b):
329-
r.pos = p
330+
# In vi normal mode, don't move past the last character
331+
if r.vi_mode == ViMode.NORMAL:
332+
eol_pos = r.eol()
333+
max_pos = max(r.bol(), eol_pos - 1) if eol_pos > r.bol() else r.bol()
334+
if p <= max_pos:
335+
r.pos = p
336+
else:
337+
self.reader.error("end of line")
330338
else:
331-
self.reader.error("end of buffer")
339+
if p <= len(b):
340+
r.pos = p
341+
else:
342+
self.reader.error("end of buffer")
332343

333344

334345
class beginning_of_line(MotionCommand):
@@ -338,7 +349,14 @@ def do(self) -> None:
338349

339350
class end_of_line(MotionCommand):
340351
def do(self) -> None:
341-
self.reader.pos = self.reader.eol()
352+
r = self.reader
353+
eol_pos = r.eol()
354+
if r.vi_mode == ViMode.NORMAL:
355+
bol_pos = r.bol()
356+
# Don't go past the last character (but stay at bol if line is empty)
357+
r.pos = max(bol_pos, eol_pos - 1) if eol_pos > bol_pos else bol_pos
358+
else:
359+
r.pos = eol_pos
342360

343361

344362
class home(MotionCommand):

Lib/_pyrepl/historical_reader.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -257,19 +257,27 @@ def __post_init__(self) -> None:
257257
)
258258

259259
def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]:
260-
return super().collect_keymap() + (
260+
bindings: list[tuple[KeySpec, CommandName]] = [
261261
(r"\C-n", "next-history"),
262262
(r"\C-p", "previous-history"),
263263
(r"\C-o", "operate-and-get-next"),
264264
(r"\C-r", "reverse-history-isearch"),
265265
(r"\C-s", "forward-history-isearch"),
266-
(r"\M-r", "restore-history"),
267-
(r"\M-.", "yank-arg"),
268266
(r"\<page down>", "history-search-forward"),
269-
(r"\x1b[6~", "history-search-forward"),
270267
(r"\<page up>", "history-search-backward"),
271-
(r"\x1b[5~", "history-search-backward"),
272-
)
268+
]
269+
270+
if not self.use_vi_mode:
271+
bindings.extend(
272+
[
273+
(r"\M-r", "restore-history"),
274+
(r"\M-.", "yank-arg"),
275+
(r"\x1b[6~", "history-search-forward"),
276+
(r"\x1b[5~", "history-search-backward"),
277+
]
278+
)
279+
280+
return super().collect_keymap() + tuple(bindings)
273281

274282
def select_item(self, i: int) -> None:
275283
self.transient_history[self.historyi] = self.get_unicode()

Lib/_pyrepl/reader.py

Lines changed: 178 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import annotations
2323

24+
import itertools
2425
import sys
2526
import _colorize
2627

@@ -30,6 +31,7 @@
3031
from . import commands, console, input
3132
from .utils import wlen, unbracket, disp_str, gen_colors, THEME
3233
from .trace import trace
34+
from . import vi_commands
3335

3436

3537
# types
@@ -41,6 +43,9 @@
4143
SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3)
4244

4345

46+
from .types import ViMode
47+
48+
4449
def make_default_syntax_table() -> dict[str, int]:
4550
# XXX perhaps should use some unicodedata here?
4651
st: dict[str, int] = {}
@@ -54,10 +59,11 @@ def make_default_syntax_table() -> dict[str, int]:
5459

5560
def make_default_commands() -> dict[CommandName, type[Command]]:
5661
result: dict[CommandName, type[Command]] = {}
57-
for v in vars(commands).values():
58-
if isinstance(v, type) and issubclass(v, Command) and v.__name__[0].islower():
59-
result[v.__name__] = v
60-
result[v.__name__.replace("_", "-")] = v
62+
all_commands = itertools.chain(vars(commands).values(), vars(vi_commands).values())
63+
for cmd in all_commands:
64+
if isinstance(cmd, type) and issubclass(cmd, Command) and cmd.__name__[0].islower():
65+
result[cmd.__name__] = cmd
66+
result[cmd.__name__.replace("_", "-")] = cmd
6167
return result
6268

6369

@@ -131,6 +137,67 @@ def make_default_commands() -> dict[CommandName, type[Command]]:
131137
)
132138

133139

140+
vi_insert_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple(
141+
[binding for binding in default_keymap if not binding[0].startswith((r"\M-", r"\x1b", r"\EOF", r"\EOH"))] +
142+
[(r"\<escape>", "vi-normal-mode")]
143+
)
144+
145+
146+
vi_normal_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple(
147+
[
148+
# Basic motions
149+
(r"h", "left"),
150+
(r"j", "down"),
151+
(r"k", "up"),
152+
(r"l", "right"),
153+
(r"0", "beginning-of-line"),
154+
(r"$", "end-of-line"),
155+
(r"w", "vi-forward-word"),
156+
(r"b", "backward-word"),
157+
(r"e", "end-of-word"),
158+
(r"^", "first-non-whitespace-character"),
159+
160+
# Edit commands
161+
(r"x", "delete"),
162+
(r"i", "vi-insert-mode"),
163+
(r"a", "vi-append-mode"),
164+
(r"A", "vi-append-eol"),
165+
(r"I", "vi-insert-bol"),
166+
(r"o", "vi-open-below"),
167+
(r"O", "vi-open-above"),
168+
169+
# Special keys still work in normal mode
170+
(r"\<left>", "left"),
171+
(r"\<right>", "right"),
172+
(r"\<up>", "up"),
173+
(r"\<down>", "down"),
174+
(r"\<home>", "beginning-of-line"),
175+
(r"\<end>", "end-of-line"),
176+
(r"\<delete>", "delete"),
177+
(r"\<backspace>", "left"),
178+
179+
# Control keys (important ones that work in both modes)
180+
(r"\C-c", "interrupt"),
181+
(r"\C-d", "delete"),
182+
(r"\C-l", "clear-screen"),
183+
(r"\C-r", "reverse-history-isearch"),
184+
185+
# Digit args for counts (1-9, not 0 which is BOL)
186+
(r"1", "digit-arg"),
187+
(r"2", "digit-arg"),
188+
(r"3", "digit-arg"),
189+
(r"4", "digit-arg"),
190+
(r"5", "digit-arg"),
191+
(r"6", "digit-arg"),
192+
(r"7", "digit-arg"),
193+
(r"8", "digit-arg"),
194+
(r"9", "digit-arg"),
195+
196+
(r"\<escape>", "invalid-key"),
197+
]
198+
)
199+
200+
134201
@dataclass(slots=True)
135202
class Reader:
136203
"""The Reader class implements the bare bones of a command reader,
@@ -214,6 +281,8 @@ class Reader:
214281
scheduled_commands: list[str] = field(default_factory=list)
215282
can_colorize: bool = False
216283
threading_hook: Callback | None = None
284+
use_vi_mode: bool = False
285+
vi_mode: ViMode = ViMode.INSERT
217286

218287
## cached metadata to speed up screen refreshes
219288
@dataclass
@@ -281,6 +350,11 @@ def __post_init__(self) -> None:
281350
self.last_refresh_cache.dimensions = (0, 0)
282351

283352
def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]:
353+
if self.use_vi_mode:
354+
if self.vi_mode == ViMode.INSERT:
355+
return vi_insert_keymap
356+
elif self.vi_mode == ViMode.NORMAL:
357+
return vi_normal_keymap
284358
return default_keymap
285359

286360
def calc_screen(self) -> list[str]:
@@ -433,6 +507,57 @@ def eow(self, p: int | None = None) -> int:
433507
p += 1
434508
return p
435509

510+
def vi_eow(self, p: int | None = None) -> int:
511+
"""Return the 0-based index of the last character of the word
512+
following p most immediately (vi 'e' semantics).
513+
514+
Unlike eow(), this returns the position ON the last word character,
515+
not past it. p defaults to self.pos; word boundaries are determined
516+
using self.syntax_table."""
517+
if p is None:
518+
p = self.pos
519+
st = self.syntax_table
520+
b = self.buffer
521+
522+
# If we're already at the end of a word, move past it
523+
if (p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD and
524+
(p + 1 >= len(b) or st.get(b[p + 1], SYNTAX_WORD) != SYNTAX_WORD)):
525+
p += 1
526+
527+
# Skip non-word characters to find the start of next word
528+
while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD:
529+
p += 1
530+
531+
# Move to the last character of this word (not past it)
532+
while p + 1 < len(b) and st.get(b[p + 1], SYNTAX_WORD) == SYNTAX_WORD:
533+
p += 1
534+
535+
# Clamp to valid buffer range
536+
return min(p, len(b) - 1) if b else 0
537+
538+
def vi_forward_word(self, p: int | None = None) -> int:
539+
"""Return the 0-based index of the first character of the next word
540+
(vi 'w' semantics).
541+
542+
Unlike eow(), this lands ON the first character of the next word,
543+
not past it. p defaults to self.pos; word boundaries are determined
544+
using self.syntax_table."""
545+
if p is None:
546+
p = self.pos
547+
st = self.syntax_table
548+
b = self.buffer
549+
550+
# Skip the rest of the current word if we're on one
551+
while p < len(b) and st.get(b[p], SYNTAX_WORD) == SYNTAX_WORD:
552+
p += 1
553+
554+
# Skip non-word characters to find the start of next word
555+
while p < len(b) and st.get(b[p], SYNTAX_WORD) != SYNTAX_WORD:
556+
p += 1
557+
558+
# Clamp to valid buffer range
559+
return min(p, len(b) - 1) if b else 0
560+
436561
def bol(self, p: int | None = None) -> int:
437562
"""Return the 0-based index of the line break preceding p most
438563
immediately.
@@ -458,6 +583,18 @@ def eol(self, p: int | None = None) -> int:
458583
p += 1
459584
return p
460585

586+
def first_non_whitespace(self, p: int | None = None) -> int:
587+
"""Return the 0-based index of the first non-whitespace character
588+
on the current line.
589+
590+
p defaults to self.pos."""
591+
bol_pos = self.bol(p)
592+
eol_pos = self.eol(p)
593+
pos = bol_pos
594+
while pos < eol_pos and self.buffer[pos].isspace() and self.buffer[pos] != '\n':
595+
pos += 1
596+
return pos
597+
461598
def max_column(self, y: int) -> int:
462599
"""Return the last x-offset for line y"""
463600
return self.screeninfo[y][0] + sum(self.screeninfo[y][1])
@@ -589,6 +726,8 @@ def prepare(self) -> None:
589726
self.pos = 0
590727
self.dirty = True
591728
self.last_command = None
729+
if self.use_vi_mode:
730+
self.enter_insert_mode()
592731
self.calc_screen()
593732
except BaseException:
594733
self.restore()
@@ -760,3 +899,38 @@ def bind(self, spec: KeySpec, command: CommandName) -> None:
760899
def get_unicode(self) -> str:
761900
"""Return the current buffer as a unicode string."""
762901
return "".join(self.buffer)
902+
903+
def enter_insert_mode(self) -> None:
904+
if self.vi_mode == ViMode.INSERT:
905+
return
906+
907+
self.vi_mode = ViMode.INSERT
908+
909+
# Switch translator to insert mode keymap
910+
self.keymap = self.collect_keymap()
911+
self.input_trans = input.KeymapTranslator(
912+
self.keymap, invalid_cls="invalid-key", character_cls="self-insert"
913+
)
914+
915+
self.dirty = True
916+
917+
def enter_normal_mode(self) -> None:
918+
if self.vi_mode == ViMode.NORMAL:
919+
return
920+
921+
self.vi_mode = ViMode.NORMAL
922+
923+
# Switch translator to normal mode keymap
924+
self.keymap = self.collect_keymap()
925+
self.input_trans = input.KeymapTranslator(
926+
self.keymap, invalid_cls="invalid-key", character_cls="invalid-key"
927+
)
928+
929+
# In vi normal mode, cursor should be ON a character, not after the last one
930+
# If we're past the end of line, move back to the last character
931+
bol_pos = self.bol()
932+
eol_pos = self.eol()
933+
if self.pos >= eol_pos and eol_pos > bol_pos:
934+
self.pos = eol_pos - 1
935+
936+
self.dirty = True

Lib/_pyrepl/readline.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@
6565

6666
MoreLinesCallable = Callable[[str], bool]
6767

68-
6968
__all__ = [
7069
"add_history",
7170
"clear_history",
@@ -344,6 +343,10 @@ def do(self) -> None:
344343
# ____________________________________________________________
345344

346345

346+
def _is_vi_mode_enabled() -> bool:
347+
return os.environ.get("PYREPL_VI_MODE", "").lower() in {"1", "true", "on", "yes"}
348+
349+
347350
@dataclass(slots=True)
348351
class _ReadlineWrapper:
349352
f_in: int = -1
@@ -362,7 +365,11 @@ def __post_init__(self) -> None:
362365
def get_reader(self) -> ReadlineAlikeReader:
363366
if self.reader is None:
364367
console = Console(self.f_in, self.f_out, encoding=ENCODING)
365-
self.reader = ReadlineAlikeReader(console=console, config=self.config)
368+
self.reader = ReadlineAlikeReader(
369+
console=console,
370+
config=self.config,
371+
use_vi_mode=_is_vi_mode_enabled()
372+
)
366373
return self.reader
367374

368375
def input(self, prompt: object = "") -> str:

Lib/_pyrepl/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import enum
12
from collections.abc import Callable, Iterator
23

34
type Callback = Callable[[], object]
@@ -8,3 +9,8 @@
89
type Completer = Callable[[str, int], str | None]
910
type CharBuffer = list[str]
1011
type CharWidths = list[int]
12+
13+
14+
class ViMode(str, enum.Enum):
15+
INSERT = "insert"
16+
NORMAL = "normal"

0 commit comments

Comments
 (0)