diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index 0589a0f437ec7c..773224b5d1a65f 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -31,6 +31,8 @@ from .trace import trace class BaseEventQueue: + _ESCAPE_TIMEOUT_MS = 50 + def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: self.compiled_keymap = keymap.compile_keymap(keymap_dict) self.keymap = self.compiled_keymap @@ -38,6 +40,7 @@ def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: self.encoding = encoding self.events: deque[Event] = deque() self.buf = bytearray() + self._pending_escape_deadline: float | None = None def get(self) -> Event | None: """ @@ -69,6 +72,48 @@ def insert(self, event: Event) -> None: trace('added event {event}', event=event) self.events.append(event) + def has_pending_escape_sequence(self) -> bool: + """ + Check if there's a potential escape sequence waiting for more input. + + Returns True if we have exactly one byte (ESC) in the buffer and + we're in the middle of keymap navigation, indicating we're waiting + to see if more bytes will arrive to complete an escape sequence. + """ + return ( + len(self.buf) == 1 + and self.buf[0] == 27 # ESC byte + and self.keymap is not self.compiled_keymap + ) + + def should_emit_standalone_escape(self, current_time_ms: float) -> bool: + """ + Check if a pending ESC should be emitted as a standalone escape key. + """ + if not self.has_pending_escape_sequence(): + return False + + if self._pending_escape_deadline is None: + self._pending_escape_deadline = current_time_ms + self._ESCAPE_TIMEOUT_MS + return False + + return current_time_ms >= self._pending_escape_deadline + + def emit_standalone_escape(self) -> None: + """ + Emit the buffered ESC byte as a standalone escape key event. + """ + self.keymap = self.compiled_keymap + # Standalone ESC event + self.insert(Event('key', '\033', b'\033')) + + # Just in case there are remaining bytes in the buffer + remaining = self.flush_buf()[1:] + for byte in remaining: + self.push(byte) + + self._pending_escape_deadline = None + def push(self, char: int | bytes) -> None: """ Processes a character by updating the buffer and handling special key mappings. @@ -78,6 +123,9 @@ def push(self, char: int | bytes) -> None: char = ord_char.to_bytes() self.buf.append(ord_char) + if self._pending_escape_deadline is not None: + self._pending_escape_deadline = None + if char in self.keymap: if self.keymap is self.compiled_keymap: # sanity check, buffer is empty when a special key comes diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 10127e58897a58..b1e7d1ed1d2325 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -23,6 +23,8 @@ import os import time +from .types import VI_MODE_NORMAL + # Categories of actions: # killing # yanking @@ -325,10 +327,19 @@ def do(self) -> None: b = r.buffer for _ in range(r.get_arg()): p = r.pos + 1 - if p <= len(b): - r.pos = p + # In vi normal mode, don't move past the last character + if r.vi_mode == VI_MODE_NORMAL: + eol_pos = r.eol() + max_pos = max(r.bol(), eol_pos - 1) if eol_pos > r.bol() else r.bol() + if p <= max_pos: + r.pos = p + else: + self.reader.error("end of line") else: - self.reader.error("end of buffer") + if p <= len(b): + r.pos = p + else: + self.reader.error("end of buffer") class beginning_of_line(MotionCommand): @@ -338,7 +349,14 @@ def do(self) -> None: class end_of_line(MotionCommand): def do(self) -> None: - self.reader.pos = self.reader.eol() + r = self.reader + eol_pos = r.eol() + if r.vi_mode == VI_MODE_NORMAL: + bol_pos = r.bol() + # Don't go past the last character (but stay at bol if line is empty) + r.pos = max(bol_pos, eol_pos - 1) if eol_pos > bol_pos else bol_pos + else: + r.pos = eol_pos class home(MotionCommand): diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index c4b95fa2e81ee6..014ff3603086c1 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -257,19 +257,27 @@ def __post_init__(self) -> None: ) def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: - return super().collect_keymap() + ( + bindings: list[tuple[KeySpec, CommandName]] = [ (r"\C-n", "next-history"), (r"\C-p", "previous-history"), (r"\C-o", "operate-and-get-next"), (r"\C-r", "reverse-history-isearch"), (r"\C-s", "forward-history-isearch"), - (r"\M-r", "restore-history"), - (r"\M-.", "yank-arg"), (r"\", "history-search-forward"), - (r"\x1b[6~", "history-search-forward"), (r"\", "history-search-backward"), - (r"\x1b[5~", "history-search-backward"), - ) + ] + + if not self.use_vi_mode: + bindings.extend( + [ + (r"\M-r", "restore-history"), + (r"\M-.", "yank-arg"), + (r"\x1b[6~", "history-search-forward"), + (r"\x1b[5~", "history-search-backward"), + ] + ) + + return super().collect_keymap() + tuple(bindings) def select_item(self, i: int) -> None: self.transient_history[self.historyi] = self.get_unicode() diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0ebd9162eca4bb..8d949e0e57aeba 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -21,6 +21,7 @@ from __future__ import annotations +import itertools import sys import _colorize @@ -28,17 +29,22 @@ from dataclasses import dataclass, field, fields from . import commands, console, input +from . import vi_commands from .utils import wlen, unbracket, disp_str, gen_colors, THEME from .trace import trace # types Command = commands.Command -from .types import Callback, SimpleContextManager, KeySpec, CommandName +from .types import Callback, SimpleContextManager, KeySpec, CommandName, ViUndoState # syntax classes SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3) +MAX_VI_UNDO_STACK_SIZE = 100 + + +from .types import VI_MODE_INSERT, VI_MODE_NORMAL, ViFindState def make_default_syntax_table() -> dict[str, int]: @@ -54,10 +60,11 @@ def make_default_syntax_table() -> dict[str, int]: def make_default_commands() -> dict[CommandName, type[Command]]: result: dict[CommandName, type[Command]] = {} - for v in vars(commands).values(): - if isinstance(v, type) and issubclass(v, Command) and v.__name__[0].islower(): - result[v.__name__] = v - result[v.__name__.replace("_", "-")] = v + all_commands = itertools.chain(vars(commands).values(), vars(vi_commands).values()) + for cmd in all_commands: + if isinstance(cmd, type) and issubclass(cmd, Command) and cmd.__name__[0].islower(): + result[cmd.__name__] = cmd + result[cmd.__name__.replace("_", "-")] = cmd return result @@ -131,6 +138,96 @@ def make_default_commands() -> dict[CommandName, type[Command]]: ) +vi_insert_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( + [binding for binding in default_keymap if not binding[0].startswith((r"\M-", r"\x1b", r"\EOF", r"\EOH"))] + + [(r"\", "vi-normal-mode")] +) + + +vi_normal_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( + [ + # Basic motions + (r"h", "left"), + (r"j", "down"), + (r"k", "up"), + (r"l", "right"), + (r"0", "beginning-of-line"), + (r"$", "end-of-line"), + (r"w", "vi-forward-word"), + (r"W", "vi-forward-word-ws"), + (r"b", "vi-backward-word"), + (r"B", "vi-backward-word-ws"), + (r"e", "end-of-word"), + (r"^", "first-non-whitespace-character"), + + # Find motions + (r"f", "vi-find-char"), + (r"F", "vi-find-char-back"), + (r"t", "vi-till-char"), + (r"T", "vi-till-char-back"), + (r";", "vi-repeat-find"), + (r",", "vi-repeat-find-opposite"), + + # Edit commands + (r"x", "vi-delete"), + (r"i", "vi-insert-mode"), + (r"a", "vi-append-mode"), + (r"A", "vi-append-eol"), + (r"I", "vi-insert-bol"), + (r"o", "vi-open-below"), + (r"O", "vi-open-above"), + + # Delete commands + (r"dw", "vi-delete-word"), + (r"dd", "vi-delete-line"), + (r"d0", "vi-delete-to-bol"), + (r"d$", "vi-delete-to-eol"), + (r"D", "vi-delete-to-eol"), + (r"X", "vi-delete-char-before"), + + # Change commands + (r"cw", "vi-change-word"), + (r"C", "vi-change-to-eol"), + (r"s", "vi-substitute-char"), + + # Replace commands + (r"r", "vi-replace-char"), + + # Undo commands + (r"u", "vi-undo"), + + # Special keys still work in normal mode + (r"\", "left"), + (r"\", "right"), + (r"\", "up"), + (r"\", "down"), + (r"\", "beginning-of-line"), + (r"\", "end-of-line"), + (r"\", "vi-delete"), + (r"\", "left"), + + # Control keys (important ones that work in both modes) + (r"\C-c", "interrupt"), + (r"\C-d", "vi-delete"), + (r"\C-l", "clear-screen"), + (r"\C-r", "reverse-history-isearch"), + + # Digit args for counts (1-9, not 0 which is BOL) + (r"1", "digit-arg"), + (r"2", "digit-arg"), + (r"3", "digit-arg"), + (r"4", "digit-arg"), + (r"5", "digit-arg"), + (r"6", "digit-arg"), + (r"7", "digit-arg"), + (r"8", "digit-arg"), + (r"9", "digit-arg"), + + (r"\", "invalid-key"), + ] +) + + @dataclass(slots=True) class Reader: """The Reader class implements the bare bones of a command reader, @@ -214,6 +311,10 @@ class Reader: scheduled_commands: list[str] = field(default_factory=list) can_colorize: bool = False threading_hook: Callback | None = None + use_vi_mode: bool = False + vi_mode: int = VI_MODE_INSERT + vi_find: ViFindState = field(default_factory=ViFindState) + undo_stack: list[ViUndoState] = field(default_factory=list) ## cached metadata to speed up screen refreshes @dataclass @@ -281,6 +382,11 @@ def __post_init__(self) -> None: self.last_refresh_cache.dimensions = (0, 0) def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: + if self.use_vi_mode: + if self.vi_mode == VI_MODE_INSERT: + return vi_insert_keymap + elif self.vi_mode == VI_MODE_NORMAL: + return vi_normal_keymap return default_keymap def calc_screen(self) -> list[str]: @@ -458,6 +564,18 @@ def eol(self, p: int | None = None) -> int: p += 1 return p + def first_non_whitespace(self, p: int | None = None) -> int: + """Return the 0-based index of the first non-whitespace character + on the current line. + + p defaults to self.pos.""" + bol_pos = self.bol(p) + eol_pos = self.eol(p) + pos = bol_pos + while pos < eol_pos and self.buffer[pos].isspace() and self.buffer[pos] != '\n': + pos += 1 + return pos + def max_column(self, y: int) -> int: """Return the last x-offset for line y""" return self.screeninfo[y][0] + sum(self.screeninfo[y][1]) @@ -481,9 +599,15 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: elif self.paste_mode: prompt = "(paste) " elif "\n" in self.buffer: + newline_count = self.buffer.count("\n") + ends_with_newline = bool(self.buffer) and self.buffer[-1] == "\n" if lineno == 0: - prompt = self.ps2 - elif self.ps4 and lineno == self.buffer.count("\n"): + prompt = self.ps1 + elif lineno < newline_count: + prompt = self.ps3 + elif ends_with_newline and lineno == newline_count: + prompt = self.ps3 + elif self.ps4 and lineno == newline_count: prompt = self.ps4 else: prompt = self.ps3 @@ -589,6 +713,9 @@ def prepare(self) -> None: self.pos = 0 self.dirty = True self.last_command = None + if self.use_vi_mode: + self.enter_insert_mode() + self.undo_stack.clear() self.calc_screen() except BaseException: self.restore() @@ -653,6 +780,14 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None: return # nothing to do command = command_type(self, *cmd) # type: ignore[arg-type] + # Save undo state in vi mode if the command modifies the buffer + if self.use_vi_mode and getattr(command_type, 'modifies_buffer', False): + if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE: + self.undo_stack.pop(0) + self.undo_stack.append(ViUndoState( + buffer_snapshot=self.buffer.copy(), + pos_snapshot=self.pos, + )) command.do() self.after_command(command) @@ -760,3 +895,46 @@ def bind(self, spec: KeySpec, command: CommandName) -> None: def get_unicode(self) -> str: """Return the current buffer as a unicode string.""" return "".join(self.buffer) + + def enter_insert_mode(self) -> None: + if self.vi_mode == VI_MODE_INSERT: + return + + self.vi_mode = VI_MODE_INSERT + + if len(self.undo_stack) >= MAX_VI_UNDO_STACK_SIZE: + self.undo_stack.pop(0) + + self.undo_stack.append(ViUndoState( + buffer_snapshot=self.buffer.copy(), + pos_snapshot=self.pos, + )) + + # Switch translator to insert mode keymap + self.keymap = self.collect_keymap() + self.input_trans = input.KeymapTranslator( + self.keymap, invalid_cls="invalid-key", character_cls="self-insert" + ) + + self.dirty = True + + def enter_normal_mode(self) -> None: + if self.vi_mode == VI_MODE_NORMAL: + return + + self.vi_mode = VI_MODE_NORMAL + + # Switch translator to normal mode keymap + self.keymap = self.collect_keymap() + self.input_trans = input.KeymapTranslator( + self.keymap, invalid_cls="invalid-key", character_cls="invalid-key" + ) + + # In vi normal mode, cursor should be ON a character, not after the last one + # If we're past the end of line, move back to the last character + bol_pos = self.bol() + eol_pos = self.eol() + if self.pos >= eol_pos and eol_pos > bol_pos: + self.pos = eol_pos - 1 + + self.dirty = True diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..c9467ba742278f 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -65,7 +65,6 @@ MoreLinesCallable = Callable[[str], bool] - __all__ = [ "add_history", "clear_history", @@ -344,6 +343,10 @@ def do(self) -> None: # ____________________________________________________________ +def _is_vi_mode_enabled() -> bool: + return os.environ.get("PYREPL_VI_MODE", "").lower() in {"1", "true", "on", "yes"} + + @dataclass(slots=True) class _ReadlineWrapper: f_in: int = -1 @@ -362,7 +365,11 @@ def __post_init__(self) -> None: def get_reader(self) -> ReadlineAlikeReader: if self.reader is None: console = Console(self.f_in, self.f_out, encoding=ENCODING) - self.reader = ReadlineAlikeReader(console=console, config=self.config) + self.reader = ReadlineAlikeReader( + console=console, + config=self.config, + use_vi_mode=_is_vi_mode_enabled() + ) return self.reader def input(self, prompt: object = "") -> str: diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py index c5b7ebc1a406bd..a8dc7704503d99 100644 --- a/Lib/_pyrepl/types.py +++ b/Lib/_pyrepl/types.py @@ -1,4 +1,6 @@ +import enum from collections.abc import Callable, Iterator +from dataclasses import dataclass, field type Callback = Callable[[], object] type SimpleContextManager = Iterator[None] @@ -8,3 +10,26 @@ type Completer = Callable[[str, int], str | None] type CharBuffer = list[str] type CharWidths = list[int] + + +VI_MODE_INSERT = 0 +VI_MODE_NORMAL = 1 + + +class ViFindDirection(str, enum.Enum): + FORWARD = "forward" + BACKWARD = "backward" + + +@dataclass +class ViFindState: + last_char: str | None = None + last_direction: ViFindDirection | None = None + last_inclusive: bool = True # f/F=True, t/T=False + pending_direction: ViFindDirection | None = None + pending_inclusive: bool = True + +@dataclass +class ViUndoState: + buffer_snapshot: CharBuffer = field(default_factory=list) + pos_snapshot: int = 0 diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 09247de748ee3b..9f69f65d40fd65 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -419,6 +419,19 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + if self.event_queue.has_pending_escape_sequence(): + current_time_ms = time.monotonic() * 1000 + + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + break + + if not self.wait(timeout=10): + current_time_ms = time.monotonic() * 1000 + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + continue + while True: try: self.push_char(self.__read(1)) diff --git a/Lib/_pyrepl/vi_commands.py b/Lib/_pyrepl/vi_commands.py new file mode 100644 index 00000000000000..e3b187565e5b22 --- /dev/null +++ b/Lib/_pyrepl/vi_commands.py @@ -0,0 +1,443 @@ +""" +Vi-specific commands for pyrepl. +""" + +from .commands import Command, MotionCommand, KillCommand, delete +from . import input as _input +from .types import ViFindDirection +from .trace import trace +from .vi_motions import ( + vi_eow, + vi_forward_word as _vi_forward_word, + vi_forward_word_ws as _vi_forward_word_ws, + vi_bow, + vi_bow_ws, + find_char_forward, + find_char_backward, +) + + +class ViKillCommand(KillCommand): + """Base class for Vi kill commands that modify the buffer.""" + modifies_buffer = True + + +# ============================================================================ +# Vi-specific Motion Commands +# ============================================================================ + +class first_non_whitespace_character(MotionCommand): + def do(self) -> None: + self.reader.pos = self.reader.first_non_whitespace() + + +class end_of_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = vi_eow(r.buffer, r.pos) + + +class vi_forward_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = _vi_forward_word(r.buffer, r.pos) + +class vi_forward_word_ws(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = _vi_forward_word_ws(r.buffer, r.pos) + +class vi_backward_word(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = vi_bow(r.buffer, r.pos) + + +class vi_backward_word_ws(MotionCommand): + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + r.pos = vi_bow_ws(r.buffer, r.pos) + + + +# ============================================================================ +# Mode Switching Commands +# ============================================================================ + +class vi_normal_mode(Command): + def do(self) -> None: + self.reader.enter_normal_mode() + + +class vi_insert_mode(Command): + def do(self) -> None: + self.reader.enter_insert_mode() + + +class vi_append_mode(Command): + def do(self) -> None: + if self.reader.pos < len(self.reader.buffer): + self.reader.pos += 1 + self.reader.enter_insert_mode() + + +class vi_append_eol(Command): + def do(self) -> None: + while self.reader.pos < len(self.reader.buffer): + if self.reader.buffer[self.reader.pos] == '\n': + break + self.reader.pos += 1 + self.reader.enter_insert_mode() + + +class vi_insert_bol(Command): + def do(self) -> None: + self.reader.pos = self.reader.first_non_whitespace() + self.reader.enter_insert_mode() + + +class vi_open_below(Command): + def do(self) -> None: + while self.reader.pos < len(self.reader.buffer): + if self.reader.buffer[self.reader.pos] == '\n': + break + self.reader.pos += 1 + + self.reader.insert('\n') + self.reader.enter_insert_mode() + + +class vi_open_above(Command): + def do(self) -> None: + while self.reader.pos > 0 and self.reader.buffer[self.reader.pos - 1] != '\n': + self.reader.pos -= 1 + + self.reader.insert('\n') + self.reader.pos -= 1 + self.reader.enter_insert_mode() + + +# ============================================================================ +# Delete Commands +# ============================================================================ + +class vi_delete(delete): + """Delete character under cursor (x).""" + modifies_buffer = True + + +class vi_delete_word(ViKillCommand): + """Delete from cursor to start of next word (dw).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + end = _vi_forward_word(r.buffer, r.pos) + if end > r.pos: + self.kill_range(r.pos, end) + + +class vi_delete_line(ViKillCommand): + """Delete entire line content (dd).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.bol(), r.eol()) + + +class vi_delete_to_bol(ViKillCommand): + """Delete from cursor to beginning of line (d0).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.bol(), r.pos) + + +class vi_delete_to_eol(ViKillCommand): + """Delete from cursor to end of line (d$ or D).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.pos, r.eol()) + + +class vi_delete_char_before(ViKillCommand): + """Delete character before cursor (X).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + if r.pos > 0: + self.kill_range(r.pos - 1, r.pos) + + +# ============================================================================ +# Find Commands (f/F/t/T) +# ============================================================================ + +def _make_char_capture_keymap(execute_cmd: str, cancel_cmd: str) -> tuple[tuple[str, str], ...]: + """Create a keymap where all printable ASCII maps to execute_cmd. + + Once execute_cmd is called, it will pop our input translator.""" + entries = [] + for i in range(32, 127): + if i == 92: # backslash needs escaping + entries.append((r"\\", execute_cmd)) + else: + entries.append((chr(i), execute_cmd)) + entries.append((r"\", cancel_cmd)) + return tuple(entries) + +_find_char_keymap = _make_char_capture_keymap("vi-find-execute", "vi-find-cancel") +_replace_char_keymap = _make_char_capture_keymap("vi-replace-execute", "vi-replace-cancel") + + +class vi_find_char(Command): + """Start forward find (f). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.vi_find.pending_direction = ViFindDirection.FORWARD + r.vi_find.pending_inclusive = True + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_find_char_back(Command): + """Start backward find (F). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.vi_find.pending_direction = ViFindDirection.BACKWARD + r.vi_find.pending_inclusive = True + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_till_char(Command): + """Start forward till (t). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.vi_find.pending_direction = ViFindDirection.FORWARD + r.vi_find.pending_inclusive = False + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_till_char_back(Command): + """Start backward till (T). Waits for target character.""" + def do(self) -> None: + r = self.reader + r.vi_find.pending_direction = ViFindDirection.BACKWARD + r.vi_find.pending_inclusive = False + trans = _input.KeymapTranslator( + _find_char_keymap, + invalid_cls="vi-find-cancel", + character_cls="vi-find-execute" + ) + r.push_input_trans(trans) + + +class vi_find_execute(MotionCommand): + """Execute the pending find with the pressed character.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + + char = self.event[-1] + if not char: + return + + direction = r.vi_find.pending_direction + inclusive = r.vi_find.pending_inclusive + + # Store for repeat with ; and , + r.vi_find.last_char = char + r.vi_find.last_direction = direction + r.vi_find.last_inclusive = inclusive + + r.vi_find.pending_direction = None + self._execute_find(char, direction, inclusive) + + def _execute_find(self, char: str, direction: ViFindDirection | None, inclusive: bool) -> None: + r = self.reader + for _ in range(r.get_arg()): + if direction == ViFindDirection.FORWARD: + new_pos = find_char_forward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = find_char_backward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos + + +class vi_find_cancel(Command): + """Cancel pending find operation.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + r.vi_find.pending_direction = None + + +# ============================================================================ +# Repeat Find Commands (; and ,) +# ============================================================================ + +class vi_repeat_find(MotionCommand): + """Repeat last f/F/t/T in the same direction (;).""" + def do(self) -> None: + r = self.reader + if r.vi_find.last_char is None: + return + + char = r.vi_find.last_char + direction = r.vi_find.last_direction + inclusive = r.vi_find.last_inclusive + + for _ in range(r.get_arg()): + if direction == ViFindDirection.FORWARD: + new_pos = find_char_forward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = find_char_backward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos + + +class vi_repeat_find_opposite(MotionCommand): + """Repeat last f/F/t/T in opposite direction (,).""" + def do(self) -> None: + r = self.reader + if r.vi_find.last_char is None: + return + + char = r.vi_find.last_char + direction = (ViFindDirection.BACKWARD + if r.vi_find.last_direction == ViFindDirection.FORWARD + else ViFindDirection.FORWARD) + inclusive = r.vi_find.last_inclusive + + for _ in range(r.get_arg()): + if direction == ViFindDirection.FORWARD: + new_pos = find_char_forward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos -= 1 + if new_pos > r.pos: + r.pos = new_pos + else: + new_pos = find_char_backward(r.buffer, r.pos, char) + if new_pos is not None: + if not inclusive: + new_pos += 1 + if new_pos < r.pos: + r.pos = new_pos + + +# ============================================================================ +# Change Commands +# ============================================================================ + +class vi_change_word(ViKillCommand): + """Change from cursor to end of word (cw).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + end = vi_eow(r.buffer, r.pos) + 1 # +1 to include last char + if end > r.pos: + self.kill_range(r.pos, end) + r.enter_insert_mode() + + +class vi_change_to_eol(ViKillCommand): + """Change from cursor to end of line (C).""" + def do(self) -> None: + r = self.reader + self.kill_range(r.pos, r.eol()) + r.enter_insert_mode() + + +class vi_substitute_char(ViKillCommand): + """Delete character under cursor and enter insert mode (s).""" + def do(self) -> None: + r = self.reader + for _ in range(r.get_arg()): + if r.pos < len(r.buffer): + self.kill_range(r.pos, r.pos + 1) + r.enter_insert_mode() + + +# ============================================================================ +# Replace Commands +# ============================================================================ + +class vi_replace_char(Command): + """Replace character under cursor with next typed character (r).""" + def do(self) -> None: + r = self.reader + translator = _input.KeymapTranslator( + _replace_char_keymap, + invalid_cls="vi-replace-cancel", + character_cls="vi-replace-execute" + ) + r.push_input_trans(translator) + +class vi_replace_execute(Command): + """Execute character replacement with the pressed character.""" + modifies_buffer = True + + def do(self) -> None: + r = self.reader + r.pop_input_trans() + pending_char = self.event[-1] + if not pending_char or r.pos >= len(r.buffer): + return + r.buffer[r.pos] = pending_char + r.dirty = True + +class vi_replace_cancel(Command): + """Cancel pending replace operation.""" + def do(self) -> None: + r = self.reader + r.pop_input_trans() + + +# ============================================================================ +# Undo Commands +# ============================================================================ + +class vi_undo(Command): + """Undo last change (u).""" + def do(self) -> None: + r = self.reader + trace("vi_undo: undo_stack size =", len(r.undo_stack)) + if not r.undo_stack: + return + state = r.undo_stack.pop() + r.buffer[:] = state.buffer_snapshot + r.pos = state.pos_snapshot + r.dirty = True diff --git a/Lib/_pyrepl/vi_motions.py b/Lib/_pyrepl/vi_motions.py new file mode 100644 index 00000000000000..bd0a1b929af0ef --- /dev/null +++ b/Lib/_pyrepl/vi_motions.py @@ -0,0 +1,189 @@ +"""Pure functions for vi motion operations. + +These functions implement vi-style cursor motions without depending on Reader state. +""" + +def _is_vi_word_char(c: str) -> bool: + return c.isalnum() or c == '_' + + +def vi_eow(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the last character of the word + following pos most immediately (vi 'e' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'e' moves to the end + of the current or next word/punctuation sequence.""" + b = buffer + p = pos + + if not b: + return 0 + + # Helper to check if at end of current sequence + def at_sequence_end(pos: int) -> bool: + if pos >= len(b) - 1: + return True + curr_is_word = _is_vi_word_char(b[pos]) + next_is_word = _is_vi_word_char(b[pos + 1]) + curr_is_space = b[pos].isspace() + next_is_space = b[pos + 1].isspace() + if curr_is_word: + return not next_is_word + elif not curr_is_space: + # Punctuation - at end if next is word or whitespace + return next_is_word or next_is_space + return True + + # If already at end of a word/punctuation, move forward + if p < len(b) and at_sequence_end(p): + p += 1 + + # Skip whitespace + while p < len(b) and b[p].isspace(): + p += 1 + + if p >= len(b): + return len(b) - 1 + + # Move to end of current word or punctuation sequence + if _is_vi_word_char(b[p]): + while p + 1 < len(b) and _is_vi_word_char(b[p + 1]): + p += 1 + else: + # Punctuation sequence + while p + 1 < len(b) and not _is_vi_word_char(b[p + 1]) and not b[p + 1].isspace(): + p += 1 + + return min(p, len(b) - 1) + + +def vi_forward_word(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the first character of the next word + (vi 'w' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'w' moves to the start + of the next word or punctuation sequence.""" + b = buffer + p = pos + + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip current word or punctuation sequence + if _is_vi_word_char(b[p]): + # On a word char - skip word chars + while p < len(b) and _is_vi_word_char(b[p]): + p += 1 + elif not b[p].isspace(): + # On punctuation - skip punctuation + while p < len(b) and not _is_vi_word_char(b[p]) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next word or punctuation + while p < len(b) and b[p].isspace(): + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + + +def vi_forward_word_ws(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the first character of the next WORD + (vi 'W' semantics). + + Treats white space as the only separator.""" + b = buffer + p = pos + + if not b or p >= len(b): + return max(0, len(b) - 1) if b else 0 + + # Skip all non-whitespace (the current WORD) + while p < len(b) and not b[p].isspace(): + p += 1 + + # Skip whitespace to find next WORD + while p < len(b) and b[p].isspace(): + p += 1 + + # Clamp to valid buffer range + return min(p, len(b) - 1) if b else 0 + + +def vi_bow(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the beginning of the word preceding pos + (vi 'b' semantics). + + Vi has three character classes: word chars (alnum + _), punctuation + (non-word, non-whitespace), and whitespace. 'b' moves to the start + of the current or previous word/punctuation sequence.""" + b = buffer + p = pos + + if not b or p <= 0: + return 0 + + p -= 1 + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): + p -= 1 + + if p < 0: + return 0 + + # Now skip the word or punctuation sequence we landed in + if _is_vi_word_char(b[p]): + while p > 0 and _is_vi_word_char(b[p - 1]): + p -= 1 + else: + # Punctuation sequence + while p > 0 and not _is_vi_word_char(b[p - 1]) and not b[p - 1].isspace(): + p -= 1 + + return p + + +def vi_bow_ws(buffer: list[str], pos: int) -> int: + """Return the 0-based index of the beginning of the WORD preceding pos + (vi 'B' semantics). + + Treats white space as the only separator.""" + b = buffer + p = pos + + if not b or p <= 0: + return 0 + + p -= 1 + + # Skip whitespace going backward + while p >= 0 and b[p].isspace(): + p -= 1 + + if p < 0: + return 0 + + # Now skip the WORD we landed in + while p > 0 and not b[p - 1].isspace(): + p -= 1 + + return p + + +def find_char_forward(buffer: list[str], pos: int, char: str) -> int | None: + """Find next occurrence of char after pos. Returns index or None.""" + for i in range(pos + 1, len(buffer)): + if buffer[i] == char: + return i + return None + + +def find_char_backward(buffer: list[str], pos: int, char: str) -> int | None: + """Find previous occurrence of char before pos. Returns index or None.""" + for i in range(pos - 1, -1, -1): + if buffer[i] == char: + return i + return None diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index c56dcd6d7dd434..1e01d025c2d61c 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -22,6 +22,7 @@ import io import os import sys +import time import ctypes import types @@ -446,6 +447,23 @@ def get_event(self, block: bool = True) -> Event | None: return None while self.event_queue.empty(): + # Check if we have a pending escape sequence that needs timeout handling + if self.event_queue.has_pending_escape_sequence(): + current_time_ms = time.monotonic() * 1000 + + if self.event_queue.should_emit_standalone_escape(current_time_ms): + # Timeout expired - emit the ESC as a standalone key + self.event_queue.emit_standalone_escape() + break + + # Wait for a short time to check for more input + if not self.wait(timeout=10): + # Check again after timeout + current_time_ms = time.monotonic() * 1000 + if self.event_queue.should_emit_standalone_escape(current_time_ms): + self.event_queue.emit_standalone_escape() + continue + rec = self._read_input() if rec is None: return None @@ -583,6 +601,7 @@ def repaint(self) -> None: raise NotImplementedError("No repaint support") + # Windows interop class CONSOLE_SCREEN_BUFFER_INFO(Structure): _fields_ = [ diff --git a/Lib/test/test_pyrepl/support.py b/Lib/test/test_pyrepl/support.py index 4f7f9d77933336..c5ce4a066ab3c5 100644 --- a/Lib/test/test_pyrepl/support.py +++ b/Lib/test/test_pyrepl/support.py @@ -83,6 +83,14 @@ def get_prompt(lineno, cursor_on_line) -> str: return reader +def prepare_vi_reader(console: Console, **kwargs): + reader = prepare_reader(console, **kwargs) + reader.use_vi_mode = True + reader.enter_normal_mode() + reader.enter_insert_mode() + return reader + + def prepare_console(events: Iterable[Event], **kwargs) -> MagicMock | Console: console = MagicMock() console.get_event.side_effect = events diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..8b1bb952ccc043 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -8,12 +8,12 @@ from .support import handle_all_events, handle_events_narrow_console from .support import ScreenEqualMixin, code_to_events -from .support import prepare_reader, prepare_console +from .support import prepare_reader, prepare_console, prepare_vi_reader from _pyrepl.console import Event from _pyrepl.reader import Reader +from _pyrepl.types import VI_MODE_INSERT, VI_MODE_NORMAL from _colorize import default_theme - overrides = {"reset": "z", "soft_keyword": "K"} colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.syntax.items()} @@ -552,9 +552,869 @@ def test_syntax_highlighting_literal_brace_in_fstring_or_tstring(self): self.maxDiff=None self.assert_screen_equal(reader, expected) + def test_vi_escape_switches_to_normal_mode(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="h", raw=bytearray(b"h")), + ], + ) + reader, _ = handle_all_events( + events, + prepare_reader=prepare_vi_reader, + ) + self.assertEqual(reader.get_unicode(), "hello") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement + def test_control_characters(self): code = 'flag = "🏳️‍🌈"' events = code_to_events(code) reader, _ = handle_all_events(events) self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) + + +@force_not_colorized_test_class +class TestViMode(TestCase): + def _run_vi(self, events, prepare_reader_hook=prepare_vi_reader): + return handle_all_events(events, prepare_reader=prepare_reader_hook) + + def test_insert_typing_and_ctrl_a_e(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x01", raw=bytearray(b"\x01")), # Ctrl-A + Event(evt="key", data="X", raw=bytearray(b"X")), + Event(evt="key", data="\x05", raw=bytearray(b"\x05")), # Ctrl-E + Event(evt="key", data="!", raw=bytearray(b"!")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "Xhello!") + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) + + def test_escape_switches_to_normal_mode_and_is_idempotent(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="h", raw=bytearray(b"h")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + self.assertEqual(reader.pos, len("hello") - 2) # After 'h' left movement + + def test_normal_mode_motion_and_edit_commands(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # go to bol + Event(evt="key", data="l", raw=bytearray(b"l")), # right + Event(evt="key", data="x", raw=bytearray(b"x")), # delete + Event(evt="key", data="a", raw=bytearray(b"a")), # append + Event(evt="key", data="!", raw=bytearray(b"!")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hl!lo") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + + def test_open_below_and_above(self): + events = itertools.chain( + code_to_events("first"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="o", raw=bytearray(b"o")), + ], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="O", raw=bytearray(b"O")), + ], + code_to_events("zero"), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\nzero\nsecond") + + def test_mode_resets_to_insert_on_prepare(self): + events = itertools.chain( + code_to_events("text"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, console = self._run_vi(events) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + reader.prepare() + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) + console.prepare.assert_called() # ensure console prepare called again + + def test_translator_stack_preserves_mode(self): + events_insert_path = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), # Ctrl-R + Event(evt="key", data="h", raw=bytearray(b"h")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events_insert_path) + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) + + events_normal_path = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events_normal_path) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + + def test_insert_bol_and_append_eol(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="I", raw=bytearray(b"I")), # Insert at BOL + Event(evt="key", data="[", raw=bytearray(b"[")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # Back to normal + Event(evt="key", data="A", raw=bytearray(b"A")), # Append at EOL + Event(evt="key", data="]", raw=bytearray(b"]")), + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "[hello]") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + + def test_insert_mode_from_normal(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to beginning + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right again + Event(evt="key", data="i", raw=bytearray(b"i")), # Insert mode + Event(evt="key", data="X", raw=bytearray(b"X")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "heXllo") + self.assertTrue(reader.vi_mode == VI_MODE_INSERT) + + def test_hjkl_motions(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to start of line + Event(evt="key", data="l", raw=bytearray(b"l")), # Right (h->e) + Event(evt="key", data="l", raw=bytearray(b"l")), # Right (e->l) + Event(evt="key", data="h", raw=bytearray(b"h")), # Left (l->e) + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'e' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hllo") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + + def test_dollar_end_of_line(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="$", raw=bytearray(b"$")), # End (on last char) + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'o' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hell") + + def test_word_motions(self): + events = itertools.chain( + code_to_events("one two"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward word + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete first char of 'two' + ], + ) + reader, _ = self._run_vi(events) + self.assertIn("one", reader.get_unicode()) + self.assertNotEqual(reader.get_unicode(), "one two") # Something was deleted + + def test_repeat_counts(self): + events = itertools.chain( + code_to_events("abcdefghij"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="3", raw=bytearray(b"3")), # Count 3 + Event(evt="key", data="l", raw=bytearray(b"l")), # Move right 3 times + Event(evt="key", data="2", raw=bytearray(b"2")), # Count 2 + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 2 chars (d, e) + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "abcfghij") + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + + def test_multiline_navigation(self): + # Test j/k navigation across multiple lines + code = "first\nsecond\nthird" + events = itertools.chain( + code_to_events(code), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # Up to "second" + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning of line + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 's' + Event(evt="key", data="j", raw=bytearray(b"j")), # Down to "third" + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 't' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\necond\nhird") + + def test_arrow_keys_in_normal_mode(self): + events = itertools.chain( + code_to_events("test"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="left", raw=bytearray(b"\x1b[D")), # Left arrow + Event(evt="key", data="left", raw=bytearray(b"\x1b[D")), # Left arrow + Event(evt="key", data="x", raw=bytearray(b"x")), # Delete 'e' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "tst") + + def test_escape_in_normal_mode_is_noop(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC again (no-op) + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC again (no-op) + ], + ) + reader, _ = self._run_vi(events) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + self.assertEqual(reader.get_unicode(), "hello") + + def test_backspace_in_normal_mode(self): + events = itertools.chain( + code_to_events("abcd"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="\x7f", raw=bytearray(b"\x7f")), # Backspace + Event(evt="key", data="\x7f", raw=bytearray(b"\x7f")), # Backspace again + ], + ) + reader, _ = self._run_vi(events) + self.assertTrue(reader.vi_mode == VI_MODE_NORMAL) + self.assertIsNotNone(reader.get_unicode()) + + def test_end_of_word_motion(self): + events = itertools.chain( + code_to_events("hello world test"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Beginning + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "hello" + ], + ) + reader, _ = self._run_vi(events) + # Should be on 'o' of "hello" (last char of word) + self.assertEqual(reader.pos, 4) + self.assertEqual(reader.buffer[reader.pos], 'o') + + # Test multiple 'e' commands + events2 = itertools.chain( + code_to_events("one two three"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "one" + Event(evt="key", data="e", raw=bytearray(b"e")), # End of "two" + ], + ) + reader2, _ = self._run_vi(events2) + # Should be on 'o' of "two" + self.assertEqual(reader2.buffer[reader2.pos], 'o') + + def test_backward_word_motion(self): + # Test from end of buffer + events = itertools.chain( + code_to_events("one two"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC at end + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to start of "two" + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 4) # At 't' of "two" + self.assertEqual(reader.buffer[reader.pos], 't') + + # Test multiple backwards + events2 = itertools.chain( + code_to_events("one two three"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "three" + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "two" + Event(evt="key", data="b", raw=bytearray(b"b")), # Back to "one" + ], + ) + reader2, _ = self._run_vi(events2) + # Should be at beginning of "one" + self.assertEqual(reader2.pos, 0) + self.assertEqual(reader2.buffer[reader2.pos], 'o') + + def test_first_non_whitespace_character(self): + events = itertools.chain( + code_to_events(" hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="^", raw=bytearray(b"^")), # First non-ws + ], + ) + reader, _ = self._run_vi(events) + # Should be at 'h' of "hello", skipping the 3 spaces + self.assertEqual(reader.pos, 3) + self.assertEqual(reader.buffer[reader.pos], 'h') + + # Test with tabs and spaces + events2 = itertools.chain( + code_to_events("\t text"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL first + Event(evt="key", data="^", raw=bytearray(b"^")), # Then to first non-ws + ], + ) + reader2, _ = self._run_vi(events2) + self.assertEqual(reader2.buffer[reader2.pos], 't') + + def test_word_motion_edge_cases(self): + # Test with underscore - in vi mode, underscore IS a word character + events = itertools.chain( + code_to_events("hello_world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward word + ], + ) + reader, _ = self._run_vi(events) + # In vi mode, underscore is part of word, so 'w' goes past end of "hello_world" + # which clamps to end of buffer (pos 10, on 'd') + self.assertEqual(reader.pos, 10) + + # Test 'e' at end of buffer stays in bounds + events2 = itertools.chain( + code_to_events("end"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="e", raw=bytearray(b"e")), # Already at end of word + Event(evt="key", data="e", raw=bytearray(b"e")), # Should stay in bounds + ], + ) + reader2, _ = self._run_vi(events2) + # Should not go past end of buffer + self.assertLessEqual(reader2.pos, len(reader2.buffer) - 1) + + # Test 'b' at beginning doesn't crash + events3 = itertools.chain( + code_to_events("start"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="b", raw=bytearray(b"b")), # Should stay at 0 + ], + ) + reader3, _ = self._run_vi(events3) + self.assertEqual(reader3.pos, 0) + + def test_repeat_count_with_word_motions(self): + events = itertools.chain( + code_to_events("one two three four"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="2", raw=bytearray(b"2")), # Count 2 + Event(evt="key", data="w", raw=bytearray(b"w")), # Forward 2 words + ], + ) + reader, _ = self._run_vi(events) + # Should be at start of "three" (2 words forward from "one") + self.assertEqual(reader.buffer[reader.pos], 't') # 't' of "three" + + # Test with 'e' + events2 = itertools.chain( + code_to_events("alpha beta gamma"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), + Event(evt="key", data="0", raw=bytearray(b"0")), + Event(evt="key", data="2", raw=bytearray(b"2")), + Event(evt="key", data="e", raw=bytearray(b"e")), # End of 2nd word + ], + ) + reader2, _ = self._run_vi(events2) + # Should be at end of "beta" + self.assertEqual(reader2.buffer[reader2.pos], 'a') # Last 'a' of "beta" + + def test_vi_word_boundaries(self): + """Test vi word motions match vim behavior. + + Vi has three character classes: + 1. Word chars: alphanumeric + underscore + 2. Punctuation: non-word, non-whitespace (forms separate words) + 3. Whitespace: delimiters + """ + # Test cases: (text, start_key_sequence, expected_pos, description) + test_cases = [ + # Underscore is part of word in vi, unlike emacs mode + ("function_name", "0w", 12, "underscore is word char, w clamps to end"), + ("hello_world test", "0w", 12, "underscore word to end"), + + # Punctuation is a separate word + ("foo.bar", "0w", 3, "w stops at dot (punctuation)"), + ("foo.bar", "0ww", 4, "second w goes to bar"), + ("foo..bar", "0w", 3, "w stops at first dot"), + ("foo..bar", "0ww", 5, "second w skips dot sequence to bar"), + ("get_value(x)", "0w", 9, "underscore word stops at ("), + ("get_value(x)", "0ww", 10, "second w goes to x"), + + # Basic word motion + ("hello world", "0w", 6, "basic word jump"), + ("one two", "0w", 5, "double space handled"), + ("abc def ghi", "0ww", 8, "two w's"), + + # End of word (e) - lands ON last char + ("function_name", "0e", 12, "e lands on last char of underscore word"), + ("foo bar", "0e", 2, "e lands on last char of foo"), + ("foo.bar", "0e", 2, "e lands on last o of foo"), + ("foo.bar", "0ee", 3, "second e lands on dot"), + ("foo.bar", "0eee", 6, "third e lands on last r of bar"), + + # Backward word (b) - cursor at end after ESC + ("foo bar", "$b", 4, "b from end lands on bar"), + ("foo bar", "$bb", 0, "two b's lands on foo"), + ("foo.bar", "$b", 4, "b from end lands on bar"), + ("foo.bar", "$bb", 3, "second b lands on dot"), + ("foo.bar", "$bbb", 0, "third b lands on foo"), + ("get_value(x)", "$b", 10, "b from end lands on x"), + ("get_value(x)", "$bb", 9, "second b lands on ("), + ("get_value(x)", "$bbb", 0, "third b lands on get_value"), + + # W (WORD motion by whitespace-delimited words) + ("foo.bar baz", "0W", 8, "W skips punctuation to baz"), + ("one,two three", "0W", 8, "W skips comma to three"), + ("hello world", "0W", 8, "W handles multiple spaces"), + ("get_value(x)", "0W", 11, "W clamps to end (no whitespace)"), + + # Backward W (B) + ("foo.bar baz", "$B", 8, "B from end lands on baz"), + ("foo.bar baz", "$BB", 0, "second B lands on foo.bar"), + ("one,two three", "$B", 8, "B from end lands on three"), + ("one,two three", "$BB", 0, "second B lands on one,two"), + ("hello world", "$B", 8, "B from end lands on world"), + ("hello world", "$BB", 0, "second B lands on hello"), + + # Edge cases + (" spaces", "0w", 3, "w from BOL skips leading spaces"), + ("trailing ", "0w", 10, "w clamps at end after trailing spaces"), + ("a", "0w", 0, "w on single char stays in bounds"), + ("", "0w", 0, "w on empty buffer stays at 0"), + ("a b c", "0www", 4, "multiple w's work correctly"), + ] + + for text, keys, expected_pos, desc in test_cases: + with self.subTest(text=text, keys=keys, desc=desc): + key_events = [] + for k in keys: + key_events.append(Event(evt="key", data=k, raw=bytearray(k.encode()))) + events = itertools.chain( + code_to_events(text), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], # ESC + key_events, + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, expected_pos, + f"Expected pos {expected_pos} but got {reader.pos} for '{text}' with keys '{keys}'") + + def test_find_char_forward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find + Event(evt="key", data="o", raw=bytearray(b"o")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 4) + self.assertEqual(reader.buffer[reader.pos], 'o') + + def test_find_char_backward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="F", raw=bytearray(b"F")), # Find back + Event(evt="key", data="l", raw=bytearray(b"l")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_till_char_forward(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="t", raw=bytearray(b"t")), # Till + Event(evt="key", data="o", raw=bytearray(b"o")), # Target char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 3) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_semicolon_repeat_find(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find + Event(evt="key", data="o", raw=bytearray(b"o")), # First 'o' + Event(evt="key", data=";", raw=bytearray(b";")), # Repeat - second 'o' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 7) + self.assertEqual(reader.buffer[reader.pos], 'o') + + def test_comma_repeat_find_opposite(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Find forward + Event(evt="key", data="l", raw=bytearray(b"l")), # First 'l' at pos 2 + Event(evt="key", data=";", raw=bytearray(b";")), # Second 'l' at pos 3 + Event(evt="key", data=",", raw=bytearray(b",")), # Reverse - back to pos 2 + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 2) + self.assertEqual(reader.buffer[reader.pos], 'l') + + def test_find_char_escape_cancels(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to normal + Event(evt="key", data="0", raw=bytearray(b"0")), # Go to BOL + Event(evt="key", data="f", raw=bytearray(b"f")), # Start find + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC to cancel + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.pos, 0) + + def test_change_word(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="c", raw=bytearray(b"c")), # change + Event(evt="key", data="w", raw=bytearray(b"w")), # word + ], + code_to_events("hi"), # replacement text + ) + reader, _ = self._run_vi(events) + self.assertEqual("".join(reader.buffer), "hi world") + + def test_replace_char(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="l", raw=bytearray(b"l")), # move right to 'e' + Event(evt="key", data="l", raw=bytearray(b"l")), # move right to first 'l' + Event(evt="key", data="r", raw=bytearray(b"r")), # replace + Event(evt="key", data="X", raw=bytearray(b"X")), # replacement char + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual("".join(reader.buffer), "heXlo") + self.assertEqual(reader.pos, 2) # cursor stays on replaced char + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) # stays in normal mode + + def test_undo_after_insert(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "") + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) + + def test_undo_after_delete_word(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="d", raw=bytearray(b"d")), # delete + Event(evt="key", data="w", raw=bytearray(b"w")), # word + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello world") + + def test_undo_after_x_delete(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="x", raw=bytearray(b"x")), # delete char + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + + def test_undo_stack_cleared_on_prepare(self): + events = itertools.chain( + code_to_events("hello"), + [Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))], + ) + reader, console = self._run_vi(events) + self.assertGreater(len(reader.undo_stack), 0) + reader.prepare() + self.assertEqual(len(reader.undo_stack), 0) + + def test_multiple_undo(self): + events = itertools.chain( + code_to_events("a"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="a", raw=bytearray(b"a")), # append + ], + code_to_events("b"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'b' + Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'a' + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "") + + def test_undo_after_replace(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="r", raw=bytearray(b"r")), # replace + Event(evt="key", data="X", raw=bytearray(b"X")), # replacement + Event(evt="key", data="u", raw=bytearray(b"u")), # undo + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello") + + def test_D_delete_to_eol(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="w", raw=bytearray(b"w")), # forward word + Event(evt="key", data="D", raw=bytearray(b"D")), # delete to EOL + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello ") + + def test_C_change_to_eol(self): + events = itertools.chain( + code_to_events("hello world"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="w", raw=bytearray(b"w")), # forward word + Event(evt="key", data="C", raw=bytearray(b"C")), # change to EOL + ], + code_to_events("there"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "hello there") + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) + + def test_s_substitute_char(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="s", raw=bytearray(b"s")), # substitute + ], + code_to_events("j"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "jello") + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) + + def test_X_delete_char_before(self): + events = itertools.chain( + code_to_events("hello"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="X", raw=bytearray(b"X")), # delete before + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "helo") + self.assertEqual(reader.vi_mode, VI_MODE_NORMAL) + + def test_dd_deletes_current_line(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("third"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to second + Event(evt="key", data="d", raw=bytearray(b"d")), # dd + Event(evt="key", data="d", raw=bytearray(b"d")), + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\n\nthird") + + def test_j_k_navigation_between_lines(self): + events = itertools.chain( + code_to_events("short"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("longer line"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up + Event(evt="key", data="$", raw=bytearray(b"$")), # end of line + Event(evt="key", data="j", raw=bytearray(b"j")), # down + ], + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "short\nlonger line") + # Cursor should be somewhere on second line + self.assertGreater(reader.pos, 6) # past the newline + + def test_dollar_stops_at_line_end(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to first + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL + Event(evt="key", data="$", raw=bytearray(b"$")), # EOL + ], + ) + reader, _ = self._run_vi(events) + # $ should stop at end of "first", not go to newline or beyond + self.assertEqual(reader.pos, 4) # 'first'[4] = 't' + + def test_zero_goes_to_line_start(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("second"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="0", raw=bytearray(b"0")), # BOL of second + ], + ) + reader, _ = self._run_vi(events) + # 0 should go to start of "second" line (position 6) + self.assertEqual(reader.pos, 6) + + def test_o_opens_line_below(self): + events = itertools.chain( + code_to_events("first"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("third"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up to first + Event(evt="key", data="o", raw=bytearray(b"o")), # open below + ], + code_to_events("second"), + ) + reader, _ = self._run_vi(events) + self.assertEqual(reader.get_unicode(), "first\nsecond\nthird") + self.assertEqual(reader.vi_mode, VI_MODE_INSERT) + + def test_w_motion_crosses_lines(self): + events = itertools.chain( + code_to_events("end"), + [Event(evt="key", data="\n", raw=bytearray(b"\n"))], + code_to_events("start"), + [ + Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC + Event(evt="key", data="k", raw=bytearray(b"k")), # up + Event(evt="key", data="$", raw=bytearray(b"$")), # end of "end" + Event(evt="key", data="w", raw=bytearray(b"w")), # word forward + ], + ) + reader, _ = self._run_vi(events) + # w from end of first line should go to start of "start" + self.assertEqual(reader.pos, 4) # position of 's' in "start" + + +@force_not_colorized_test_class +class TestHistoricalReaderBindings(TestCase): + def test_meta_bindings_present_only_in_emacs_mode(self): + console = prepare_console(iter(())) + reader = prepare_reader(console) + emacs_keymap = dict(reader.collect_keymap()) + self.assertIn(r"\M-r", emacs_keymap) + self.assertIn(r"\x1b[6~", emacs_keymap) + + reader.use_vi_mode = True + reader.enter_insert_mode() + vi_keymap = dict(reader.collect_keymap()) + self.assertNotIn(r"\M-r", vi_keymap) + self.assertNotIn(r"\x1b[6~", vi_keymap) + self.assertIn(r"\C-r", vi_keymap)