Refactor file internals to separate class

This commit is contained in:
Anthony Sottile
2020-03-28 16:28:26 -07:00
parent 04aaf9530e
commit 3844dcf329
15 changed files with 690 additions and 529 deletions

235
babi/buf.py Normal file
View 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)

View File

@@ -21,6 +21,8 @@ from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
from typing import Union from typing import Union
from babi.buf import Buf
from babi.buf import Modification
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.hl.interface import FileHL from babi.hl.interface import FileHL
from babi.hl.interface import HLFactory 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.hl.trailing_whitespace import TrailingWhitespace
from babi.horizontal_scrolling import line_x from babi.horizontal_scrolling import line_x
from babi.horizontal_scrolling import scrolled_line 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.margin import Margin
from babi.prompt import PromptResult from babi.prompt import PromptResult
from babi.status import Status from babi.status import Status
@@ -41,15 +41,6 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any]) 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]: def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
sha256 = hashlib.sha256() sha256 = hashlib.sha256()
lines = [] lines = []
@@ -63,7 +54,8 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
break break
else: else:
lines.append(line) lines.append(line)
_restore_lines_eof_invariant(lines) # always make sure we end in a newline
lines.append('')
(nl, _), = newlines.most_common(1) (nl, _), = newlines.most_common(1)
mixed = len({k for k, v in newlines.items() if v}) > 1 mixed = len({k for k, v in newlines.items() if v}) > 1
return lines, nl, mixed, sha256.hexdigest() return lines, nl, mixed, sha256.hexdigest()
@@ -71,13 +63,13 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
class Action: class Action:
def __init__( def __init__(
self, *, name: str, spy: ListSpy, self, *, name: str, modifications: List[Modification],
start_x: int, start_y: int, start_modified: bool, start_x: int, start_y: int, start_modified: bool,
end_x: int, end_y: int, end_modified: bool, end_x: int, end_y: int, end_modified: bool,
final: bool, final: bool,
): ):
self.name = name self.name = name
self.spy = spy self.modifications = modifications
self.start_x = start_x self.start_x = start_x
self.start_y = start_y self.start_y = start_y
self.start_modified = start_modified self.start_modified = start_modified
@@ -87,9 +79,8 @@ class Action:
self.final = final self.final = final
def apply(self, file: 'File') -> 'Action': def apply(self, file: 'File') -> 'Action':
spy = ListSpy(file.lines)
action = Action( 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_x=self.end_x, start_y=self.end_y,
start_modified=self.end_modified, start_modified=self.end_modified,
end_x=self.start_x, end_y=self.start_y, end_x=self.start_x, end_y=self.start_y,
@@ -97,11 +88,9 @@ class Action:
final=True, final=True,
) )
self.spy.undo(spy) file.buf.x = self.start_x
file.x = self.start_x file.buf.y = self.start_y
file.y = self.start_y
file.modified = self.start_modified file.modified = self.start_modified
file.touch(spy.min_line_touched)
return action return action
@@ -162,8 +151,8 @@ class _SearchIter:
self.reg = reg self.reg = reg
self.offset = offset self.offset = offset
self.wrapped = False self.wrapped = False
self._start_x = file.x + offset self._start_x = file.buf.x + offset
self._start_y = file.y self._start_y = file.buf.y
def __iter__(self) -> '_SearchIter': def __iter__(self) -> '_SearchIter':
return self return self
@@ -179,28 +168,28 @@ class _SearchIter:
return Found(y, match) return Found(y, match)
def __next__(self) -> Tuple[int, Match[str]]: def __next__(self) -> Tuple[int, Match[str]]:
x = self.file.x + self.offset x = self.file.buf.x + self.offset
y = self.file.y 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: if match:
return self._stop_if_past_original(y, match) return self._stop_if_past_original(y, match)
if self.wrapped: if self.wrapped:
for line_y in range(y + 1, self._start_y + 1): 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: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
else: else:
for line_y in range(y + 1, len(self.file.lines)): for line_y in range(y + 1, len(self.file.buf)):
match = self.reg.search(self.file.lines[line_y]) match = self.reg.search(self.file.buf[line_y])
if match: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
self.wrapped = True self.wrapped = True
for line_y in range(0, self._start_y + 1): 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: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
@@ -216,10 +205,10 @@ class File:
) -> None: ) -> None:
self.filename = filename self.filename = filename
self.modified = False self.modified = False
self.lines: MutableSequenceNoSlice = [] self.buf = Buf([])
self.nl = '\n' self.nl = '\n'
self.file_y = self.y = self.x = self.x_hint = 0
self.sha256: Optional[str] = None self.sha256: Optional[str] = None
self._in_edit_action = False
self.undo_stack: List[Action] = [] self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = [] self.redo_stack: List[Action] = []
self._hl_factories = hl_factories self._hl_factories = hl_factories
@@ -229,7 +218,7 @@ class File:
self._file_hls: Tuple[FileHL, ...] = () self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded(self, status: Status, stdin: str) -> None: def ensure_loaded(self, status: Status, stdin: str) -> None:
if self.lines: if self.buf:
return return
if self.filename == '-': if self.filename == '-':
@@ -237,10 +226,10 @@ class File:
self.filename = None self.filename = None
self.modified = True self.modified = True
sio = io.StringIO(stdin) 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): elif self.filename is not None and os.path.isfile(self.filename):
with open(self.filename, newline='') as f: 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: else:
if self.filename is not None: if self.filename is not None:
if os.path.lexists(self.filename): if os.path.lexists(self.filename):
@@ -248,8 +237,9 @@ class File:
self.filename = None self.filename = None
else: else:
status.update('(new file)') status.update('(new file)')
sio = io.StringIO('') lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
self.buf = Buf(lines)
if mixed: if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}') status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -258,7 +248,7 @@ class File:
file_hls = [] file_hls = []
for factory in self._hl_factories: for factory in self._hl_factories:
if self.filename is not None: 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) file_hls.append(hl)
else: else:
file_hls.append(factory.blank_file_highlighter()) file_hls.append(factory.blank_file_highlighter())
@@ -266,156 +256,109 @@ class File:
*file_hls, *file_hls,
self._trailing_whitespace, self._replace_hl, self.selection, 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: def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>' return f'<{type(self).__name__} {self.filename!r}>'
# movement # 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 @action
def up(self, margin: Margin) -> None: def up(self, margin: Margin) -> None:
if self.y > 0: self.buf.up(margin)
self._decrement_y()
self._set_x_after_vertical_movement()
@action @action
def down(self, margin: Margin) -> None: def down(self, margin: Margin) -> None:
if self.y < len(self.lines) - 1: self.buf.down(margin)
self._increment_y(margin)
self._set_x_after_vertical_movement()
@action @action
def right(self, margin: Margin) -> None: def right(self, margin: Margin) -> None:
if self.x >= len(self.lines[self.y]): self.buf.right(margin)
if self.y < len(self.lines) - 1:
self.x = 0
self._increment_y(margin)
else:
self.x += 1
self.x_hint = self.x
@action @action
def left(self, margin: Margin) -> None: def left(self, margin: Margin) -> None:
if self.x == 0: self.buf.left(margin)
if self.y > 0:
self._decrement_y()
self.x = len(self.lines[self.y])
else:
self.x -= 1
self.x_hint = self.x
@action @action
def home(self, margin: Margin) -> None: def home(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
@action @action
def end(self, margin: Margin) -> None: 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 @action
def ctrl_up(self, margin: Margin) -> None: def ctrl_up(self, margin: Margin) -> None:
self.file_y = max(0, self.file_y - 1) self.buf.file_up(margin)
self.y = min(self.y, self.file_y + margin.body_lines - 1)
self._set_x_after_vertical_movement()
@action @action
def ctrl_down(self, margin: Margin) -> None: def ctrl_down(self, margin: Margin) -> None:
self.file_y = min(len(self.lines) - 1, self.file_y + 1) self.buf.file_down(margin)
self.y = max(self.y, self.file_y)
self._set_x_after_vertical_movement()
@action @action
def ctrl_right(self, margin: Margin) -> None: 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 we're at the second to last character, jump to end of line
if self.x == len(line) - 1: if self.buf.x == len(line) - 1:
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
# if we're at the end of the line, jump forward to the next non-ws # 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 ( while (
self.y < len(self.lines) - 1 and ( self.buf.y < len(self.buf) - 1 and (
self.x == len(self.lines[self.y]) or self.buf.x == len(self.buf[self.buf.y]) or
self.lines[self.y][self.x].isspace() self.buf[self.buf.y][self.buf.x].isspace()
) )
): ):
if self.x == len(self.lines[self.y]): self.buf.right(margin)
self._increment_y(margin)
self.x = self.x_hint = 0
else:
self.x = self.x_hint = self.x + 1
# if we're inside the line, jump to next position that's not our type # if we're inside the line, jump to next position that's not our type
else: else:
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
tp = line[self.x].isalnum() tp = line[self.buf.x].isalnum()
while self.x < len(line) and tp == line[self.x].isalnum(): while self.buf.x < len(line) and tp == line[self.buf.x].isalnum():
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
@action @action
def ctrl_left(self, margin: Margin) -> None: 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 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(): if self.buf.x == 1 and not line[:self.buf.x].isspace():
self.x = self.x_hint = 0 self.buf.left(margin)
# if we're at the beginning or it's all space up to here jump to the # if we're at the beginning or it's all space up to here jump to the
# end of the previous non-space line # end of the previous non-space line
elif self.x == 0 or line[:self.x].isspace(): elif self.buf.x == 0 or line[:self.buf.x].isspace():
self.x = self.x_hint = 0 self.buf.x = 0
while self.y > 0 and (self.x == 0 or not self.lines[self.y]): while self.buf.y > 0 and self.buf.x == 0:
self._decrement_y() self.buf.left(margin)
self.x = self.x_hint = len(self.lines[self.y])
else: else:
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
tp = line[self.x - 1].isalnum() tp = line[self.buf.x - 1].isalnum()
while self.x > 0 and tp == line[self.x - 1].isalnum(): while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum():
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
@action @action
def ctrl_home(self, margin: Margin) -> None: def ctrl_home(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
self.y = self.file_y = 0 self.buf.y = self.buf.file_y = 0
@action @action
def ctrl_end(self, margin: Margin) -> None: def ctrl_end(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@action @action
def go_to_line(self, lineno: int, margin: Margin) -> None: def go_to_line(self, lineno: int, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
if lineno == 0: if lineno == 0:
self.y = 0 self.buf.y = 0
elif lineno > len(self.lines): elif lineno > len(self.buf):
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
elif lineno < 0: elif lineno < 0:
self.y = max(0, lineno + len(self.lines)) self.buf.y = max(0, lineno + len(self.buf))
else: else:
self.y = lineno - 1 self.buf.y = lineno - 1
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@action @action
def search( def search(
@@ -430,14 +373,14 @@ class File:
except StopIteration: except StopIteration:
status.update('no matches') status.update('no matches')
else: 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') status.update('this is the only occurrence')
else: else:
if search.wrapped: if search.wrapped:
status.update('search wrapped') status.update('search wrapped')
self.y = line_y self.buf.y = line_y
self.x = self.x_hint = match.start() self.buf.x = match.start()
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@clear_selection @clear_selection
def replace( def replace(
@@ -452,38 +395,36 @@ class File:
res: Union[str, PromptResult] = '' res: Union[str, PromptResult] = ''
search = _SearchIter(self, reg, offset=0) search = _SearchIter(self, reg, offset=0)
for line_y, match in search: for line_y, match in search:
self.y = line_y end = match.end()
self.x = self.x_hint = match.start() self.buf.y = line_y
self.scroll_screen_if_needed(screen.margin) self.buf.x = match.start()
self.buf.scroll_screen_if_needed(screen.margin)
if res != 'a': # make `a` replace the rest of them 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() screen.draw()
res = screen.quick_prompt('replace', ('yes', 'no', 'all')) res = screen.quick_prompt('replace', ('yes', 'no', 'all'))
if res in {'y', 'a'}: if res in {'y', 'a'}:
count += 1 count += 1
with self.edit_action_context('replace', final=True): with self.edit_action_context('replace', final=True):
replaced = match.expand(replace) replaced = match.expand(replace)
line = screen.file.lines[line_y] line = screen.file.buf[line_y]
if '\n' in replaced: if '\n' in replaced:
replaced_lines = replaced.split('\n') replaced_lines = replaced.split('\n')
self.lines[line_y] = ( self.buf[line_y] = (
f'{line[:match.start()]}{replaced_lines[0]}' f'{line[:match.start()]}{replaced_lines[0]}'
) )
for i, ins_line in enumerate(replaced_lines[1:-1], 1): 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 last_insert = line_y + len(replaced_lines) - 1
self.lines.insert( self.buf.insert(
last_insert, last_insert, f'{replaced_lines[-1]}{line[end:]}',
f'{replaced_lines[-1]}{line[match.end():]}',
) )
self.y = last_insert self.buf.y = last_insert
self.x = self.x_hint = 0 self.buf.x = 0
search.offset = len(replaced_lines[-1]) search.offset = len(replaced_lines[-1])
else: else:
self.lines[line_y] = ( self.buf[line_y] = (
f'{line[:match.start()]}' f'{line[:match.start()]}{replaced}{line[end:]}'
f'{replaced}'
f'{line[match.end():]}'
) )
search.offset = len(replaced) search.offset = len(replaced)
elif res == 'n': elif res == 'n':
@@ -500,21 +441,21 @@ class File:
@action @action
def page_up(self, margin: Margin) -> None: def page_up(self, margin: Margin) -> None:
if self.y < margin.body_lines: if self.buf.y < margin.body_lines:
self.y = self.file_y = 0 self.buf.y = self.buf.file_y = 0
else: else:
pos = max(self.file_y - margin.page_size, 0) pos = max(self.buf.file_y - margin.page_size, 0)
self.y = self.file_y = pos self.buf.y = self.buf.file_y = pos
self.x = self.x_hint = 0 self.buf.x = 0
@action @action
def page_down(self, margin: Margin) -> None: def page_down(self, margin: Margin) -> None:
if self.file_y + margin.body_lines >= len(self.lines): if self.buf.file_y + margin.body_lines >= len(self.buf):
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
else: else:
pos = self.file_y + margin.page_size pos = self.buf.file_y + margin.page_size
self.y = self.file_y = pos self.buf.y = self.buf.file_y = pos
self.x = self.x_hint = 0 self.buf.x = 0
# editing # editing
@@ -522,47 +463,44 @@ class File:
@clear_selection @clear_selection
def backspace(self, margin: Margin) -> None: def backspace(self, margin: Margin) -> None:
# backspace at the beginning of the file does nothing # 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 pass
# backspace at the end of the file does not change the contents # backspace at the end of the file does not change the contents
elif self.y == len(self.lines) - 1: elif self.buf.y == len(self.buf) - 1:
self._decrement_y() self.buf.left(margin)
self.x = self.x_hint = len(self.lines[self.y])
# at the beginning of the line, we join the current line and # at the beginning of the line, we join the current line and
# the previous line # the previous line
elif self.x == 0: elif self.buf.x == 0:
victim = self.lines.pop(self.y) y, victim = self.buf.y, self.buf.pop(self.buf.y)
new_x = len(self.lines[self.y - 1]) self.buf.left(margin)
self.lines[self.y - 1] += victim self.buf[y - 1] += victim
self._decrement_y()
self.x = self.x_hint = new_x
else: else:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x - 1] + s[self.x:] self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:]
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
@edit_action('delete text', final=False) @edit_action('delete text', final=False)
@clear_selection @clear_selection
def delete(self, margin: Margin) -> None: def delete(self, margin: Margin) -> None:
# noop at end of the file # noop at end of the file
if self.y == len(self.lines) - 1: if self.buf.y == len(self.buf) - 1:
pass pass
# if we're at the end of the line, collapse the line afterwards # if we're at the end of the line, collapse the line afterwards
elif self.x == len(self.lines[self.y]): elif self.buf.x == len(self.buf[self.buf.y]):
victim = self.lines.pop(self.y + 1) victim = self.buf.pop(self.buf.y + 1)
self.lines[self.y] += victim self.buf[self.buf.y] += victim
else: else:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x] + s[self.x + 1:] self.buf[self.buf.y] = s[:self.buf.x] + s[self.buf.x + 1:]
@edit_action('line break', final=False) @edit_action('line break', final=False)
@clear_selection @clear_selection
def enter(self, margin: Margin) -> None: def enter(self, margin: Margin) -> None:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x] self.buf[self.buf.y] = s[:self.buf.x]
self.lines.insert(self.y + 1, s[self.x:]) self.buf.insert(self.buf.y + 1, s[self.buf.x:])
self._increment_y(margin) self.buf.down(margin)
self.x = self.x_hint = 0 self.buf.x = 0
@edit_action('indent selection', final=True) @edit_action('indent selection', final=True)
def _indent_selection(self, margin: Margin) -> None: def _indent_selection(self, margin: Margin) -> None:
@@ -570,21 +508,21 @@ class File:
sel_y, sel_x = self.selection.start sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1): for l_y in range(s_y, e_y + 1):
if self.lines[l_y]: if self.buf[l_y]:
self.lines[l_y] = ' ' * 4 + self.lines[l_y] self.buf[l_y] = ' ' * 4 + self.buf[l_y]
if l_y == self.y: if l_y == self.buf.y:
self.x = self.x_hint = self.x + 4 self.buf.x = self.buf.x + 4
if l_y == sel_y and sel_x != 0: if l_y == sel_y and sel_x != 0:
sel_x += 4 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) @edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None: def _tab(self, margin: Margin) -> None:
n = 4 - self.x % 4 n = 4 - self.buf.x % 4
line = self.lines[self.y] line = self.buf[self.buf.y]
self.lines[self.y] = line[:self.x] + n * ' ' + line[self.x:] self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
self.x = self.x_hint = self.x + n self.buf.x = self.buf.x + n
_restore_lines_eof_invariant(self.lines) self.buf.restore_eof_invariant()
def tab(self, margin: Margin) -> None: def tab(self, margin: Margin) -> None:
if self.selection.start is not None: if self.selection.start is not None:
@@ -606,21 +544,21 @@ class File:
sel_y, sel_x = self.selection.start sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1): 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: if n:
self.lines[l_y] = self.lines[l_y][n:] self.buf[l_y] = self.buf[l_y][n:]
if l_y == self.y: if l_y == self.buf.y:
self.x = self.x_hint = max(self.x - n, 0) self.buf.x = max(self.buf.x - n, 0)
if l_y == sel_y: if l_y == sel_y:
sel_x = max(sel_x - n, 0) 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) @edit_action('dedent', final=True)
def _dedent(self, margin: Margin) -> None: 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: if n:
self.lines[self.y] = self.lines[self.y][n:] self.buf[self.buf.y] = self.buf[self.buf.y][n:]
self.x = self.x_hint = max(self.x - n, 0) self.buf.x = max(self.buf.x - n, 0)
def shift_tab(self, margin: Margin) -> None: def shift_tab(self, margin: Margin) -> None:
if self.selection.start is not None: if self.selection.start is not None:
@@ -634,20 +572,20 @@ class File:
ret = [] ret = []
(s_y, s_x), (e_y, e_x) = self.selection.get() (s_y, s_x), (e_y, e_x) = self.selection.get()
if s_y == e_y: if s_y == e_y:
ret.append(self.lines[s_y][s_x:e_x]) ret.append(self.buf[s_y][s_x:e_x])
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[s_y][e_x:] self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[s_y][e_x:]
else: 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): for l_y in range(s_y + 1, e_y):
ret.append(self.lines[l_y]) ret.append(self.buf[l_y])
ret.append(self.lines[e_y][:e_x]) 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): for _ in range(s_y + 1, e_y + 1):
self.lines.pop(s_y + 1) self.buf.pop(s_y + 1)
self.y = s_y self.buf.y = s_y
self.x = self.x_hint = s_x self.buf.x = s_x
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
return tuple(ret) return tuple(ret)
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]: def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
@@ -656,21 +594,21 @@ class File:
cut_buffer = () cut_buffer = ()
with self.edit_action_context('cut', final=False): 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 return cut_buffer
else: else:
victim = self.lines.pop(self.y) victim = self.buf.pop(self.buf.y)
self.x = self.x_hint = 0 self.buf.x = 0
return cut_buffer + (victim,) return cut_buffer + (victim,)
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer: for cut_line in cut_buffer:
line = self.lines[self.y] line = self.buf[self.buf.y]
before, after = line[:self.x], line[self.x:] before, after = line[:self.buf.x], line[self.buf.x:]
self.lines[self.y] = before + cut_line self.buf[self.buf.y] = before + cut_line
self.lines.insert(self.y + 1, after) self.buf.insert(self.buf.y + 1, after)
self._increment_y(margin) self.buf.down(margin)
self.x = self.x_hint = 0 self.buf.x = 0
@edit_action('uncut', final=True) @edit_action('uncut', final=True)
@clear_selection @clear_selection
@@ -684,30 +622,30 @@ class File:
cut_buffer: Tuple[str, ...], margin: Margin, cut_buffer: Tuple[str, ...], margin: Margin,
) -> None: ) -> None:
self._uncut(cut_buffer, margin) self._uncut(cut_buffer, margin)
self._decrement_y() self.buf.up(margin)
self.x = self.x_hint = len(self.lines[self.y]) self.buf.x = len(self.buf[self.buf.y])
self.lines[self.y] += self.lines.pop(self.y + 1) self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None: def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
# self.lines intentionally does not support slicing so we use islice # self.buf intentionally does not support slicing so we use islice
lines = sorted(itertools.islice(self.lines, s_y, e_y)) lines = sorted(itertools.islice(self.buf, s_y, e_y))
for i, line in zip(range(s_y, e_y), lines): for i, line in zip(range(s_y, e_y), lines):
self.lines[i] = line self.buf[i] = line
self.y = s_y self.buf.y = s_y
self.x = self.x_hint = 0 self.buf.x = 0
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@edit_action('sort', final=True) @edit_action('sort', final=True)
def sort(self, margin: Margin) -> None: 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) @edit_action('sort selection', final=True)
@clear_selection @clear_selection
def sort_selection(self, margin: Margin) -> None: def sort_selection(self, margin: Margin) -> None:
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.lines) - 1) e_y = min(e_y + 1, len(self.buf) - 1)
if self.lines[e_y - 1] == '': if self.buf[e_y - 1] == '':
e_y -= 1 e_y -= 1
self._sort(margin, s_y, e_y) self._sort(margin, s_y, e_y)
@@ -756,13 +694,13 @@ class File:
@edit_action('text', final=False) @edit_action('text', final=False)
@clear_selection @clear_selection
def c(self, wch: str, margin: Margin) -> None: def c(self, wch: str, margin: Margin) -> None:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x] + wch + s[self.x:] self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
self.x = self.x_hint = self.x + len(wch) self.buf.x = self.buf.x + len(wch)
_restore_lines_eof_invariant(self.lines) self.buf.restore_eof_invariant()
def finalize_previous_action(self) -> None: 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() self.selection.clear()
if self.undo_stack: if self.undo_stack:
self.undo_stack[-1].final = True self.undo_stack[-1].final = True
@@ -781,57 +719,53 @@ class File:
final: bool, final: bool,
) -> Generator[None, None, None]: ) -> Generator[None, None, None]:
continue_last = self._continue_last_action(name) continue_last = self._continue_last_action(name)
if continue_last: if not continue_last and self.undo_stack:
spy = self.undo_stack[-1].spy self.undo_stack[-1].final = True
else:
if self.undo_stack:
self.undo_stack[-1].final = True
spy = ListSpy(self.lines)
before_x, before_line = self.x, self.y before_x, before_line = self.buf.x, self.buf.y
before_modified = self.modified before_modified = self.modified
assert not isinstance(self.lines, ListSpy), 'recursive action?' assert not self._in_edit_action, f'recursive action? {name}'
orig, self.lines = self.lines, spy self._in_edit_action = True
try: try:
yield with self.buf.record() as modifications:
yield
finally: finally:
self.lines = orig self._in_edit_action = False
self.redo_stack.clear() self.redo_stack.clear()
if continue_last: if continue_last:
self.undo_stack[-1].end_x = self.x self.undo_stack[-1].end_x = self.buf.x
self.undo_stack[-1].end_y = self.y self.undo_stack[-1].end_y = self.buf.y
self.touch(spy.min_line_touched) self.undo_stack[-1].modifications.extend(modifications)
elif spy.has_modifications: elif modifications:
self.modified = True self.modified = True
action = Action( action = Action(
name=name, spy=spy, name=name, modifications=modifications,
start_x=before_x, start_y=before_line, start_x=before_x, start_y=before_line,
start_modified=before_modified, 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, end_modified=True,
final=final, final=final,
) )
self.undo_stack.append(action) self.undo_stack.append(action)
self.touch(spy.min_line_touched)
@contextlib.contextmanager @contextlib.contextmanager
def select(self) -> Generator[None, None, None]: def select(self) -> Generator[None, None, None]:
if self.selection.start is None: if self.selection.start is None:
start = (self.y, self.x) start = (self.buf.y, self.buf.x)
else: else:
start = self.selection.start start = self.selection.start
try: try:
yield yield
finally: finally:
self.selection.set(*start, self.y, self.x) self.selection.set(*start, self.buf.y, self.buf.x)
# positioning # positioning
def rendered_y(self, margin: Margin) -> int: 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: 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( def move_cursor(
self, self,
@@ -840,21 +774,18 @@ class File:
) -> None: ) -> None:
stdscr.move(self.rendered_y(margin), self.rendered_x()) 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: 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: 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): for i in range(to_display):
draw_y = i + margin.header draw_y = i + margin.header
l_y = self.file_y + i l_y = self.buf.file_y + i
x = self.x if l_y == self.y else 0 x = self.buf.x if l_y == self.buf.y else 0
line = scrolled_line(self.lines[l_y], x, curses.COLS) line = scrolled_line(self.buf[l_y], x, curses.COLS)
stdscr.insstr(draw_y, 0, line) stdscr.insstr(draw_y, 0, line)
l_x = line_x(x, curses.COLS) l_x = line_x(x, curses.COLS)

