command mode (can now be quit with :q)
Co-authored-by: Hayden Young <hayden@haydennyyy.com>
This commit is contained in:
228
babi.py
228
babi.py
@@ -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}')
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user