Skip to content

Commit fa1a578

Browse files
committed
Implement undo
1 parent f863425 commit fa1a578

File tree

5 files changed

+132
-1
lines changed

5 files changed

+132
-1
lines changed

Lib/_pyrepl/commands.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ def do(self) -> None:
6161

6262

6363
class KillCommand(Command):
64+
modifies_buffer = True
65+
6466
def kill_range(self, start: int, end: int) -> None:
6567
if start == end:
6668
return
@@ -422,6 +424,8 @@ def do(self) -> None:
422424

423425

424426
class backspace(EditCommand):
427+
modifies_buffer = True
428+
425429
def do(self) -> None:
426430
r = self.reader
427431
b = r.buffer
@@ -435,6 +439,8 @@ def do(self) -> None:
435439

436440

437441
class delete(EditCommand):
442+
modifies_buffer = True
443+
438444
def do(self) -> None:
439445
r = self.reader
440446
b = r.buffer

Lib/_pyrepl/reader.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@
3636

3737
# types
3838
Command = commands.Command
39-
from .types import Callback, SimpleContextManager, KeySpec, CommandName
39+
from .types import Callback, SimpleContextManager, KeySpec, CommandName, ViUndoState
4040

4141

4242
# syntax classes
4343
SYNTAX_WHITESPACE, SYNTAX_WORD, SYNTAX_SYMBOL = range(3)
44+
MAX_VI_UNDO_STACK_SIZE = 100
4445

4546

4647
from .types import ViMode, ViFindState
@@ -192,6 +193,9 @@ def make_default_commands() -> dict[CommandName, type[Command]]:
192193
# Replace commands
193194
(r"r", "vi-replace-char"),
194195

196+
# Undo commands
197+
(r"u", "vi-undo"),
198+
195199
# Special keys still work in normal mode
196200
(r"\<left>", "left"),
197201
(r"\<right>", "right"),
@@ -310,6 +314,7 @@ class Reader:
310314
use_vi_mode: bool = False
311315
vi_mode: ViMode = ViMode.INSERT
312316
vi_find: ViFindState = field(default_factory=ViFindState)
317+
undo_stack: list[ViUndoState] = field(default_factory=list)
313318

314319
## cached metadata to speed up screen refreshes
315320
@dataclass
@@ -894,6 +899,7 @@ def prepare(self) -> None:
894899
self.last_command = None
895900
if self.use_vi_mode:
896901
self.enter_insert_mode()
902+
self.undo_stack.clear()
897903
self.calc_screen()
898904
except BaseException:
899905
self.restore()
@@ -958,6 +964,13 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None:
958964
return # nothing to do
959965

960966
command = command_type(self, *cmd) # type: ignore[arg-type]
967+
968+
# Save undo state in vi mode if the command modifies the buffer
969+
if self.use_vi_mode and getattr(command_type, 'modifies_buffer', False):
970+
self.undo_stack.append(ViUndoState(
971+
buffer_snapshot=self.buffer.copy(),
972+
pos_snapshot=self.pos,
973+
))
961974
command.do()
962975

963976
self.after_command(command)
@@ -1072,6 +1085,14 @@ def enter_insert_mode(self) -> None:
10721085

10731086
self.vi_mode = ViMode.INSERT
10741087

