edit multiple files
This commit is contained in:
239
babi.py
239
babi.py
@@ -3,6 +3,7 @@ import argparse
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
import enum
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
@@ -11,6 +12,7 @@ from typing import Generator
|
||||
from typing import IO
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
VERSION_STR = 'babi v0'
|
||||
@@ -128,9 +130,12 @@ class Position:
|
||||
curses.KEY_PPAGE: page_up,
|
||||
curses.KEY_NPAGE: page_down,
|
||||
}
|
||||
|
||||
def dispatch(self, key: int, margin: Margin, lines: List[str]) -> None:
|
||||
return self.DISPATCH[key](self, margin, lines)
|
||||
DISPATCH_KEY = {
|
||||
b'^A': home,
|
||||
b'^E': end,
|
||||
b'^Y': page_up,
|
||||
b'^V': page_down,
|
||||
}
|
||||
|
||||
def cursor_y(self, margin: Margin) -> int:
|
||||
return self.cursor_line - self.file_line + margin.header
|
||||
@@ -203,21 +208,12 @@ def _init_colors(stdscr: '_curses._CursesWindow') -> None:
|
||||
|
||||
|
||||
class Header:
|
||||
def __init__(self, filename: str) -> None:
|
||||
self._filename = filename
|
||||
self._modified = False
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
return self._modified
|
||||
|
||||
@modified.setter
|
||||
def modified(self, modified: bool) -> None:
|
||||
self._modified = modified
|
||||
def __init__(self, file: 'File') -> None:
|
||||
self.file = file
|
||||
|
||||
def draw(self, stdscr: '_curses._CursesWindow') -> None:
|
||||
filename = self._filename
|
||||
if self._modified:
|
||||
filename = self.file.filename or '<<new file>>'
|
||||
if self.file.modified:
|
||||
filename += ' *'
|
||||
centered = filename.center(curses.COLS)[len(VERSION_STR) + 2:]
|
||||
s = f' {VERSION_STR} {centered}'
|
||||
@@ -254,8 +250,103 @@ class Status:
|
||||
self._status = ''
|
||||
|
||||
|
||||
class File:
|
||||
def __init__(self, filename: Optional[str]) -> None:
|
||||
self.filename = filename
|
||||
self.modified = False
|
||||
self.pos = Position()
|
||||
self.lines: List[str] = []
|
||||
self.nl = '\n'
|
||||
|
||||
def ensure_loaded(self, status: Status, margin: Margin) -> None:
|
||||
if self.lines:
|
||||
return
|
||||
|
||||
if self.filename is not None and os.path.isfile(self.filename):
|
||||
with open(self.filename, newline='') as f:
|
||||
self.lines, self.nl, mixed = _get_lines(f)
|
||||
else:
|
||||
if self.filename is not None:
|
||||
if os.path.lexists(self.filename):
|
||||
status.update(f'{self.filename!r} is not a file', margin)
|
||||
self.filename = None
|
||||
else:
|
||||
status.update('(new file)', margin)
|
||||
self.lines, self.nl, mixed = _get_lines(io.StringIO(''))
|
||||
|
||||
if mixed:
|
||||
status.update(
|
||||
f'mixed newlines will be converted to {self.nl!r}', margin,
|
||||
)
|
||||
self.modified = True
|
||||
|
||||
def backspace(self, margin: Margin) -> None:
|
||||
# backspace at the beginning of the file does nothing
|
||||
if self.pos.cursor_line == 0 and self.pos.x == 0:
|
||||
pass
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
elif self.pos.x == 0:
|
||||
victim = self.lines.pop(self.pos.cursor_line)
|
||||
new_x = len(self.lines[self.pos.cursor_line - 1])
|
||||
self.lines[self.pos.cursor_line - 1] += victim
|
||||
self.pos.up(margin, self.lines)
|
||||
self.pos.x = self.pos.x_hint = new_x
|
||||
# deleting the fake end-of-file doesn't cause modification
|
||||
self.modified |= self.pos.cursor_line < len(self.lines) - 1
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
else:
|
||||
s = self.lines[self.pos.cursor_line]
|
||||
self.lines[self.pos.cursor_line] = (
|
||||
s[:self.pos.x - 1] + s[self.pos.x:]
|
||||
)
|
||||
self.pos.left(margin, self.lines)
|
||||
self.modified = True
|
||||
|
||||
def delete(self, margin: Margin) -> None:
|
||||
# noop at end of the file
|
||||
if self.pos.cursor_line == len(self.lines) - 1:
|
||||
pass
|
||||
# if we're at the end of the line, collapse the line afterwards
|
||||
elif self.pos.x == len(self.lines[self.pos.cursor_line]):
|
||||
self.lines[self.pos.cursor_line] += (
|
||||
self.lines[self.pos.cursor_line + 1]
|
||||
)
|
||||
self.lines.pop(self.pos.cursor_line + 1)
|
||||
self.modified = True
|
||||
else:
|
||||
s = self.lines[self.pos.cursor_line]
|
||||
self.lines[self.pos.cursor_line] = (
|
||||
s[:self.pos.x] + s[self.pos.x + 1:]
|
||||
)
|
||||
self.modified = True
|
||||
|
||||
def enter(self, margin: Margin) -> None:
|
||||
s = self.lines[self.pos.cursor_line]
|
||||
self.lines[self.pos.cursor_line] = s[:self.pos.x]
|
||||
self.lines.insert(self.pos.cursor_line + 1, s[self.pos.x:])
|
||||
self.pos.down(margin, self.lines)
|
||||
self.pos.x = self.pos.x_hint = 0
|
||||
self.modified = True
|
||||
|
||||
DISPATCH = {
|
||||
curses.KEY_BACKSPACE: backspace,
|
||||
curses.KEY_DC: delete,
|
||||
ord('\r'): enter,
|
||||
}
|
||||
|
||||
def c(self, wch: str, margin: Margin) -> None:
|
||||
s = self.lines[self.pos.cursor_line]
|
||||
self.lines[self.pos.cursor_line] = (
|
||||
s[:self.pos.x] + wch + s[self.pos.x:]
|
||||
)
|
||||
self.pos.right(margin, self.lines)
|
||||
self.modified = True
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
|
||||
def _color_test(stdscr: '_curses._CursesWindow') -> None:
|
||||
Header('<<color test>>').draw(stdscr)
|
||||
Header(File('<<color test>>')).draw(stdscr)
|
||||
|
||||
maxy, maxx = stdscr.getmaxyx()
|
||||
if maxy < 19 or maxx < 68: # pragma: no cover (will be deleted)
|
||||
@@ -328,33 +419,23 @@ def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool]:
|
||||
return lines, nl, mixed
|
||||
|
||||
|
||||
def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None:
|
||||
if args.color_test:
|
||||
return _color_test(stdscr)
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV')
|
||||
|
||||
filename = args.filename
|
||||
pos = Position()
|
||||
|
||||
def _edit(stdscr: '_curses._CursesWindow', file: File) -> EditResult:
|
||||
margin = Margin.from_screen(stdscr)
|
||||
header = Header(filename or '<<new file>>')
|
||||
status = Status()
|
||||
|
||||
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:
|
||||
status.update(f'mixed newlines will be converted to {nl!r}', margin)
|
||||
header.modified = True
|
||||
file.ensure_loaded(status, margin)
|
||||
header = Header(file)
|
||||
|
||||
while True:
|
||||
status.tick()
|
||||
|
||||
if margin.header:
|
||||
header.draw(stdscr)
|
||||
_write_lines(stdscr, pos, margin, lines)
|
||||
_write_lines(stdscr, file.pos, margin, file.lines)
|
||||
status.draw(stdscr, margin)
|
||||
pos.move_cursor(stdscr, margin)
|
||||
file.pos.move_cursor(stdscr, margin)
|
||||
|
||||
wch = stdscr.get_wch()
|
||||
key = wch if isinstance(wch, int) else ord(wch)
|
||||
@@ -363,69 +444,26 @@ def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None:
|
||||
if key == curses.KEY_RESIZE:
|
||||
curses.update_lines_cols()
|
||||
margin = Margin.from_screen(stdscr)
|
||||
pos.maybe_scroll_down(margin)
|
||||
file.pos.maybe_scroll_down(margin)
|
||||
elif key in Position.DISPATCH:
|
||||
pos.dispatch(key, margin, lines)
|
||||
elif keyname == b'^A':
|
||||
pos.home(margin, lines)
|
||||
elif keyname == b'^E':
|
||||
pos.end(margin, lines)
|
||||
elif keyname == b'^Y':
|
||||
pos.page_up(margin, lines)
|
||||
elif keyname == b'^V':
|
||||
pos.page_down(margin, lines)
|
||||
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':
|
||||
return
|
||||
return EditResult.EXIT
|
||||
# TODO: use M-Right / M-Left when I figure out how escapes work
|
||||
elif keyname == b'^G':
|
||||
return EditResult.PREV
|
||||
elif keyname == b'^H':
|
||||
return EditResult.NEXT
|
||||
elif keyname == b'^Z':
|
||||
curses.endwin()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
stdscr = _init_screen()
|
||||
elif key == curses.KEY_BACKSPACE:
|
||||
# backspace at the beginning of the file does nothing
|
||||
if pos.cursor_line == 0 and pos.x == 0:
|
||||
pass
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
elif pos.x == 0:
|
||||
victim = lines.pop(pos.cursor_line)
|
||||
new_x = len(lines[pos.cursor_line - 1])
|
||||
lines[pos.cursor_line - 1] += victim
|
||||
pos.up(margin, lines)
|
||||
pos.x = pos.x_hint = new_x
|
||||
# deleting the fake end-of-file doesn't cause modification
|
||||
header.modified |= pos.cursor_line < len(lines) - 1
|
||||
_restore_lines_eof_invariant(lines)
|
||||
else:
|
||||
s = lines[pos.cursor_line]
|
||||
lines[pos.cursor_line] = s[:pos.x - 1] + s[pos.x:]
|
||||
pos.left(margin, lines)
|
||||
header.modified = True
|
||||
elif key == curses.KEY_DC:
|
||||
# noop at end of the file
|
||||
if pos.cursor_line == len(lines) - 1:
|
||||
pass
|
||||
# if we're at the end of the line, collapse the line afterwards
|
||||
elif pos.x == len(lines[pos.cursor_line]):
|
||||
lines[pos.cursor_line] += lines[pos.cursor_line + 1]
|
||||
lines.pop(pos.cursor_line + 1)
|
||||
header.modified = True
|
||||
else:
|
||||
s = lines[pos.cursor_line]
|
||||
lines[pos.cursor_line] = s[:pos.x] + s[pos.x + 1:]
|
||||
header.modified = True
|
||||
elif wch == '\r':
|
||||
s = lines[pos.cursor_line]
|
||||
lines[pos.cursor_line] = s[:pos.x]
|
||||
lines.insert(pos.cursor_line + 1, s[pos.x:])
|
||||
pos.down(margin, lines)
|
||||
pos.x = pos.x_hint = 0
|
||||
header.modified = True
|
||||
elif key in file.DISPATCH:
|
||||
file.DISPATCH[key](file, margin)
|
||||
elif isinstance(wch, str) and wch.isprintable():
|
||||
s = lines[pos.cursor_line]
|
||||
lines[pos.cursor_line] = s[:pos.x] + wch + s[pos.x:]
|
||||
pos.right(margin, lines)
|
||||
header.modified = True
|
||||
_restore_lines_eof_invariant(lines)
|
||||
file.c(wch, margin)
|
||||
else:
|
||||
status.update(f'unknown key: {keyname} ({key})', margin)
|
||||
|
||||
@@ -445,6 +483,25 @@ def _init_screen() -> '_curses._CursesWindow':
|
||||
return stdscr
|
||||
|
||||
|
||||
def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None:
|
||||
if args.color_test:
|
||||
return _color_test(stdscr)
|
||||
files = [File(filename) for filename in args.filenames or [None]]
|
||||
i = 0
|
||||
while files:
|
||||
i = i % len(files)
|
||||
file = files[i]
|
||||
res = _edit(stdscr, file)
|
||||
if res == EditResult.EXIT:
|
||||
del files[i]
|
||||
elif res == EditResult.NEXT:
|
||||
i += 1
|
||||
elif res == EditResult.PREV:
|
||||
i -= 1
|
||||
else:
|
||||
raise AssertionError(f'unreachable {res}')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def make_stdscr() -> Generator['_curses._CursesWindow', None, None]:
|
||||
"""essentially `curses.wrapper` but split out to implement ^Z"""
|
||||
@@ -458,7 +515,7 @@ def make_stdscr() -> Generator['_curses._CursesWindow', None, None]:
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--color-test', action='store_true')
|
||||
parser.add_argument('filename', nargs='?')
|
||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||
args = parser.parse_args()
|
||||
with make_stdscr() as stdscr:
|
||||
c_main(stdscr, args)
|
||||
|
||||
@@ -256,6 +256,19 @@ def test_mixed_newlines(tmpdir):
|
||||
h.await_text(r"mixed newlines will be converted to '\n'")
|
||||
|
||||
|
||||
def test_new_file():
|
||||
with run('this_is_a_new_file') as h, and_exit(h):
|
||||
h.await_text('this_is_a_new_file')
|
||||
h.await_text('(new file)')
|
||||
|
||||
|
||||
def test_not_a_file(tmpdir):
|
||||
d = tmpdir.join('d').ensure_dir()
|
||||
with run(str(d)) as h, and_exit(h):
|
||||
h.await_text('<<new file>>')
|
||||
h.await_text("d' is not a file")
|
||||
|
||||
|
||||
def test_arrow_key_movement(tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write(
|
||||
@@ -695,3 +708,39 @@ def test_suspend(tmpdir):
|
||||
h.press('C-x')
|
||||
h.press_and_enter('exit')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_multiple_files(tmpdir):
|
||||
a = tmpdir.join('file_a')
|
||||
a.write('a text')
|
||||
b = tmpdir.join('file_b')
|
||||
b.write('b text')
|
||||
c = tmpdir.join('file_c')
|
||||
c.write('c text')
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.await_text('file_a')
|
||||
h.await_text('a text')
|
||||
h.press('Right')
|
||||
h.await_cursor_position(x=1, y=1)
|
||||
|
||||
h.press('C-h') # TODO: alt+right
|
||||
h.await_text('file_b')
|
||||
h.await_text('b text')
|
||||
h.await_cursor_position(x=0, y=1)
|
||||
|
||||
h.press('C-g') # TODO: alt+left
|
||||
h.await_text('file_a')
|
||||
h.await_cursor_position(x=1, y=1)
|
||||
|
||||
# wrap around
|
||||
h.press('C-g') # TODO: alt+left
|
||||
h.await_text('file_c')
|
||||
h.await_text('c text')
|
||||
|
||||
h.press('C-x')
|
||||
h.await_text('file_a')
|
||||
h.press('C-x')
|
||||
h.await_text('file_b')
|
||||
h.press('C-x')
|
||||
h.await_exit()
|
||||
|
||||
Reference in New Issue
Block a user