babi can load text and ~mostly navigate with the arrow keys
This commit is contained in:
197
babi.py
197
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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user