View File

@@ -2,7 +2,7 @@ from typing import NamedTuple
from typing import Tuple from typing import Tuple
from babi._types import Protocol from babi._types import Protocol
from babi.list_spy import SequenceNoSlice from babi.buf import Buf
class HL(NamedTuple): class HL(NamedTuple):
@@ -23,8 +23,8 @@ class FileHL(Protocol):
def include_edge(self) -> bool: ... def include_edge(self) -> bool: ...
@property @property
def regions(self) -> RegionsMapping: ... def regions(self) -> RegionsMapping: ...
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ... def highlight_until(self, lines: Buf, idx: int) -> None: ...
def touch(self, lineno: int) -> None: ... def register_callbacks(self, buf: Buf) -> None: ...
class HLFactory(Protocol): class HLFactory(Protocol):

View File

@@ -4,9 +4,9 @@ import curses
from typing import Dict from typing import Dict
from typing import Generator from typing import Generator
from babi.buf import Buf
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class Replace: class Replace:
@@ -15,10 +15,10 @@ class Replace:
def __init__(self) -> None: def __init__(self) -> None:
self.regions: Dict[int, HLs] = collections.defaultdict(tuple) 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""" """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""" """our highlight regions are populated in other ways"""
@contextlib.contextmanager @contextlib.contextmanager

View File

@@ -4,9 +4,9 @@ from typing import Dict
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from babi.buf import Buf
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class Selection: class Selection:
@@ -17,7 +17,10 @@ class Selection:
self.start: Optional[Tuple[int, int]] = None self.start: Optional[Tuple[int, int]] = None
self.end: 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: if self.start is None or self.end is None:
return return
@@ -36,9 +39,6 @@ class Selection:
) )
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),) 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]]: def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
assert self.start is not None and self.end is not None assert self.start is not None and self.end is not None
if self.start < self.end: if self.start < self.end:

View File

@@ -7,6 +7,7 @@ from typing import NamedTuple
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.highlight import Compiler from babi.highlight import Compiler
from babi.highlight import Grammars from babi.highlight import Grammars
@@ -14,7 +15,6 @@ from babi.highlight import highlight_line
from babi.highlight import State from babi.highlight import State
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
from babi.theme import Style from babi.theme import Style
from babi.theme import Theme from babi.theme import Theme
from babi.user_data import prefix_data from babi.user_data import prefix_data
@@ -86,7 +86,24 @@ class FileSyntax:
return new_state, tuple(regs) 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: if self._hl is None:
# the docs claim better performance with power of two sizing # the docs claim better performance with power of two sizing
size = max(4096, 2 ** (int(math.log(len(lines), 2)) + 2)) size = max(4096, 2 ** (int(math.log(len(lines), 2)) + 2))
@@ -103,10 +120,6 @@ class FileSyntax:
self._states.append(state) self._states.append(state)
self.regions.append(regions) self.regions.append(regions)
def touch(self, lineno: int) -> None:
del self._states[lineno:]
del self.regions[lineno:]
class Syntax(NamedTuple): class Syntax(NamedTuple):
grammars: Grammars grammars: Grammars

View File

@@ -1,10 +1,10 @@
import curses import curses
from typing import List from typing import List
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class TrailingWhitespace: class TrailingWhitespace:
@@ -30,9 +30,23 @@ class TrailingWhitespace:
attr = curses.color_pair(pair) attr = curses.color_pair(pair)
return (HL(x=i, end=len(line), attr=attr),) 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): for i in range(len(self.regions), idx):
self.regions.append(self._trailing_ws(lines[i])) self.regions.append(self._trailing_ws(lines[i]))
def touch(self, lineno: int) -> None:
del self.regions[lineno:]

View File

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

View File

@@ -5,6 +5,7 @@ import sys
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from babi.buf import Buf
from babi.file import File from babi.file import File
from babi.perf import Perf from babi.perf import Perf
from babi.perf import perf_log from babi.perf import perf_log
@@ -68,7 +69,7 @@ def c_main(
def _key_debug(stdscr: 'curses._CursesWindow') -> int: def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen = Screen(stdscr, ['<<key debug>>'], Perf()) screen = Screen(stdscr, ['<<key debug>>'], Perf())
screen.file.lines = [''] screen.file.buf = Buf([''])
while True: while True:
screen.status.update('press q to quit') 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) screen.file.move_cursor(screen.stdscr, screen.margin)
key = screen.get_char() 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) screen.file.down(screen.margin)
if key.wch == curses.KEY_RESIZE: if key.wch == curses.KEY_RESIZE:
screen.resize() screen.resize()

View File

@@ -17,6 +17,11 @@ class Margin(NamedTuple):
else: else:
return self.body_lines - 2 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 @classmethod
def from_current_screen(cls) -> 'Margin': def from_current_screen(cls) -> 'Margin':
if curses.LINES == 1: if curses.LINES == 1:

View File

@@ -225,7 +225,7 @@ class Screen:
def resize(self) -> None: def resize(self) -> None:
curses.update_lines_cols() curses.update_lines_cols()
self.margin = Margin.from_current_screen() 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() self.draw()
def quick_prompt( def quick_prompt(
@@ -317,9 +317,9 @@ class Screen:
self.file.go_to_line(lineno, self.margin) self.file.go_to_line(lineno, self.margin)
def current_position(self) -> None: def current_position(self) -> None:
line = f'line {self.file.y + 1}' line = f'line {self.file.buf.y + 1}'
col = f'col {self.file.x + 1}' col = f'col {self.file.buf.x + 1}'
line_count = max(len(self.file.lines) - 1, 1) line_count = max(len(self.file.buf) - 1, 1)
lines_word = 'line' if line_count == 1 else 'lines' lines_word = 'line' if line_count == 1 else 'lines'
self.status.update(f'{line}, {col} (of {line_count} {lines_word})') self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
@@ -358,7 +358,7 @@ class Screen:
else: else:
action = from_stack.pop() action = from_stack.pop()
to_stack.append(action.apply(self.file)) 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.status.update(f'{op}: {action.name}')
self.file.selection.clear() self.file.selection.clear()
@@ -421,7 +421,7 @@ class Screen:
else: else:
sha256 = hashlib.sha256(b'').hexdigest() 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() sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
# the file on disk is the same as when we opened it # the file on disk is the same as when we opened it
@@ -434,7 +434,7 @@ class Screen:
self.file.modified = False self.file.modified = False
self.file.sha256 = sha256_to_save 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' lines = 'lines' if num_lines != 1 else 'line'
self.status.update(f'saved! ({num_lines} {lines} written)') self.status.update(f'saved! ({num_lines} {lines} written)')

178
tests/buf_test.py Normal file
View 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']

View File

@@ -25,6 +25,18 @@ def test_modify_file_with_windows_newlines(run, tmpdir):
assert f.read_binary() == b'\r\nfoo\r\nbar\r\n' 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): def test_new_file(run):
with run('this_is_a_new_file') as h, and_exit(h): with run('this_is_a_new_file') as h, and_exit(h):
h.await_text('this_is_a_new_file') h.await_text('this_is_a_new_file')

View File

@@ -4,6 +4,7 @@ from unittest import mock
import pytest import pytest
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.syntax import Syntax 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 = Syntax(grammars, THEME, ColorManager.make())
syntax._init_screen(stdscr) syntax._init_screen(stdscr)
file_hl = syntax.file_highlighter('foo.demo', '') 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 == [ assert file_hl.regions == [
(HL(0, 3, curses.A_BOLD | 2 << 8),), (HL(0, 3, curses.A_BOLD | 2 << 8),),
(), (),

View File

@@ -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']