editor/editor.py
2020-09-25 19:50:33 +02:00

407 lines
12 KiB
Python

# TODO: is_modified and undo/redo might be buggy
# TODO: Prompt to exit anyway on modified buffer?
import argparse
import curses.ascii
import sys
from typing import Any
from typing import Iterator
from typing import List
from typing import Optional
from typing import overload
from typing import Sequence
from typing import Tuple
from typing import Union
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("filename")
args = parser.parse_args(argv)
return curses.wrapper(c_main, args.filename)
def c_main(stdscr: "curses._CursesWindow", filename: str) -> int:
Editor(filename).run(stdscr)
return 0
class Editor:
def __init__(self, filename: str):
with open(filename) as f:
lines = f.read().split("\n")
self.buffer = Buffer(lines, filename)
self.cursor = Cursor()
self.window = Window(curses.COLS, curses.LINES - 1)
self.undo_stack: List[Tuple[Buffer, Cursor]] = []
self.redo_stack: List[Tuple[Buffer, Cursor]] = []
self.command_line = ""
# Main
def run(self, stdscr: "curses._CursesWindow") -> None:
while True:
self.render(stdscr)
self.command_line = ""
self.handle_key(stdscr)
def render(self, stdscr: "curses._CursesWindow") -> None:
stdscr.erase()
# Window
for y, line in enumerate(self.window.visible_lines(self.buffer)):
stdscr.addstr(y, 0, line)
# Status line
stdscr.addstr(
self.window.height - 1,
0,
self.window.status_line(self.buffer, self.cursor),
curses.A_REVERSE,
)
# Command line
stdscr.addstr(self.window.height, 0, self.command_line)
# Cursor
stdscr.move(*self.window.cursor_position(self.cursor))
def handle_key(self, stdscr: "curses._CursesWindow") -> None:
c = self.getkey(stdscr)
with open("log.txt", "a") as f:
f.write(c + "\n")
if c == "C-q":
self.exit()
elif c == "C-p":
self.previous_line()
elif c == "C-n":
self.next_line()
elif c == "C-b":
self.backward_char()
elif c == "C-f":
self.forward_char()
elif c == "C-a":
self.move_beginning_of_line()
elif c == "C-e":
self.move_end_of_line()
elif c == "C-j": # <enter>
self.newline()
elif c == "<backspace>":
self.delete_char()
elif c == "C-d": # del
self.delete_forward_char()
elif c == "C-s":
self.save_buffer()
elif c == "C-_": # C-/
self.undo()
elif c == "M-/":
self.redo()
else:
self.add_char(c)
def exit(self) -> None:
# TODO: If any buffer has been modified, print an error message instead.
# TODO: Add a prompt that waits for the user to press enter to continue.
if self.buffer.is_modified:
self.send_message(
f'No write since last change for buffer "{self.buffer.filename}"',
)
return
sys.exit(0)
# Keyboard
def getkey(self, stdscr: "curses._CursesWindow") -> str:
# TODO: Make a simple Key class that knows that some keys
# have multiple possible chars. E.g., C-j = RET = ...
c = stdscr.getch()
# Meta
if c == curses.ascii.ESC:
stdscr.nodelay(True)
c2 = stdscr.getch()
stdscr.nodelay(False)
if c2 == curses.ERR: # no additional key pressed
return "<escape>"
# Ctrl + Meta
c2_unctrl = curses.unctrl(c2).decode("ascii")
if curses.ascii.isctrl(c2):
c2_key = c2_unctrl[1:].lower()
return f"C-M-{c2_key}"
return f"M-{c2_unctrl}"
# Ctrl
if curses.ascii.isctrl(c):
c_key = curses.unctrl(c).decode("ascii")[1:].lower()
return f"C-{c_key}"
if c == curses.ascii.DEL:
return "<backspace>"
# Plain char
if curses.ascii.isprint(c):
return curses.unctrl(c).decode("ascii")
raise NotImplementedError(f"Unknown character key code: {c}")
# Cursor movement
def previous_line(self) -> None:
self.cursor = self.cursor.up(self.buffer)
self.window.scroll_up(self.cursor)
def next_line(self) -> None:
self.cursor = self.cursor.down(self.buffer)
self.window.scroll_down(self.cursor, self.buffer)
def backward_char(self) -> None:
self.cursor = self.cursor.left(self.buffer)
self.window.scroll_up(self.cursor)
def forward_char(self) -> None:
self.cursor = self.cursor.right(self.buffer)
self.window.scroll_down(self.cursor, self.buffer)
# Buffer editing
def delete_char(self) -> None:
if not (self.cursor.y == 0 and self.cursor.x == 0):
self._checkpoint()
# Move the cursor with the unmodified buffer, else it ends up at the
# end of the new, combined line.
cursor = self.cursor.left(self.buffer)
self.buffer = self.buffer.delete_char(self.cursor)
self.cursor = cursor
self.window.scroll_up(self.cursor)
def delete_forward_char(self) -> None:
if not (
self.cursor.y == len(self.buffer) - 1
and self.cursor.x == len(self.buffer[self.cursor.y])
):
self._checkpoint()
self.buffer = self.buffer.delete_forward_char(self.cursor)
def add_char(self, c: str) -> None:
self._checkpoint()
self.buffer = self.buffer.add_char(self.cursor, c)
self.cursor = self.cursor.right(self.buffer)
def newline(self) -> None:
self._checkpoint()
self.buffer = self.buffer.newline(self.cursor)
self.cursor = self.cursor.right(self.buffer)
self.window.scroll_down(self.cursor, self.buffer)
def move_beginning_of_line(self) -> None:
self.cursor = self.cursor.move_beginning_of_line()
def move_end_of_line(self) -> None:
self.cursor = self.cursor.move_end_of_line(self.buffer)
# IO
def save_buffer(self) -> None:
# TODO: This isn't safe! We should check if the file changed externally.
self.buffer.save()
self.send_message(f'"{self.buffer.filename}" {len(self.buffer)}L written')
# Undo/redo
def _checkpoint(self) -> None:
self.redo_stack = []
self.undo_stack.append((self.buffer, self.cursor))
def undo(self) -> None:
if self.undo_stack:
self.redo_stack.append((self.buffer, self.cursor))
self.buffer, self.cursor = self.undo_stack.pop()
else:
self.send_message("Already at oldest change")
def redo(self) -> None:
if self.redo_stack:
self.undo_stack.append((self.buffer, self.cursor))
self.buffer, self.cursor = self.redo_stack.pop()
else:
self.send_message("Already at newest change")
# Messages
def send_message(self, message: str) -> None:
self.command_line = message
class Cursor:
MAX_X = sys.maxsize
def __init__(self, x: int = 0, y: int = 0, x_hint: Optional[int] = None):
self.x = x
self.y = y
self._x_hint = x if x_hint is None else x_hint
# Primary interface
def up(self, buffer: "Buffer") -> "Cursor":
return self.line_move(-1, buffer)
def down(self, buffer: "Buffer") -> "Cursor":
return self.line_move(1, buffer)
def left(self, buffer: "Buffer") -> "Cursor":
if self.x == 0 and self.y > 0:
return self.up(buffer).move_end_of_line(buffer)
return self.column_move(-1, buffer)
def right(self, buffer: "Buffer") -> "Cursor":
if self.x == len(buffer[self.y]) and self.y < len(buffer):
return self.down(buffer).move_beginning_of_line()
return self.column_move(1, buffer)
def move_beginning_of_line(self) -> "Cursor":
return Cursor(0, self.y, 0)
def move_end_of_line(self, buffer: "Buffer") -> "Cursor":
return Cursor(len(buffer[self.y]), self.y, self.MAX_X)
# Convenience functions
def line_move(self, n: int, buffer: "Buffer") -> "Cursor":
y = clamp(self.y + n, 0, len(buffer) - 1)
x = min(self._x_hint, len(buffer[y]))
return Cursor(x, y, self._x_hint)
def column_move(self, n: int, buffer: "Buffer") -> "Cursor":
x = clamp(self.x + n, 0, len(buffer[self.y]))
return Cursor(x, self.y, x)
def clamp(x: Any, lower: Any, upper: Any) -> Any:
if x < lower:
return lower
if x > upper:
return upper
return x
class Buffer:
def __init__(self, lines: List[str], filename: str):
self.lines = lines
self.filename = filename
self.is_modified = False
# Helpers
def copy(self) -> "Buffer":
return Buffer(self.lines.copy(), self.filename)
def pop(self, index: int) -> str:
self.is_modified = True
return self.lines.pop(index)
def insert(self, index: int, line: str) -> None:
self.is_modified = True
self.lines.insert(index, line)
def __setitem__(self, index: int, value: str) -> None:
self.is_modified = True
self.lines[index] = value
@overload
def __getitem__(self, index: int) -> str:
...
@overload
def __getitem__(self, index: slice) -> List[str]:
...
def __getitem__(self, index: Union[int, slice]) -> Union[str, List[str]]:
if isinstance(index, slice):
return self.lines[index]
return self.lines[index]
def __len__(self) -> int:
return len(self.lines)
def __iter__(self) -> Iterator[str]:
yield from self.lines
# Primary interface - editing
def delete_char(self, cursor: Cursor) -> "Buffer":
if cursor.x == 0:
return self.join_previous_line(cursor)
buffer = self.copy()
line = buffer.pop(cursor.y)
buffer.insert(cursor.y, line[: cursor.x - 1] + line[cursor.x :])
return buffer
def join_previous_line(self, cursor: Cursor) -> "Buffer":
buffer = self.copy()
buffer[cursor.y - 1] += buffer.pop(cursor.y)
return buffer
def delete_forward_char(self, cursor: Cursor) -> "Buffer":
if cursor.x == len(self[cursor.y]):
return self.join_next_line(cursor)
buffer = self.copy()
line = buffer.pop(cursor.y)
buffer.insert(cursor.y, line[: cursor.x] + line[cursor.x + 1 :])
return buffer
def join_next_line(self, cursor: Cursor) -> "Buffer":
buffer = self.copy()
buffer[cursor.y] += buffer.pop(cursor.y + 1)
return buffer
def add_char(self, cursor: Cursor, c: str) -> "Buffer":
buffer = self.copy()
line = buffer.pop(cursor.y)
buffer.insert(cursor.y, line[: cursor.x] + c + line[cursor.x :])
return buffer
def newline(self, cursor: Cursor) -> "Buffer":
buffer = self.copy()
line = buffer.pop(cursor.y)
buffer.insert(cursor.y, line[: cursor.x])
buffer.insert(cursor.y + 1, line[cursor.x :])
return buffer
# Files
def save(self) -> None:
with open(self.filename, "w") as f:
f.write("\n".join(self))
self.is_modified = False
class Window:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self.x = 0
self.y = 0
@property
def y_end(self) -> int:
return self.y + self.height - 1 # status height
def visible_lines(self, buffer: Buffer) -> List[str]:
return buffer[self.y : self.y_end]
def cursor_position(self, cursor: Cursor) -> Tuple[int, int]:
return cursor.y - self.y, cursor.x - self.x
def scroll_up(self, cursor: Cursor, margin: int = 1) -> None:
if self.y > 0 and cursor.y < self.y + margin:
self.y -= 1
def scroll_down(self, cursor: Cursor, buffer: Buffer, margin: int = 1) -> None:
if self.y_end < len(buffer) and cursor.y >= self.y_end - margin:
self.y += 1
def status_line(self, buffer: Buffer, cursor: Cursor) -> str:
file_status = " " + buffer.filename
if buffer.is_modified:
file_status += " [+]"
cursor_status = f"L: {cursor.y + 1}/{len(buffer)} C: {cursor.x + 1}" + " "
pad_length = self.width - len(file_status) - len(cursor_status) - 1
return file_status + " " * pad_length + cursor_status