diff --git a/babi.py b/babi.py index 3c189df..c3f7f42 100644 --- a/babi.py +++ b/babi.py @@ -1,13 +1,112 @@ import _curses import argparse +import collections import curses +import io from typing import Dict +from typing import IO from typing import List +from typing import NamedTuple from typing import Tuple VERSION_STR = 'babi v0' +class Margin(NamedTuple): + header: bool + footer: bool + + @property + def body_lines(self) -> int: + return curses.LINES - self.header - self.footer + + @classmethod + def from_screen(cls, screen: '_curses._CursesWindow') -> 'Margin': + if curses.LINES == 1: + return cls(header=False, footer=False) + elif curses.LINES == 2: + return cls(header=False, footer=True) + else: + return cls(header=True, footer=True) + + +class Position: + def __init__( + self, + file_line: int = 0, + cursor_line: int = 0, + x: int = 0, + cursor_x_hint: int = 0, + ) -> None: + self.file_line = file_line + self.cursor_line = cursor_line + self.x = x + self.cursor_x_hint = 0 + + def __repr__(self) -> str: + attrs = ', '.join(f'{k}={v}' for k, v in self.__dict__.items()) + return f'{type(self).__name__}({attrs})' + + def _scroll_amount(self) -> int: + return int(curses.LINES / 2 + .5) + + def _set_x_after_vertical_movement(self, lines: List[str]) -> None: + self.x = min(len(lines[self.cursor_line]), self.cursor_x_hint) + + def maybe_scroll_down(self, margin: Margin) -> None: + if self.cursor_line >= self.file_line + margin.body_lines: + self.file_line += self._scroll_amount() + + def down(self, margin: Margin, lines: List[str]) -> None: + if self.cursor_line < len(lines) - 1: + self.cursor_line += 1 + self.maybe_scroll_down(margin) + self._set_x_after_vertical_movement(lines) + + def maybe_scroll_up(self, margin: Margin) -> None: + if self.cursor_line < self.file_line: + self.file_line -= self._scroll_amount() + + def up(self, margin: Margin, lines: List[str]) -> None: + if self.cursor_line > 0: + self.cursor_line -= 1 + self.maybe_scroll_up(margin) + self._set_x_after_vertical_movement(lines) + + def right(self, margin: Margin, lines: List[str]) -> None: + if self.x >= len(lines[self.cursor_line]): + if self.cursor_line < len(lines) - 1: + self.x = 0 + self.cursor_line += 1 + self.maybe_scroll_down(margin) + else: + self.x += 1 + self.cursor_x_hint = self.x + + def left(self, margin: Margin, lines: List[str]) -> None: + if self.x == 0: + if self.cursor_line > 0: + self.cursor_line -= 1 + self.x = len(lines[self.cursor_line]) + self.maybe_scroll_up(margin) + else: + self.x -= 1 + self.cursor_x_hint = self.x + + DISPATCH = { + curses.KEY_DOWN: down, + curses.KEY_UP: up, + curses.KEY_LEFT: left, + curses.KEY_RIGHT: right, + } + + def dispatch(self, key: int, margin: Margin, lines: List[str]) -> None: + return self.DISPATCH[key](self, margin, lines) + + def cursor_y(self, margin: Margin) -> int: + return self.cursor_line - self.file_line + margin.header + + def _get_color_pair_mapping() -> Dict[Tuple[int, int], int]: ret = {} i = 0 @@ -85,26 +184,27 @@ def _write_header( stdscr.addstr(0, 0, s, curses.A_REVERSE) -def _write_lines(stdscr: '_curses._CursesWindow', lines: List[str]) -> None: - if curses.LINES == 1: - header, footer = 0, 0 - elif curses.LINES == 2: - header, footer = 0, 1 - else: - header, footer = 1, 1 - - max_lines = curses.LINES - header - footer - lines_to_display = min(len(lines), max_lines) +def _write_lines( + stdscr: '_curses._CursesWindow', + position: Position, + margin: Margin, + lines: List[str], +) -> None: + lines_to_display = min(len(lines) - position.file_line, margin.body_lines) for i in range(lines_to_display): - line = lines[i][:curses.COLS].rstrip('\r\n').ljust(curses.COLS) - stdscr.insstr(i + header, 0, line) + line = lines[position.file_line + i][:curses.COLS].ljust(curses.COLS) + stdscr.insstr(i + margin.header, 0, line) blankline = ' ' * curses.COLS - for i in range(lines_to_display, max_lines): - stdscr.insstr(i + header, 0, blankline) + for i in range(lines_to_display, margin.body_lines): + stdscr.insstr(i + margin.header, 0, blankline) -def _write_status(stdscr: '_curses._CursesWindow', status: str) -> None: - if curses.LINES > 1 or status: +def _write_status( + stdscr: '_curses._CursesWindow', + margin: Margin, + status: str, +) -> None: + if margin.footer or status: stdscr.insstr(curses.LINES - 1, 0, ' ' * curses.COLS) if status: status = f' {status} ' @@ -112,8 +212,30 @@ def _write_status(stdscr: '_curses._CursesWindow', status: str) -> None: stdscr.addstr(curses.LINES - 1, offset, status, curses.A_REVERSE) -def _move(stdscr: '_curses._CursesWindow', x: int, y: int) -> None: - stdscr.move(y + (curses.LINES > 2), x) +def _move_cursor( + stdscr: '_curses._CursesWindow', + position: Position, + margin: Margin, +) -> None: + # TODO: need to handle line wrapping here + stdscr.move(position.cursor_y(margin), position.x) + + +def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool]: + lines = [] + newlines = collections.Counter({'\n': 0}) # default to `\n` + for line in sio: + for ending in ('\r\n', '\n'): + if line.endswith(ending): + lines.append(line[:-1 * len(ending)]) + newlines[ending] += 1 + break + else: + lines.append(line) + lines.append('') # we use this as a padding line for display + (nl, _), = newlines.most_common(1) + mixed = len({k for k, v in newlines.items() if v}) > 1 + return lines, nl, mixed def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: @@ -122,16 +244,12 @@ def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: if args.color_test: return _color_test(stdscr) + modified = False filename = args.filename status = '' status_action_counter = -1 - position_y, position_x = 0, 0 - - if args.filename is not None: - with open(args.filename) as f: - lines = list(f) - else: - lines = [] + position = Position() + margin = Margin.from_screen(stdscr) def _set_status(s: str) -> None: nonlocal status, status_action_counter @@ -142,16 +260,25 @@ def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: else: status_action_counter = 25 + if args.filename is not None: + with open(args.filename, newline='') as f: + lines, nl, mixed = _get_lines(f) + else: + lines, nl, mixed = _get_lines(io.StringIO('')) + if mixed: + _set_status(f'mixed newlines will be converted to {nl!r}') + modified = True + while True: if status_action_counter == 0: status = '' status_action_counter -= 1 if curses.LINES > 2: - _write_header(stdscr, filename, modified=False) - _write_lines(stdscr, lines) - _write_status(stdscr, status) - _move(stdscr, x=position_x, y=position_y) + _write_header(stdscr, filename, modified=modified) + _write_lines(stdscr, position, margin, lines) + _write_status(stdscr, margin, status) + _move_cursor(stdscr, position, margin) wch = stdscr.get_wch() key = wch if isinstance(wch, int) else ord(wch) @@ -159,14 +286,10 @@ def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: if key == curses.KEY_RESIZE: curses.update_lines_cols() - elif key == curses.KEY_DOWN: - position_y = min(position_y + 1, curses.LINES - 2) - elif key == curses.KEY_UP: - position_y = max(position_y - 1, 0) - elif key == curses.KEY_RIGHT: - position_x = min(position_x + 1, curses.COLS - 1) - elif key == curses.KEY_LEFT: - position_x = max(position_x - 1, 0) + margin = Margin.from_screen(stdscr) + position.maybe_scroll_down(margin) + elif key in Position.DISPATCH: + position.dispatch(key, margin, lines) elif keyname == b'^X': return else: diff --git a/tests/babi_test.py b/tests/babi_test.py index fcf35eb..c816abc 100644 --- a/tests/babi_test.py +++ b/tests/babi_test.py @@ -1,4 +1,5 @@ import contextlib +import io import shlex import sys from typing import List @@ -9,6 +10,26 @@ from hecate import Runner import babi +def test_position_repr(): + ret = repr(babi.Position()) + assert ret == 'Position(file_line=0, cursor_line=0, x=0, cursor_x_hint=0)' + + +@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): + ret = babi._get_lines(io.StringIO(s)) + assert ret == (lines, nl, mixed) + + class PrintsErrorRunner(Runner): def __init__(self, *args, **kwargs): self._screenshots: List[str] = [] @@ -58,21 +79,31 @@ class PrintsErrorRunner(Runner): w, h = self.tmux.execute_command(*cmd).split() return int(w), int(h) + def get_cursor_position(self): + cmd = ('display', '-t0', '-p', '#{cursor_x}\t#{cursor_y}') + x, y = self.tmux.execute_command(*cmd).split() + return int(x), int(y) + @contextlib.contextmanager def resize(self, width, height): current_w, current_h = self.get_pane_size() + sleep_cmd = ( + 'bash', '-c', + f'echo {"*" * (current_w * current_h)} && ' + f'exec sleep infinity', + ) panes = 0 hsplit_w = current_w - width - 1 if hsplit_w > 0: - cmd = ('split-window', '-ht0', '-l', hsplit_w, 'sleep', 'infinity') + cmd = ('split-window', '-ht0', '-l', hsplit_w, *sleep_cmd) self.tmux.execute_command(*cmd) panes += 1 vsplit_h = current_h - height - 1 if vsplit_h > 0: # pragma: no branch # TODO - cmd = ('split-window', '-vt0', '-l', vsplit_h, 'sleep', 'infinity') + cmd = ('split-window', '-vt0', '-l', vsplit_h, *sleep_cmd) self.tmux.execute_command(*cmd) panes += 1 @@ -114,21 +145,6 @@ def test_can_start_without_color(): pass -def test_window_bounds(tmpdir): - f = tmpdir.join('f.txt') - f.write(f'{"x" * 40}\n' * 40) - - with run(str(f), width=30, height=30) as h, and_exit(h): - h.await_text('x' * 30) - # make sure we don't go off the top left of the screen - h.press('LEFT') - h.press('UP') - # make sure we don't go off the bottom of the screen - for i in range(32): - h.press('RIGHT') - h.press('DOWN') - - def test_window_height_2(tmpdir): # 2 tall: # - header is hidden, otherwise behaviour is normal @@ -164,6 +180,7 @@ def test_window_height_1(tmpdir): h.await_text('unknown key') h.press('Right') h.await_text_missing('unknown key') + h.press('Down') def test_status_clearing_behaviour(): @@ -183,3 +200,118 @@ def test_reacts_to_resize(): with h.resize(40, 20): # the first line should be different after resize h.await_text_missing(first_line) + + +def test_mixed_newlines(tmpdir): + f = tmpdir.join('f') + f.write_binary(b'foo\nbar\r\n') + with run(str(f)) as h, and_exit(h): + # should start as modified + h.await_text('f *') + h.await_text(r"mixed newlines will be converted to '\n'") + + +def test_arrow_key_movement(tmpdir): + f = tmpdir.join('f') + f.write( + 'short\n' + '\n' + 'long long long long\n', + ) + with run(str(f)) as h, and_exit(h): + h.await_text('short') + assert h.get_cursor_position() == (0, 1) + # should not go off the beginning of the file + h.press('Left') + assert h.get_cursor_position() == (0, 1) + h.press('Up') + assert h.get_cursor_position() == (0, 1) + # left and right should work + h.press('Right') + h.press('Right') + assert h.get_cursor_position() == (2, 1) + h.press('Left') + assert h.get_cursor_position() == (1, 1) + # up should still be a noop on line 1 + h.press('Up') + assert h.get_cursor_position() == (1, 1) + # down once should put it on the beginning of the second line + h.press('Down') + assert h.get_cursor_position() == (0, 2) + # down again should restore the x positon on the next line + h.press('Down') + assert h.get_cursor_position() == (1, 3) + # down once more should put it on the special end-of-file line + h.press('Down') + assert h.get_cursor_position() == (0, 4) + # should not go off the end of the file + h.press('Down') + assert h.get_cursor_position() == (0, 4) + h.press('Right') + assert h.get_cursor_position() == (0, 4) + # left should put it at the end of the line + h.press('Left') + assert h.get_cursor_position() == (19, 3) + # right should put it to the next line + h.press('Right') + assert h.get_cursor_position() == (0, 4) + # if the hint-x is too high it should not go past the end of line + h.press('Left') + h.press('Up') + h.press('Up') + assert h.get_cursor_position() == (5, 1) + # and moving back down should still retain the hint-x + h.press('Down') + h.press('Down') + assert h.get_cursor_position() == (19, 3) + + +def test_scrolling_arrow_key_movement(tmpdir): + f = tmpdir.join('f') + f.write('\n'.join(f'line_{i}' for i in range(10))) + + with run(str(f), height=10) as h, and_exit(h): + h.await_text('line_7') + # we should not have scrolled after 7 presses + for _ in range(7): + h.press('Down') + h.await_text('line_0') + assert h.get_cursor_position() == (0, 8) + # but this should scroll down + h.press('Down') + h.await_text('line_8') + assert h.get_cursor_position() == (0, 4) + assert h.screenshot().splitlines()[4] == 'line_8' + # we should not have scrolled after 3 up presses + for _ in range(3): + h.press('Up') + h.await_text('line_9') + # but this should scroll up + h.press('Up') + h.await_text('line_0') + + +def test_resize_scrolls_up(tmpdir): + f = tmpdir.join('f') + f.write('\n'.join(f'line_{i}' for i in range(10))) + + with run(str(f)) as h, and_exit(h): + h.await_text('line_9') + + for _ in range(7): + h.press('Down') + assert h.get_cursor_position() == (0, 8) + + # a resize to a height of 10 should not scroll + with h.resize(80, 10): + h.await_text_missing('line_8') + assert h.get_cursor_position() == (0, 8) + + h.await_text('line_8') + + # but a resize to smaller should + with h.resize(80, 9): + h.await_text_missing('line_0') + assert h.get_cursor_position() == (0, 3) + # make sure we're still on the same line + assert h.screenshot().splitlines()[3] == 'line_7'