-
-
Save nicholatian/dae5a47db5b0d2e1078958a7bde7767c to your computer and use it in GitHub Desktop.
E, a curses editor prototype in Python.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
import curses | |
''' | |
high-level concept of curses editor program | |
what’s so special about this editor program? | |
- no commands ever involve holding keys for combos. | |
- no commands use ctrl, meta/alt, shift, or super/win keys. | |
- commands begin by pressing Tab, entering a string, and then Return. | |
- any in-progress command can be canceled using Backspace. | |
this is an approach similar to what is seen in power user editors like vi(m) | |
and emacs. only difference is, its much more keyboard-agnostic. its also | |
agnostic to sticky-key accessibility support patterns (no holding keys for | |
commands). this is very nice, but especially so on things like macbooks, which | |
throw ctrl/cmd for a loop, and may lack things like fn and esc keys on models | |
without a touchbar. | |
this is an early prototype written in python. eventually this software will be | |
written in ANSI C, once it is more feature complete. its made for the 640-wide | |
teletypes. it is simple, and it tries to stay as simple as possible while | |
being empowering to the user. | |
Commands list: | |
`n` toggle nav mode. wasd moves the text cursor pos. ijkl moves viewport. | |
`u-x` insert unicode codepoint x. leading zeroes not required. | |
`o x` open file in path x. supports ~, but not shell vars. | |
`t x` creates file in path x. supports ~, but not shell vars. | |
`s x` save file in path x. supports ~, but not shell vars. | |
`ss` quick save currently opened file. | |
`cls` close the file, creating a new empty buffer in memory. | |
`q` quit! it’s that easy.™ | |
`tabsz x` set tab size to x. valid values are nonnegative integers. | |
this is using the python curses wrapper for prototyping. | |
the application has a simple bar at the bottom, where the command buffer will | |
show up. | |
the rendering of everything in the window, including the file buffer, is | |
separated from the stage where input is obtained (getch). | |
some challenges in rendering include: | |
1. rendering tabs manually, and accounting for their visual length | |
2. bounding navigation to the edges of the buffer content | |
3. syntax highlighting? | |
once these things are done, file io can be implemented. | |
''' | |
class State: | |
def __init__(self): | |
# curses window object | |
self.win = curses.initscr() | |
# keycode last pressed | |
self.key = 0 | |
# the opened file, as a list of lines | |
self.buffer = [''] | |
# the path of the currently opened file | |
self.openedfile = '' | |
# the command buffer | |
self.cmdbuffer = '' | |
# switch for command mode | |
self.cmdmode = False | |
# switch for nav mode | |
self.navmode = False | |
# window dimensions | |
self.win_w = 0 | |
self.win_h = 0 | |
# cursor position in buffer (wasd) | |
self.bufcur_x = 0 | |
self.bufcur_y = 0 | |
# nav mode’s sticky bufcur_x | |
self.sticky_x = 0 | |
# window offset (ijkl) | |
self.bufofs_x = 0 | |
self.bufofs_y = 0 | |
# tab size (3) | |
self.tabsz = 1 | |
self.globl = 0 | |
def store_winsz(s): | |
h, w = s.win.getmaxyx() | |
s.win_w = w | |
s.win_h = h | |
def ren_statbar(s): | |
lhs = '' | |
rhs = 'LF | T:' + str(s.tabsz) + ' ' | |
if s.cmdmode: | |
lhs = ' command: ' + s.cmdbuffer | |
else: | |
lhs = ' -*- ' | |
if s.navmode: | |
rhs = 'nav | ' + rhs | |
else: | |
rhs = 'wri | ' + rhs | |
mid = ' ' * (s.win_w - len(lhs) - len(rhs) - 1) | |
s.win.attron(curses.color_pair(3)) | |
s.win.addstr(s.win_h - 1, 0, lhs + mid + rhs) | |
s.win.attroff(curses.color_pair(3)) | |
def render(s): | |
# store window size | |
store_winsz(s) | |
# render status bar | |
ren_statbar(s) | |
# render lines of buffer | |
buflen = len(s.buffer) | |
i = 0 | |
while i < s.win_h - 1 and i + s.bufofs_y < buflen: | |
text = s.buffer[i + s.bufofs_y].replace('\t', ' ' * s.tabsz)[s.bufofs_x:s.bufofs_x + s.win_w] | |
textlen = len(text) | |
s.win.addstr(i, 0, text) | |
if s.win_w - textlen > 0: | |
s.win.addstr(i, textlen, ' ' * (s.win_w - textlen)) | |
i += 1 | |
# blank the remaining lines | |
i = buflen - s.bufofs_y | |
blank = ' ' * (s.win_w - 1) | |
while i < s.win_h - 1: | |
s.win.addstr(i, 0, blank) | |
i += 2 | |
# fix the cursor | |
if s.cmdmode: | |
s.win.move(s.win_h - 1, 10 + len(s.cmdbuffer)) | |
else: | |
y = s.bufcur_y - s.bufofs_y | |
x = s.bufcur_x - s.bufofs_x | |
if y < 0 or x < 0: | |
y = s.win_h - 1 | |
x = s.win_w - 1 | |
s.win.move(y, x) | |
# refresh and done | |
s.win.refresh() | |
def do_move_wri(s): | |
if s.key == curses.KEY_UP: | |
s.bufcur_y -= 1 if s.bufcur_y > 0 else 0 | |
linelen = len(s.buffer[s.bufcur_y]) | |
if s.sticky_x <= linelen: | |
s.bufcur_x = s.sticky_x | |
else: | |
s.bufcur_x = linelen | |
if s.bufcur_x > linelen: | |
s.bufcur_x = linelen | |
elif s.key == curses.KEY_LEFT: | |
s.bufcur_x -= 1 if s.bufcur_x > 0 else 0 | |
s.sticky_x = s.bufcur_x | |
elif s.key == curses.KEY_DOWN: | |
s.bufcur_y += 1 if len(s.buffer) - 1 > s.bufcur_y else 0 | |
linelen = len(s.buffer[s.bufcur_y]) | |
if s.sticky_x <= linelen: | |
s.bufcur_x = s.sticky_x | |
else: | |
s.bufcur_x = linelen | |
if s.bufcur_x > linelen: | |
s.bufcur_x = linelen | |
elif s.key == curses.KEY_RIGHT: | |
s.bufcur_x += 1 if len(s.buffer[s.bufcur_y]) > s.bufcur_x else 0 | |
s.sticky_x = s.bufcur_x | |
def do_move_nav(s): | |
if s.key == curses.KEY_UP: | |
s.bufofs_y -= 1 if s.bufofs_y > 0 else 0 | |
elif s.key == curses.KEY_LEFT: | |
s.bufofs_x -= 1 if s.bufofs_x > 0 else 0 | |
elif s.key == curses.KEY_DOWN: | |
s.bufofs_y += 1 if len(s.buffer) - 1 > s.bufofs_y else 0 | |
elif s.key == curses.KEY_RIGHT: | |
visible = False | |
i = s.bufofs_y | |
buflen = len(s.buffer) | |
while i < s.bufofs_y + s.win_h - 1 and i < buflen: | |
if s.buffer[i][s.bufofs_x + 1:] != '': | |
visible = True | |
break | |
i += 1 | |
s.bufofs_x += 1 if visible else 0 | |
def ins_chr(s): | |
if s.bufcur_x == len(s.buffer[s.bufcur_y]): | |
s.globl = s.key | |
s.buffer[s.bufcur_y] += chr(s.key) | |
else: | |
lhs = s.buffer[s.bufcur_y][:s.bufcur_x] | |
rhs = s.buffer[s.bufcur_y][s.bufcur_x:] | |
s.buffer[s.bufcur_y] = lhs + chr(s.key) + rhs | |
s.bufcur_x += 1 | |
if s.bufcur_x - s.bufofs_x > 79: | |
s.bufofs_x += 1 | |
def do_cmd(s): | |
from os.path import expanduser, isfile | |
cmd = s.cmdbuffer | |
if cmd.startswith('u-'): | |
cp = int(cmd[2:], 16) | |
s.globl = cp | |
if cp != None: | |
s.key = cp | |
ins_chr(s) | |
elif cmd == 'n': | |
s.navmode = not s.navmode | |
s.sticky_x = s.bufcur_x | |
elif cmd.startswith('o '): | |
path = expanduser(cmd[2:]) | |
s.openedfile = path | |
f = open(path, 'r') | |
s.buffer = f.read().split('\n') | |
f.close() | |
s.bufofs_x = 0 | |
s.bufofs_y = 0 | |
s.bufcur_x = 0 | |
s.bufcur_y = 0 | |
elif cmd.startswith('t '): | |
path = expanduser(cmd[2:]) | |
if not isfile(path): | |
f = open(path, 'w') | |
f.flush() | |
f.close() | |
elif cmd.startswith('s '): | |
path = expanduser(cmd[2:]) | |
s.openedfile = path | |
f = open(path, 'w') | |
f.write('\n'.join(s.buffer) + '\n') | |
f.flush() | |
f.close() | |
elif cmd == 'ss' and s.openedfile != '': | |
f = open(s.openedfile, 'w') | |
f.write('\n'.join(s.buffer) + '\n') | |
f.flush() | |
f.close() | |
elif cmd == 'cls': | |
s.openedfile = '' | |
s.buffer = [''] | |
s.bufofs_x = 0 | |
s.bufofs_y = 0 | |
s.bufcur_x = 0 | |
s.bufcur_y = 0 | |
elif cmd == 'q': | |
return 1 | |
return 0 | |
def mainloop(s): | |
render(s) | |
s.key = s.win.getch() | |
if s.key == curses.ERR: | |
return 0 | |
elif s.key == 9: | |
if s.cmdmode: | |
ins_chr(s) | |
s.cmdmode = False | |
else: | |
s.cmdmode = True | |
elif s.key == 10: | |
# return/enter key | |
if s.cmdmode: | |
r = do_cmd(s) | |
s.cmdmode = False | |
s.cmdbuffer = '' | |
if r: | |
return 1 | |
elif not s.navmode: | |
if s.bufcur_y < len(s.buffer) - 1: | |
lhs = s.buffer[:s.bufcur_y + 1] | |
rhs = s.buffer[s.bufcur_y + 1:] | |
s.buffer = lhs + [''] + rhs | |
else: | |
s.buffer.append('') | |
s.bufcur_y += 1 | |
s.bufcur_x = 0 | |
elif s.key == 127: | |
# backspace/delete key (NOT the Del key) | |
if s.cmdmode: | |
s.cmdmode = False | |
s.cmdbuffer = '' | |
elif not s.navmode: | |
if s.buffer == [''] or (s.bufcur_x == 0 and s.bufcur_y == 0): | |
pass | |
elif s.buffer[s.bufcur_y] == '': | |
if s.bufcur_y < len(s.buffer) - 1: | |
lhs = s.buffer[:s.bufcur_y] | |
rhs = s.buffer[s.bufcur_y + 1:] | |
s.buffer = lhs + rhs | |
else: | |
s.buffer = s.buffer[:-1] | |
s.bufcur_y -= 1 | |
s.bufcur_x = len(s.buffer[s.bufcur_y]) | |
else: | |
newx = s.bufcur_x - 1 | |
if s.bufcur_x == 0: | |
newx = len(s.buffer[:s.bufcur_y - 1]) | |
line = s.buffer[s.bufcur_y - 1] + s.buffer[s.bufcur_y] | |
lhs = s.buffer[:s.bufcur_y - 1] | |
if lhs == []: | |
lhs.append(line) | |
else: | |
lhs[-1] = line | |
rhs = s.buffer[s.bufcur_y + 1:] | |
s.buffer = lhs + rhs | |
s.bufcur_y -= 1 | |
s.bufcur_x = newx | |
elif s.bufcur_x < len(s.buffer[s.bufcur_y]): | |
lhs = s.buffer[s.bufcur_y][:s.bufcur_x - 1] | |
rhs = s.buffer[s.bufcur_y][s.bufcur_x:] | |
s.buffer[s.bufcur_y] = lhs + rhs | |
else: | |
s.buffer[s.bufcur_y] = s.buffer[s.bufcur_y][:-1] | |
s.bufcur_x = newx | |
elif s.cmdmode: | |
s.cmdbuffer += chr(s.key) | |
elif s.key == curses.KEY_UP or s.key == curses.KEY_LEFT or \ | |
s.key == curses.KEY_DOWN or s.key == curses.KEY_RIGHT: | |
if s.navmode: | |
do_move_nav(s) | |
else: | |
do_move_wri(s) | |
else: | |
# normal typing mode | |
ins_chr(s) | |
return 0 | |
def curses_init(s): | |
# add some colours to the global curses library | |
curses.start_color() | |
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK) | |
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK) | |
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) | |
# set up quirks | |
s.win.nodelay(True) | |
curses.cbreak() | |
curses.noecho() | |
s.win.keypad(True) | |
def curses_fini(s): | |
s.win.keypad(False) | |
curses.echo() | |
curses.nocbreak() | |
curses.endwin() | |
def main(args): | |
from time import sleep | |
s = None | |
try: | |
s = State() | |
curses_init(s) | |
while not mainloop(s): | |
sleep(0.016) | |
finally: | |
curses_fini(s) | |
print(s.globl) | |
return 0 | |
if __name__ == '__main__': | |
from sys import argv, exit | |
exit(main(argv)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment