Refactor file internals to separate class
This commit is contained in:
235
babi/buf.py
Normal file
235
babi/buf.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import contextlib
|
||||
from typing import Callable
|
||||
from typing import Generator
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.margin import Margin
|
||||
|
||||
SetCallback = Callable[['Buf', int, str], None]
|
||||
DelCallback = Callable[['Buf', int, str], None]
|
||||
InsCallback = Callable[['Buf', int], None]
|
||||
|
||||
|
||||
class Modification(Protocol):
|
||||
def __call__(self, buf: 'Buf') -> None: ...
|
||||
|
||||
|
||||
class SetModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf[self.idx] = self.s
|
||||
|
||||
|
||||
class InsModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf.insert(self.idx, self.s)
|
||||
|
||||
|
||||
class DelModification(NamedTuple):
|
||||
idx: int
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
del buf[self.idx]
|
||||
|
||||
|
||||
class Buf:
|
||||
def __init__(self, lines: List[str]) -> None:
|
||||
self._lines = lines
|
||||
self.file_y = self.y = self._x = self._x_hint = 0
|
||||
|
||||
self._set_callbacks: List[SetCallback] = []
|
||||
self._del_callbacks: List[DelCallback] = []
|
||||
self._ins_callbacks: List[InsCallback] = []
|
||||
|
||||
# read only interface
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f'{type(self).__name__}('
|
||||
f'{self._lines!r}, x={self.x}, y={self.y}, file_y={self.file_y}'
|
||||
f')'
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._lines)
|
||||
|
||||
def __getitem__(self, idx: int) -> str:
|
||||
return self._lines[idx]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
yield from self._lines
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._lines)
|
||||
|
||||
# mutators
|
||||
|
||||
def __setitem__(self, idx: int, val: str) -> None:
|
||||
victim = self._lines[idx]
|
||||
|
||||
self._lines[idx] = val
|
||||
|
||||
for set_callback in self._set_callbacks:
|
||||
set_callback(self, idx, victim)
|
||||
|
||||
def __delitem__(self, idx: int) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
victim = self._lines[idx]
|
||||
|
||||
del self._lines[idx]
|
||||
|
||||
for del_callback in self._del_callbacks:
|
||||
del_callback(self, idx, victim)
|
||||
|
||||
def insert(self, idx: int, val: str) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
|
||||
self._lines.insert(idx, val)
|
||||
|
||||
for ins_callback in self._ins_callbacks:
|
||||
ins_callback(self, idx)
|
||||
|
||||
# also mutators, but implemented using above functions
|
||||
|
||||
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 restore_eof_invariant(self) -> None:
|
||||
"""the file lines will always contain a blank empty string at the end'
|
||||
to simplify rendering. call this whenever the last line may change
|
||||
"""
|
||||
if self[-1] != '':
|
||||
self.append('')
|
||||
|
||||
# event handling
|
||||
|
||||
def add_set_callback(self, cb: SetCallback) -> None:
|
||||
self._set_callbacks.append(cb)
|
||||
|
||||
def remove_set_callback(self, cb: SetCallback) -> None:
|
||||
self._set_callbacks.remove(cb)
|
||||
|
||||
def add_del_callback(self, cb: DelCallback) -> None:
|
||||
self._del_callbacks.append(cb)
|
||||
|
||||
def remove_del_callback(self, cb: DelCallback) -> None:
|
||||
self._del_callbacks.remove(cb)
|
||||
|
||||
def add_ins_callback(self, cb: InsCallback) -> None:
|
||||
self._ins_callbacks.append(cb)
|
||||
|
||||
def remove_ins_callback(self, cb: InsCallback) -> None:
|
||||
self._ins_callbacks.remove(cb)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def record(self) -> Generator[List[Modification], None, None]:
|
||||
modifications: List[Modification] = []
|
||||
|
||||
def set_cb(buf: 'Buf', idx: int, victim: str) -> None:
|
||||
modifications.append(SetModification(idx, victim))
|
||||
|
||||
def del_cb(buf: 'Buf', idx: int, victim: str) -> None:
|
||||
modifications.append(InsModification(idx, victim))
|
||||
|
||||
def ins_cb(buf: 'Buf', idx: int) -> None:
|
||||
modifications.append(DelModification(idx))
|
||||
|
||||
self.add_set_callback(set_cb)
|
||||
self.add_del_callback(del_cb)
|
||||
self.add_ins_callback(ins_cb)
|
||||
try:
|
||||
yield modifications
|
||||
finally:
|
||||
self.remove_ins_callback(ins_cb)
|
||||
self.remove_del_callback(del_cb)
|
||||
self.remove_set_callback(set_cb)
|
||||
|
||||
def apply(self, modifications: List[Modification]) -> List[Modification]:
|
||||
with self.record() as ret_modifications:
|
||||
for modification in reversed(modifications):
|
||||
modification(self)
|
||||
return ret_modifications
|
||||
|
||||
# position properties
|
||||
|
||||
@property
|
||||
def displayable_count(self) -> int:
|
||||
return len(self._lines) - self.file_y
|
||||
|
||||
@property
|
||||
def x(self) -> int:
|
||||
return self._x
|
||||
|
||||
@x.setter
|
||||
def x(self, x: int) -> None:
|
||||
self._x = x
|
||||
self._x_hint = x
|
||||
|
||||
# movement
|
||||
|
||||
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||
# if the `y` is not on screen, make it so
|
||||
if not (self.file_y <= self.y < self.file_y + margin.body_lines):
|
||||
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||
|
||||
def _set_x_after_vertical_movement(self) -> None:
|
||||
self._x = min(len(self._lines[self.y]), self._x_hint)
|
||||
|
||||
def up(self, margin: Margin) -> None:
|
||||
if self.y > 0:
|
||||
self.y -= 1
|
||||
if self.y < self.file_y:
|
||||
self.file_y = max(self.file_y - margin.scroll_amount, 0)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
def down(self, margin: Margin) -> None:
|
||||
if self.y < len(self._lines) - 1:
|
||||
self.y += 1
|
||||
if self.y >= self.file_y + margin.body_lines:
|
||||
self.file_y += margin.scroll_amount
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
def right(self, margin: Margin) -> None:
|
||||
if self.x >= len(self._lines[self.y]):
|
||||
if self.y < len(self._lines) - 1:
|
||||
self.down(margin)
|
||||
self.x = 0
|
||||
else:
|
||||
self.x += 1
|
||||
|
||||
def left(self, margin: Margin) -> None:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self.up(margin)
|
||||
self.x = len(self._lines[self.y])
|
||||
else:
|
||||
self.x -= 1
|
||||
|
||||
# screen movement
|
||||
|
||||
def file_up(self, margin: Margin) -> None:
|
||||
if self.file_y > 0:
|
||||
self.file_y -= 1
|
||||
if self.y > self.file_y + margin.body_lines - 1:
|
||||
self.up(margin)
|
||||
|
||||
def file_down(self, margin: Margin) -> None:
|
||||
if self.file_y < len(self._lines) - 1:
|
||||
self.file_y += 1
|
||||
if self.y < self.file_y:
|
||||
self.down(margin)
|
||||
467
babi/file.py
467
babi/file.py
@@ -21,6 +21,8 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.buf import Modification
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import FileHL
|
||||
from babi.hl.interface import HLFactory
|
||||
@@ -29,8 +31,6 @@ from babi.hl.selection import Selection
|
||||
from babi.hl.trailing_whitespace import TrailingWhitespace
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.list_spy import ListSpy
|
||||
from babi.list_spy import MutableSequenceNoSlice
|
||||
from babi.margin import Margin
|
||||
from babi.prompt import PromptResult
|
||||
from babi.status import Status
|
||||
@@ -41,15 +41,6 @@ if TYPE_CHECKING:
|
||||
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if not lines or lines[-1] != '':
|
||||
lines.append('')
|
||||
|
||||
|
||||
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
|
||||
sha256 = hashlib.sha256()
|
||||
lines = []
|
||||
@@ -63,7 +54,8 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
_restore_lines_eof_invariant(lines)
|
||||
# always make sure we end in a newline
|
||||
lines.append('')
|
||||
(nl, _), = newlines.most_common(1)
|
||||
mixed = len({k for k, v in newlines.items() if v}) > 1
|
||||
return lines, nl, mixed, sha256.hexdigest()
|
||||
@@ -71,13 +63,13 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
|
||||
|
||||
class Action:
|
||||
def __init__(
|
||||
self, *, name: str, spy: ListSpy,
|
||||
self, *, name: str, modifications: List[Modification],
|
||||
start_x: int, start_y: int, start_modified: bool,
|
||||
end_x: int, end_y: int, end_modified: bool,
|
||||
final: bool,
|
||||
):
|
||||
self.name = name
|
||||
self.spy = spy
|
||||
self.modifications = modifications
|
||||
self.start_x = start_x
|
||||
self.start_y = start_y
|
||||
self.start_modified = start_modified
|
||||
@@ -87,9 +79,8 @@ class Action:
|
||||
self.final = final
|
||||
|
||||
def apply(self, file: 'File') -> 'Action':
|
||||
spy = ListSpy(file.lines)
|
||||
action = Action(
|
||||
name=self.name, spy=spy,
|
||||
name=self.name, modifications=file.buf.apply(self.modifications),
|
||||
start_x=self.end_x, start_y=self.end_y,
|
||||
start_modified=self.end_modified,
|
||||
end_x=self.start_x, end_y=self.start_y,
|
||||
@@ -97,11 +88,9 @@ class Action:
|
||||
final=True,
|
||||
)
|
||||
|
||||
self.spy.undo(spy)
|
||||
file.x = self.start_x
|
||||
file.y = self.start_y
|
||||
file.buf.x = self.start_x
|
||||
file.buf.y = self.start_y
|
||||
file.modified = self.start_modified
|
||||
file.touch(spy.min_line_touched)
|
||||
|
||||
return action
|
||||
|
||||
@@ -162,8 +151,8 @@ class _SearchIter:
|
||||
self.reg = reg
|
||||
self.offset = offset
|
||||
self.wrapped = False
|
||||
self._start_x = file.x + offset
|
||||
self._start_y = file.y
|
||||
self._start_x = file.buf.x + offset
|
||||
self._start_y = file.buf.y
|
||||
|
||||
def __iter__(self) -> '_SearchIter':
|
||||
return self
|
||||
@@ -179,28 +168,28 @@ class _SearchIter:
|
||||
return Found(y, match)
|
||||
|
||||
def __next__(self) -> Tuple[int, Match[str]]:
|
||||
x = self.file.x + self.offset
|
||||
y = self.file.y
|
||||
x = self.file.buf.x + self.offset
|
||||
y = self.file.buf.y
|
||||
|
||||
match = self.reg.search(self.file.lines[y], x)
|
||||
match = self.reg.search(self.file.buf[y], x)
|
||||
if match:
|
||||
return self._stop_if_past_original(y, match)
|
||||
|
||||
if self.wrapped:
|
||||
for line_y in range(y + 1, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
match = self.reg.search(self.file.buf[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
else:
|
||||
for line_y in range(y + 1, len(self.file.lines)):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
for line_y in range(y + 1, len(self.file.buf)):
|
||||
match = self.reg.search(self.file.buf[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
self.wrapped = True
|
||||
|
||||
for line_y in range(0, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
match = self.reg.search(self.file.buf[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
@@ -216,10 +205,10 @@ class File:
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
self.modified = False
|
||||
self.lines: MutableSequenceNoSlice = []
|
||||
self.buf = Buf([])
|
||||
self.nl = '\n'
|
||||
self.file_y = self.y = self.x = self.x_hint = 0
|
||||
self.sha256: Optional[str] = None
|
||||
self._in_edit_action = False
|
||||
self.undo_stack: List[Action] = []
|
||||
self.redo_stack: List[Action] = []
|
||||
self._hl_factories = hl_factories
|
||||
@@ -229,7 +218,7 @@ class File:
|
||||
self._file_hls: Tuple[FileHL, ...] = ()
|
||||
|
||||
def ensure_loaded(self, status: Status, stdin: str) -> None:
|
||||
if self.lines:
|
||||
if self.buf:
|
||||
return
|
||||
|
||||
if self.filename == '-':
|
||||
@@ -237,10 +226,10 @@ class File:
|
||||
self.filename = None
|
||||
self.modified = True
|
||||
sio = io.StringIO(stdin)
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
elif self.filename is not None and os.path.isfile(self.filename):
|
||||
with open(self.filename, newline='') as f:
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(f)
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(f)
|
||||
else:
|
||||
if self.filename is not None:
|
||||
if os.path.lexists(self.filename):
|
||||
@@ -248,8 +237,9 @@ class File:
|
||||
self.filename = None
|
||||
else:
|
||||
status.update('(new file)')
|
||||
sio = io.StringIO('')
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
|
||||
|
||||
self.buf = Buf(lines)
|
||||
|
||||
if mixed:
|
||||
status.update(f'mixed newlines will be converted to {self.nl!r}')
|
||||
@@ -258,7 +248,7 @@ class File:
|
||||
file_hls = []
|
||||
for factory in self._hl_factories:
|
||||
if self.filename is not None:
|
||||
hl = factory.file_highlighter(self.filename, self.lines[0])
|
||||
hl = factory.file_highlighter(self.filename, self.buf[0])
|
||||
file_hls.append(hl)
|
||||
else:
|
||||
file_hls.append(factory.blank_file_highlighter())
|
||||
@@ -266,156 +256,109 @@ class File:
|
||||
*file_hls,
|
||||
self._trailing_whitespace, self._replace_hl, self.selection,
|
||||
)
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.register_callbacks(self.buf)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<{type(self).__name__} {self.filename!r}>'
|
||||
|
||||
# movement
|
||||
|
||||
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||
# if the `y` is not on screen, make it so
|
||||
if self.file_y <= self.y < self.file_y + margin.body_lines:
|
||||
return
|
||||
|
||||
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||
|
||||
def _scroll_amount(self) -> int:
|
||||
return int(curses.LINES / 2 + .5)
|
||||
|
||||
def _set_x_after_vertical_movement(self) -> None:
|
||||
self.x = min(len(self.lines[self.y]), self.x_hint)
|
||||
|
||||
def _increment_y(self, margin: Margin) -> None:
|
||||
self.y += 1
|
||||
if self.y >= self.file_y + margin.body_lines:
|
||||
self.file_y += self._scroll_amount()
|
||||
|
||||
def _decrement_y(self) -> None:
|
||||
self.y -= 1
|
||||
if self.y < self.file_y:
|
||||
self.file_y -= self._scroll_amount()
|
||||
self.file_y = max(self.file_y, 0)
|
||||
|
||||
@action
|
||||
def up(self, margin: Margin) -> None:
|
||||
if self.y > 0:
|
||||
self._decrement_y()
|
||||
self._set_x_after_vertical_movement()
|
||||
self.buf.up(margin)
|
||||
|
||||
@action
|
||||
def down(self, margin: Margin) -> None:
|
||||
if self.y < len(self.lines) - 1:
|
||||
self._increment_y(margin)
|
||||
self._set_x_after_vertical_movement()
|
||||
self.buf.down(margin)
|
||||
|
||||
@action
|
||||
def right(self, margin: Margin) -> None:
|
||||
if self.x >= len(self.lines[self.y]):
|
||||
if self.y < len(self.lines) - 1:
|
||||
self.x = 0
|
||||
self._increment_y(margin)
|
||||
else:
|
||||
self.x += 1
|
||||
self.x_hint = self.x
|
||||
self.buf.right(margin)
|
||||
|
||||
@action
|
||||
def left(self, margin: Margin) -> None:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self._decrement_y()
|
||||
self.x = len(self.lines[self.y])
|
||||
else:
|
||||
self.x -= 1
|
||||
self.x_hint = self.x
|
||||
self.buf.left(margin)
|
||||
|
||||
@action
|
||||
def home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.buf.x = 0
|
||||
|
||||
@action
|
||||
def end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
self.buf.x = len(self.buf[self.buf.y])
|
||||
|
||||
@action
|
||||
def ctrl_up(self, margin: Margin) -> None:
|
||||
self.file_y = max(0, self.file_y - 1)
|
||||
self.y = min(self.y, self.file_y + margin.body_lines - 1)
|
||||
self._set_x_after_vertical_movement()
|
||||
self.buf.file_up(margin)
|
||||
|
||||
@action
|
||||
def ctrl_down(self, margin: Margin) -> None:
|
||||
self.file_y = min(len(self.lines) - 1, self.file_y + 1)
|
||||
self.y = max(self.y, self.file_y)
|
||||
self._set_x_after_vertical_movement()
|
||||
self.buf.file_down(margin)
|
||||
|
||||
@action
|
||||
def ctrl_right(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
line = self.buf[self.buf.y]
|
||||
# if we're at the second to last character, jump to end of line
|
||||
if self.x == len(line) - 1:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
if self.buf.x == len(line) - 1:
|
||||
self.buf.right(margin)
|
||||
# if we're at the end of the line, jump forward to the next non-ws
|
||||
elif self.x == len(line):
|
||||
elif self.buf.x == len(line):
|
||||
while (
|
||||
self.y < len(self.lines) - 1 and (
|
||||
self.x == len(self.lines[self.y]) or
|
||||
self.lines[self.y][self.x].isspace()
|
||||
self.buf.y < len(self.buf) - 1 and (
|
||||
self.buf.x == len(self.buf[self.buf.y]) or
|
||||
self.buf[self.buf.y][self.buf.x].isspace()
|
||||
)
|
||||
):
|
||||
if self.x == len(self.lines[self.y]):
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
self.buf.right(margin)
|
||||
# if we're inside the line, jump to next position that's not our type
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
tp = line[self.x].isalnum()
|
||||
while self.x < len(line) and tp == line[self.x].isalnum():
|
||||
self.x = self.x_hint = self.x + 1
|
||||
self.buf.right(margin)
|
||||
tp = line[self.buf.x].isalnum()
|
||||
while self.buf.x < len(line) and tp == line[self.buf.x].isalnum():
|
||||
self.buf.right(margin)
|
||||
|
||||
@action
|
||||
def ctrl_left(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
line = self.buf[self.buf.y]
|
||||
# if we're at position 1 and it's not a space, go to the beginning
|
||||
if self.x == 1 and not line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
if self.buf.x == 1 and not line[:self.buf.x].isspace():
|
||||
self.buf.left(margin)
|
||||
# if we're at the beginning or it's all space up to here jump to the
|
||||
# end of the previous non-space line
|
||||
elif self.x == 0 or line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
while self.y > 0 and (self.x == 0 or not self.lines[self.y]):
|
||||
self._decrement_y()
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
elif self.buf.x == 0 or line[:self.buf.x].isspace():
|
||||
self.buf.x = 0
|
||||
while self.buf.y > 0 and self.buf.x == 0:
|
||||
self.buf.left(margin)
|
||||
else:
|
||||
self.x = self.x_hint = self.x - 1
|
||||
tp = line[self.x - 1].isalnum()
|
||||
while self.x > 0 and tp == line[self.x - 1].isalnum():
|
||||
self.x = self.x_hint = self.x - 1
|
||||
self.buf.left(margin)
|
||||
tp = line[self.buf.x - 1].isalnum()
|
||||
while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum():
|
||||
self.buf.left(margin)
|
||||
|
||||
@action
|
||||
def ctrl_home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = self.file_y = 0
|
||||
self.buf.x = 0
|
||||
self.buf.y = self.buf.file_y = 0
|
||||
|
||||
@action
|
||||
def ctrl_end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = len(self.lines) - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
self.buf.x = 0
|
||||
self.buf.y = len(self.buf) - 1
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def go_to_line(self, lineno: int, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.buf.x = 0
|
||||
if lineno == 0:
|
||||
self.y = 0
|
||||
elif lineno > len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
self.buf.y = 0
|
||||
elif lineno > len(self.buf):
|
||||
self.buf.y = len(self.buf) - 1
|
||||
elif lineno < 0:
|
||||
self.y = max(0, lineno + len(self.lines))
|
||||
self.buf.y = max(0, lineno + len(self.buf))
|
||||
else:
|
||||
self.y = lineno - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
self.buf.y = lineno - 1
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def search(
|
||||
@@ -430,14 +373,14 @@ class File:
|
||||
except StopIteration:
|
||||
status.update('no matches')
|
||||
else:
|
||||
if line_y == self.y and match.start() == self.x:
|
||||
if line_y == self.buf.y and match.start() == self.buf.x:
|
||||
status.update('this is the only occurrence')
|
||||
else:
|
||||
if search.wrapped:
|
||||
status.update('search wrapped')
|
||||
self.y = line_y
|
||||
self.x = self.x_hint = match.start()
|
||||
self.scroll_screen_if_needed(margin)
|
||||
self.buf.y = line_y
|
||||
self.buf.x = match.start()
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
@clear_selection
|
||||
def replace(
|
||||
@@ -452,38 +395,36 @@ class File:
|
||||
res: Union[str, PromptResult] = ''
|
||||
search = _SearchIter(self, reg, offset=0)
|
||||
for line_y, match in search:
|
||||
self.y = line_y
|
||||
self.x = self.x_hint = match.start()
|
||||
self.scroll_screen_if_needed(screen.margin)
|
||||
end = match.end()
|
||||
self.buf.y = line_y
|
||||
self.buf.x = match.start()
|
||||
self.buf.scroll_screen_if_needed(screen.margin)
|
||||
if res != 'a': # make `a` replace the rest of them
|
||||
with self._replace_hl.region(self.y, self.x, match.end()):
|
||||
with self._replace_hl.region(self.buf.y, self.buf.x, end):
|
||||
screen.draw()
|
||||
res = screen.quick_prompt('replace', ('yes', 'no', 'all'))
|
||||
if res in {'y', 'a'}:
|
||||
count += 1
|
||||
with self.edit_action_context('replace', final=True):
|
||||
replaced = match.expand(replace)
|
||||
line = screen.file.lines[line_y]
|
||||
line = screen.file.buf[line_y]
|
||||
if '\n' in replaced:
|
||||
replaced_lines = replaced.split('\n')
|
||||
self.lines[line_y] = (
|
||||
self.buf[line_y] = (
|
||||
f'{line[:match.start()]}{replaced_lines[0]}'
|
||||
)
|
||||
for i, ins_line in enumerate(replaced_lines[1:-1], 1):
|
||||
self.lines.insert(line_y + i, ins_line)
|
||||
self.buf.insert(line_y + i, ins_line)
|
||||
last_insert = line_y + len(replaced_lines) - 1
|
||||
self.lines.insert(
|
||||
last_insert,
|
||||
f'{replaced_lines[-1]}{line[match.end():]}',
|
||||
self.buf.insert(
|
||||
last_insert, f'{replaced_lines[-1]}{line[end:]}',
|
||||
)
|
||||
self.y = last_insert
|
||||
self.x = self.x_hint = 0
|
||||
self.buf.y = last_insert
|
||||
self.buf.x = 0
|
||||
search.offset = len(replaced_lines[-1])
|
||||
else:
|
||||
self.lines[line_y] = (
|
||||
f'{line[:match.start()]}'
|
||||
f'{replaced}'
|
||||
f'{line[match.end():]}'
|
||||
self.buf[line_y] = (
|
||||
f'{line[:match.start()]}{replaced}{line[end:]}'
|
||||
)
|
||||
search.offset = len(replaced)
|
||||
elif res == 'n':
|
||||
@@ -500,21 +441,21 @@ class File:
|
||||
|
||||
@action
|
||||
def page_up(self, margin: Margin) -> None:
|
||||
if self.y < margin.body_lines:
|
||||
self.y = self.file_y = 0
|
||||
if self.buf.y < margin.body_lines:
|
||||
self.buf.y = self.buf.file_y = 0
|
||||
else:
|
||||
pos = max(self.file_y - margin.page_size, 0)
|
||||
self.y = self.file_y = pos
|
||||
self.x = self.x_hint = 0
|
||||
pos = max(self.buf.file_y - margin.page_size, 0)
|
||||
self.buf.y = self.buf.file_y = pos
|
||||
self.buf.x = 0
|
||||
|
||||
@action
|
||||
def page_down(self, margin: Margin) -> None:
|
||||
if self.file_y + margin.body_lines >= len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
if self.buf.file_y + margin.body_lines >= len(self.buf):
|
||||
self.buf.y = len(self.buf) - 1
|
||||
else:
|
||||
pos = self.file_y + margin.page_size
|
||||
self.y = self.file_y = pos
|
||||
self.x = self.x_hint = 0
|
||||
pos = self.buf.file_y + margin.page_size
|
||||
self.buf.y = self.buf.file_y = pos
|
||||
self.buf.x = 0
|
||||
|
||||
# editing
|
||||
|
||||
@@ -522,47 +463,44 @@ class File:
|
||||
@clear_selection
|
||||
def backspace(self, margin: Margin) -> None:
|
||||
# backspace at the beginning of the file does nothing
|
||||
if self.y == 0 and self.x == 0:
|
||||
if self.buf.y == 0 and self.buf.x == 0:
|
||||
pass
|
||||
# backspace at the end of the file does not change the contents
|
||||
elif self.y == len(self.lines) - 1:
|
||||
self._decrement_y()
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
elif self.buf.y == len(self.buf) - 1:
|
||||
self.buf.left(margin)
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
elif self.x == 0:
|
||||
victim = self.lines.pop(self.y)
|
||||
new_x = len(self.lines[self.y - 1])
|
||||
self.lines[self.y - 1] += victim
|
||||
self._decrement_y()
|
||||
self.x = self.x_hint = new_x
|
||||
elif self.buf.x == 0:
|
||||
y, victim = self.buf.y, self.buf.pop(self.buf.y)
|
||||
self.buf.left(margin)
|
||||
self.buf[y - 1] += victim
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x - 1] + s[self.x:]
|
||||
self.x = self.x_hint = self.x - 1
|
||||
s = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:]
|
||||
self.buf.left(margin)
|
||||
|
||||
@edit_action('delete text', final=False)
|
||||
@clear_selection
|
||||
def delete(self, margin: Margin) -> None:
|
||||
# noop at end of the file
|
||||
if self.y == len(self.lines) - 1:
|
||||
if self.buf.y == len(self.buf) - 1:
|
||||
pass
|
||||
# if we're at the end of the line, collapse the line afterwards
|
||||
elif self.x == len(self.lines[self.y]):
|
||||
victim = self.lines.pop(self.y + 1)
|
||||
self.lines[self.y] += victim
|
||||
elif self.buf.x == len(self.buf[self.buf.y]):
|
||||
victim = self.buf.pop(self.buf.y + 1)
|
||||
self.buf[self.buf.y] += victim
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + s[self.x + 1:]
|
||||
s = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = s[:self.buf.x] + s[self.buf.x + 1:]
|
||||
|
||||
@edit_action('line break', final=False)
|
||||
@clear_selection
|
||||
def enter(self, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x]
|
||||
self.lines.insert(self.y + 1, s[self.x:])
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
s = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = s[:self.buf.x]
|
||||
self.buf.insert(self.buf.y + 1, s[self.buf.x:])
|
||||
self.buf.down(margin)
|
||||
self.buf.x = 0
|
||||
|
||||
@edit_action('indent selection', final=True)
|
||||
def _indent_selection(self, margin: Margin) -> None:
|
||||
@@ -570,21 +508,21 @@ class File:
|
||||
sel_y, sel_x = self.selection.start
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
if self.lines[l_y]:
|
||||
self.lines[l_y] = ' ' * 4 + self.lines[l_y]
|
||||
if l_y == self.y:
|
||||
self.x = self.x_hint = self.x + 4
|
||||
if self.buf[l_y]:
|
||||
self.buf[l_y] = ' ' * 4 + self.buf[l_y]
|
||||
if l_y == self.buf.y:
|
||||
self.buf.x = self.buf.x + 4
|
||||
if l_y == sel_y and sel_x != 0:
|
||||
sel_x += 4
|
||||
self.selection.set(sel_y, sel_x, self.y, self.x)
|
||||
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
n = 4 - self.x % 4
|
||||
line = self.lines[self.y]
|
||||
self.lines[self.y] = line[:self.x] + n * ' ' + line[self.x:]
|
||||
self.x = self.x_hint = self.x + n
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
n = 4 - self.buf.x % 4
|
||||
line = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
|
||||
self.buf.x = self.buf.x + n
|
||||
self.buf.restore_eof_invariant()
|
||||
|
||||
def tab(self, margin: Margin) -> None:
|
||||
if self.selection.start is not None:
|
||||
@@ -606,21 +544,21 @@ class File:
|
||||
sel_y, sel_x = self.selection.start
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
n = self._dedent_line(self.lines[l_y])
|
||||
n = self._dedent_line(self.buf[l_y])
|
||||
if n:
|
||||
self.lines[l_y] = self.lines[l_y][n:]
|
||||
if l_y == self.y:
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
self.buf[l_y] = self.buf[l_y][n:]
|
||||
if l_y == self.buf.y:
|
||||
self.buf.x = max(self.buf.x - n, 0)
|
||||
if l_y == sel_y:
|
||||
sel_x = max(sel_x - n, 0)
|
||||
self.selection.set(sel_y, sel_x, self.y, self.x)
|
||||
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
|
||||
|
||||
@edit_action('dedent', final=True)
|
||||
def _dedent(self, margin: Margin) -> None:
|
||||
n = self._dedent_line(self.lines[self.y])
|
||||
n = self._dedent_line(self.buf[self.buf.y])
|
||||
if n:
|
||||
self.lines[self.y] = self.lines[self.y][n:]
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
self.buf[self.buf.y] = self.buf[self.buf.y][n:]
|
||||
self.buf.x = max(self.buf.x - n, 0)
|
||||
|
||||
def shift_tab(self, margin: Margin) -> None:
|
||||
if self.selection.start is not None:
|
||||
@@ -634,20 +572,20 @@ class File:
|
||||
ret = []
|
||||
(s_y, s_x), (e_y, e_x) = self.selection.get()
|
||||
if s_y == e_y:
|
||||
ret.append(self.lines[s_y][s_x:e_x])
|
||||
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[s_y][e_x:]
|
||||
ret.append(self.buf[s_y][s_x:e_x])
|
||||
self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[s_y][e_x:]
|
||||
else:
|
||||
ret.append(self.lines[s_y][s_x:])
|
||||
ret.append(self.buf[s_y][s_x:])
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
ret.append(self.lines[l_y])
|
||||
ret.append(self.lines[e_y][:e_x])
|
||||
ret.append(self.buf[l_y])
|
||||
ret.append(self.buf[e_y][:e_x])
|
||||
|
||||
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[e_y][e_x:]
|
||||
self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[e_y][e_x:]
|
||||
for _ in range(s_y + 1, e_y + 1):
|
||||
self.lines.pop(s_y + 1)
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = s_x
|
||||
self.scroll_screen_if_needed(margin)
|
||||
self.buf.pop(s_y + 1)
|
||||
self.buf.y = s_y
|
||||
self.buf.x = s_x
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
return tuple(ret)
|
||||
|
||||
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
|
||||
@@ -656,21 +594,21 @@ class File:
|
||||
cut_buffer = ()
|
||||
|
||||
with self.edit_action_context('cut', final=False):
|
||||
if self.y == len(self.lines) - 1:
|
||||
if self.buf.y == len(self.buf) - 1:
|
||||
return cut_buffer
|
||||
else:
|
||||
victim = self.lines.pop(self.y)
|
||||
self.x = self.x_hint = 0
|
||||
victim = self.buf.pop(self.buf.y)
|
||||
self.buf.x = 0
|
||||
return cut_buffer + (victim,)
|
||||
|
||||
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
for cut_line in cut_buffer:
|
||||
line = self.lines[self.y]
|
||||
before, after = line[:self.x], line[self.x:]
|
||||
self.lines[self.y] = before + cut_line
|
||||
self.lines.insert(self.y + 1, after)
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
line = self.buf[self.buf.y]
|
||||
before, after = line[:self.buf.x], line[self.buf.x:]
|
||||
self.buf[self.buf.y] = before + cut_line
|
||||
self.buf.insert(self.buf.y + 1, after)
|
||||
self.buf.down(margin)
|
||||
self.buf.x = 0
|
||||
|
||||
@edit_action('uncut', final=True)
|
||||
@clear_selection
|
||||
@@ -684,30 +622,30 @@ class File:
|
||||
cut_buffer: Tuple[str, ...], margin: Margin,
|
||||
) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
self._decrement_y()
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
self.lines[self.y] += self.lines.pop(self.y + 1)
|
||||
self.buf.up(margin)
|
||||
self.buf.x = len(self.buf[self.buf.y])
|
||||
self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
|
||||
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
|
||||
# self.lines intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.lines, s_y, e_y))
|
||||
# self.buf intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.buf, s_y, e_y))
|
||||
for i, line in zip(range(s_y, e_y), lines):
|
||||
self.lines[i] = line
|
||||
self.buf[i] = line
|
||||
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = 0
|
||||
self.scroll_screen_if_needed(margin)
|
||||
self.buf.y = s_y
|
||||
self.buf.x = 0
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin) -> None:
|
||||
self._sort(margin, 0, len(self.lines) - 1)
|
||||
self._sort(margin, 0, len(self.buf) - 1)
|
||||
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin) -> None:
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
e_y = min(e_y + 1, len(self.lines) - 1)
|
||||
if self.lines[e_y - 1] == '':
|
||||
e_y = min(e_y + 1, len(self.buf) - 1)
|
||||
if self.buf[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
self._sort(margin, s_y, e_y)
|
||||
|
||||
@@ -756,13 +694,13 @@ class File:
|
||||
@edit_action('text', final=False)
|
||||
@clear_selection
|
||||
def c(self, wch: str, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + wch + s[self.x:]
|
||||
self.x = self.x_hint = self.x + len(wch)
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
s = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
|
||||
self.buf.x = self.buf.x + len(wch)
|
||||
self.buf.restore_eof_invariant()
|
||||
|
||||
def finalize_previous_action(self) -> None:
|
||||
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
|
||||
assert not self._in_edit_action, 'nested edit/movement'
|
||||
self.selection.clear()
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
@@ -781,57 +719,53 @@ class File:
|
||||
final: bool,
|
||||
) -> Generator[None, None, None]:
|
||||
continue_last = self._continue_last_action(name)
|
||||
if continue_last:
|
||||
spy = self.undo_stack[-1].spy
|
||||
else:
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
spy = ListSpy(self.lines)
|
||||
if not continue_last and self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
|
||||
before_x, before_line = self.x, self.y
|
||||
before_x, before_line = self.buf.x, self.buf.y
|
||||
before_modified = self.modified
|
||||
assert not isinstance(self.lines, ListSpy), 'recursive action?'
|
||||
orig, self.lines = self.lines, spy
|
||||
assert not self._in_edit_action, f'recursive action? {name}'
|
||||
self._in_edit_action = True
|
||||
try:
|
||||
yield
|
||||
with self.buf.record() as modifications:
|
||||
yield
|
||||
finally:
|
||||
self.lines = orig
|
||||
self._in_edit_action = False
|
||||
self.redo_stack.clear()
|
||||
if continue_last:
|
||||
self.undo_stack[-1].end_x = self.x
|
||||
self.undo_stack[-1].end_y = self.y
|
||||
self.touch(spy.min_line_touched)
|
||||
elif spy.has_modifications:
|
||||
self.undo_stack[-1].end_x = self.buf.x
|
||||
self.undo_stack[-1].end_y = self.buf.y
|
||||
self.undo_stack[-1].modifications.extend(modifications)
|
||||
elif modifications:
|
||||
self.modified = True
|
||||
action = Action(
|
||||
name=name, spy=spy,
|
||||
name=name, modifications=modifications,
|
||||
start_x=before_x, start_y=before_line,
|
||||
start_modified=before_modified,
|
||||
end_x=self.x, end_y=self.y,
|
||||
end_x=self.buf.x, end_y=self.buf.y,
|
||||
end_modified=True,
|
||||
final=final,
|
||||
)
|
||||
self.undo_stack.append(action)
|
||||
self.touch(spy.min_line_touched)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def select(self) -> Generator[None, None, None]:
|
||||
if self.selection.start is None:
|
||||
start = (self.y, self.x)
|
||||
start = (self.buf.y, self.buf.x)
|
||||
else:
|
||||
start = self.selection.start
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.selection.set(*start, self.y, self.x)
|
||||
self.selection.set(*start, self.buf.y, self.buf.x)
|
||||
|
||||
# positioning
|
||||
|
||||
def rendered_y(self, margin: Margin) -> int:
|
||||
return self.y - self.file_y + margin.header
|
||||
return self.buf.y - self.buf.file_y + margin.header
|
||||
|
||||
def rendered_x(self) -> int:
|
||||
return self.x - line_x(self.x, curses.COLS)
|
||||
return self.buf.x - line_x(self.buf.x, curses.COLS)
|
||||
|
||||
def move_cursor(
|
||||
self,
|
||||
@@ -840,21 +774,18 @@ class File:
|
||||
) -> None:
|
||||
stdscr.move(self.rendered_y(margin), self.rendered_x())
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.touch(lineno)
|
||||
|
||||
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
|
||||
to_display = min(len(self.lines) - self.file_y, margin.body_lines)
|
||||
to_display = min(self.buf.displayable_count, margin.body_lines)
|
||||
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.highlight_until(self.lines, self.file_y + to_display)
|
||||
# XXX: this will go away?
|
||||
file_hl.highlight_until(self.buf, self.buf.file_y + to_display)
|
||||
|
||||
for i in range(to_display):
|
||||
draw_y = i + margin.header
|
||||
l_y = self.file_y + i
|
||||
x = self.x if l_y == self.y else 0
|
||||
line = scrolled_line(self.lines[l_y], x, curses.COLS)
|
||||
l_y = self.buf.file_y + i
|
||||
x = self.buf.x if l_y == self.buf.y else 0
|
||||
line = scrolled_line(self.buf[l_y], x, curses.COLS)
|
||||
stdscr.insstr(draw_y, 0, line)
|
||||
|
||||
l_x = line_x(x, curses.COLS)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import NamedTuple
|
||||
from typing import Tuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.buf import Buf
|
||||
|
||||
|
||||
class HL(NamedTuple):
|
||||
@@ -23,8 +23,8 @@ class FileHL(Protocol):
|
||||
def include_edge(self) -> bool: ...
|
||||
@property
|
||||
def regions(self) -> RegionsMapping: ...
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ...
|
||||
def touch(self, lineno: int) -> None: ...
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None: ...
|
||||
def register_callbacks(self, buf: Buf) -> None: ...
|
||||
|
||||
|
||||
class HLFactory(Protocol):
|
||||
|
||||
@@ -4,9 +4,9 @@ import curses
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class Replace:
|
||||
@@ -15,10 +15,10 @@ class Replace:
|
||||
def __init__(self) -> None:
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
||||
@@ -4,9 +4,9 @@ from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class Selection:
|
||||
@@ -17,7 +17,10 @@ class Selection:
|
||||
self.start: Optional[Tuple[int, int]] = None
|
||||
self.end: Optional[Tuple[int, int]] = None
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
if self.start is None or self.end is None:
|
||||
return
|
||||
|
||||
@@ -36,9 +39,6 @@ class Selection:
|
||||
)
|
||||
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
||||
assert self.start is not None and self.end is not None
|
||||
if self.start < self.end:
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.highlight import Compiler
|
||||
from babi.highlight import Grammars
|
||||
@@ -14,7 +15,6 @@ from babi.highlight import highlight_line
|
||||
from babi.highlight import State
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.theme import Style
|
||||
from babi.theme import Theme
|
||||
from babi.user_data import prefix_data
|
||||
@@ -86,7 +86,24 @@ class FileSyntax:
|
||||
|
||||
return new_state, tuple(regs)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
buf.add_set_callback(self._set_cb)
|
||||
buf.add_del_callback(self._del_cb)
|
||||
buf.add_ins_callback(self._ins_cb)
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
if self._hl is None:
|
||||
# the docs claim better performance with power of two sizing
|
||||
size = max(4096, 2 ** (int(math.log(len(lines), 2)) + 2))
|
||||
@@ -103,10 +120,6 @@ class FileSyntax:
|
||||
self._states.append(state)
|
||||
self.regions.append(regions)
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
del self._states[lineno:]
|
||||
del self.regions[lineno:]
|
||||
|
||||
|
||||
class Syntax(NamedTuple):
|
||||
grammars: Grammars
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import curses
|
||||
from typing import List
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class TrailingWhitespace:
|
||||
@@ -30,9 +30,23 @@ class TrailingWhitespace:
|
||||
attr = curses.color_pair(pair)
|
||||
return (HL(x=i, end=len(line), attr=attr),)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
if idx < len(self.regions):
|
||||
self.regions[idx] = self._trailing_ws(lines[idx])
|
||||
|
||||
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
assert idx < len(self.regions) # currently all `del` happen on screen
|
||||
del self.regions[idx]
|
||||
|
||||
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||
if idx < len(self.regions):
|
||||
self.regions.insert(idx, self._trailing_ws(lines[idx]))
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
buf.add_set_callback(self._set_cb)
|
||||
buf.add_del_callback(self._del_cb)
|
||||
buf.add_ins_callback(self._ins_cb)
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
for i in range(len(self.regions), idx):
|
||||
self.regions.append(self._trailing_ws(lines[i]))
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
del self.regions[lineno:]
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import functools
|
||||
import sys
|
||||
from typing import Callable
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
|
||||
from babi._types import Protocol
|
||||
|
||||
|
||||
class SequenceNoSlice(Protocol):
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, idx: int) -> str: ...
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
for i in range(len(self)):
|
||||
yield self[i]
|
||||
|
||||
|
||||
class MutableSequenceNoSlice(SequenceNoSlice, Protocol):
|
||||
def __setitem__(self, idx: int, val: str) -> None: ...
|
||||
def __delitem__(self, idx: int) -> None: ...
|
||||
def insert(self, idx: int, val: str) -> None: ...
|
||||
|
||||
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]] = []
|
||||
self.min_line_touched = sys.maxsize
|
||||
|
||||
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.min_line_touched = min(idx, self.min_line_touched)
|
||||
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]))
|
||||
self.min_line_touched = min(idx, self.min_line_touched)
|
||||
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.min_line_touched = min(idx, self.min_line_touched)
|
||||
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)
|
||||
@@ -5,6 +5,7 @@ import sys
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.file import File
|
||||
from babi.perf import Perf
|
||||
from babi.perf import perf_log
|
||||
@@ -68,7 +69,7 @@ def c_main(
|
||||
|
||||
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], Perf())
|
||||
screen.file.lines = ['']
|
||||
screen.file.buf = Buf([''])
|
||||
|
||||
while True:
|
||||
screen.status.update('press q to quit')
|
||||
@@ -76,7 +77,7 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen.file.move_cursor(screen.stdscr, screen.margin)
|
||||
|
||||
key = screen.get_char()
|
||||
screen.file.lines.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
||||
screen.file.buf.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
||||
screen.file.down(screen.margin)
|
||||
if key.wch == curses.KEY_RESIZE:
|
||||
screen.resize()
|
||||
|
||||
@@ -17,6 +17,11 @@ class Margin(NamedTuple):
|
||||
else:
|
||||
return self.body_lines - 2
|
||||
|
||||
@property
|
||||
def scroll_amount(self) -> int:
|
||||
# integer round up without banker's rounding (so 1/2 => 1 instead of 0)
|
||||
return int(curses.LINES / 2 + .5)
|
||||
|
||||
@classmethod
|
||||
def from_current_screen(cls) -> 'Margin':
|
||||
if curses.LINES == 1:
|
||||
|
||||
@@ -225,7 +225,7 @@ class Screen:
|
||||
def resize(self) -> None:
|
||||
curses.update_lines_cols()
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||
self.draw()
|
||||
|
||||
def quick_prompt(
|
||||
@@ -317,9 +317,9 @@ class Screen:
|
||||
self.file.go_to_line(lineno, self.margin)
|
||||
|
||||
def current_position(self) -> None:
|
||||
line = f'line {self.file.y + 1}'
|
||||
col = f'col {self.file.x + 1}'
|
||||
line_count = max(len(self.file.lines) - 1, 1)
|
||||
line = f'line {self.file.buf.y + 1}'
|
||||
col = f'col {self.file.buf.x + 1}'
|
||||
line_count = max(len(self.file.buf) - 1, 1)
|
||||
lines_word = 'line' if line_count == 1 else 'lines'
|
||||
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
|
||||
|
||||
@@ -358,7 +358,7 @@ class Screen:
|
||||
else:
|
||||
action = from_stack.pop()
|
||||
to_stack.append(action.apply(self.file))
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||
self.status.update(f'{op}: {action.name}')
|
||||
self.file.selection.clear()
|
||||
|
||||
@@ -421,7 +421,7 @@ class Screen:
|
||||
else:
|
||||
sha256 = hashlib.sha256(b'').hexdigest()
|
||||
|
||||
contents = self.file.nl.join(self.file.lines)
|
||||
contents = self.file.nl.join(self.file.buf)
|
||||
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
|
||||
|
||||
# the file on disk is the same as when we opened it
|
||||
@@ -434,7 +434,7 @@ class Screen:
|
||||
|
||||
self.file.modified = False
|
||||
self.file.sha256 = sha256_to_save
|
||||
num_lines = len(self.file.lines) - 1
|
||||
num_lines = len(self.file.buf) - 1
|
||||
lines = 'lines' if num_lines != 1 else 'line'
|
||||
self.status.update(f'saved! ({num_lines} {lines} written)')
|
||||
|
||||
|
||||
178
tests/buf_test.py
Normal file
178
tests/buf_test.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import pytest
|
||||
|
||||
from babi.buf import Buf
|
||||
|
||||
|
||||
def test_buf_repr():
|
||||
ret = repr(Buf(['a', 'b', 'c']))
|
||||
assert ret == "Buf(['a', 'b', 'c'], x=0, y=0, file_y=0)"
|
||||
|
||||
|
||||
def test_buf_item_retrieval():
|
||||
buf = Buf(['a', 'b', 'c'])
|
||||
assert buf[1] == 'b'
|
||||
assert buf[-1] == 'c'
|
||||
with pytest.raises(IndexError):
|
||||
buf[3]
|
||||
|
||||
|
||||
def test_buf_del():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
del buf[1]
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_del_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
del buf[-1]
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_insert():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.insert(1, 'q')
|
||||
|
||||
assert lst == ['a', 'q', 'b', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_insert_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.insert(-1, 'q')
|
||||
|
||||
assert lst == ['a', 'b', 'q', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_set_value():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[1] = 'hello'
|
||||
|
||||
assert lst == ['a', 'hello', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_set_value_idx_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[-1] = 'hello'
|
||||
|
||||
assert lst == ['a', 'b', 'hello']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_multiple_modifications():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[1] = 'hello'
|
||||
buf.insert(1, 'ohai')
|
||||
del buf[0]
|
||||
|
||||
assert lst == ['ohai', 'hello', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_iter():
|
||||
buf = Buf(['a', 'b', 'c'])
|
||||
buf_iter = iter(buf)
|
||||
assert next(buf_iter) == 'a'
|
||||
assert next(buf_iter) == 'b'
|
||||
assert next(buf_iter) == 'c'
|
||||
with pytest.raises(StopIteration):
|
||||
next(buf_iter)
|
||||
|
||||
|
||||
def test_buf_append():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.append('q')
|
||||
|
||||
assert lst == ['a', 'b', 'c', 'q']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_pop_default():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.pop()
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_pop_idx():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.pop(1)
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
@@ -25,6 +25,18 @@ def test_modify_file_with_windows_newlines(run, tmpdir):
|
||||
assert f.read_binary() == b'\r\nfoo\r\nbar\r\n'
|
||||
|
||||
|
||||
def test_saving_file_with_multiple_lines_at_end_maintains_those(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('foo\n\n')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.await_text('*')
|
||||
h.press('^S')
|
||||
h.await_text('saved!')
|
||||
|
||||
assert f.read() == 'afoo\n\n'
|
||||
|
||||
|
||||
def test_new_file(run):
|
||||
with run('this_is_a_new_file') as h, and_exit(h):
|
||||
h.await_text('this_is_a_new_file')
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.syntax import Syntax
|
||||
@@ -161,7 +162,7 @@ def test_syntax_highlight_cache_first_line(stdscr, make_grammars):
|
||||
syntax = Syntax(grammars, THEME, ColorManager.make())
|
||||
syntax._init_screen(stdscr)
|
||||
file_hl = syntax.file_highlighter('foo.demo', '')
|
||||
file_hl.highlight_until(['int', 'int'], 2)
|
||||
file_hl.highlight_until(Buf(['int', 'int']), 2)
|
||||
assert file_hl.regions == [
|
||||
(HL(0, 3, curses.A_BOLD | 2 << 8),),
|
||||
(),
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from babi.list_spy import ListSpy
|
||||
|
||||
|
||||
def test_list_spy_repr():
|
||||
assert repr(ListSpy(['a', 'b', 'c'])) == "ListSpy(['a', 'b', 'c'])"
|
||||
|
||||
|
||||
def test_list_spy_item_retrieval():
|
||||
spy = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = ListSpy(lst)
|
||||
spy.pop(1)
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
Reference in New Issue
Block a user