#!/usr/bin/python3 """Dumb text editor. Now able to edit its own source code enough that I added the last few features (undo and preserving file permissions on save) by editing it with itself. Keyboard commands suck, but are a minimal Emacs mostly-subset: - ^X saves - ^S searches forward (as in Raskin's THE, search is the only way to move more than a character at a time) - ^R searches backward (repeated ^S and ^R commands repeat the search) - ^F and ^B move the cursor forward and backward - backspace, delete, or ^H deletes a character to the left (the only way to delete) - ^_ or ^/ is undo - ^C quits For the moment I'm using an ASCII terminal interface via the Unix tty interface, redrawing the screen from scratch every time, and I'm just using a Python immutable string as my buffer. For a 2.4 kilobyte file, which this was at the time, a 43-second editing session involves 240 ms of CPU time; a 9.9-second editing session on full key repeat is 340 ms of CPU time. That's 3.5% CPU time, a factor of 30 faster than is needed to keep up, which suggests that maybe on a 70 kilobyte file this will fall behind key repeat. But that's a really long way from being its biggest problem. Problems remaining for minimal usability: - ^C exits without saving or asking for confirmation - no cut and paste Slightly less urgent problems: - movement is still awkward, despite search, though with search it's no longer completely unusable - you can't delete from the end of the search string with backspace - cursor disappears at line ends - scrolling backwards displays a partial line instead of finding the line beginning to display the entire line - no back button (like Emacs ^X^X or ^U^@) for large movements - undo uses a stack model and there is no redo - screen redisplay is ugly as shit and doesn't take screen size into account - cursor movement keys don't exit out of search - scrolling for search only animates forward (and can be slow) - no way to search for \n, which is a problem when you don't have line-by-line movement commands! - no syntax highlighting - no autoindent Things that would be cool: - What if a failed search takes you to the end of the file? Like, if you're looking for #)@* and there isn't a #)@* in the file? That would be handy! Credit goes to Mina. - Scroll position shouldn't be at the very end or beginning of the screen, usually. You usually want to see at least a few lines of context. """ import os, sys, subprocess class Buffer: def __init__(self, filename, contents): self.filename = filename self.buf = contents self.yad = 0 self.scroll = 0 self.done = False self.status = 'Ok' self.bindings = default_bindings self.last_search_string = 'no last search string' self.undo_stack = [] def run(self): while not self.done: self.redisplay() key = sys.stdin.read(1) self.handle_key(key) def redisplay(self): boak = ['\033[H'] # top left of screen row = 0 col = 0 highlighted = False if self.yad < self.scroll: self.scroll = self.yad i = self.scroll scroll_down = i + 1 while row < 23: c = self.buf[i] i += 1 if highlighted: boak.append('\033[0m') # turn off highlighting highlighted = False if c == '\n' or col == 80: boak.append(' ' * (80 - col)) # XXX clear to end of line boak.append('\n') row += 1 col = 0 if row == 1: scroll_down = i if c == '\n': continue elif ord(c) < 32: c = '.' if i == self.yad + 1: boak.append('\033[47m') # white background highlighted = True boak.append(c) col += 1 boak.append(self.status) boak.append(' ') sys.stdout.writelines(boak) sys.stdout.flush() if self.yad > i: # cursor is outside visible screen self.scroll = scroll_down self.redisplay() # hopefully this doesn't result in an iloop def handle_key(self, key): self.message('Got ' + repr(key)) if key in self.bindings: self.bindings[key](self, key) def message(self, msg): self.status = msg def insert(self, c): self.save_undo_state() self.buf = self.buf[:self.yad] + c + self.buf[self.yad:] self.yad += 1 def delete(self, start, end): self.save_undo_state() self.buf = self.buf[:start] + self.buf[end:] def save_undo_state(self): self.undo_stack.append((self.yad, self.buf)) def end(self): return len(self.buf) def save(self): tmp_fname = self.filename + '.tmp.%d' % os.getpid() sys.stdout.write('saving to %s...' % tmp_fname) sys.stdout.flush() with open(tmp_fname, 'w') as fo: n = fo.write(self.buf) fo.flush() os.fsync(fo.fileno()) os.chmod(tmp_fname, os.stat(self.filename).st_mode) os.rename(tmp_fname, self.filename) self.message('saved %d bytes to %s' % (n, self.filename)) def search(self): try: if self.searching_forward: self.yad = self.buf.index(self.search_string, self.yad) else: end = self.yad + len(self.search_string) self.yad = self.buf.rindex(self.search_string, 0, end) except ValueError: self.message("Can't find %r" % self.search_string) def self_insert_command(buf, key): buf.message('inserting ' + repr(key)) buf.insert(key) def delete_backward_char(buf, key): if buf.yad > 0: buf.delete(buf.yad-1, buf.yad) buf.yad -= 1 buf.message('') def forward_char(buf, key): if buf.yad < buf.end(): buf.yad += 1 buf.message('') def backward_char(buf, key): if buf.yad > 0: buf.yad -= 1 buf.message('') def undo(buf, key): try: buf.yad, buf.buf = buf.undo_stack.pop() except IndexError: buf.message('Undo stack empty') def abort(buf, key): buf.bindings = default_bindings buf.message('Abort') def extend_search_string(buf, key): buf.search_string += key buf.message('Find %r' % buf.search_string) buf.search() def start_search(buf, key): buf.bindings = search_bindings buf.search_string = '' buf.searching_forward = True buf.message('Find?') def start_search_backwards(buf, key): start_search(buf, key) buf.searching_forward = False def end_search(buf, key): buf.last_search_string = buf.search_string del buf.search_string, buf.searching_forward buf.bindings = default_bindings buf.message('Found.') def next_forward(buf, key): if buf.search_string == '': buf.search_string = buf.last_search_string elif buf.searching_forward: forward_char(buf, key) buf.searching_forward = True buf.message('-> %r' % buf.search_string) buf.search() def next_backward(buf, key): if buf.search_string == '': buf.search_string = buf.last_search_string elif not buf.searching_forward: backward_char(buf, key) buf.searching_forward = False buf.message('<- %r' % buf.search_string) buf.search() def ctrl(c): return chr(ord(c) & 0x1f) assert ctrl('a') == ctrl('A') == '\x01' default_bindings = {} search_bindings = {} for i in range(ord(' '), ord('~') + 1): default_bindings[chr(i)] = self_insert_command search_bindings[chr(i)] = extend_search_string default_bindings['\n'] = self_insert_command default_bindings['\x7f'] = default_bindings[ctrl('h')] = delete_backward_char default_bindings[ctrl('f')] = forward_char default_bindings[ctrl('b')] = backward_char default_bindings[ctrl('x')] = lambda buf, key: buf.save() default_bindings[ctrl('g')] = search_bindings[ctrl('g')] = abort default_bindings[ctrl('s')] = start_search default_bindings[ctrl('r')] = start_search_backwards default_bindings[ctrl('_')] = undo search_bindings['\n'] = end_search search_bindings[ctrl('s')] = next_forward search_bindings[ctrl('r')] = next_backward def edit(filename): with open(filename) as fo: buf = fo.read() status, tty_settings = subprocess.getstatusoutput('stty -g') if status != 0: raise ValueError("can't stty", status) os.system('stty cbreak -echo -ixon') try: Buffer(filename, buf).run() finally: os.system('stty ' + tty_settings) if __name__ == '__main__': edit(sys.argv[1])