command mode (can now be quit with :q)

Co-authored-by: Hayden Young <hayden@haydennyyy.com>
This commit is contained in:
Anthony Sottile
2019-10-13 23:04:21 -07:00
parent 07d03d8ad0
commit 72bc067fa5
2 changed files with 235 additions and 91 deletions

228
babi.py
View File

@@ -19,6 +19,35 @@ from typing import Union
VERSION_STR = 'babi v0' VERSION_STR = 'babi v0'
def _line_x(x: int, width: int) -> int:
margin = min(width - 3, 6)
if x + 1 < width:
return 0
elif width == 1:
return x
else:
return (
width - margin - 2 +
(x + 1 - width) //
(width - margin - 2) *
(width - margin - 2)
)
def _scrolled_line(s: str, x: int, width: int, *, current: bool) -> str:
line_x = _line_x(x, width)
if current and line_x:
s = f'«{s[line_x + 1:]}'
if line_x and len(s) > width:
return f'{s[:width - 1]}»'
else:
return s.ljust(width)
elif len(s) > width:
return f'{s[:width - 1]}»'
else:
return s.ljust(width)
class Margin(NamedTuple): class Margin(NamedTuple):
header: bool header: bool
footer: bool footer: bool
@@ -85,27 +114,6 @@ def _init_colors(stdscr: 'curses._CursesWindow') -> None:
curses.init_pair(pair, fg, bg) curses.init_pair(pair, fg, bg)
class Header:
def __init__(self, file: 'File', idx: int, n_files: int) -> None:
self.file = file
self.idx = idx
self.n_files = n_files
def draw(self, stdscr: 'curses._CursesWindow') -> None:
filename = self.file.filename or '<<new file>>'
if self.file.modified:
filename += ' *'
if self.n_files > 1:
files = f'[{self.idx + 1}/{self.n_files}] '
version_width = len(VERSION_STR) + 2 + len(files)
else:
files = ''
version_width = len(VERSION_STR) + 2
centered = filename.center(curses.COLS)[version_width:]
s = f' {VERSION_STR} {files}{centered}{files}'
stdscr.insstr(0, 0, s, curses.A_REVERSE)
class Status: class Status:
def __init__(self) -> None: def __init__(self) -> None:
self._status = '' self._status = ''
@@ -135,6 +143,40 @@ class Status:
if self._action_counter < 0: if self._action_counter < 0:
self._status = '' self._status = ''
def prompt(self, screen: 'Screen', prompt: str) -> str:
pos = 0
buf = ''
while True:
width = curses.COLS - len(prompt)
cmd = f'{prompt}{_scrolled_line(buf, pos, width, current=True)}'
screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE)
line_x = _line_x(pos, width)
screen.stdscr.move(curses.LINES - 1, pos - line_x)
key = _get_char(screen.stdscr)
if key.key == curses.KEY_RESIZE:
screen.resize()
elif key.key == curses.KEY_LEFT:
pos = max(0, pos - 1)
elif key.key == curses.KEY_RIGHT:
pos = min(len(buf), pos + 1)
elif key.key == curses.KEY_HOME or key.keyname == b'^A':
pos = 0
elif key.key == curses.KEY_END or key.keyname == b'^E':
pos = len(buf)
elif key.key == curses.KEY_BACKSPACE:
if pos > 0:
buf = buf[:pos - 1] + buf[pos:]
pos -= 1
elif key.key == curses.KEY_DC:
if pos < len(buf):
buf = buf[:pos] + buf[pos + 1:]
elif isinstance(key.wch, str) and key.wch.isprintable():
buf = buf[:pos] + key.wch + buf[pos:]
pos += 1
elif key.key == ord('\r'):
return buf
def _restore_lines_eof_invariant(lines: List[str]) -> None: def _restore_lines_eof_invariant(lines: List[str]) -> None:
"""The file lines will always contain a blank empty string at the end to """The file lines will always contain a blank empty string at the end to
@@ -396,18 +438,7 @@ class File:
return self.cursor_line - self.file_line + margin.header return self.cursor_line - self.file_line + margin.header
def line_x(self) -> int: def line_x(self) -> int:
margin = min(curses.COLS - 3, 6) return _line_x(self.x, curses.COLS)
if self.x + 1 < curses.COLS:
return 0
elif curses.COLS == 1:
return self.x
else:
return (
curses.COLS - margin - 2 +
(self.x + 1 - curses.COLS) //
(curses.COLS - margin - 2) *
(curses.COLS - margin - 2)
)
def cursor_x(self) -> int: def cursor_x(self) -> int:
return self.x - self.line_x() return self.x - self.line_x()
@@ -424,25 +455,61 @@ class File:
for i in range(to_display): for i in range(to_display):
line_idx = self.file_line + i line_idx = self.file_line + i
line = self.lines[line_idx] line = self.lines[line_idx]
line_x = self.line_x() current = line_idx == self.cursor_line
if line_idx == self.cursor_line and line_x: line = _scrolled_line(line, self.x, curses.COLS, current=current)
line = f'«{line[line_x + 1:]}'
if len(line) > curses.COLS:
line = f'{line[:curses.COLS - 1]}»'
else:
line = line.ljust(curses.COLS)
elif len(line) > curses.COLS:
line = f'{line[:curses.COLS - 1]}»'
else:
line = line.ljust(curses.COLS)
stdscr.insstr(i + margin.header, 0, line) stdscr.insstr(i + margin.header, 0, line)
blankline = ' ' * curses.COLS blankline = ' ' * curses.COLS
for i in range(to_display, margin.body_lines): for i in range(to_display, margin.body_lines):
stdscr.insstr(i + margin.header, 0, blankline) stdscr.insstr(i + margin.header, 0, blankline)
class Screen:
def __init__(
self,
stdscr: 'curses._CursesWindow',
files: List[File],
) -> None:
self.stdscr = stdscr
self.files = files
self.i = 0
self.status = Status()
self.margin = Margin.from_screen(self.stdscr)
@property
def file(self) -> File:
return self.files[self.i]
def _draw_header(self) -> None:
filename = self.file.filename or '<<new file>>'
if self.file.modified:
filename += ' *'
if len(self.files) > 1:
files = f'[{self.i + 1}/{len(self.files)}] '
version_width = len(VERSION_STR) + 2 + len(files)
else:
files = ''
version_width = len(VERSION_STR) + 2
centered = filename.center(curses.COLS)[version_width:]
s = f' {VERSION_STR} {files}{centered}{files}'
self.stdscr.insstr(0, 0, s, curses.A_REVERSE)
def draw(self) -> None:
if self.margin.header:
self._draw_header()
self.file.draw(self.stdscr, self.margin)
self.status.draw(self.stdscr, self.margin)
def resize(self) -> None:
curses.update_lines_cols()
self.margin = Margin.from_screen(self.stdscr)
self.file.maybe_scroll_down(self.margin)
self.draw()
def _color_test(stdscr: 'curses._CursesWindow') -> None: def _color_test(stdscr: 'curses._CursesWindow') -> None:
Header(File('<<color test>>'), 1, 1).draw(stdscr) header = f' {VERSION_STR}'
header += '<< color test >>'.center(curses.COLS)[len(header):]
stdscr.insstr(0, 0, header, curses.A_REVERSE)
maxy, maxx = stdscr.getmaxyx() maxy, maxx = stdscr.getmaxyx()
if maxy < 19 or maxx < 68: # pragma: no cover (will be deleted) if maxy < 19 or maxx < 68: # pragma: no cover (will be deleted)
@@ -517,44 +584,39 @@ def _get_char(stdscr: 'curses._CursesWindow') -> Key:
return Key(wch, key, keyname) return Key(wch, key, keyname)
def _resize(stdscr: 'curses._CursesWindow', file: File) -> Margin:
curses.update_lines_cols()
margin = Margin.from_screen(stdscr)
file.maybe_scroll_down(margin)
return margin
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV') EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV')
def _edit( def _edit(screen: Screen) -> EditResult:
stdscr: 'curses._CursesWindow', screen.file.ensure_loaded(screen.status, screen.margin)
file: File,
header: Header,
) -> EditResult:
margin = Margin.from_screen(stdscr)
status = Status()
file.ensure_loaded(status, margin)
while True: while True:
status.tick() screen.status.tick()
if margin.header: screen.draw()
header.draw(stdscr) screen.file.move_cursor(screen.stdscr, screen.margin)
file.draw(stdscr, margin)
status.draw(stdscr, margin)
file.move_cursor(stdscr, margin)
key = _get_char(stdscr) key = _get_char(screen.stdscr)
if key.key == curses.KEY_RESIZE: if key.key == curses.KEY_RESIZE:
margin = _resize(stdscr, file) screen.resize()
elif key.key in File.DISPATCH: elif key.key in File.DISPATCH:
file.DISPATCH[key.key](file, margin) screen.file.DISPATCH[key.key](screen.file, screen.margin)
elif key.keyname in File.DISPATCH_KEY: elif key.keyname in File.DISPATCH_KEY:
file.DISPATCH_KEY[key.keyname](file, margin) screen.file.DISPATCH_KEY[key.keyname](screen.file, screen.margin)
elif key.keyname == b'^[': # escape
response = screen.status.prompt(screen, '')
if response == ':q':
return EditResult.EXIT
elif response == ':w':
screen.file.save(screen.status, screen.margin)
else:
screen.status.update(
f'{response} is not a valid command.',
screen.margin,
)
elif key.keyname == b'^S': elif key.keyname == b'^S':
file.save(status, margin) screen.file.save(screen.status, screen.margin)
elif key.keyname == b'^X': elif key.keyname == b'^X':
return EditResult.EXIT return EditResult.EXIT
elif key.keyname == b'kLFT3': elif key.keyname == b'kLFT3':
@@ -564,29 +626,27 @@ def _edit(
elif key.keyname == b'^Z': elif key.keyname == b'^Z':
curses.endwin() curses.endwin()
os.kill(os.getpid(), signal.SIGSTOP) os.kill(os.getpid(), signal.SIGSTOP)
stdscr = _init_screen() screen.stdscr = _init_screen()
margin = _resize(stdscr, file) screen.resize()
elif isinstance(key.wch, str) and key.wch.isprintable(): elif isinstance(key.wch, str) and key.wch.isprintable():
file.c(key.wch, margin) screen.file.c(key.wch, screen.margin)
else: else:
status.update(f'unknown key: {key}', margin) screen.status.update(f'unknown key: {key}', screen.margin)
def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> None: def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> None:
if args.color_test: if args.color_test:
return _color_test(stdscr) return _color_test(stdscr)
files = [File(filename) for filename in args.filenames or [None]] screen = Screen(stdscr, [File(f) for f in args.filenames or [None]])
i = 0 while screen.files:
while files: screen.i = screen.i % len(screen.files)
i = i % len(files) res = _edit(screen)
header = Header(files[i], i, len(files))
res = _edit(stdscr, files[i], header)
if res == EditResult.EXIT: if res == EditResult.EXIT:
del files[i] del screen.files[screen.i]
elif res == EditResult.NEXT: elif res == EditResult.NEXT:
i += 1 screen.i += 1
elif res == EditResult.PREV: elif res == EditResult.PREV:
i -= 1 screen.i -= 1
else: else:
raise AssertionError(f'unreachable {res}') raise AssertionError(f'unreachable {res}')

View File

@@ -183,6 +183,17 @@ def and_exit(h):
h.await_exit() h.await_exit()
def trigger_command_mode(h):
# in order to enter a steady state, trigger an unknown key first and then
# press escape to open the command mode. this is necessary as `Escape` is
# the start of "escape sequences" and sending characters too quickly will
# be interpreted as a single keypress
h.press('^J')
h.await_text('unknown key')
h.press('Escape')
h.await_text_missing('unknown key')
@pytest.mark.parametrize('color', (True, False)) @pytest.mark.parametrize('color', (True, False))
def test_color_test(color): def test_color_test(color):
with run('--color-test', color=color) as h, and_exit(h): with run('--color-test', color=color) as h, and_exit(h):
@@ -243,13 +254,6 @@ def test_status_clearing_behaviour():
h.await_text_missing('unknown key') 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(): def test_reacts_to_resize():
with run() as h, and_exit(h): with run() as h, and_exit(h):
first_line = h.get_screen_line(0) first_line = h.get_screen_line(0)
@@ -865,3 +869,83 @@ def test_save_file_when_it_did_not_exist(tmpdir):
h.await_text_missing('*') h.await_text_missing('*')
assert f.read() == 'hello world\n' assert f.read() == 'hello world\n'
def test_quit_via_colon_q():
with run() as h:
trigger_command_mode(h)
h.press_and_enter(':q')
h.await_exit()
def test_key_navigation_in_command_mode():
with run() as h, and_exit(h):
trigger_command_mode(h)
h.press('hello world')
h.await_cursor_position(x=11, y=23)
h.press('Left')
h.await_cursor_position(x=10, y=23)
h.press('Right')
h.await_cursor_position(x=11, y=23)
h.press('Home')
h.await_cursor_position(x=0, y=23)
h.press('End')
h.await_cursor_position(x=11, y=23)
h.press('^A')
h.await_cursor_position(x=0, y=23)
h.press('^E')
h.await_cursor_position(x=11, y=23)
h.press('DC') # does nothing at end
h.await_cursor_position(x=11, y=23)
h.await_text('\nhello world\n')
h.press('Bspace')
h.await_cursor_position(x=10, y=23)
h.await_text('\nhello worl\n')
h.press('Home')
h.press('Bspace') # does nothing at beginning
h.await_cursor_position(x=0, y=23)
h.await_text('\nhello worl\n')
h.press('DC')
h.await_cursor_position(x=0, y=23)
h.await_text('\nello worl\n')
# unknown keys don't do anything
h.press('^J')
h.await_text('\nello worl\n')
h.press('Enter')
def test_save_via_command_mode(tmpdir):
f = tmpdir.join('f')
with run(str(f)) as h, and_exit(h):
h.press('hello world')
trigger_command_mode(h)
h.press_and_enter(':w')
assert f.read() == 'hello world\n'
def test_resizing_and_scrolling_in_command_mode():
with run(width=20) as h, and_exit(h):
h.press('a' * 15)
h.await_text(f'\n{"a" * 15}\n')
trigger_command_mode(h)
h.press('b' * 15)
h.await_text(f'\n{"b" * 15}\n')
with h.resize(width=16, height=24):
h.await_text('\n«aaaaaa\n') # the text contents
h.await_text('\n«bbbbbb\n') # the text contents
h.await_cursor_position(x=7, y=23)
h.press('Left')
h.await_cursor_position(x=14, y=23)
h.await_text(f'\n{"b" * 15}\n')
h.press('Enter')