undo / redo

This commit is contained in:
Anthony Sottile
2019-11-02 08:37:15 -07:00
parent faf37fab47
commit 3843a01391
3 changed files with 563 additions and 33 deletions

View File

@@ -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__['"]:$

308
babi.py
View File

@@ -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 = ''
else:
line = screen.file.lines[screen.file.cursor_line] + '\n'
if prevkey.keyname == b'^K':
screen.cut_buffer += line
cut_buffer = screen.cut_buffer
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':

View File

@@ -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)