diff --git a/code.py b/code.py new file mode 100644 index 0000000..8460699 --- /dev/null +++ b/code.py @@ -0,0 +1,5 @@ +import editor +try: + editor.edit("code.py") +except KeyboardInterrupt: + pass diff --git a/dang.py b/dang.py new file mode 100644 index 0000000..2d3bb1e --- /dev/null +++ b/dang.py @@ -0,0 +1,105 @@ +import select +import sys + +try: + import termios + + _orig_attr = None + def _nonblocking(): + global _orig_attr + _orig_attr = termios.tcgetattr(sys.stdin) + attr = termios.tcgetattr(sys.stdin) + attr[3] &= ~(termios.ECHO | termios.ICANON) + attr[6][termios.VMIN] = 1 + attr[6][termios.VTIME] = 0 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, attr) + + def _blocking(): + if _orig_attr is not None: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, _orig_attr) + +except: + def _nonblocking(): + pass + + def _blocking(): + pass + +LINES = 24 +COLS = 80 + +special_keys = { + '\x1b': ..., # all prefixes of special keys must be entered as Ellipsis + '\x1b[': ..., + '\x1b[A': "KEY_UP", + '\x1b[B': "KEY_DOWN", + '\x1b[C': "KEY_RIGHT", + '\x1b[D': "KEY_LEFT", + '\x1b[H': "KEY_HOME", + '\x1b[F': "KEY_END", +} + +class Screen: + def __init__(self): + self._poll = select.poll() + self._poll.register(sys.stdin, select.POLLIN) + self._pending = '' + + def _sys_stdin_readable(self): + return hasattr(sys.stdin, 'readable') and sys.stdin.readable() + def _sys_stdout_flush(self): + if hasattr(sys.stdout, 'flush'): + sys.stdout.flush() + + def _terminal_read_blocking(self): + return sys.stdin.read(1) + + def _terminal_read_timeout(self, timeout): + if self._sys_stdin_readable() or self._poll.poll(timeout): + r = sys.stdin.read(1) + return r + return None + + def move(self, y, x): + print(end=f"\033[{y+1};{x+1}H") + def erase(self): + print(end="\033H\033[2J") + def addstr(self, y, x, text): + self.move(y, x) + print(end=text) + def getkey(self): + self._sys_stdout_flush() + pending = self._pending + if pending and (code := special_keys.get(pending)) is None: + self._pending = pending[1:] + return pending[0] + + while True: + if pending: + c = self._terminal_read_timeout(50) + if c is None: + self._pending = pending[1:] + return pending[0] + else: + c = self._terminal_read_blocking() + c = pending + c + + code = special_keys.get(c) + + if code is None: + self._pending = c[1:] + return c[0] + elif code is not Ellipsis: + return code + + pending = c + +def wrapper(func, *args, **kwds): + stdscr = Screen() + try: + _nonblocking() + func(stdscr, *args, **kwds) + finally: + _blocking() + stdscr.move(LINES-1, 0) + print("\n") diff --git a/dangtest.py b/dangtest.py new file mode 100644 index 0000000..fbeecd6 --- /dev/null +++ b/dangtest.py @@ -0,0 +1,7 @@ +from dang import wrapper + +def main(stdscr): + while True: + print(repr(stdscr.getkey())) + +wrapper(main) diff --git a/editor.py b/editor.py index c9a7727..174c83b 100644 --- a/editor.py +++ b/editor.py @@ -1,7 +1,38 @@ -import argparse -import curses +import dang as curses import sys +import gc +class MaybeDisableReload: + def __enter__(self): + try: + from supervisor import runtime + except ImportError: + return + + self._old_autoreload = runtime.autoreload + runtime.autoreload = False + + def __exit__(self, exc_type, exc_value, traceback): + try: + from supervisor import runtime + except ImportError: + return + + runtime.autoreload = self._old_autoreload + +def gc_mem_free_hint(): + if hasattr(gc, 'mem_free'): + gc.collect() + return f" | free: {gc.mem_free()}" + return "" + +def readonly(): + try: + import storage + except ImportError: + return False + + return storage.getmount('/').readonly class Buffer: def __init__(self, lines): @@ -92,6 +123,8 @@ class Cursor: self.row += 1 self.col = 0 + def end(self, buffer): + self.col = len(buffer[self.row]) class Window: def __init__(self, n_rows, n_cols, row=0, col=0): @@ -125,37 +158,67 @@ def left(window, buffer, cursor): window.up(cursor) window.horizontal_scroll(cursor) - def right(window, buffer, cursor): cursor.right(buffer) window.down(buffer, cursor) window.horizontal_scroll(cursor) +def home(window, buffer, cursor): + cursor.col = 0 + window.horizontal_scroll(cursor) -def main(stdscr): - parser = argparse.ArgumentParser() - parser.add_argument("filename") - args = parser.parse_args() +def end(window, buffer, cursor): + cursor.end(buffer) + window.horizontal_scroll(cursor) - with open(args.filename) as f: +def editor(stdscr, filename): + with open(filename) as f: buffer = Buffer(f.read().splitlines()) window = Window(curses.LINES - 1, curses.COLS - 1) cursor = Cursor() + stdscr.erase() + + img = [''] * window.n_rows + def setline(row, line): + if img[row] == line: + return + img[row] = line + stdscr.addstr(row, 0, line) + while True: - stdscr.erase() - for row, line in enumerate(buffer[window.row:window.row + window.n_rows]): + for row, line in enumerate(buffer[window.row:window.row + window.n_rows-1]): if row == cursor.row - window.row and window.col > 0: line = "«" + line[window.col + 1:] if len(line) > window.n_cols: line = line[:window.n_cols - 1] + "»" - stdscr.addstr(row, 0, line) + line += ' ' * (window.n_cols - len(line)) + setline(row, line) + + row = window.n_rows - 1 + if readonly(): + line = f"{filename:12} (readonly) | ^C: quit{gc_mem_free_hint()}" + else: + line = f"{filename:12} | ^X: write & exit | ^C: quit w/o save{gc_mem_free_hint()}" + setline(row, line) + stdscr.move(*window.translate(cursor)) k = stdscr.getkey() - if k == "q": - sys.exit(0) + if len(k) == 1 and ' ' <= k <= '~': + buffer.insert(cursor, k) + for _ in k: + right(window, buffer, cursor) + elif k == "\x18" and not readonly: # ctrl-x + with open(filename, "w") as f: + for row in buffer: + f.write(row) + return + elif k == "KEY_HOME": + home(window, buffer, cursor) + elif k == "KEY_END": + end(window, buffer, cursor) elif k == "KEY_LEFT": left(window, buffer, cursor) elif k == "KEY_DOWN": @@ -177,11 +240,14 @@ def main(stdscr): if (cursor.row, cursor.col) > (0, 0): left(window, buffer, cursor) buffer.delete(cursor) - else: - buffer.insert(cursor, k) - for _ in k: - right(window, buffer, cursor) +def edit(filename): + with MaybeDisableReload(): + curses.wrapper(editor, filename) if __name__ == "__main__": - curses.wrapper(main) + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("filename") + args = parser.parse_args() + edit(filename)