From babb024c51b956335bc38c57ae797819bf98d8ef Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 22 Feb 2020 14:47:14 -0800 Subject: [PATCH] move File into its own file --- babi/file.py | 873 ++++++++++++++++++++++++++++++++++++++++ babi/main.py | 857 +-------------------------------------- tests/file_test.py | 29 +- tests/get_lines_test.py | 27 -- 4 files changed, 905 insertions(+), 881 deletions(-) create mode 100644 babi/file.py delete mode 100644 tests/get_lines_test.py diff --git a/babi/file.py b/babi/file.py new file mode 100644 index 0000000..e7ac3ad --- /dev/null +++ b/babi/file.py @@ -0,0 +1,873 @@ +import collections +import contextlib +import curses +import functools +import hashlib +import io +import itertools +import os.path +from typing import Any +from typing import Callable +from typing import cast +from typing import Generator +from typing import IO +from typing import List +from typing import Match +from typing import NamedTuple +from typing import Optional +from typing import Pattern +from typing import Tuple +from typing import TYPE_CHECKING +from typing import TypeVar +from typing import Union + +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 + +if TYPE_CHECKING: + from babi.main import Screen # XXX: circular + +TCallable = TypeVar('TCallable', bound=Callable[..., Any]) +HIGHLIGHT = curses.A_REVERSE | curses.A_DIM + + +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 = [] + newlines = collections.Counter({'\n': 0}) # default to `\n` + for line in sio: + sha256.update(line.encode()) + for ending in ('\r\n', '\n'): + if line.endswith(ending): + lines.append(line[:-1 * len(ending)]) + newlines[ending] += 1 + break + else: + lines.append(line) + _restore_lines_eof_invariant(lines) + (nl, _), = newlines.most_common(1) + mixed = len({k for k, v in newlines.items() if v}) > 1 + return lines, nl, mixed, sha256.hexdigest() + + +class Action: + def __init__( + self, *, name: str, spy: ListSpy, + 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.start_x = start_x + self.start_y = start_y + self.start_modified = start_modified + self.end_x = end_x + self.end_y = end_y + self.end_modified = end_modified + self.final = final + + def apply(self, file: 'File') -> 'Action': + spy = ListSpy(file.lines) + action = Action( + name=self.name, spy=spy, + 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, + end_modified=self.start_modified, + final=True, + ) + + self.spy.undo(spy) + file.x = self.start_x + file.y = self.start_y + file.modified = self.start_modified + + return action + + +def action(func: TCallable) -> TCallable: + @functools.wraps(func) + def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + self.finalize_previous_action() + return func(self, *args, **kwargs) + return cast(TCallable, action_inner) + + +def edit_action( + name: str, + *, + final: bool, +) -> Callable[[TCallable], TCallable]: + def edit_action_decorator(func: TCallable) -> TCallable: + @functools.wraps(func) + def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + with self.edit_action_context(name, final=final): + return func(self, *args, **kwargs) + return cast(TCallable, edit_action_inner) + return edit_action_decorator + + +def keep_selection(func: TCallable) -> TCallable: + @functools.wraps(func) + def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + with self.select(): + return func(self, *args, **kwargs) + return cast(TCallable, keep_selection_inner) + + +def clear_selection(func: TCallable) -> TCallable: + @functools.wraps(func) + def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: + ret = func(self, *args, **kwargs) + self.select_start = None + return ret + return cast(TCallable, clear_selection_inner) + + +class Found(NamedTuple): + y: int + match: Match[str] + + +class _SearchIter: + def __init__( + self, + file: 'File', + reg: Pattern[str], + *, + offset: int, + ) -> None: + self.file = file + self.reg = reg + self.offset = offset + self.wrapped = False + self._start_x = file.x + offset + self._start_y = file.y + + def __iter__(self) -> '_SearchIter': + return self + + def _stop_if_past_original(self, y: int, match: Match[str]) -> Found: + if ( + self.wrapped and ( + y > self._start_y or + y == self._start_y and match.start() >= self._start_x + ) + ): + raise StopIteration() + return Found(y, match) + + def __next__(self) -> Tuple[int, Match[str]]: + x = self.file.x + self.offset + y = self.file.y + + match = self.reg.search(self.file.lines[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]) + 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]) + 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]) + if match: + return self._stop_if_past_original(line_y, match) + + raise StopIteration() + + +class File: + def __init__(self, filename: Optional[str]) -> None: + self.filename = filename + self.modified = False + self.lines: MutableSequenceNoSlice = [] + self.nl = '\n' + self.file_y = self.y = self.x = self.x_hint = 0 + self.sha256: Optional[str] = None + self.undo_stack: List[Action] = [] + self.redo_stack: List[Action] = [] + self.select_start: Optional[Tuple[int, int]] = None + + def ensure_loaded(self, status: Status) -> None: + if self.lines: + return + + if 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) + else: + if self.filename is not None: + if os.path.lexists(self.filename): + status.update(f'{self.filename!r} is not a file') + self.filename = None + else: + status.update('(new file)') + sio = io.StringIO('') + self.lines, self.nl, mixed, self.sha256 = get_lines(sio) + + if mixed: + status.update(f'mixed newlines will be converted to {self.nl!r}') + self.modified = True + + def __repr__(self) -> str: + attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items()) + return f'{type(self).__name__}(\n {attrs},\n)' + + # 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, margin: Margin) -> 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(margin) + self._set_x_after_vertical_movement() + + @action + def down(self, margin: Margin) -> None: + if self.y < len(self.lines) - 1: + self._increment_y(margin) + self._set_x_after_vertical_movement() + + @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 + + @action + def left(self, margin: Margin) -> None: + if self.x == 0: + if self.y > 0: + self._decrement_y(margin) + self.x = len(self.lines[self.y]) + else: + self.x -= 1 + self.x_hint = self.x + + @action + def home(self, margin: Margin) -> None: + self.x = self.x_hint = 0 + + @action + def end(self, margin: Margin) -> None: + self.x = self.x_hint = len(self.lines[self.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() + + @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() + + @action + def ctrl_right(self, margin: Margin) -> None: + line = self.lines[self.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 we're at the end of the line, jump forward to the next non-ws + elif self.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() + ) + ): + 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 + # 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 + + @action + def ctrl_left(self, margin: Margin) -> None: + line = self.lines[self.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 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(margin) + self.x = self.x_hint = len(self.lines[self.y]) + 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 + + @action + def ctrl_home(self, margin: Margin) -> None: + self.x = self.x_hint = 0 + self.y = self.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) + + @action + def go_to_line(self, lineno: int, margin: Margin) -> None: + self.x = self.x_hint = 0 + if lineno == 0: + self.y = 0 + elif lineno > len(self.lines): + self.y = len(self.lines) - 1 + elif lineno < 0: + self.y = max(0, lineno + len(self.lines)) + else: + self.y = lineno - 1 + self.scroll_screen_if_needed(margin) + + @action + def search( + self, + reg: Pattern[str], + status: Status, + margin: Margin, + ) -> None: + search = _SearchIter(self, reg, offset=1) + try: + line_y, match = next(iter(search)) + except StopIteration: + status.update('no matches') + else: + if line_y == self.y and match.start() == self.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) + + @clear_selection + def replace( + self, + screen: 'Screen', + reg: Pattern[str], + replace: str, + ) -> None: + self.finalize_previous_action() + + def highlight() -> None: + self.highlight( + screen.stdscr, screen.margin, + y=self.y, x=self.x, n=len(match[0]), + color=HIGHLIGHT, include_edge=True, + ) + + count = 0 + 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) + if res != 'a': # make `a` replace the rest of them + screen.draw() + highlight() + with screen.resize_cb(highlight): + res = screen.quick_prompt( + 'replace [y(es), n(o), a(ll)]?', 'yna', + ) + 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 = line[:match.start()] + replaced + line[match.end():] + screen.file.lines[line_y] = line + search.offset = len(replaced) + elif res == 'n': + search.offset = 1 + else: + assert res is PromptResult.CANCELLED + return + + if res == '': # we never went through the loop + screen.status.update('no matches') + else: + occurrences = 'occurrence' if count == 1 else 'occurrences' + screen.status.update(f'replaced {count} {occurrences}') + + @action + def page_up(self, margin: Margin) -> None: + if self.y < margin.body_lines: + self.y = self.file_y = 0 + else: + pos = max(self.file_y - margin.page_size, 0) + self.y = self.file_y = pos + self._set_x_after_vertical_movement() + + @action + def page_down(self, margin: Margin) -> None: + if self.file_y + margin.body_lines >= len(self.lines): + self.y = len(self.lines) - 1 + else: + pos = self.file_y + margin.page_size + self.y = self.file_y = pos + self._set_x_after_vertical_movement() + + # editing + + @edit_action('backspace text', final=False) + @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: + pass + # backspace at the end of the file does not change the contents + elif self.y == len(self.lines) - 1: + self._decrement_y(margin) + self.x = self.x_hint = len(self.lines[self.y]) + # 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(margin) + self.x = self.x_hint = new_x + 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 + + @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: + 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 + else: + s = self.lines[self.y] + self.lines[self.y] = s[:self.x] + s[self.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 + + @edit_action('indent selection', final=True) + def _indent_selection(self, margin: Margin) -> None: + assert self.select_start is not None + sel_y, sel_x = self.select_start + (s_y, _), (e_y, _) = self._get_selection() + 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 == sel_y and sel_x != 0: + self.select_start = (sel_y, sel_x + 4) + if l_y == self.y: + self.x = self.x_hint = self.x + 4 + + @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) + + def tab(self, margin: Margin) -> None: + if self.select_start: + self._indent_selection(margin) + else: + self._tab(margin) + + @staticmethod + def _dedent_line(s: str) -> int: + bound = min(len(s), 4) + i = 0 + while i < bound and s[i] == ' ': + i += 1 + return i + + @edit_action('dedent selection', final=True) + def _dedent_selection(self, margin: Margin) -> None: + assert self.select_start is not None + sel_y, sel_x = self.select_start + (s_y, _), (e_y, _) = self._get_selection() + for l_y in range(s_y, e_y + 1): + n = self._dedent_line(self.lines[l_y]) + if n: + self.lines[l_y] = self.lines[l_y][n:] + if l_y == sel_y: + self.select_start = (sel_y, max(sel_x - n, 0)) + if l_y == self.y: + self.x = self.x_hint = max(self.x - n, 0) + + @edit_action('dedent', final=True) + def _dedent(self, margin: Margin) -> None: + n = self._dedent_line(self.lines[self.y]) + if n: + self.lines[self.y] = self.lines[self.y][n:] + self.x = self.x_hint = max(self.x - n, 0) + + def shift_tab(self, margin: Margin) -> None: + if self.select_start: + self._dedent_selection(margin) + else: + self._dedent(margin) + + @edit_action('cut selection', final=True) + @clear_selection + def cut_selection(self, margin: Margin) -> Tuple[str, ...]: + ret = [] + (s_y, s_x), (e_y, e_x) = self._get_selection() + 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:] + else: + ret.append(self.lines[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]) + + self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[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) + return tuple(ret) + + def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]: + # only continue a cut if the last action is a non-final cut + if not self._continue_last_action('cut'): + cut_buffer = () + + with self.edit_action_context('cut', final=False): + if self.y == len(self.lines) - 1: + return () + else: + victim = self.lines.pop(self.y) + self.x = self.x_hint = 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 + + @edit_action('uncut', final=True) + @clear_selection + def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: + self._uncut(cut_buffer, margin) + + @edit_action('uncut selection', final=True) + @clear_selection + def uncut_selection( + self, + cut_buffer: Tuple[str, ...], margin: Margin, + ) -> None: + self._uncut(cut_buffer, margin) + self._decrement_y(margin) + self.x = self.x_hint = len(self.lines[self.y]) + self.lines[self.y] += self.lines.pop(self.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)) + for i, line in zip(range(s_y, e_y), lines): + self.lines[i] = line + + self.y = s_y + self.x = self.x_hint = 0 + self.scroll_screen_if_needed(margin) + + @edit_action('sort', final=True) + def sort(self, margin: Margin) -> None: + self._sort(margin, 0, len(self.lines) - 1) + + @edit_action('sort selection', final=True) + @clear_selection + def sort_selection(self, margin: Margin) -> None: + (s_y, _), (e_y, _) = self._get_selection() + e_y = min(e_y + 1, len(self.lines) - 1) + if self.lines[e_y - 1] == '': + e_y -= 1 + self._sort(margin, s_y, e_y) + + DISPATCH = { + # movement + b'KEY_UP': up, + b'KEY_DOWN': down, + b'KEY_RIGHT': right, + b'KEY_LEFT': left, + b'KEY_HOME': home, + b'^A': home, + b'KEY_END': end, + b'^E': end, + b'KEY_PPAGE': page_up, + b'^Y': page_up, + b'KEY_NPAGE': page_down, + b'^V': page_down, + b'kUP5': ctrl_up, + b'kDN5': ctrl_down, + b'kRIT5': ctrl_right, + b'kLFT5': ctrl_left, + b'kHOM5': ctrl_home, + b'kEND5': ctrl_end, + # editing + b'KEY_BACKSPACE': backspace, + b'^H': backspace, # ^Backspace + b'KEY_DC': delete, + b'^M': enter, + b'^I': tab, + b'KEY_BTAB': shift_tab, + # selection (shift + movement) + b'KEY_SR': keep_selection(up), + b'KEY_SF': keep_selection(down), + b'KEY_SLEFT': keep_selection(left), + b'KEY_SRIGHT': keep_selection(right), + b'KEY_SHOME': keep_selection(home), + b'KEY_SEND': keep_selection(end), + b'KEY_SPREVIOUS': keep_selection(page_up), + b'KEY_SNEXT': keep_selection(page_down), + b'kRIT6': keep_selection(ctrl_right), + b'kLFT6': keep_selection(ctrl_left), + b'kHOM6': keep_selection(ctrl_home), + b'kEND6': keep_selection(ctrl_end), + } + + @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 + 1 + _restore_lines_eof_invariant(self.lines) + + def finalize_previous_action(self) -> None: + assert not isinstance(self.lines, ListSpy), 'nested edit/movement' + self.select_start = None + if self.undo_stack: + self.undo_stack[-1].final = True + + def _continue_last_action(self, name: str) -> bool: + return ( + bool(self.undo_stack) and + self.undo_stack[-1].name == name and + not self.undo_stack[-1].final + ) + + @contextlib.contextmanager + def edit_action_context( + self, name: str, + *, + 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) + + before_x, before_line = self.x, self.y + before_modified = self.modified + assert not isinstance(self.lines, ListSpy), 'recursive action?' + orig, self.lines = self.lines, spy + try: + yield + finally: + self.lines = orig + self.redo_stack.clear() + if continue_last: + self.undo_stack[-1].end_x = self.x + self.undo_stack[-1].end_y = self.y + elif spy.has_modifications: + self.modified = True + action = Action( + name=name, spy=spy, + start_x=before_x, start_y=before_line, + start_modified=before_modified, + end_x=self.x, end_y=self.y, + end_modified=True, + final=final, + ) + self.undo_stack.append(action) + + @contextlib.contextmanager + def select(self) -> Generator[None, None, None]: + if self.select_start is None: + select_start = (self.y, self.x) + else: + select_start = self.select_start + try: + yield + finally: + self.select_start = select_start + + # positioning + + def rendered_y(self, margin: Margin) -> int: + return self.y - self.file_y + margin.header + + def rendered_x(self) -> int: + return self.x - line_x(self.x, curses.COLS) + + def move_cursor( + self, + stdscr: 'curses._CursesWindow', + margin: Margin, + ) -> None: + stdscr.move(self.rendered_y(margin), self.rendered_x()) + + def _get_selection(self) -> Tuple[Tuple[int, int], Tuple[int, int]]: + assert self.select_start is not None + select_end = (self.y, self.x) + if select_end < self.select_start: + return select_end, self.select_start + else: + return self.select_start, select_end + + def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: + to_display = min(len(self.lines) - self.file_y, margin.body_lines) + for i in range(to_display): + line_idx = self.file_y + i + line = self.lines[line_idx] + x = self.x if line_idx == self.y else 0 + line = scrolled_line(line, x, curses.COLS) + stdscr.insstr(i + margin.header, 0, line) + blankline = ' ' * curses.COLS + for i in range(to_display, margin.body_lines): + stdscr.insstr(i + margin.header, 0, blankline) + + if self.select_start is not None: + (s_y, s_x), (e_y, e_x) = self._get_selection() + + if s_y == e_y: + self.highlight( + stdscr, margin, + y=s_y, x=s_x, n=e_x - s_x, + color=HIGHLIGHT, include_edge=True, + ) + else: + self.highlight( + stdscr, margin, + y=s_y, x=s_x, n=len(self.lines[s_y]) - s_x + 1, + color=HIGHLIGHT, include_edge=True, + ) + for l_y in range(s_y + 1, e_y): + self.highlight( + stdscr, margin, + y=l_y, x=0, n=len(self.lines[l_y]) + 1, + color=HIGHLIGHT, include_edge=True, + ) + self.highlight( + stdscr, margin, + y=e_y, x=0, n=e_x, + color=HIGHLIGHT, include_edge=True, + ) + + def highlight( + self, + stdscr: 'curses._CursesWindow', margin: Margin, + *, + y: int, x: int, n: int, color: int, + include_edge: bool, + ) -> None: + h_y = y - self.file_y + margin.header + if y == self.y: + l_x = line_x(self.x, curses.COLS) + if x < l_x: + h_x = 0 + n -= l_x - x + else: + h_x = x - l_x + else: + l_x = 0 + h_x = x + if not include_edge and len(self.lines[y]) > l_x + curses.COLS: + raise NotImplementedError('h_n = min(curses.COLS - h_x - 1, n)') + else: + h_n = n + if ( + h_y < margin.header or + h_y > margin.header + margin.body_lines or + h_x >= curses.COLS + ): + return + stdscr.chgat(h_y, h_x, h_n, color) diff --git a/babi/main.py b/babi/main.py index ed4ebdf..1533faf 100644 --- a/babi/main.py +++ b/babi/main.py @@ -1,36 +1,26 @@ import argparse -import collections import contextlib import curses import enum -import functools import hashlib -import io -import itertools import os import re import signal import sys -from typing import Any from typing import Callable -from typing import cast from typing import Generator -from typing import IO from typing import List -from typing import Match from typing import NamedTuple from typing import Optional from typing import Pattern from typing import Sequence from typing import Tuple -from typing import TypeVar from typing import Union +from babi.file import Action +from babi.file import File +from babi.file import get_lines from babi.history import History -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.perf import Perf from babi.prompt import Prompt @@ -38,9 +28,7 @@ from babi.prompt import PromptResult from babi.status import Status VERSION_STR = 'babi v0' -TCallable = TypeVar('TCallable', bound=Callable[..., Any]) EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV') -HIGHLIGHT = curses.A_REVERSE | curses.A_DIM # TODO: find a place to populate these, surely there's a database somewhere SEQUENCE_KEYNAME = { @@ -76,843 +64,6 @@ class Key(NamedTuple): keyname: bytes -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 = [] - newlines = collections.Counter({'\n': 0}) # default to `\n` - for line in sio: - sha256.update(line.encode()) - for ending in ('\r\n', '\n'): - if line.endswith(ending): - lines.append(line[:-1 * len(ending)]) - newlines[ending] += 1 - break - else: - lines.append(line) - _restore_lines_eof_invariant(lines) - (nl, _), = newlines.most_common(1) - mixed = len({k for k, v in newlines.items() if v}) > 1 - return lines, nl, mixed, sha256.hexdigest() - - -class Action: - def __init__( - self, *, name: str, spy: ListSpy, - 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.start_x = start_x - self.start_y = start_y - self.start_modified = start_modified - self.end_x = end_x - self.end_y = end_y - self.end_modified = end_modified - self.final = final - - def apply(self, file: 'File') -> 'Action': - spy = ListSpy(file.lines) - action = Action( - name=self.name, spy=spy, - 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, - end_modified=self.start_modified, - final=True, - ) - - self.spy.undo(spy) - file.x = self.start_x - file.y = self.start_y - file.modified = self.start_modified - - return action - - -def action(func: TCallable) -> TCallable: - @functools.wraps(func) - def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: - self.finalize_previous_action() - return func(self, *args, **kwargs) - return cast(TCallable, action_inner) - - -def edit_action( - name: str, - *, - final: bool, -) -> Callable[[TCallable], TCallable]: - def edit_action_decorator(func: TCallable) -> TCallable: - @functools.wraps(func) - def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: - with self.edit_action_context(name, final=final): - return func(self, *args, **kwargs) - return cast(TCallable, edit_action_inner) - return edit_action_decorator - - -def keep_selection(func: TCallable) -> TCallable: - @functools.wraps(func) - def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: - with self.select(): - return func(self, *args, **kwargs) - return cast(TCallable, keep_selection_inner) - - -def clear_selection(func: TCallable) -> TCallable: - @functools.wraps(func) - def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any: - ret = func(self, *args, **kwargs) - self.select_start = None - return ret - return cast(TCallable, clear_selection_inner) - - -class Found(NamedTuple): - y: int - match: Match[str] - - -class _SearchIter: - def __init__( - self, - file: 'File', - reg: Pattern[str], - *, - offset: int, - ) -> None: - self.file = file - self.reg = reg - self.offset = offset - self.wrapped = False - self._start_x = file.x + offset - self._start_y = file.y - - def __iter__(self) -> '_SearchIter': - return self - - def _stop_if_past_original(self, y: int, match: Match[str]) -> Found: - if ( - self.wrapped and ( - y > self._start_y or - y == self._start_y and match.start() >= self._start_x - ) - ): - raise StopIteration() - return Found(y, match) - - def __next__(self) -> Tuple[int, Match[str]]: - x = self.file.x + self.offset - y = self.file.y - - match = self.reg.search(self.file.lines[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]) - 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]) - 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]) - if match: - return self._stop_if_past_original(line_y, match) - - raise StopIteration() - - -class File: - def __init__(self, filename: Optional[str]) -> None: - self.filename = filename - self.modified = False - self.lines: MutableSequenceNoSlice = [] - self.nl = '\n' - self.file_y = self.y = self.x = self.x_hint = 0 - self.sha256: Optional[str] = None - self.undo_stack: List[Action] = [] - self.redo_stack: List[Action] = [] - self.select_start: Optional[Tuple[int, int]] = None - - def ensure_loaded(self, status: Status) -> None: - if self.lines: - return - - if 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) - else: - if self.filename is not None: - if os.path.lexists(self.filename): - status.update(f'{self.filename!r} is not a file') - self.filename = None - else: - status.update('(new file)') - sio = io.StringIO('') - self.lines, self.nl, mixed, self.sha256 = _get_lines(sio) - - if mixed: - status.update(f'mixed newlines will be converted to {self.nl!r}') - self.modified = True - - def __repr__(self) -> str: - attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items()) - return f'{type(self).__name__}(\n {attrs},\n)' - - # 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, margin: Margin) -> 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(margin) - self._set_x_after_vertical_movement() - - @action - def down(self, margin: Margin) -> None: - if self.y < len(self.lines) - 1: - self._increment_y(margin) - self._set_x_after_vertical_movement() - - @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 - - @action - def left(self, margin: Margin) -> None: - if self.x == 0: - if self.y > 0: - self._decrement_y(margin) - self.x = len(self.lines[self.y]) - else: - self.x -= 1 - self.x_hint = self.x - - @action - def home(self, margin: Margin) -> None: - self.x = self.x_hint = 0 - - @action - def end(self, margin: Margin) -> None: - self.x = self.x_hint = len(self.lines[self.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() - - @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() - - @action - def ctrl_right(self, margin: Margin) -> None: - line = self.lines[self.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 we're at the end of the line, jump forward to the next non-ws - elif self.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() - ) - ): - 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 - # 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 - - @action - def ctrl_left(self, margin: Margin) -> None: - line = self.lines[self.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 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(margin) - self.x = self.x_hint = len(self.lines[self.y]) - 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 - - @action - def ctrl_home(self, margin: Margin) -> None: - self.x = self.x_hint = 0 - self.y = self.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) - - @action - def go_to_line(self, lineno: int, margin: Margin) -> None: - self.x = self.x_hint = 0 - if lineno == 0: - self.y = 0 - elif lineno > len(self.lines): - self.y = len(self.lines) - 1 - elif lineno < 0: - self.y = max(0, lineno + len(self.lines)) - else: - self.y = lineno - 1 - self.scroll_screen_if_needed(margin) - - @action - def search( - self, - reg: Pattern[str], - status: Status, - margin: Margin, - ) -> None: - search = _SearchIter(self, reg, offset=1) - try: - line_y, match = next(iter(search)) - except StopIteration: - status.update('no matches') - else: - if line_y == self.y and match.start() == self.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) - - @clear_selection - def replace( - self, - screen: 'Screen', - reg: Pattern[str], - replace: str, - ) -> None: - self.finalize_previous_action() - - def highlight() -> None: - self.highlight( - screen.stdscr, screen.margin, - y=self.y, x=self.x, n=len(match[0]), - color=HIGHLIGHT, include_edge=True, - ) - - count = 0 - 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) - if res != 'a': # make `a` replace the rest of them - screen.draw() - highlight() - with screen.resize_cb(highlight): - res = screen.quick_prompt( - 'replace [y(es), n(o), a(ll)]?', 'yna', - ) - 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 = line[:match.start()] + replaced + line[match.end():] - screen.file.lines[line_y] = line - search.offset = len(replaced) - elif res == 'n': - search.offset = 1 - else: - assert res is PromptResult.CANCELLED - return - - if res == '': # we never went through the loop - screen.status.update('no matches') - else: - occurrences = 'occurrence' if count == 1 else 'occurrences' - screen.status.update(f'replaced {count} {occurrences}') - - @action - def page_up(self, margin: Margin) -> None: - if self.y < margin.body_lines: - self.y = self.file_y = 0 - else: - pos = max(self.file_y - margin.page_size, 0) - self.y = self.file_y = pos - self._set_x_after_vertical_movement() - - @action - def page_down(self, margin: Margin) -> None: - if self.file_y + margin.body_lines >= len(self.lines): - self.y = len(self.lines) - 1 - else: - pos = self.file_y + margin.page_size - self.y = self.file_y = pos - self._set_x_after_vertical_movement() - - # editing - - @edit_action('backspace text', final=False) - @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: - pass - # backspace at the end of the file does not change the contents - elif self.y == len(self.lines) - 1: - self._decrement_y(margin) - self.x = self.x_hint = len(self.lines[self.y]) - # 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(margin) - self.x = self.x_hint = new_x - 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 - - @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: - 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 - else: - s = self.lines[self.y] - self.lines[self.y] = s[:self.x] + s[self.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 - - @edit_action('indent selection', final=True) - def _indent_selection(self, margin: Margin) -> None: - assert self.select_start is not None - sel_y, sel_x = self.select_start - (s_y, _), (e_y, _) = self._get_selection() - 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 == sel_y and sel_x != 0: - self.select_start = (sel_y, sel_x + 4) - if l_y == self.y: - self.x = self.x_hint = self.x + 4 - - @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) - - def tab(self, margin: Margin) -> None: - if self.select_start: - self._indent_selection(margin) - else: - self._tab(margin) - - @staticmethod - def _dedent_line(s: str) -> int: - bound = min(len(s), 4) - i = 0 - while i < bound and s[i] == ' ': - i += 1 - return i - - @edit_action('dedent selection', final=True) - def _dedent_selection(self, margin: Margin) -> None: - assert self.select_start is not None - sel_y, sel_x = self.select_start - (s_y, _), (e_y, _) = self._get_selection() - for l_y in range(s_y, e_y + 1): - n = self._dedent_line(self.lines[l_y]) - if n: - self.lines[l_y] = self.lines[l_y][n:] - if l_y == sel_y: - self.select_start = (sel_y, max(sel_x - n, 0)) - if l_y == self.y: - self.x = self.x_hint = max(self.x - n, 0) - - @edit_action('dedent', final=True) - def _dedent(self, margin: Margin) -> None: - n = self._dedent_line(self.lines[self.y]) - if n: - self.lines[self.y] = self.lines[self.y][n:] - self.x = self.x_hint = max(self.x - n, 0) - - def shift_tab(self, margin: Margin) -> None: - if self.select_start: - self._dedent_selection(margin) - else: - self._dedent(margin) - - @edit_action('cut selection', final=True) - @clear_selection - def cut_selection(self, margin: Margin) -> Tuple[str, ...]: - ret = [] - (s_y, s_x), (e_y, e_x) = self._get_selection() - 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:] - else: - ret.append(self.lines[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]) - - self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[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) - return tuple(ret) - - def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]: - # only continue a cut if the last action is a non-final cut - if not self._continue_last_action('cut'): - cut_buffer = () - - with self.edit_action_context('cut', final=False): - if self.y == len(self.lines) - 1: - return () - else: - victim = self.lines.pop(self.y) - self.x = self.x_hint = 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 - - @edit_action('uncut', final=True) - @clear_selection - def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: - self._uncut(cut_buffer, margin) - - @edit_action('uncut selection', final=True) - @clear_selection - def uncut_selection( - self, - cut_buffer: Tuple[str, ...], margin: Margin, - ) -> None: - self._uncut(cut_buffer, margin) - self._decrement_y(margin) - self.x = self.x_hint = len(self.lines[self.y]) - self.lines[self.y] += self.lines.pop(self.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)) - for i, line in zip(range(s_y, e_y), lines): - self.lines[i] = line - - self.y = s_y - self.x = self.x_hint = 0 - self.scroll_screen_if_needed(margin) - - @edit_action('sort', final=True) - def sort(self, margin: Margin) -> None: - self._sort(margin, 0, len(self.lines) - 1) - - @edit_action('sort selection', final=True) - @clear_selection - def sort_selection(self, margin: Margin) -> None: - (s_y, _), (e_y, _) = self._get_selection() - e_y = min(e_y + 1, len(self.lines) - 1) - if self.lines[e_y - 1] == '': - e_y -= 1 - self._sort(margin, s_y, e_y) - - DISPATCH = { - # movement - b'KEY_UP': up, - b'KEY_DOWN': down, - b'KEY_RIGHT': right, - b'KEY_LEFT': left, - b'KEY_HOME': home, - b'^A': home, - b'KEY_END': end, - b'^E': end, - b'KEY_PPAGE': page_up, - b'^Y': page_up, - b'KEY_NPAGE': page_down, - b'^V': page_down, - b'kUP5': ctrl_up, - b'kDN5': ctrl_down, - b'kRIT5': ctrl_right, - b'kLFT5': ctrl_left, - b'kHOM5': ctrl_home, - b'kEND5': ctrl_end, - # editing - b'KEY_BACKSPACE': backspace, - b'^H': backspace, # ^Backspace - b'KEY_DC': delete, - b'^M': enter, - b'^I': tab, - b'KEY_BTAB': shift_tab, - # selection (shift + movement) - b'KEY_SR': keep_selection(up), - b'KEY_SF': keep_selection(down), - b'KEY_SLEFT': keep_selection(left), - b'KEY_SRIGHT': keep_selection(right), - b'KEY_SHOME': keep_selection(home), - b'KEY_SEND': keep_selection(end), - b'KEY_SPREVIOUS': keep_selection(page_up), - b'KEY_SNEXT': keep_selection(page_down), - b'kRIT6': keep_selection(ctrl_right), - b'kLFT6': keep_selection(ctrl_left), - b'kHOM6': keep_selection(ctrl_home), - b'kEND6': keep_selection(ctrl_end), - } - - @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 + 1 - _restore_lines_eof_invariant(self.lines) - - def finalize_previous_action(self) -> None: - assert not isinstance(self.lines, ListSpy), 'nested edit/movement' - self.select_start = None - if self.undo_stack: - self.undo_stack[-1].final = True - - def _continue_last_action(self, name: str) -> bool: - return ( - bool(self.undo_stack) and - self.undo_stack[-1].name == name and - not self.undo_stack[-1].final - ) - - @contextlib.contextmanager - def edit_action_context( - self, name: str, - *, - 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) - - before_x, before_line = self.x, self.y - before_modified = self.modified - assert not isinstance(self.lines, ListSpy), 'recursive action?' - orig, self.lines = self.lines, spy - try: - yield - finally: - self.lines = orig - self.redo_stack.clear() - if continue_last: - self.undo_stack[-1].end_x = self.x - self.undo_stack[-1].end_y = self.y - elif spy.has_modifications: - self.modified = True - action = Action( - name=name, spy=spy, - start_x=before_x, start_y=before_line, - start_modified=before_modified, - end_x=self.x, end_y=self.y, - end_modified=True, - final=final, - ) - self.undo_stack.append(action) - - @contextlib.contextmanager - def select(self) -> Generator[None, None, None]: - if self.select_start is None: - select_start = (self.y, self.x) - else: - select_start = self.select_start - try: - yield - finally: - self.select_start = select_start - - # positioning - - def rendered_y(self, margin: Margin) -> int: - return self.y - self.file_y + margin.header - - def rendered_x(self) -> int: - return self.x - line_x(self.x, curses.COLS) - - def move_cursor( - self, - stdscr: 'curses._CursesWindow', - margin: Margin, - ) -> None: - stdscr.move(self.rendered_y(margin), self.rendered_x()) - - def _get_selection(self) -> Tuple[Tuple[int, int], Tuple[int, int]]: - assert self.select_start is not None - select_end = (self.y, self.x) - if select_end < self.select_start: - return select_end, self.select_start - else: - return self.select_start, select_end - - def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: - to_display = min(len(self.lines) - self.file_y, margin.body_lines) - for i in range(to_display): - line_idx = self.file_y + i - line = self.lines[line_idx] - x = self.x if line_idx == self.y else 0 - line = scrolled_line(line, x, curses.COLS) - stdscr.insstr(i + margin.header, 0, line) - blankline = ' ' * curses.COLS - for i in range(to_display, margin.body_lines): - stdscr.insstr(i + margin.header, 0, blankline) - - if self.select_start is not None: - (s_y, s_x), (e_y, e_x) = self._get_selection() - - if s_y == e_y: - self.highlight( - stdscr, margin, - y=s_y, x=s_x, n=e_x - s_x, - color=HIGHLIGHT, include_edge=True, - ) - else: - self.highlight( - stdscr, margin, - y=s_y, x=s_x, n=len(self.lines[s_y]) - s_x + 1, - color=HIGHLIGHT, include_edge=True, - ) - for l_y in range(s_y + 1, e_y): - self.highlight( - stdscr, margin, - y=l_y, x=0, n=len(self.lines[l_y]) + 1, - color=HIGHLIGHT, include_edge=True, - ) - self.highlight( - stdscr, margin, - y=e_y, x=0, n=e_x, - color=HIGHLIGHT, include_edge=True, - ) - - def highlight( - self, - stdscr: 'curses._CursesWindow', margin: Margin, - *, - y: int, x: int, n: int, color: int, - include_edge: bool, - ) -> None: - h_y = y - self.file_y + margin.header - if y == self.y: - l_x = line_x(self.x, curses.COLS) - if x < l_x: - h_x = 0 - n -= l_x - x - else: - h_x = x - l_x - else: - l_x = 0 - h_x = x - if not include_edge and len(self.lines[y]) > l_x + curses.COLS: - raise NotImplementedError('h_n = min(curses.COLS - h_x - 1, n)') - else: - h_n = n - if ( - h_y < margin.header or - h_y > margin.header + margin.body_lines or - h_x >= curses.COLS - ): - return - stdscr.chgat(h_y, h_x, h_n, color) - - class Screen: def __init__( self, @@ -1169,7 +320,7 @@ class Screen: if os.path.isfile(self.file.filename): with open(self.file.filename) as f: - *_, sha256 = _get_lines(f) + *_, sha256 = get_lines(f) else: sha256 = hashlib.sha256(b'').hexdigest() diff --git a/tests/file_test.py b/tests/file_test.py index 47c1346..b30ac13 100644 --- a/tests/file_test.py +++ b/tests/file_test.py @@ -1,4 +1,9 @@ -from babi.main import File +import io + +import pytest + +from babi.file import File +from babi.file import get_lines def test_position_repr(): @@ -19,3 +24,25 @@ def test_position_repr(): ' select_start=None,\n' ')' ) + + +@pytest.mark.parametrize( + ('s', 'lines', 'nl', 'mixed'), + ( + pytest.param('', [''], '\n', False, id='trivial'), + pytest.param('1\n2\n', ['1', '2', ''], '\n', False, id='lf'), + pytest.param('1\r\n2\r\n', ['1', '2', ''], '\r\n', False, id='crlf'), + pytest.param('1\r\n2\n', ['1', '2', ''], '\n', True, id='mixed'), + pytest.param('1\n2', ['1', '2', ''], '\n', False, id='noeol'), + ), +) +def test_get_lines(s, lines, nl, mixed): + # sha256 tested below + ret_lines, ret_nl, ret_mixed, _ = get_lines(io.StringIO(s)) + assert (ret_lines, ret_nl, ret_mixed) == (lines, nl, mixed) + + +def test_get_lines_sha256_checksum(): + ret = get_lines(io.StringIO('')) + sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + assert ret == ([''], '\n', False, sha256) diff --git a/tests/get_lines_test.py b/tests/get_lines_test.py deleted file mode 100644 index a01de74..0000000 --- a/tests/get_lines_test.py +++ /dev/null @@ -1,27 +0,0 @@ -import io - -import pytest - -from babi.main import _get_lines - - -@pytest.mark.parametrize( - ('s', 'lines', 'nl', 'mixed'), - ( - pytest.param('', [''], '\n', False, id='trivial'), - pytest.param('1\n2\n', ['1', '2', ''], '\n', False, id='lf'), - pytest.param('1\r\n2\r\n', ['1', '2', ''], '\r\n', False, id='crlf'), - pytest.param('1\r\n2\n', ['1', '2', ''], '\n', True, id='mixed'), - pytest.param('1\n2', ['1', '2', ''], '\n', False, id='noeol'), - ), -) -def test_get_lines(s, lines, nl, mixed): - # sha256 tested below - ret_lines, ret_nl, ret_mixed, _ = _get_lines(io.StringIO(s)) - assert (ret_lines, ret_nl, ret_mixed) == (lines, nl, mixed) - - -def test_get_lines_sha256_checksum(): - ret = _get_lines(io.StringIO('')) - sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' - assert ret == ([''], '\n', False, sha256)