#!/usr/bin/python3 # -*- coding: utf-8 -*- """Edit tables. Well, a table, anyway. This is about as much of the orgtbl-mode command set as I was up to implementing in 2½ hours this morning. It does *not* support the orgtbl-mode or org-mode table *serialization*, instead using TSV. It supports a certain primitive degree of windowing, but scrolls only by entire table columns, and not in any sort of sensible way. It has no “undo”. It displays only in a character-cell terminal. It has no nesting and no ability for a cell to contain multiple lines of text. A document is unavoidably only a single table, and you cannot rename it within the program. """ import os, sys, errno, contextlib # Building up the keymap. ctrl = lambda c: chr(ord(c) & 31) csi = lambda s: '\033[' + s class Command: def __init__(self, name, *moreargs): self.name = name self.moreargs = moreargs def __call__(self, target, *args, **kw): return getattr(target, self.name)(*(self.moreargs + args), **kw) class CommandMaker: def __getattr__(self, name): return Command(name) methods = CommandMaker() up_command = Command('move', 0, -1) down_command = Command('move', 0, +1) left_command = Command('move', -1, 0) right_command = Command('move', +1, 0) keymap = [ # Emacs keystrokes (ctrl('b'), left_command), (ctrl('f'), right_command), (ctrl('n'), down_command), (ctrl('p'), up_command), # 1;3 below is “alt” # 1;4 below is “shift alt” (csi('A'), up_command), (csi('1;3A'), Command('swap', 0, -1)), (csi('B'), down_command), (csi('1;3B'), Command('swap', 0, +1)), (csi('1;4B'), methods.insert_row), (csi('C'), right_command), (csi('1;3C'), Command('swap', +1, 0)), (csi('1;4C'), methods.insert_column), (csi('D'), left_command), (csi('1;3D'), Command('swap', -1, 0)), (csi('Z'), left_command), # shift-tab (ctrl('s'), methods.save), (ctrl('q'), methods.quit), # standard everyday keystrokes (ctrl('h'), methods.backspace), (chr(127), methods.backspace), ('\n', methods.enter_command), ('\t', right_command), (csi('6~'), Command('move', 0, +20)), # PgDn (csi('5~'), Command('move', 0, -20)), # PgUp # table-specific stuff (ctrl('d'), methods.sort_descending), (ctrl('u'), methods.sort_ascending), ] for i in range(ord(' '), 127): keymap.append((chr(i), methods.self_insert_command)) class Tabuf: "Table buffer." def __init__(self, filename): self.filename = filename self.exited = False # XXX separate into a view object? self.kbuf = '' self.msg = ':)' self.dirty = False try: self.load() except IOError as e: if e.errno != errno.ENOENT: raise self.contents = [['x', '']] self.x, self.y = 0, 1 def width(self): return len(self.contents) def height(self): return len(self.contents[0]) def redraw(self, output): widths = [max(1, max(len(s) for s in col)) for col in self.contents] output.write('\033[2J\033[0H') # clear screen, home output.write('\033[32m%s\033[0m\n' % self.msg) for i in range(max(0, self.y - 10), min(self.height(), self.y + 10)): for j in range(max(0, self.x - 5), min(self.width(), self.x + 5)): self.draw_cell(output, self.contents[j][i], widths[j], highlight_if=((self.x, self.y) == (j, i))) output.write('\n') output.flush() def draw_cell(self, output, text, width, highlight_if): text = text.ljust(width) if highlight_if: text = '\033[44m%s\033[0m' % text # blue background output.write(text + ' ') def handle(self, char): self.last_char = char self.kbuf += char might_continue = False for charseq, command in keymap: if self.kbuf.startswith(charseq): self.kbuf = self.kbuf[len(charseq):] return command(self) elif charseq.startswith(self.kbuf): might_continue = True if not might_continue: self.msg = 'unknown command seq %r' % self.kbuf self.kbuf = '' def self_insert_command(self): self.contents[self.x][self.y] += self.last_char self.dirty = True def backspace(self): self.contents[self.x][self.y] = self.contents[self.x][self.y][:-1] self.dirty = True def enter_command(self): self.y += 1 if self.y >= self.height(): self.append_row() def move(self, dx, dy): y_move, self.x = divmod(self.x + dx, self.width()) self.y = (self.y + dy + y_move) % self.height() def swap(self, dx, dy): x, y = self.x, self.y y_move, self.x = divmod(self.x + dx, self.width()) self.y = (self.y + dy + y_move) % self.height() if x != self.x: self.contents[self.x], self.contents[x] = self.contents[x], self.contents[self.x] if y != self.y: for col in self.contents: col[self.y], col[y] = col[y], col[self.y] self.dirty = True def insert_column(self): self.contents.insert(self.x, [''] * self.height()) self.dirty = True def insert_row(self): for col in self.contents: col.insert(self.y, '') self.dirty = True def append_row(self): for col in self.contents: col.append('') def sort_descending(self): order = sorted(range(1, self.height()), key=self.contents[self.x].__getitem__, reverse=True) self.sort_by(order) def sort_ascending(self): order = sorted(range(1, self.height()), key=self.contents[self.x].__getitem__) self.sort_by(order) def sort_by(self, order): for x in range(self.width()): col = self.contents[x] self.contents[x] = col[:1] + [col[i] for i in order] if self.y: # may be in the header row self.y = order.index(self.y) + 1 # offset from the header row self.dirty = True def load(self): with open(self.filename, 'r') as fo: data = [(line[:-1] if line.endswith('\n') else line).split('\t') for line in fo] self.contents = list(map(list, zip(*data))) self.x, self.y = 0, 0 def save(self): tmpname = self.filename + '.tmp' with open(tmpname, 'w') as fo: for row in list(zip(*self.contents)): fo.write('\t'.join(row) + '\n') os.fsync(fo.fileno()) os.rename(tmpname, self.filename) self.msg = 'saved ' + self.filename self.dirty = False def quit(self): if self.dirty: self.msg = 'really quit? you have unsaved changes; ^Q again to really quit' self.dirty = False return self.exited = True def untitled(): "Create an unused untitled filename." i = 0 while True: filename = 'untitled%s.tables' % i if not os.path.exists(filename): return filename i += 1 def main(argv, infile, outfile): buf = Tabuf(argv[1] if len(argv) > 1 else untitled()) while not buf.exited: buf.redraw(outfile) buf.handle(infile.read(1)) @contextlib.contextmanager def _cbreak(): "Run something with cbreak turned on, restoring tty settings when done." settings = os.popen('stty -g').read().strip() os.system('stty -ixon -ixoff cbreak') try: yield finally: os.spawnv(os.P_WAIT, '/bin/stty', ['stty', settings]) cbreak = _cbreak() if __name__ == '__main__': with cbreak: main(sys.argv, sys.stdin, sys.stdout)