From 39c24da0925a0f9ce527faa887e7f85f1318da27 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Sat, 28 Sep 2019 14:49:33 -0700 Subject: [PATCH] Implement ^Home and ^End + escape sequences Resolves #6 --- babi.py | 94 +++++++++++++++++++++++++++++++++++++--------- tests/babi_test.py | 32 +++++++++++++++- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/babi.py b/babi.py index 9dc98e7..80719b2 100644 --- a/babi.py +++ b/babi.py @@ -13,6 +13,7 @@ from typing import List from typing import NamedTuple from typing import Optional from typing import Tuple +from typing import Union VERSION_STR = 'babi v0' @@ -103,6 +104,16 @@ class Position: def end(self, margin: Margin, lines: List[str]) -> None: self.x = self.x_hint = len(lines[self.cursor_line]) + def ctrl_home(self, margin: Margin, lines: List[str]) -> None: + self.x = self.x_hint = 0 + self.cursor_line = self.file_line = 0 + + def ctrl_end(self, margin: Margin, lines: List[str]) -> None: + self.x = self.x_hint = 0 + self.cursor_line = len(lines) - 1 + if self.file_line < self.cursor_line - margin.body_lines: + self.file_line = self.cursor_line - margin.body_lines * 3 // 4 + 1 + def page_up(self, margin: Margin, lines: List[str]) -> None: if self.cursor_line < margin.body_lines: self.cursor_line = self.file_line = 0 @@ -134,6 +145,8 @@ class Position: b'^E': end, b'^Y': page_up, b'^V': page_down, + b'kHOM5': ctrl_home, + b'kEND5': ctrl_end, } def cursor_y(self, margin: Margin) -> int: @@ -417,6 +430,53 @@ def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool]: return lines, nl, mixed +class Key(NamedTuple): + wch: Union[int, str] + key: int + keyname: bytes + + +# TODO: find a place to populate these, surely there's a database somewhere +SEQUENCE_KEY = { + '\033OH': curses.KEY_HOME, + '\033OF': curses.KEY_END, +} +SEQUENCE_KEYNAME = { + '\033[1;5H': b'kHOM5', # C-Home + '\033[1;5F': b'kEND5', # C-End + '\033OH': b'KEY_HOME', + '\033OF': b'KEY_END', +} + + +def _get_char(stdscr: 'curses._CursesWindow') -> Key: + wch = stdscr.get_wch() + if isinstance(wch, str) and wch == '\033': + stdscr.nodelay(True) + try: + while True: + try: + new_wch = stdscr.get_wch() + if isinstance(new_wch, str): + wch += new_wch + else: # pragma: no cover (impossible?) + curses.unget_wch(new_wch) + break + except curses.error: + break + finally: + stdscr.nodelay(False) + + if len(wch) > 1: + key = SEQUENCE_KEY.get(wch, -1) + keyname = SEQUENCE_KEYNAME.get(wch, b'unknown') + return Key(wch, key, keyname) + + key = wch if isinstance(wch, int) else ord(wch) + keyname = curses.keyname(key) + return Key(wch, key, keyname) + + EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV') @@ -435,38 +495,38 @@ def _edit(stdscr: 'curses._CursesWindow', file: File) -> EditResult: status.draw(stdscr, margin) file.pos.move_cursor(stdscr, margin) - wch = stdscr.get_wch() - key = wch if isinstance(wch, int) else ord(wch) - keyname = curses.keyname(key) + key = _get_char(stdscr) - if key == curses.KEY_RESIZE: + if key.key == curses.KEY_RESIZE: curses.update_lines_cols() margin = Margin.from_screen(stdscr) file.pos.maybe_scroll_down(margin) - elif key in Position.DISPATCH: - file.pos.DISPATCH[key](file.pos, margin, file.lines) - elif keyname in Position.DISPATCH_KEY: - file.pos.DISPATCH_KEY[keyname](file.pos, margin, file.lines) - elif keyname == b'^X': + elif key.key in Position.DISPATCH: + file.pos.DISPATCH[key.key](file.pos, margin, file.lines) + elif key.keyname in Position.DISPATCH_KEY: + file.pos.DISPATCH_KEY[key.keyname](file.pos, margin, file.lines) + elif key.keyname == b'^X': return EditResult.EXIT # TODO: use M-Right / M-Left when I figure out how escapes work - elif keyname == b'^G': + elif key.keyname == b'^G': return EditResult.PREV - elif keyname == b'^H': + elif key.keyname == b'^H': return EditResult.NEXT - elif keyname == b'^Z': + elif key.keyname == b'^Z': curses.endwin() os.kill(os.getpid(), signal.SIGSTOP) stdscr = _init_screen() - elif key in file.DISPATCH: - file.DISPATCH[key](file, margin) - elif isinstance(wch, str) and wch.isprintable(): - file.c(wch, margin) + elif key.key in file.DISPATCH: + file.DISPATCH[key.key](file, margin) + elif isinstance(key.wch, str) and key.wch.isprintable(): + file.c(key.wch, margin) else: - status.update(f'unknown key: {keyname} ({key})', margin) + status.update(f'unknown key: {key}', margin) def _init_screen() -> 'curses._CursesWindow': + # set the escape delay so curses does not pause waiting for sequences + os.environ.setdefault('ESCDELAY', '25') stdscr = curses.initscr() curses.noecho() curses.cbreak() diff --git a/tests/babi_test.py b/tests/babi_test.py index 62f190a..783b38e 100644 --- a/tests/babi_test.py +++ b/tests/babi_test.py @@ -239,6 +239,13 @@ def test_status_clearing_behaviour(): h.await_text_missing('unknown key') +def test_escape_key_behaviour(): + # TODO: eventually escape will have a command utility, for now: unknown + with run() as h, and_exit(h): + h.press('Escape') + h.await_text('unknown key') + + def test_reacts_to_resize(): with run() as h, and_exit(h): first_line = h.get_screen_line(0) @@ -374,7 +381,6 @@ def test_page_up_page_down_size_small_window(tmpdir): assert h.get_cursor_line() == 'line_0' -@pytest.mark.skip(reason='not implemented') # pragma: no cover def test_ctrl_home(tmpdir): f = tmpdir.join('f') f.write('\n'.join(f'line_{i}' for i in range(10))) @@ -389,6 +395,30 @@ def test_ctrl_home(tmpdir): h.await_cursor_position(x=0, y=1) +def test_ctrl_end(tmpdir): + f = tmpdir.join('f') + f.write('\n'.join(f'line_{i}' for i in range(10))) + + with run(str(f), height=6) as h, and_exit(h): + h.press('^End') + h.await_cursor_position(x=0, y=3) + assert h.get_screen_line(2) == 'line_9' + + +def test_ctrl_end_already_on_last_page(tmpdir): + f = tmpdir.join('f') + f.write('\n'.join(f'line_{i}' for i in range(10))) + + with run(str(f), height=8) as h, and_exit(h): + h.press('PageDown') + h.await_cursor_position(x=0, y=1) + h.await_text('line_9') + + h.press('^End') + h.await_cursor_position(x=0, y=7) + assert h.get_screen_line(6) == 'line_9' + + def test_scrolling_arrow_key_movement(tmpdir): f = tmpdir.join('f') f.write('\n'.join(f'line_{i}' for i in range(10)))