undo / redo
This commit is contained in:
@@ -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__['"]:$
|
||||
|
||||
|
||||
310
babi.py
310
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':
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user