2121
2222from __future__ import annotations
2323
24+ import itertools
2425import sys
2526import _colorize
2627
3031from . import commands , console , input
3132from .utils import wlen , unbracket , disp_str , gen_colors , THEME
3233from .trace import trace
34+ from . import vi_commands
3335
3436
3537# types
4143SYNTAX_WHITESPACE , SYNTAX_WORD , SYNTAX_SYMBOL = range (3 )
4244
4345
46+ from .types import ViMode
47+
48+
4449def 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
5560def 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 )
135202class 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
0 commit comments