diff --git a/babi.py b/babi.py index 4199b37..7b3ca42 100644 --- a/babi.py +++ b/babi.py @@ -161,7 +161,7 @@ def _color_test(stdscr: '_curses._CursesWindow') -> None: _write_header(stdscr, '<>', modified=False) maxy, maxx = stdscr.getmaxyx() - if maxy < 19 or maxx < 68: + if maxy < 19 or maxx < 68: # pragma: no cover (will be deleted) raise SystemExit('--color-test needs a window of at least 68 x 19') y = 1 @@ -243,6 +243,15 @@ def _move_cursor( stdscr.move(position.cursor_y(margin), position.cursor_x()) +def _restore_lines_eof_invariant(lines: List[str]) -> 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]: lines = [] newlines = collections.Counter({'\n': 0}) # default to `\n` @@ -254,7 +263,7 @@ def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool]: break else: lines.append(line) - lines.append('') # we use this as a padding line for display + _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 @@ -314,6 +323,34 @@ def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: position.dispatch(key, margin, lines) elif keyname == b'^X': return + elif key == curses.KEY_BACKSPACE: + # backspace at the beginning of the file does nothing + if position.cursor_line == 0 and position.x == 0: + pass + # at the beginning of the line, we join the current line and + # the previous line + elif position.x == 0: + victim = lines.pop(position.cursor_line) + new_x = len(lines[position.cursor_line - 1]) + lines[position.cursor_line - 1] += victim + position.up(margin, lines) + position.x = position.cursor_x_hint = new_x + # deleting the fake end-of-file doesn't cause modification + modified |= position.cursor_line < len(lines) - 1 + _restore_lines_eof_invariant(lines) + else: + s = lines[position.cursor_line] + lines[position.cursor_line] = ( + s[:position.x - 1] + s[position.x:] + ) + position.left(margin, lines) + modified = True + elif isinstance(wch, str) and wch.isprintable(): + s = lines[position.cursor_line] + lines[position.cursor_line] = s[:position.x] + wch + s[position.x:] + position.right(margin, lines) + modified = True + _restore_lines_eof_invariant(lines) else: _set_status(f'unknown key: {keyname} ({key})') diff --git a/tests/babi_test.py b/tests/babi_test.py index 49dd949..1f3fff1 100644 --- a/tests/babi_test.py +++ b/tests/babi_test.py @@ -58,9 +58,16 @@ class PrintsErrorRunner(Runner): with self._onerror(): return super().await_exit(*args, **kwargs) - def await_text(self, *args, **kwargs): + def await_text(self, text, timeout=None): + """copied from the base implementation but doesn't munge newlines""" with self._onerror(): - return super().await_text(*args, **kwargs) + for _ in self.poll_until_timeout(timeout): + screen = self.screenshot() + if text in screen: + return + raise AssertionError( + f'Timeout while waiting for text {text!r} to appear', + ) def await_text_missing(self, s): """largely based on await_text""" @@ -395,3 +402,64 @@ def test_window_width_1(tmpdir): h.press('Right') h.await_text('hello') assert h.get_cursor_position() == (3, 1) + + +def test_basic_text_editing(tmpdir): + with run() as h, and_exit(h): + h.press('hello world') + h.await_text('hello world') + h.press('Down') + h.press('bye!') + h.await_text('bye!') + assert h.screenshot().strip().endswith('world\nbye!') + + +def test_backspace_at_beginning_of_file(): + with run() as h, and_exit(h): + h.press('Bspace') + h.await_text_missing('unknown key') + assert h.screenshot().strip().splitlines()[1:] == [] + assert '*' not in h.screenshot() + + +def test_backspace_joins_lines(tmpdir): + f = tmpdir.join('f') + f.write('foo\nbar\nbaz\n') + + with run(str(f)) as h, and_exit(h): + h.await_text('foo') + h.press('Down') + h.press('Bspace') + h.await_text('foobar') + h.await_text('f *') + assert h.get_cursor_position() == (3, 1) + # pressing down should retain the X position + h.press('Down') + assert h.get_cursor_position() == (3, 2) + + +def test_backspace_at_end_of_file_still_allows_scrolling_down(tmpdir): + f = tmpdir.join('f') + f.write('hello world') + + with run(str(f)) as h, and_exit(h): + h.await_text('hello world') + h.press('Down') + h.press('Bspace') + h.press('Down') + assert h.get_cursor_position() == (0, 2) + assert '*' not in h.screenshot() + + +def test_backspace_deletes_text(tmpdir): + f = tmpdir.join('f') + f.write('ohai there') + + with run(str(f)) as h, and_exit(h): + h.await_text('ohai there') + for _ in range(3): + h.press('Right') + h.press('Bspace') + h.await_text('ohi') + h.await_text('f *') + assert h.get_cursor_position() == (2, 1) diff --git a/tox.ini b/tox.ini index 2645b6e..68f1ebd 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ commands = coverage erase coverage run -m pytest {posargs:tests} coverage combine - coverage report + coverage report --fail-under 100 [testenv:pre-commit] skip_install = true