From 3843a01391aa3898c7bef96cdad916058ba2fa11 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 2 Nov 2019 08:37:15 -0700 Subject: [PATCH] undo / redo --- .coveragerc | 4 + babi.py | 310 ++++++++++++++++++++++++++++++++++++++++----- tests/babi_test.py | 282 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 563 insertions(+), 33 deletions(-) diff --git a/.coveragerc b/.coveragerc index c06f6a8..fdfed19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -24,6 +24,10 @@ exclude_lines = ^\s*return NotImplemented\b ^\s*raise$ + # Ignore typing-related things + ^if (False|TYPE_CHECKING): + : \.\.\.$ + # Don't complain if non-runnable code isn't run: ^if __name__ == ['"]__main__['"]:$ diff --git a/babi.py b/babi.py index 5214284..e895628 100644 --- a/babi.py +++ b/babi.py @@ -3,20 +3,33 @@ import collections import contextlib import curses import enum +import functools import hashlib import io import os import signal +from typing import Any +from typing import Callable +from typing import cast from typing import Dict from typing import Generator from typing import IO +from typing import Iterator from typing import List from typing import NamedTuple from typing import Optional from typing import Tuple +from typing import TYPE_CHECKING +from typing import TypeVar from typing import Union +if TYPE_CHECKING: + from typing import Protocol # python3.8+ +else: + Protocol = object + VERSION_STR = 'babi v0' +TCallable = TypeVar('TCallable', bound=Callable[..., Any]) def _line_x(x: int, width: int) -> int: @@ -48,6 +61,77 @@ def _scrolled_line(s: str, x: int, width: int, *, current: bool) -> str: return s.ljust(width) +class MutableSequenceNoSlice(Protocol): + def __len__(self) -> int: ... + def __getitem__(self, idx: int) -> str: ... + def __setitem__(self, idx: int, val: str) -> None: ... + def __delitem__(self, idx: int) -> None: ... + def insert(self, idx: int, val: str) -> None: ... + + def __iter__(self) -> Iterator[str]: + for i in range(len(self)): + yield self[i] + + def append(self, val: str) -> None: + self.insert(len(self), val) + + def pop(self, idx: int = -1) -> str: + victim = self[idx] + del self[idx] + return victim + + +def _del(lst: MutableSequenceNoSlice, *, idx: int) -> None: + del lst[idx] + + +def _set(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None: + lst[idx] = val + + +def _ins(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None: + lst.insert(idx, val) + + +class ListSpy(MutableSequenceNoSlice): + def __init__(self, lst: MutableSequenceNoSlice) -> None: + self._lst = lst + self._undo: List[Callable[[MutableSequenceNoSlice], None]] = [] + + def __repr__(self) -> str: + return f'{type(self).__name__}({self._lst})' + + def __len__(self) -> int: + return len(self._lst) + + def __getitem__(self, idx: int) -> str: + return self._lst[idx] + + def __setitem__(self, idx: int, val: str) -> None: + self._undo.append(functools.partial(_set, idx=idx, val=self._lst[idx])) + self._lst[idx] = val + + def __delitem__(self, idx: int) -> None: + if idx < 0: + idx %= len(self) + self._undo.append(functools.partial(_ins, idx=idx, val=self._lst[idx])) + del self._lst[idx] + + def insert(self, idx: int, val: str) -> None: + if idx < 0: + idx %= len(self) + self._undo.append(functools.partial(_del, idx=idx)) + self._lst.insert(idx, val) + + def undo(self, lst: MutableSequenceNoSlice) -> None: + for fn in reversed(self._undo): + fn(lst) + + @property + def has_modifications(self) -> bool: + return bool(self._undo) + + class Margin(NamedTuple): header: bool footer: bool @@ -180,7 +264,7 @@ class Status: return buf -def _restore_lines_eof_invariant(lines: List[str]) -> None: +def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None: """The file lines will always contain a blank empty string at the end to simplify rendering. This should be called whenever the end of the file might change. @@ -208,14 +292,102 @@ def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]: return lines, nl, mixed, sha256.hexdigest() +class Action: + def __init__( + self, *, name: str, spy: ListSpy, + start_x: int, start_line: int, start_modified: bool, + end_x: int, end_line: int, end_modified: bool, + ): + self.name = name + self.spy = spy + self.start_x = start_x + self.start_line = start_line + self.start_modified = start_modified + self.end_x = end_x + self.end_line = end_line + self.end_modified = end_modified + self.final = False + + def apply(self, file: 'File') -> 'Action': + spy = ListSpy(file.lines) + action = Action( + name=self.name, spy=spy, + start_x=self.end_x, start_line=self.end_line, + start_modified=self.end_modified, + end_x=self.start_x, end_line=self.start_line, + end_modified=self.start_modified, + ) + + self.spy.undo(spy) + file.x = self.start_x + file.cursor_line = self.start_line + file.modified = self.start_modified + + return action + + +def action(func: TCallable) -> TCallable: + @functools.wraps(func) + def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + assert not isinstance(self.lines, ListSpy), 'nested edit/movement' + if self.undo_stack: + self.undo_stack[-1].final = True + return func(self, *args, **kwargs) + return cast(TCallable, action_inner) + + +def edit_action(name: str) -> Callable[[TCallable], TCallable]: + def edit_action_decorator(func: TCallable) -> TCallable: + @functools.wraps(func) + def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + continue_last = ( + self.undo_stack and + self.undo_stack[-1].name == name and + not self.undo_stack[-1].final + ) + if continue_last: + spy = self.undo_stack[-1].spy + else: + if self.undo_stack: + self.undo_stack[-1].final = True + spy = ListSpy(self.lines) + + before_x, before_line = self.x, self.cursor_line + before_modified = self.modified + assert not isinstance(self.lines, ListSpy), 'recursive action?' + orig, self.lines = self.lines, spy + try: + return func(self, *args, **kwargs) + finally: + self.lines = orig + self.redo_stack.clear() + if continue_last: + self.undo_stack[-1].end_x = self.x + self.undo_stack[-1].end_line = self.cursor_line + self.undo_stack[-1].end_modified = self.modified + elif spy.has_modifications: + action = Action( + name=name, spy=spy, + start_x=before_x, start_line=before_line, + start_modified=before_modified, + end_x=self.x, end_line=self.cursor_line, + end_modified=self.modified, + ) + self.undo_stack.append(action) + return cast(TCallable, edit_action_inner) + return edit_action_decorator + + class File: def __init__(self, filename: Optional[str]) -> None: self.filename = filename self.modified = False - self.lines: List[str] = [] + self.lines: MutableSequenceNoSlice = [] self.nl = '\n' self.file_line = self.cursor_line = self.x = self.x_hint = 0 self.sha256: Optional[str] = None + self.undo_stack: List[Action] = [] + self.redo_stack: List[Action] = [] def ensure_loaded(self, status: Status) -> None: if self.lines: @@ -244,6 +416,17 @@ class File: # movement + def _scroll_screen_if_needed(self, margin: Margin) -> None: + # if the `cursor_line` is not on screen, make it so + if ( + self.file_line <= + self.cursor_line < + self.file_line + margin.body_lines + ): + return + + self.file_line = max(self.cursor_line - margin.body_lines // 2, 0) + def _scroll_amount(self) -> int: return int(curses.LINES / 2 + .5) @@ -254,23 +437,26 @@ class File: if self.cursor_line >= self.file_line + margin.body_lines: self.file_line += self._scroll_amount() + @action def down(self, margin: Margin) -> None: if self.cursor_line < len(self.lines) - 1: self.cursor_line += 1 self.maybe_scroll_down(margin) self._set_x_after_vertical_movement() - def maybe_scroll_up(self, margin: Margin) -> None: + def _maybe_scroll_up(self, margin: Margin) -> None: if self.cursor_line < self.file_line: self.file_line -= self._scroll_amount() self.file_line = max(self.file_line, 0) + @action def up(self, margin: Margin) -> None: if self.cursor_line > 0: self.cursor_line -= 1 - self.maybe_scroll_up(margin) + self._maybe_scroll_up(margin) self._set_x_after_vertical_movement() + @action def right(self, margin: Margin) -> None: if self.x >= len(self.lines[self.cursor_line]): if self.cursor_line < len(self.lines) - 1: @@ -281,32 +467,37 @@ class File: self.x += 1 self.x_hint = self.x + @action def left(self, margin: Margin) -> None: if self.x == 0: if self.cursor_line > 0: self.cursor_line -= 1 self.x = len(self.lines[self.cursor_line]) - self.maybe_scroll_up(margin) + self._maybe_scroll_up(margin) else: self.x -= 1 self.x_hint = self.x + @action def home(self, margin: Margin) -> None: self.x = self.x_hint = 0 + @action def end(self, margin: Margin) -> None: self.x = self.x_hint = len(self.lines[self.cursor_line]) + @action def ctrl_home(self, margin: Margin) -> None: self.x = self.x_hint = 0 self.cursor_line = self.file_line = 0 + @action def ctrl_end(self, margin: Margin) -> None: self.x = self.x_hint = 0 self.cursor_line = len(self.lines) - 1 - if self.file_line < self.cursor_line - margin.body_lines: - self.file_line = self.cursor_line - margin.body_lines * 3 // 4 + 1 + self._scroll_screen_if_needed(margin) + @action def page_up(self, margin: Margin) -> None: if self.cursor_line < margin.body_lines: self.cursor_line = self.file_line = 0 @@ -315,6 +506,7 @@ class File: self.cursor_line = self.file_line = pos self._set_x_after_vertical_movement() + @action def page_down(self, margin: Margin) -> None: if self.file_line + margin.body_lines >= len(self.lines): self.cursor_line = len(self.lines) - 1 @@ -325,6 +517,7 @@ class File: # editing + @edit_action('backspace text') def backspace(self, margin: Margin) -> None: # backspace at the beginning of the file does nothing if self.cursor_line == 0 and self.x == 0: @@ -335,7 +528,8 @@ class File: victim = self.lines.pop(self.cursor_line) new_x = len(self.lines[self.cursor_line - 1]) self.lines[self.cursor_line - 1] += victim - self.up(margin) + self.cursor_line -= 1 + self._maybe_scroll_up(margin) self.x = self.x_hint = new_x # deleting the fake end-of-file doesn't cause modification self.modified |= self.cursor_line < len(self.lines) - 1 @@ -343,31 +537,55 @@ class File: else: s = self.lines[self.cursor_line] self.lines[self.cursor_line] = s[:self.x - 1] + s[self.x:] - self.left(margin) + self.x = self.x_hint = self.x - 1 self.modified = True + @edit_action('delete text') def delete(self, margin: Margin) -> None: # noop at end of the file if self.cursor_line == len(self.lines) - 1: pass # if we're at the end of the line, collapse the line afterwards elif self.x == len(self.lines[self.cursor_line]): - self.lines[self.cursor_line] += self.lines[self.cursor_line + 1] - self.lines.pop(self.cursor_line + 1) + victim = self.lines.pop(self.cursor_line + 1) + self.lines[self.cursor_line] += victim self.modified = True else: s = self.lines[self.cursor_line] self.lines[self.cursor_line] = s[:self.x] + s[self.x + 1:] self.modified = True + @edit_action('line break') def enter(self, margin: Margin) -> None: s = self.lines[self.cursor_line] self.lines[self.cursor_line] = s[:self.x] self.lines.insert(self.cursor_line + 1, s[self.x:]) - self.down(margin) + self.cursor_line += 1 + self.maybe_scroll_down(margin) self.x = self.x_hint = 0 self.modified = True + @edit_action('cut') + def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]: + if self.cursor_line == len(self.lines) - 1: + return () + else: + victim = self.lines.pop(self.cursor_line) + self.x = self.x_hint = 0 + self.modified = True + return cut_buffer + (victim,) + + @edit_action('uncut') + def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: + for cut_line in cut_buffer: + line = self.lines[self.cursor_line] + before, after = line[:self.x], line[self.x:] + self.lines[self.cursor_line] = before + cut_line + self.lines.insert(self.cursor_line + 1, after) + self.cursor_line += 1 + self.x = self.x_hint = 0 + self.maybe_scroll_down(margin) + DISPATCH = { # movement curses.KEY_DOWN: down, @@ -393,13 +611,41 @@ class File: b'kEND5': ctrl_end, } + @edit_action('text') def c(self, wch: str, margin: Margin) -> None: s = self.lines[self.cursor_line] self.lines[self.cursor_line] = s[:self.x] + wch + s[self.x:] - self.right(margin) + self.x = self.x_hint = self.x + 1 self.modified = True _restore_lines_eof_invariant(self.lines) + def _undo_redo( + self, + op: str, + from_stack: List[Action], + to_stack: List[Action], + status: Status, + margin: Margin, + ) -> None: + if not from_stack: + status.update(f'nothing to {op}!') + else: + action = from_stack.pop() + to_stack.append(action.apply(self)) + self._scroll_screen_if_needed(margin) + status.update(f'{op}: {action.name}') + + def undo(self, status: Status, margin: Margin) -> None: + self._undo_redo( + 'undo', self.undo_stack, self.redo_stack, status, margin, + ) + + def redo(self, status: Status, margin: Margin) -> None: + self._undo_redo( + 'redo', self.redo_stack, self.undo_stack, status, margin, + ) + + @action def save(self, status: Status) -> None: # TODO: make directories if they don't exist # TODO: maybe use mtime / stat as a shortcut for hashing below @@ -432,6 +678,14 @@ class File: lines = 'lines' if num_lines != 1 else 'line' status.update(f'saved! ({num_lines} {lines} written)') + # fix up modified state in undo / redo stacks + for stack in (self.undo_stack, self.redo_stack): + first = True + for action in reversed(stack): + action.end_modified = not first + action.start_modified = True + first = False + # positioning def cursor_y(self, margin: Margin) -> int: @@ -474,7 +728,7 @@ class Screen: self.i = 0 self.status = Status() self.margin = Margin.from_screen(self.stdscr) - self.cut_buffer = '' + self.cut_buffer: Tuple[str, ...] = () @property def file(self) -> File: @@ -571,7 +825,9 @@ def _get_char(stdscr: 'curses._CursesWindow') -> Key: finally: stdscr.nodelay(False) - if len(wch) > 1: + if len(wch) == 2: + return Key(wch, -1, f'M-{wch[1]}'.encode()) + elif len(wch) > 1: key = SEQUENCE_KEY.get(wch, -1) keyname = SEQUENCE_KEYNAME.get(wch, b'unknown') return Key(wch, key, keyname) @@ -607,23 +863,17 @@ def _edit(screen: Screen) -> EditResult: elif key.keyname in File.DISPATCH_KEY: screen.file.DISPATCH_KEY[key.keyname](screen.file, screen.margin) elif key.keyname == b'^K': - if screen.file.file_line == len(screen.file.lines) - 1: - screen.cut_buffer = '' + if prevkey.keyname == b'^K': + cut_buffer = screen.cut_buffer else: - line = screen.file.lines[screen.file.cursor_line] + '\n' - if prevkey.keyname == b'^K': - screen.cut_buffer += line - else: - screen.cut_buffer = line - del screen.file.lines[screen.file.cursor_line] - screen.file.x = screen.file.x_hint = 0 - screen.file.modified = True + cut_buffer = () + screen.cut_buffer = screen.file.cut(cut_buffer) elif key.keyname == b'^U': - for c in screen.cut_buffer: - if c == '\n': - screen.file.enter(screen.margin) - else: - screen.file.c(c, screen.margin) + screen.file.uncut(screen.cut_buffer, screen.margin) + elif key.keyname == b'M-u': + screen.file.undo(screen.status, screen.margin) + elif key.keyname == b'M-U': + screen.file.redo(screen.status, screen.margin) elif key.keyname == b'^[': # escape response = screen.status.prompt(screen, '') if response == ':q': diff --git a/tests/babi_test.py b/tests/babi_test.py index 7ee729b..e697353 100644 --- a/tests/babi_test.py +++ b/tests/babi_test.py @@ -10,6 +10,147 @@ from hecate import Runner import babi +def test_list_spy_repr(): + assert repr(babi.ListSpy(['a', 'b', 'c'])) == "ListSpy(['a', 'b', 'c'])" + + +def test_list_spy_item_retrieval(): + spy = babi.ListSpy(['a', 'b', 'c']) + assert spy[1] == 'b' + assert spy[-1] == 'c' + with pytest.raises(IndexError): + spy[3] + + +def test_list_spy_del(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + del spy[1] + + assert lst == ['a', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_del_with_negative(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + del spy[-1] + + assert lst == ['a', 'b'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_insert(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy.insert(1, 'q') + + assert lst == ['a', 'q', 'b', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_insert_with_negative(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy.insert(-1, 'q') + + assert lst == ['a', 'b', 'q', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_set_value(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy[1] = 'hello' + + assert lst == ['a', 'hello', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_multiple_modifications(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy[1] = 'hello' + spy.insert(1, 'ohai') + del spy[0] + + assert lst == ['ohai', 'hello', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_iter(): + spy = babi.ListSpy(['a', 'b', 'c']) + spy_iter = iter(spy) + assert next(spy_iter) == 'a' + assert next(spy_iter) == 'b' + assert next(spy_iter) == 'c' + with pytest.raises(StopIteration): + next(spy_iter) + + +def test_list_spy_append(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy.append('q') + + assert lst == ['a', 'b', 'c', 'q'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_pop_default(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy.pop() + + assert lst == ['a', 'b'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + +def test_list_spy_pop_idx(): + lst = ['a', 'b', 'c'] + + spy = babi.ListSpy(lst) + spy.pop(1) + + assert lst == ['a', 'c'] + + spy.undo(lst) + + assert lst == ['a', 'b', 'c'] + + def test_position_repr(): ret = repr(babi.File('f.txt')) assert ret == ( @@ -23,6 +164,8 @@ def test_position_repr(): ' x=0,\n' ' x_hint=0,\n' ' sha256=None,\n' + ' undo_stack=[],\n' + ' redo_stack=[],\n' ')' ) @@ -417,14 +560,14 @@ def test_ctrl_end_already_on_last_page(tmpdir): f = tmpdir.join('f') f.write('\n'.join(f'line_{i}' for i in range(10))) - with run(str(f), height=8) as h, and_exit(h): + with run(str(f), height=9) as h, and_exit(h): h.press('PageDown') h.await_cursor_position(x=0, y=1) h.await_text('line_9') h.press('^End') - h.await_cursor_position(x=0, y=7) - assert h.get_screen_line(6) == 'line_9' + h.await_cursor_position(x=0, y=6) + assert h.get_screen_line(5) == 'line_9' def test_scrolling_arrow_key_movement(tmpdir): @@ -1031,6 +1174,14 @@ def test_cut_at_beginning_of_file(): h.await_text_missing('*') +def test_cut_end_of_file(): + with run() as h, and_exit(h): + h.press('hi') + h.press('Down') + h.press('^K') + h.press('hi') + + def test_cut_uncut_multiple_file_buffers(tmpdir): f1 = tmpdir.join('f1') f1.write('hello\nworld\n') @@ -1044,3 +1195,128 @@ def test_cut_uncut_multiple_file_buffers(tmpdir): h.await_text_missing('world') h.press('^U') h.await_text('hello\ngood\nbye\n') + + +def test_nothing_to_undo_redo(): + with run() as h, and_exit(h): + h.press('M-u') + h.await_text('nothing to undo!') + h.press('M-U') + h.await_text('nothing to redo!') + + +def test_undo_redo(): + with run() as h, and_exit(h): + h.press('hello') + h.await_text('hello') + h.press('M-u') + h.await_text('undo: text') + h.await_text_missing('hello') + h.await_text_missing(' *') + h.press('M-U') + h.await_text('redo: text') + h.await_text('hello') + h.await_text(' *') + + +def test_undo_redo_movement_interrupts_actions(): + with run() as h, and_exit(h): + h.press('hello') + h.press('Left') + h.press('Right') + h.press('world') + h.press('M-u') + h.await_text('undo: text') + h.await_text('hello') + + +def test_undo_redo_action_interrupts_actions(): + with run() as h, and_exit(h): + h.press('hello') + h.await_text('hello') + h.press('Bspace') + h.await_text_missing('hello') + h.press('M-u') + h.await_text('hello') + h.press('world') + h.await_text('helloworld') + h.press('M-u') + h.await_text_missing('world') + h.await_text('hello') + + +def test_undo_redo_mixed_newlines(tmpdir): + f = tmpdir.join('f') + f.write_binary(b'foo\nbar\r\n') + + with run(str(f)) as h, and_exit(h): + h.press('hello') + h.press('M-u') + h.await_text('undo: text') + h.await_text(' *') + + +def test_undo_redo_with_save(tmpdir): + f = tmpdir.join('f').ensure() + + with run(str(f)) as h, and_exit(h): + h.press('hello') + h.press('^S') + h.await_text_missing(' *') + h.press('M-u') + h.await_text(' *') + h.press('M-U') + h.await_text_missing(' *') + h.press('M-u') + h.await_text(' *') + h.press('^S') + h.await_text_missing(' *') + h.press('M-U') + h.await_text(' *') + + +def test_undo_redo_implicit_linebreak(tmpdir): + f = tmpdir.join('f') + + with run(str(f)) as h, and_exit(h): + h.press('hello') + h.press('M-u') + h.press('^S') + h.await_text('saved!') + assert f.read() == '' + h.press('M-U') + h.press('^S') + h.await_text('saved!') + assert f.read() == 'hello\n' + + +def test_redo_cleared_after_action(tmpdir): + with run() as h, and_exit(h): + h.press('hello') + h.press('M-u') + h.press('world') + h.press('M-U') + h.await_text('nothing to redo!') + + +def test_undo_no_action_when_noop(): + with run() as h, and_exit(h): + h.press('hello') + h.press('Enter') + h.press('world') + h.press('Down') + h.press('^K') + h.press('M-u') + h.await_text('undo: text') + h.await_cursor_position(x=0, y=2) + + +def test_undo_redo_causes_scroll(): + with run(height=8) as h, and_exit(h): + for i in range(10): + h.press('Enter') + h.await_cursor_position(x=0, y=3) + h.press('M-u') + h.await_cursor_position(x=0, y=1) + h.press('M-U') + h.await_cursor_position(x=0, y=4)