1088+
if len(self.undo_stack) > MAX_VI_UNDO_STACK_SIZE:
1089+
self.undo_stack.pop(0)
1090+
1091+
self.undo_stack.append(ViUndoState(
1092+
buffer_snapshot=self.buffer.copy(),
1093+
pos_snapshot=self.pos,
1094+
))
1095+
10751096
# Switch translator to insert mode keymap
10761097
self.keymap = self.collect_keymap()
10771098
self.input_trans = input.KeymapTranslator(

Lib/_pyrepl/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ class ViFindState:
2929
last_inclusive: bool = True # f/F=True, t/T=False
3030
pending_direction: ViFindDirection | None = None
3131
pending_inclusive: bool = True
32+
33+
@dataclass
34+
class ViUndoState:
35+
buffer_snapshot: CharBuffer = field(default_factory=list)
36+
pos_snapshot: int = 0

Lib/_pyrepl/vi_commands.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .commands import Command, MotionCommand, KillCommand
66
from . import input as _input
77
from .types import ViFindDirection
8+
from .trace import trace
89

910

1011
# ============================================================================
@@ -361,6 +362,8 @@ def do(self) -> None:
361362

362363
class vi_replace_execute(Command):
363364
"""Execute character replacement with the pressed character."""
365+
modifies_buffer = True
366+
364367
def do(self) -> None:
365368
r = self.reader
366369
r.pop_input_trans()
@@ -375,3 +378,20 @@ class vi_replace_cancel(Command):
375378
def do(self) -> None:
376379
r = self.reader
377380
r.pop_input_trans()
381+
382+
383+
# ============================================================================
384+
# Undo Commands
385+
# ============================================================================
386+
387+
class vi_undo(Command):
388+
"""Undo last change (u)."""
389+
def do(self) -> None:
390+
r = self.reader
391+
trace("vi_undo: undo_stack size =", len(r.undo_stack))
392+
if not r.undo_stack:
393+
return
394+
state = r.undo_stack.pop()
395+
r.buffer[:] = state.buffer_snapshot
396+
r.pos = state.pos_snapshot
397+
r.dirty = True

Lib/test/test_pyrepl/test_reader.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,85 @@ def test_replace_char(self):
11731173
self.assertEqual(reader.pos, 2) # cursor stays on replaced char
11741174
self.assertEqual(reader.vi_mode, ViMode.NORMAL) # stays in normal mode
11751175

1176+
def test_undo_after_insert(self):
1177+
events = itertools.chain(
1178+
code_to_events("hello"),
1179+
[
1180+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1181+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo
1182+
],
1183+
)
1184+
reader, _ = self._run_vi(events)
1185+
self.assertEqual(reader.get_unicode(), "")
1186+
self.assertEqual(reader.vi_mode, ViMode.NORMAL)
1187+
1188+
def test_undo_after_delete_word(self):
1189+
events = itertools.chain(
1190+
code_to_events("hello world"),
1191+
[
1192+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1193+
Event(evt="key", data="0", raw=bytearray(b"0")), # BOL
1194+
Event(evt="key", data="d", raw=bytearray(b"d")), # delete
1195+
Event(evt="key", data="w", raw=bytearray(b"w")), # word
1196+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo
1197+
],
1198+
)
1199+
reader, _ = self._run_vi(events)
1200+
self.assertEqual(reader.get_unicode(), "hello world")
1201+
1202+
def test_undo_after_x_delete(self):
1203+
events = itertools.chain(
1204+
code_to_events("hello"),
1205+
[
1206+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1207+
Event(evt="key", data="x", raw=bytearray(b"x")), # delete char
1208+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo
1209+
],
1210+
)
1211+
reader, _ = self._run_vi(events)
1212+
self.assertEqual(reader.get_unicode(), "hello")
1213+
1214+
def test_undo_stack_cleared_on_prepare(self):
1215+
events = itertools.chain(
1216+
code_to_events("hello"),
1217+
[Event(evt="key", data="\x1b", raw=bytearray(b"\x1b"))],
1218+
)
1219+
reader, console = self._run_vi(events)
1220+
self.assertGreater(len(reader.undo_stack), 0)
1221+
reader.prepare()
1222+
self.assertEqual(len(reader.undo_stack), 0)
1223+
1224+
def test_multiple_undo(self):
1225+
events = itertools.chain(
1226+
code_to_events("a"),
1227+
[
1228+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1229+
Event(evt="key", data="a", raw=bytearray(b"a")), # append
1230+
],
1231+
code_to_events("b"),
1232+
[
1233+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1234+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'b'
1235+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo 'a'
1236+
],
1237+
)
1238+
reader, _ = self._run_vi(events)
1239+
self.assertEqual(reader.get_unicode(), "")
1240+
1241+
def test_undo_after_replace(self):
1242+
events = itertools.chain(
1243+
code_to_events("hello"),
1244+
[
1245+
Event(evt="key", data="\x1b", raw=bytearray(b"\x1b")), # ESC
1246+
Event(evt="key", data="0", raw=bytearray(b"0")), # BOL
1247+
Event(evt="key", data="r", raw=bytearray(b"r")), # replace
1248+
Event(evt="key", data="X", raw=bytearray(b"X")), # replacement
1249+
Event(evt="key", data="u", raw=bytearray(b"u")), # undo
1250+
],
1251+
)
1252+
reader, _ = self._run_vi(events)
1253+
self.assertEqual(reader.get_unicode(), "hello")
1254+
11761255

11771256
@force_not_colorized_test_class
11781257
class TestHistoricalReaderBindings(TestCase):

0 commit comments

Comments
 (0)