#!/usr/bin/env python """ emano.py - The Simple Powerful Term Editor Author: Sean B. Palmer, inamidst.com About: http://inamidst.com/proj/emano/ @@ WARNING: This is not yet stable code: * Don't edit any files that you consider to be valuable * Remember to backup your work before using this program Usage: $ python -OO emano.py @@ Todo: * Use full absolute paths for backup files * Implement a Minimal Point of Orientation ("Emano => ") * Do a clear screen on exiting the editor * Drop duplicates in the history file, and make the paths absolute * Way to get a list of just the unsaved files * Difference between Empty File and New File? Make it consistent * Numbers after identically named list entries * Titles in the listwin, e.g. "(Commands List)". With a maxlen? * "Inline text" links * Mode to leave properties unchanged * (default)?(Input|Output)Encoding * Trailing newlines are stripped * Better catching of errors when in a list window * Configuration file, keymappings etc. * Option to reuse completely empty new tabs * Modchar doesn't appear to work on emano: URIs * Toggle fill on and off * Stop Ctrl+Backspace doing stuff * Justify without a newline selected adds a newline * Resizing the window * Code/comments justify * Move blocks of code * Overwrite warning! * Switching between CRLF and LF * Backup as an option * Most recently used files * Get various document statistics * Better messages in listwin mode * File unsaved rather than file modfied, for - input? * moveToDialogue * paren-matching Ideas: * A quick-edit mode for very large files * Undo clumping * Distributed editing * Undo streams saved as cached diffs * Split the files into a .tar.gz-able module * Use Python's distutils, including a Makefile * Translation modules, .po files and so on * Comprehensive test suite * Diff stream branching Defer: * Also, cleaning up the command list as specified earlier * Rename @@ to switchTab? * No modchar if the filename is unknown * Indent mode * Tabs may insert spaces * Ask to save helpfile, exceptions, etc.? Done: * Get commwin.set(augment=...) to work in ListWindow * Fix the modification status on pages that've been HTTP PUT saved * Add asterisks to History * Get grep to work from beneath instances of the regexp * Add just a discreet "?" on unrecognized character * Wrap Ctrl+C in the list window * Quit from a list window (Ctrl+W? Q? C?) * Ctrl+W on a single tab doesn't bring up a message. * HTTP PUT publishing * Decent unicode insertion and encoding handling * Type filter in list window * Can't cut a last line * Delete doesn't work at the very top with pos startline * Error on closing the first tab--it makes a new one! * A separate quick-reference page * Multiple files being open seems to bork; uses the URI as unique? * moveScreen should be moveCursor * Ctrl+W a tab in no-man's-land screenpos bug * Indenting a line off the screen borks * Indenting at the end of a screen line kabooms * Selecting and Ctrl moving a line raises an exception * Indent anywhere with Ctrl+I * Unindent anywhere with Ctrl+U * List window * Smart-comment with Ctrl+# * Directory browsing * Page Up/Down * Hm, can emano possibly have one single shared clipboard buffer? * Only show "..." on actual truncation * Cutting a large region of text messes up; it doesn't jump * Trailing # typo! * Check that all tabs are not modified before global quit * Modification character in the statusbar? * Barf with nice message if not xterm etc. * try/except around all events * Text justify: Ctrl+J Config options: * -c --comment (smart comment character) * -i --indent (characters to indent by) * -w --wrap (wrap to this number of columns) """ import sys, os, re, md5, urllib, textwrap import curses, curses.ascii # @@ Versioning # I like date-based versioning schemes, but the Linux/BSD community tend to # expect v.v[.v] versions. So to combine the two I could use Major, Month Since # 2005-01, Day of Month. Hence 2005-09-08 -> 0.9.8. # Temporary measure for backup caching purposes dotemano = os.path.expanduser('~/.emano') class Opener(urllib.URLopener): def __init__(self, *args): self.version = 'Mozilla/5.0 (emano)' urllib.URLopener.__init__(self, *args) def http_error_default(self, url, fp, errcode, errmsg, headers): return urllib.addinfourl(fp, [headers, errcode], "http:" + url) urllib._urlopener = Opener() class Charmap(dict): def __getattr__(self, attr): return dict.__getitem__(self, attr) c = Charmap({ 'UP': 'KEY_UP', # cuul 'DOWN': 'KEY_DOWN', # kcud1 'RIGHT': 'KEY_RIGHT', # cuf1 'LEFT': 'KEY_LEFT', # kcub1 'DELETE': 'KEY_DC', # kdch1 'PAGE_UP': 'KEY_PPAGE', # kpp 'PAGE_DOWN': 'KEY_NPAGE', # knp 'HOME': 'KEY_HOME', # khome 'END': 'KEY_END', # kend 'RETURN': 13, # Ctrl+M # 'CTRL_UP_cygwin': 'OA', # @@ # 'CTRL_DOWN_cygwin': 'OB', # @@ # 'CTRL_UP_xterm': '[5A', # @@ # 'CTRL_DOWN_xterm': '[5B', # @@ 'CTRL_A': 1, 'CTRL_B': 2, 'CTRL_C': 3, 'CTRL_D': 4, 'CTRL_E': 5, 'CTRL_F': 6, 'CTRL_G': 7, 'CTRL_H': 8, 'CTRL_I': 9, 'CTRL_J': 10, 'CTRL_K': 11, 'CTRL_L': 12, 'CTRL_M': 13, 'CTRL_N': 14, 'CTRL_O': 15, 'CTRL_P': 16, 'CTRL_Q': 17, 'CTRL_R': 18, 'CTRL_S': 19, 'CTRL_T': 20, 'CTRL_U': 21, 'CTRL_V': 22, 'CTRL_W': 23, 'CTRL_X': 24, 'CTRL_Y': 25, 'CTRL_Z': 26, # 'CTRL_Hash': 28, # @@ 'BACKSPACE': 127 }) loonycode = { u'\u00A1': '!', u'\u00A2': '!', u'\u00A5': 'Y', u'\u00A9': 'c', u'\u00AB': '<', u'\u00BB': '<', u'\u00BF': '?', u'\u00C0': 'A', u'\u00C1': 'A', u'\u00C2': 'A', u'\u00C3': 'A', u'\u00C4': 'A', u'\u00C5': 'A', u'\u00C7': 'C', u'\u00C8': 'E', u'\u00C9': 'E', u'\u00CA': 'E', u'\u00CB': 'E', u'\u00CC': 'I', u'\u00CD': 'I', u'\u00CE': 'I', u'\u00CF': 'I', u'\u00D0': 'D', u'\u00D1': 'N', u'\u00D2': 'O', u'\u00D3': 'O', u'\u00D4': 'O', u'\u00D5': 'O', u'\u00D6': 'O', u'\u00D8': 'O', u'\u00D9': 'U', u'\u00DA': 'U', u'\u00DB': 'U', u'\u00DC': 'U', u'\u00DD': 'Y', u'\u00E0': 'a', u'\u00E1': 'a', u'\u00E2': 'a', u'\u00E3': 'a', u'\u00E4': 'a', u'\u00E5': 'a', u'\u00E7': 'c', u'\u00E8': 'e', u'\u00E9': 'e', u'\u00EA': 'e', u'\u00EB': 'e', u'\u00EC': 'i', u'\u00ED': 'i', u'\u00EE': 'i', u'\u00EF': 'i', u'\u00F1': 'n', u'\u00F2': 'o', u'\u00F3': 'o', u'\u00F4': 'o', u'\u00F5': 'o', u'\u00F6': 'o', u'\u00F7': '%', u'\u00F8': 'o', u'\u00F9': 'u', u'\u00FA': 'u', u'\u00FB': 'u', u'\u00FC': 'u', u'\u00FD': 'y', u'\u00FF': 'y', u'\u203D': '?', u'\u2014': '-', u'\u201C': '"', u'\u201D': '"' } def backup(fn, data): # Temporary measure def encode(fn): fn = urllib.quote(fn) return fn.replace('/', '_') bakfn = os.path.join(dotemano, encode(fn)) import codecs f = codecs.open(bakfn, 'w', 'utf-8') try: f.write(data) except UnicodeDecodeError: f.write('%r' % data) f.close() def format(string, variables): from string import Template template = Template(string) return template.safe_substitute(variables) class Tab(object): def __init__(self, editor): self.id = None self.pos = [0, 0] self.startline = 0 self.startchar = {} self.selection = [] self.editor = editor self.openDefault() self.rehash() def reset(self): self.lines = [] # @@@@@!@!!:!!:!?!:!"OJ%KP(!UI%! self.linesep = None self.encoding = None def openDefault(self): self.lines = [''] self.linesep = '\n' self.encoding = 'utf-8' def openURI(self, uri): """Open and feed it into the Tab.""" self.reset() self.encoding = self.editor.config.DefaultInputEncoding # Add to these handlers to support a new URI scheme def handleHTTP(self, uri): u = urllib.urlopen(uri) info = u.info() if isinstance(info, list): pass # @@ got some kind of error message elif info.has_key('Content-Type'): contentType = info.get('Content-Type') if 'charset=' in contentType: # @@ Check for a better charset= parsing algorithm charset = contentType[contentType.find('charset=')+8:] self.encoding = charset.strip('"') for line in u: self.append(line) if line.endswith('\n'): self.append('') u.close() return True # @@ def handleDATA, and def handleFTP. FTP upload would be nice too def handleEMANO(self, uri): line = eval(uri[6:]) self.append(line) return True def handleNONE(self, uri): if os.path.isfile(uri): f = open(uri) for line in f: self.append(line) if line.endswith('\n'): self.append('') f.close() elif os.path.isdir(uri): self.editor.menu.browseDirectory(uri) else: self.editor.commwin.set("No such file: %r" % uri) return False return True def unsupported(self, uri): raise Exception("Sorry, this type of URI is not yet supported") # Strip any trailing hashes off of the URI if uri.endswith('#'): uri = uri.rstrip('#') # @@ 1 should be the default message persistence period self.editor.commwin.set('Warning: stripped trailing #', 1) # Get the URI scheme being used r_scheme = re.compile('^([^/:]+):') match = r_scheme.match(uri) if match: scheme = match.group(1).lower() else: scheme = None result = { 'http': handleHTTP, 'emano': handleEMANO, None: handleNONE }.get(scheme, unsupported)(self, uri) if result: self.id = uri # @@ Backup facility should be optional try: backup(uri, self.linesep.join(self.lines)) except UnicodeDecodeError: lines = ('%r' % line for line in self.lines) backup(uri, self.linesep.join(lines)) self.rehash() return True else: return False def append(self, line): if self.linesep is None: if line.endswith('\r\n'): self.linesep = '\r\n' elif line.endswith('\n'): self.linesep = '\n' else: self.linesep = '' line = line.rstrip('\r\n') if self.encoding != 'bytes': try: line = line.decode(self.encoding) except UnicodeDecodeError: # d'oh, pwned byteLines = [] for oldline in self.lines: byteLines.append(oldline.encode(self.encoding)) self.lines = byteLines self.lines.append(line) self.encoding = 'bytes' elif not isinstance(line, str): line = str(line) self.lines.append(line) def getContent(self, lines=None): if lines is None: lines = self.lines if (self.encoding not in (None, 'bytes')): lines = [line.encode(self.encoding) for line in lines] return self.linesep.join(lines) def getStartchar(self, line): return self.startchar.get(line) or 0 def setStartchar(self, line, equals=None, add=None): if equals is not None: self.startchar[line] = equals elif add is not None: self.startchar[line] = add else: raise ValueError("Was expecting another arg") def getTitle(self): if self.id is not None: return self.id if not self.lines: return 'Empty File (0 bytes)' # @@ Should base this value on screensize etc. if len(self.lines[0]) > 35: first = self.lines[0][:35] + '...' else: first = self.lines[0] length = len(self.getContent()) unit = ('byte', 'bytes')[length != 1] if first: return 'New File ("%s"; length: %s %s)' % (first, length, unit) return 'New File (length: %s %s)' % (length, unit) # return '[file of length: %s %s]' % (length, unit) def feedlines(self, lines): self.lines = lines self.rehash() def rehash(self): # @@ use digest rather than hexdigest? # @@ could just use an adler or crc32 checksum too since it's faster linelength = len(self.lines) content = self.getContent() textlength = len(content) if isinstance(content, unicode): content = content.encode('utf-8') hash = md5.new(content).hexdigest() self.hash = (linelength, textlength, hash) def modified(self): """Return boolean True if tab is modified; False otherwise.""" linelength, textlength, hash = self.hash newlinelength = len(self.lines) if linelength != newlinelength: return True content = self.getContent() newtextlength = len(content) if textlength != newtextlength: return True if newtextlength > 1000000: # Don't md5sum particularly large files return True if isinstance(content, unicode): content = content.encode('utf-8') newhash = md5.new(content).hexdigest() return (hash != newhash) def getTopAndBot(self): fore, aft = tuple(self.selection) # Find which of these comes first if fore < aft: top, bot = fore, aft else: top, bot = aft, fore return top, bot def isSelectionLine(self, y): if not self.selection: return False (topy, topx), (boty, botx) = self.getTopAndBot() if (y >= topy) and (y <= boty): return True return False def getSelection(self): if self.selection: (topy, topx), (boty, botx) = self.getTopAndBot() lines = self.lines[topy:boty + 1] if len(lines) > 1: lines[0] = lines[0][topx:] lines[-1] = lines[-1][:botx] else: lines[0] = lines[0][topx:botx] return self.linesep.join(lines) else: return False def deleteSelection(self): if self.selection: (topy, topx), (boty, botx) = self.getTopAndBot() lines = self.lines[topy:boty + 1] replacement = [] replacement.append(lines[0][:topx]) replacement[0] += lines[-1][botx:] self.lines[topy:boty + 1] = replacement return True else: return False def pasteSelection(self, selection, y, x): lines = selection.split(self.linesep) oldFirstLine = self.lines[y] self.lines[y] = oldFirstLine[:x] + lines[0] if len(lines) > 1: xlen = len(lines[-1]) lines[-1] += oldFirstLine[x:] else: xlen = len(self.lines[y]) self.lines[y] += oldFirstLine[x:] self.lines = self.lines[:y + 1] + lines[1:] + self.lines[y + 1:] return y + len(lines[1:]), xlen class Window(object): pass class MainWindow(Window): def __init__(self, editor): self.editor = editor self.typed = False self.mappings = { c.UP: self.moveUp, c.DOWN: self.moveDown, c.RIGHT: self.moveRight, c.LEFT: self.moveLeft, c.DELETE: self.delChar, c.PAGE_UP: self.pageUp, c.PAGE_DOWN: self.pageDown, c.HOME: self.startOfLine, c.END: self.endOfLine, # c.CTRL_UP_cygwin: self.editor.menu.moveLineUp, # c.CTRL_DOWN_cygwin: self.editor.menu.moveLineDown, # c.CTRL_UP_xterm: self.editor.menu.moveLineUp, # c.CTRL_DOWN_xterm: self.editor.menu.moveLineDown, c.CTRL_A: self.editor.menu.selectAll, c.CTRL_C: self.editor.menu.copy, c.CTRL_D: self.selectFrom, c.CTRL_F: self.editor.menu.findDialogue, c.CTRL_G: self.editor.menu.grepDialogue, c.CTRL_H: self.editor.menu.help, c.CTRL_I: self.editor.menu.indentLine, c.CTRL_J: self.editor.menu.justify, c.CTRL_K: self.editor.menu.cutLine, c.RETURN: self.newLine, c.CTRL_N: self.editor.menu.commandMenu, c.CTRL_O: self.editor.menu.openDialogue, c.CTRL_Q: self.editor.menu.quitDialogue, c.CTRL_R: self.editor.menu.quickReference, c.CTRL_S: self.editor.menu.saveAsDialogue, c.CTRL_T: self.editor.menu.chooseTab, c.CTRL_U: self.editor.menu.unindentLine, c.CTRL_V: self.editor.menu.paste, c.CTRL_W: self.editor.menu.closeCurrentTab, c.CTRL_X: self.editor.menu.cut, # c.CTRL_Hash: self.editor.menu.smartComment, c.BACKSPACE: self.deleteChar } def insertChar(self, c): y, x, q, p = self.position() line = self.editor.tab.lines[q] if (x < self.editor.maxx) or (len(line) > self.editor.maxx): if c <= 0xFF: # @@ self.editor.tab.lines[q] = line[:p] + chr(c) + line[p:] else: self.editor.tab.lines[q] = line[:p] + unichr(c) + line[p:] if x >= self.editor.maxx: self.editor.tab.setStartchar(q, add=(self.editor.maxx - 5)) self.drawline(y) # signif order again? can't be self.editor.moveCursor(y, 5) # @@ what if it only goes one over? else: self.drawline(y) self.editor.moveCursor(y, x + 1) else: self.newLine() # # @@ ...reinserting? # self.editor.commwin.set('Reinserting %s...' % c, 1) # self.insertChar(c) # @@ if fill... if c <= 0xFF: text = chr(c) else: text = unichr(c) if (' ' in line) or ('\t' in line): space = max(line.rfind(' '), line.rfind('\t')) + 1 self.editor.tab.lines[q] = line[:space] text = line[space:] + text self.editor.tab.lines[q + 1] = text self.draw() # @@ draw two lines # self.drawline(y) self.editor.moveCursor(y + 1, len(text)) def drawline(self, y, raw=False, currentq=None): # if currentq is None: # currentq = self.position()[2] q = y + self.editor.tab.startline try: line = self.editor.tab.lines[q] except IndexError: line = '' schar = self.editor.tab.getStartchar(q) if schar: line = '<-' + line[schar + 2:] maxx = self.editor.maxx if len(line) < maxx: spaces = ' ' * ((maxx - len(line)) + 1) elif len(line) > maxx: spaces, line = '', line[:maxx - 2] + '->' else: spaces = '' # Replace hi-bytes with question mark mojibake! if self.editor.tab.encoding == 'bytes': r_hibyte = re.compile(r'([\x80-\xff])') line = r_hibyte.sub('?', line) else: # Replace unicode with loonycode (ASCII codepoint substitutions) r_unicode = re.compile(ur'([\u0080-\uFFFF])') def replaceUnicode(m): char = m.group(1) if loonycode.has_key(char): return loonycode[char] return '?' line = r_unicode.sub(replaceUnicode, line) if self.editor.tab.isSelectionLine(q): (topy, topx), (boty, botx) = self.editor.tab.getTopAndBot() if (topy == q) or (boty == q): # this is the line in which the selection fores self.editor.screen.addstr(y, 0, line) if topy == q: # @@ def safe(num, upper=None) if schar: startpos = max(0, topx - schar) else: startpos = topx else: startpos = 0 if boty == q: if schar: endpos = botx - schar else: endpos = botx else: endpos = len(line) self.editor.screen.addstr(y, 0, line + spaces) args = (y, startpos, line[startpos:endpos], curses.A_REVERSE) # try: self.editor.screen.addstr(*args) # finally: print args, startpos, endpos # @@ only draw in mainwin focus mode? else: self.editor.screen.addstr(y, 0, line + spaces) self.editor.screen.addstr(y, 0, line, curses.A_REVERSE) else: self.editor.screen.addstr(y, 0, line + spaces) if not raw: self.editor.screen.refresh() def draw(self): # pos = self.editor.screen.getyx() self.editor.setTabPos() # currentq, currentp = self.editor.readTabPos() currentq, currentp = tuple(self.editor.tab.pos) for y in xrange(self.editor.maxy + 1): self.drawline(y, raw=True, currentq=currentq) self.editor.screen.refresh() # self.editor.moveCursor(*...) self.editor.getTabPos() def position(self): if hasattr(self.editor, 'tab'): y, x = self.editor.screen.getyx() q = y + self.editor.tab.startline p = x + self.editor.tab.getStartchar(q) return y, x, q, p # you have to love the ordering else: return 0, 0, 0, 0 # @@! def moveTo(self, q, p): tablen = len(self.editor.tab.lines) - 1 if (q < 0) or (q > tablen): raise ValueError("q is not valid") if (p < 0) or (p > len(self.editor.tab.lines[q])): raise ValueError("p is not valid") currentq = self.editor.mainwin.position()[2] def visibleOnScreen(q, p, currentq=currentq): screentop = self.editor.tab.startline screenbottom = min(screentop + self.editor.maxy, tablen) mapping = {} # @@ should be able to just compile for the line we're inspecting for linepos in xrange(screentop, screenbottom): schar = self.editor.tab.getStartchar(linepos) linelen = len(self.editor.tab.lines[linepos]) epos = min(linelen - schar, self.editor.maxx + linelen) mapping[linepos] = (schar, epos) if mapping.has_key(q): spos, epos = mapping[q] if (p <= spos) and (p >= epos): return True return False if visibleOnScreen(q, p): y = q - self.editor.tab.startline x = p - self.editor.tab.getStartchar(q) self.editor.moveCursor(y, x) self.draw() return schar = self.editor.tab.getStartchar(q) if schar: p -= schar if ((q > self.editor.tab.startline) and (q < self.editor.tab.startline + self.editor.maxy - 2)): self.draw() self.editor.moveCursor(q - self.editor.tab.startline, p) elif q >= self.editor.maxy: self.editor.tab.startline = q self.editor.moveCursor(0, p) self.draw() # @@ order significant else: self.editor.tab.startline = 0 self.editor.moveCursor(q, p) self.draw() # @@ order significant def pageUp(self): for i in xrange(24): # @@ for now self.moveUp() def pageDown(self): for i in xrange(24): # @@ for now self.moveDown() def moveUp(self): # @@ document this y, x, q, p = self.position() if q: # not the very first line of the tab if self.editor.tab.getStartchar(q): self.editor.tab.setStartchar(q, 0) # @@! self.drawline(y) if (self.editor.tab.startline < 1) or y: linelen = len(self.editor.tab.lines[q - 1]) nx = min(p, linelen, self.editor.maxx) self.editor.moveCursor(y - 1, nx) else: self.editor.tab.startline -= 1 self.draw() linelen = len(self.editor.tab.lines[q - 1]) nx = min(p, linelen, self.editor.maxx) self.editor.moveCursor(y, nx) def moveDown(self): # @@ document this y, x, q, p = self.position() if q < (len(self.editor.tab.lines) - 1): if self.editor.tab.getStartchar(q): # 1) only if we can move down; b) to some sane value self.editor.tab.setStartchar(q, 0) # @@! self.drawline(y) if q < (len(self.editor.tab.lines) - 1) and y < self.editor.maxy: linelen = len(self.editor.tab.lines[q + 1]) nx = min(p, linelen, self.editor.maxx) self.editor.moveCursor(y + 1, nx) elif q < (len(self.editor.tab.lines) - 1) and y == self.editor.maxy: self.editor.tab.startline += 1 self.draw() # @@ why? moving off bottom of screen? linelen = len(self.editor.tab.lines[q + 1]) nx = min(p, linelen, self.editor.maxx) self.editor.moveCursor(y, nx) def moveLeft(self): # @@ document this y, x, q, p = self.position() schar = self.editor.tab.getStartchar(q) if x: self.editor.moveCursor(y, x - 1) elif schar: start = max(0, schar - self.editor.maxx - 5) self.editor.tab.setStartchar(q, start) self.drawline(y) self.editor.moveCursor(y, min(p - 1, self.editor.maxx)) # elif y: # linelength = len(self.editor.tab.lines[q - 1]) # self.editor.moveCursor(y - 1, min(self.editor.maxx, linelength)) # elif self.editor.tab.startline: # # if at screen(0, 0) and there's a line above... elif (y or self.editor.tab.startline): self.moveUp() self.endOfLine() def moveRight(self): # @@ document this y, x, q, p = self.position() schar = self.editor.tab.getStartchar(q) # Moving off the right hand side of the non-last line, onto the next line if (y < self.editor.maxy and p >= len(self.editor.tab.lines[q]) and q < (len(self.editor.tab.lines) - 1)): if schar: self.editor.tab.setStartchar(q, 0) # @@! self.drawline(y) self.editor.moveCursor(y + 1, 0) # Moving into a continuation zone elif (x >= self.editor.maxx and len(self.editor.tab.lines[q]) > self.editor.maxx): self.editor.tab.setStartchar(q, add=(self.editor.maxx - 5)) self.drawline(y) self.editor.moveCursor(y, 5) # @@ what if it only goes one over? return True # Moving off the right hand side of the last line elif (q >= (len(self.editor.tab.lines) - 1) and p >= len(self.editor.tab.lines[q])): return False # order significant! # Moving off the right hand side of the last screen line elif (y >= self.editor.maxy and p >= len(self.editor.tab.lines[q]) and q < (len(self.editor.tab.lines) - 1)): if schar: self.editor.tab.setStartchar(q, 0) # @@! self.drawline(y) self.moveDown() self.startOfLine() # Everything else else: self.editor.moveCursor(y, x + 1) return True def startOfLine(self): y, x, q, p = self.position() if self.editor.tab.getStartchar(q): # @@ make setStartchar del if the equals arg is 0 self.editor.tab.setStartchar(q, 0) self.drawline(y) self.editor.moveCursor(y, 0) def endOfLine(self): y, x, q, p = self.position() linelen = len(self.editor.tab.lines[q]) if linelen > self.editor.maxx: self.editor.tab.setStartchar(q, linelen - 15) nx = 15 self.drawline(y) # significant order! # self.editor.moveCursor(y, nx) else: nx = linelen # self.editor.moveCursor(y, nx) self.editor.moveCursor(y, nx) def newLine(self): # @@ betterise this so that it only redraws # as much as it needs to y, x, q, p = self.position() if self.editor.tab.getStartchar(q): self.editor.tab.setStartchar(q, 0) # @@! self.drawline(y) line = self.editor.tab.lines[q] self.editor.tab.lines[q] = line[:p] rest = line[p:] self.editor.tab.lines.insert(q + 1, rest) if y < self.editor.maxy: self.draw() self.editor.moveCursor(y + 1, 0) else: self.editor.tab.startline += 1 self.draw() self.editor.moveCursor(y, 0) def delChar(self): moved = self.moveRight() if moved: self.deleteChar() def deleteChar(self): y, x, q, p = self.position() schar = self.editor.tab.getStartchar(q) if schar and (x == 0): line = self.editor.tab.lines[q] self.editor.tab.lines[q] = line[:(p - 1)] + line[p:] start = max(0, schar - self.editor.maxx - 5) self.editor.tab.setStartchar(q, start) self.drawline(y) self.editor.moveCursor(y, min(p - 1, self.editor.maxx)) # Delete across the beginning of a line elif (x == 0) and (y > 0): line = self.editor.tab.lines[q] prev = self.editor.tab.lines[q - 1] self.editor.tab.lines[q - 1] += line del self.editor.tab.lines[q] if len(prev) > self.editor.maxx: self.editor.tab.setStartchar(q - 1, len(prev) - 5) nx = 5 else: nx = len(prev) self.draw() self.editor.moveCursor(y - 1, nx) elif (x == 0) and (y == 0) and self.editor.tab.startline: # @@ note the minimal differences to the clause above line = self.editor.tab.lines[q] prev = self.editor.tab.lines[q - 1] self.editor.tab.lines[q - 1] += line del self.editor.tab.lines[q] if len(prev) > self.editor.maxx: self.editor.tab.setStartchar(q - 1, len(prev) - 5) nx = 5 else: nx = len(prev) self.editor.tab.startline -= 1 self.draw() self.editor.moveCursor(y, nx) # Delete everywhere else but the top left of the *screen* @@! elif y or x: line = self.editor.tab.lines[q] self.editor.tab.lines[q] = line[:(p - 1)] + line[p:] # self.drawline(y) # @@ grumble: @@ why is this? because of deleting across newlines? # @@ hehe, mini-conversation with self. like Morbus self.draw() self.editor.moveCursor(y, x - 1) def selectFrom(self): # Ctrl+D to start selection of a region if not self.editor.tab.selection: y, x, q, p = self.position() self.editor.tab.selection = [[q, p], [q, p]] else: self.selectTo(warn=True) def selectTo(self, warn=False): self.editor.tab.selection = [] self.draw() if warn: self.editor.commwin.set('Cancelled selection...') def updateSelection(self): y, x, q, p = self.position() if y < (self.editor.maxy + 1): self.editor.tab.selection[1] = [q, p] self.draw() # @@ only draw in mainwin focus mode? def getEvent(self, e): if self.mappings.has_key(e): self.mappings[e]() result = True else: result = False if self.editor.tab.selection: self.updateSelection() if (self.editor.focus is self.editor.mainwin): self.editor.commwin.default() return result def event(self, e): self.typed = True self.event = self.getEvent return self.event(e) class CommandWindow(Window): def __init__(self, editor): self.editor = editor self.mappings = { c.RIGHT: self.moveRight, c.LEFT: self.moveLeft, c.DELETE: self.delChar, c.HOME: self.startOfLine, c.END: self.endOfLine, c.CTRL_C: self.cancel, # Ctrl+C c.RETURN: self.collectInput, # Return c.BACKSPACE: self.deleteChar } self.template = ('Emano $modchar> $uri$space ' + 'Line $line, Pos $pos $codepoint(Tab $tab/$tabs)') self.current = '' # Draw self onto editor.screen self.default() self.status = '@@ use this' # @@ heh self.stream = False self.input = '' self.inputstart = 0 # @@! def default(self, augment=None): if hasattr(self, 'persistfor') and self.persistfor > 0: self.persistfor -= 1 return # Set to the default status if self.editor.tabs and self.editor.tab.selection: def coord((y, x)): return '(Line %s Pos %s)' % (y + 1, x + 1) start, finish = tuple(map(coord, self.editor.tab.selection)) self.set('Selected region: %s to %s' % (start, finish)) return # Get the current screen position y, x, q, p = self.editor.mainwin.position() ny, nx = q + 1, p + 1 if len(self.editor.tabs) and self.editor.tab.modified(): modchar = '-' else: modchar = '=' if not ((len(self.editor.tabs) > 1) or (self.editor.tabs and self.editor.tab.id)): help = 'Help? Ctrl+H ' else: help = '' if len(self.editor.tabs) > 1: tab = self.editor.tabs.index(self.editor.tab) + 1 tabs = len(self.editor.tabs) else: tab, tabs = 1, 1 codepoint = '' if len(self.editor.tabs): try: char = self.editor.tab.lines[q][p] except IndexError: pass else: cp = ord(char) if isinstance(char, unicode): if cp > 0x7F: codepoint = '{U+%04X} ' % cp elif cp > 0x7F: codepoint = r'{\x%02X} ' % cp variables = {'modchar': modchar, 'line': ny, 'pos': nx, 'help': help, 'tab': tab, 'tabs': tabs, 'codepoint': codepoint} template = format(self.template, variables) if augment is not None: template += augment if '$uri' in template: # if hasattr(self.editor, 'tab'): # tablen = len(self.editor.tab.getContent()) # else: tablen = 0 if self.editor.tabs and self.editor.tab.id: uri = self.editor.tab.id # elif not ((len(self.editor.tabs) > 1) or # (self.editor.tabs and self.editor.tab.id)): elif not self.editor.mainwin.typed: uri = 'Unsaved File. (Help? Ctrl+H)' else: uri = 'Unsaved File' length = len(template) if '$space' in template: length -= 6 # i.e. '$space' -> '' i = self.editor.maxx + 3 - length if len(uri) > i: # The awesome sliding filename code uri = uri[:7] + '[...]' + uri[- i + 12:] template = template.replace('$uri', uri) if '$space' in template: if (len(template) - 6) < self.editor.maxx: space = ' ' * (self.editor.maxx - len(template) + 5) else: space = '' template = template.replace('$space', space) # @@ Ctrl+C+R+P+B+T self.set(template) # @@ msg def set(self, msg=None, n=None): if msg is None: msg = '' if not msg.startswith(' '): msg = ' ' + msg length = len(msg) if n: self.persistfor = n y, x = self.editor.screen.getyx() maxy, maxx = self.editor.maxy, self.editor.maxx if len(msg) < maxx: msg += ' ' * (maxx - len(msg)) # + 1 else: msg = msg[:maxx] # @@ warn? self.current = msg # Hack to make curses print to the bottom right hand corner # Cf. http://swhack.com/logs/2005-03-06#T01-37-04 self.editor.screen.addch(maxy + 1, maxx - 1, ' ', curses.A_REVERSE) self.editor.screen.insch(maxy + 1, maxx - 1, ' ', curses.A_REVERSE) self.editor.screen.addstr(maxy + 1, 0, msg, curses.A_REVERSE) self.editor.screen.refresh() self.editor.moveCursor(y, x) # @@ hmm? why? return length def insertChar(self, c): y, x = self.editor.screen.getyx() Y = y + self.editor.tab.startline # oil refinery ahead... @@ document this better if x >= (self.editor.maxx - 1): return X = x - self.inputstart self.input = (self.input[:X] + chr(c) + self.input[X:]) self.input = self.input[:self.editor.maxx - self.inputstart] # @@ is this really needed here? s = ' ' * (self.editor.maxx - len(self.input) - self.inputstart) self.editor.screen.addstr(self.editor.maxy + 1, self.inputstart, self.input + s, curses.A_REVERSE) self.editor.screen.refresh() self.editor.moveCursor(self.editor.maxy + 1, x + 1) if self.stream: self.dispatch(self.input) def moveLeft(self): y, x = self.editor.screen.getyx() if x <= self.inputstart: return self.editor.moveCursor(y, x - 1) def moveRight(self, n=None): y, x = self.editor.screen.getyx() if ((x >= self.inputstart + len(self.input)) or (x >= self.editor.maxx - 1)): return False self.editor.moveCursor(y, x + 1) return True def startOfLine(self): y, x = self.editor.screen.getyx() self.editor.moveCursor(y, self.inputstart) def endOfLine(self): y, x = self.editor.screen.getyx() self.editor.moveCursor(y, self.inputstart + len(self.input)) def delChar(self): moved = self.moveRight() if moved: self.deleteChar() def deleteChar(self): y, x = self.editor.screen.getyx() i = 1 if x <= self.inputstart: return X = x - self.inputstart self.input = self.input[:(X - i)] + self.input[X:] self.input = self.input[:self.editor.maxx - self.inputstart] s = ' ' * (self.editor.maxx - len(self.input) - self.inputstart) self.editor.screen.addstr(self.editor.maxy + 1, self.inputstart, self.input + s, curses.A_REVERSE) self.editor.screen.refresh() self.editor.moveCursor(self.editor.maxy + 1, x - i) if self.stream: self.dispatch(self.input) def dialogue(self, msg, method, stream=False): length = self.set(msg) # Give this command window focus self.editor.setFocus(self) # self.editor.commwin # @@ what is this for? # self.bufpos = self.editor.getPos() # @@! # @@! When focussing to and fro, place should be autoset! # @@ check for the msg being too big for the status self.editor.moveCursor(self.editor.maxy + 1, length) self.input = '' self.inputstart = length self.dispatch = method self.stream = stream def setInput(self, input): self.input = input s = ' ' * (self.editor.maxx - len(self.input) - self.inputstart) self.editor.screen.addstr(self.editor.maxy + 1, self.inputstart, self.input + s, curses.A_REVERSE) self.editor.screen.refresh() self.editor.moveCursor(self.editor.maxy + 1, self.inputstart + len(self.input)) def collectInput(self): input = self.input self.input = '' self.inputstart = None # @@ bleargh if hasattr(self, 'returnFocus') and self.returnFocus: self.editor.setFocus(self.returnFocus) self.returnFocus = False else: self.editor.setFocus(self.editor.mainwin) # self.editor.moveCursor(*tuple(self.bufpos[:2])) # @@! if not self.stream: self.dispatch(input) else: self.stream = False self.dispatch = False def cancel(self): self.input = '' self.inputstart = None self.editor.setFocus(self.editor.mainwin) self.set('Cancelled...') # self.editor.moveCursor(*tuple(self.bufpos[:2])) # @@! def refresh(self, augment=None): if augment: msg = self.current[:-len(augment)] + augment else: msg = self.current self.set(msg) def event(self, e): if self.mappings.has_key(e): self.mappings[e]() return True else: return False class ListWindow(Window): def __init__(self, editor): self.editor = editor self.mappings = { c.UP: self.moveUp, c.DOWN: self.moveDown, c.LEFT: self.moveLeft, c.RIGHT: self.moveRight, c.PAGE_UP: self.pageUp, c.PAGE_DOWN: self.pageDown, c.CTRL_H: self.editor.menu.help, # Ctrl+H c.RETURN: self.select, # c.CTRL_C: self.editor.menu.exit, # @@ temporary c.CTRL_W: self.close, c.BACKSPACE: self.backspace, c.DELETE: self.delete } self.startline = 0 self.pos = 0 self.items = [] self.filter = '' # self.reset() def reset(self): self.startline = 0 self.pos = 0 self.items = [] self.filter = '' self.editor.commwin.set('Filter: ') def position(self): y, x = self.editor.screen.getyx() z = y + self.startline return y, x, z def append(self, name, value, default=False): if not isinstance(name, basestring): self.editor.commwin.set('Error: %r is not a string' % name) return # if not name.startswith(' * '): # was: '* ' # name = ' * ' + name if default is True: self.pos = len(self.items) self.items.append((name, value)) # # @@ might be better to use an incrementer for speed # # self.manifest.append(len(self.items) - 1) def getItems(self): for (name, value) in self.items: if (not self.filter) or (self.filter in name): yield (name, value) def length(self): i = 0 for item in self.getItems(): i += 1 return i def get(self, i): import itertools items = itertools.islice(self.getItems(), i, sys.maxint) try: return items.next() except StopIteration: raise Exception("Asking for: %s of %s" % (i, self.length())) def draw(self, force=False): py, px = self.editor.screen.getyx() if force or ((py > self.editor.maxy) and hasattr(self, 'hicache')): highlight = self.hicache else: highlight = py # if the line has moved, move the focus to it # if the line is off the screen, redraw # if the line has gone, go to the nearest screen line if self.length() < self.editor.maxy: self.startline = 0 import itertools start, finish = self.startline, self.startline + self.editor.maxy + 1 items = itertools.islice(self.getItems(), start, finish) items = list(items) if hasattr(self, 'highlight') and py > self.editor.maxy: for n, item in enumerate(items): if item == self.highlight: highlight = n highlight = min(len(items) - 1, highlight) highlight = max(0, highlight) # if there are no items if len(items) < (finish - self.startline): items += [('', None)] * (finish - self.startline - len(items)) for y, item in enumerate(items): try: line, value = item except: raise ValueError("%s" % item) if y == highlight: self.highlight = (line, value) if len(line) < self.editor.maxx: spaces = ' ' * ((self.editor.maxx - len(line)) + 1) else: spaces, line = '', line[:self.editor.maxx] if (y == highlight) and (py > self.editor.maxy): self.editor.screen.addstr(y, 0, line + spaces, curses.A_REVERSE) elif y == highlight: first, rest = (line + spaces)[:1], (line + spaces)[1:] self.editor.screen.addstr(y, 0, first) self.editor.screen.addstr(y, 1, rest, curses.A_REVERSE) else: self.editor.screen.addstr(y, 0, line + spaces) self.hicache = highlight self.editor.screen.refresh() if py > self.editor.maxy: self.editor.moveCursor(py, px) else: self.editor.moveCursor(highlight, px) def collectFilter(self): # @@ The persistence of line marking stuff in self.draw def updateFilter(text): self.filter = text self.draw() # @@ pass the return? # Change the listeners for (page) up/down in the CommandWindow import copy mappings = copy.copy(self.editor.commwin.mappings) def mk(callback): def collectAndCall(self=self, callback=callback, mappings=mappings): self.editor.commwin.mappings = mappings self.editor.commwin.collectInput() self.draw(force=True) # i.e. self.editor.listwin.draw() callback() return collectAndCall newmappings = { c.UP: self.moveUp, c.DOWN: self.moveDown, c.PAGE_UP: self.pageUp, c.PAGE_DOWN: self.pageDown, c.RETURN: self.select, c.CTRL_W: self.close } for (event, method) in newmappings.iteritems(): self.editor.commwin.mappings[event] = mk(method) self.editor.commwin.returnFocus = self self.editor.commwin.dialogue('Filter: ', updateFilter, stream=True) self.editor.commwin.input = self.filter # @@ move up/down? py, px = self.editor.screen.getyx() self.editor.moveCursor(self.editor.maxy + 1, px + len(self.filter)) self.draw() # to cover up that leading space def insertChar(self, char): self.collectFilter() self.editor.commwin.insertChar(char) def backspace(self): self.collectFilter() self.editor.commwin.deleteChar() def delete(self): self.collectFilter() self.editor.commwin.delChar() def moveLeft(self): self.collectFilter() self.editor.commwin.moveLeft() def moveRight(self): self.collectFilter() self.editor.commwin.moveRight() def pageUp(self): for i in xrange(24): # @@ for now self.moveUp() def pageDown(self): for i in xrange(24): # @@ for now self.moveDown() def moveUp(self): # @@ document this y, x, z = self.position() if y > 0: self.editor.moveCursor(y - 1, x) self.draw() elif self.startline > 0: self.startline -= 1 self.editor.moveCursor(y, x) self.draw() # @@ why? moving off top of screen? def moveDown(self): # @@ document this y, x, z = self.position() if z < (self.length() - 1) and y < self.editor.maxy: self.editor.moveCursor(y + 1, x) self.draw() elif z < (self.length() - 1) and y == self.editor.maxy: self.startline += 1 self.editor.moveCursor(y, x) self.draw() # @@ why? moving off bottom of screen? def select(self): y, x, z = self.position() func, args = self.get(z)[1] if args is None: args = tuple() self.reset() self.editor.setFocus(self.editor.mainwin) self.editor.mainwin.draw() func(*args) def close(self): self.reset() self.editor.setFocus(self.editor.mainwin) self.editor.mainwin.draw() self.editor.commwin.default() def event(self, e): # @@ Are these dependent on term setting? if self.mappings.has_key(e): self.mappings[e]() return True else: return False class Menu(object): commands = textwrap.dedent("""\ Navigation Arrow Keys = Move the cursor Page Up/Down = Move up or down twenty lines Tabs and Files Ctrl+O = Open a file Ctrl+S = Save as Ctrl+Q = Quit all tabs Ctrl+W = Close tab (e.g. this one) Ctrl+T = Select tab from a list Clipboard Ctrl+C = Start selection / copy selection Ctrl+X = Start selection / cut selection Ctrl+K = Cut line and append to clipboard Ctrl+V = Paste Ctrl+A = Select all Formatting Ctrl+I = Indent a line Ctrl+U = Unindent a line Ctrl+J = Justify a paragraph or selection Other Ctrl+F = Find string Ctrl+G = Grep (search for a regexp) Ctrl+D = Start selection Ctrl+H = Open help file Ctrl+R = Quick reference Ctrl+N = List commands """) def __init__(self, editor): self.editor = editor def dialogue(self, *args): self.editor.commwin.dialogue(*args) def openDialogue(self): self.dialogue('Open file: ', self.open) def open(self, uri): # @@ use a better variable name than "id" self.editor.commwin.set('Opening %s...' % uri) tab = self.editor.newTab() result = tab.openURI(uri) if result: self.editor.setTab(tab) self.editor.mainwin.draw() self.editor.moveCursor(0, 0) self.editor.commwin.set('Editing %s' % uri) # @@ Should be config for this history = os.path.join(dotemano, 'history') f = open(history, 'a') print >> f, uri f.close() def openLines(self, lines, linesep=None): # @@ use a better variable name than "id" self.editor.commwin.set('Opening %s lines...' % len(lines)) tab = self.editor.newTab() # @@ Should be a method for this tab.reset() tab.encoding = self.editor.config.DefaultInputEncoding if linesep is not None: tab.linesep = linesep for line in lines: tab.append(line) tab.rehash() self.editor.setTab(tab) self.editor.mainwin.draw() self.editor.moveCursor(0, 0) self.editor.commwin.set('Editing %s lines' % len(lines)) def reloadTab(self): # @@ This will lose any changes uri = self.editor.tab.id self.editor.tab.openURI(uri) self.editor.mainwin.draw() self.editor.moveCursor(0, 0) # @@ Some kind of status message def history(self): history = os.path.join(dotemano, 'history') if os.path.isfile(history): self.editor.listwin.reset() f = open(history) for line in f: line = line.rstrip('\r\n') self.editor.listwin.append(' * ' + line, (self.open, (line,))) f.close() # @@ Should put this is listwin.activate or something if not self.editor.listwin.items: self.editor.listwin.reset() self.editor.commwin.set('Sorry, no results found.') else: self.editor.setFocus(self.editor.listwin) self.editor.listwin.draw() else: self.editor.commwin.set('Error: No such file: %s' % history) def saveAsDialogue(self): self.dialogue('Save as: ', self.saveAs) if self.editor.tab.id: self.editor.commwin.setInput(self.editor.tab.id) def saveAs(self, uri): if not uri: self.editor.commwin.set('Error: invalid filename') elif uri.endswith('#'): # This is annoying in nano self.editor.commwin.set("Error: I don't save filenames with #") elif uri.startswith('http://'): # @@ HTTP PUT # self.editor.commwin.set("Error: can't yet save over HTTP") import httplib, urlparse scheme, host, path, params, query, frag = urlparse.urlparse(uri) body = self.editor.tab.getContent() h = httplib.HTTPConnection(host) h.request("PUT", path, body) response = h.getresponse() msg = 'Got: %s %s from %s (200, 201, or 204 indicate success)' msg = msg % (response.status, response.reason or '-', host) self.editor.commwin.set(msg) if int(response.status) in (200, 201, 204): self.editor.tab.rehash() # elif os.path.exists(uri): # at crs's insistance! # self.editor.commwin.set("Sorry: I won't overwrite extant files") elif uri.startswith('emano:'): attribute = uri[6:] i = attribute.rfind('.') content = self.editor.tab.getContent() content = content.rstrip('\n') # @@! where's that added? setattr(eval(attribute[:i]), attribute[i+1:], content) self.editor.commwin.set('Successfully wrote to %s' % uri) else: f = open(uri, 'w') f.write(self.editor.tab.getContent()) f.close() self.editor.tab.id = uri self.editor.tab.rehash() self.editor.commwin.set('Successfully wrote to %s' % uri) def quitDialogue(self): mods = 0 buflen = len(self.editor.tabs) this = self.editor.tab.modified() for tab in self.editor.tabs: if tab.modified(): mods += 1 if mods: if mods == 1: if this: msg = 'File modified: ' else: msg = '1 of %s files modified: ' % buflen else: msg = '%s of %s files modified: ' % (mods, buflen) self.dialogue(msg + 'really quit? [Y/n]: ', self.quit) else: self.exit() def quit(self, input): input = input.lower() if (not input) or input.startswith('y'): self.exit() else: self.editor.commwin.set('Cancelled quit...') def exit(self): sys.exit(0) def newTab(self): tab = self.editor.newTab() # Set the active tab to the new tab self.editor.setTab(tab) # The following two must be in that order! self.editor.getTabPos() # @@ move? self.editor.mainwin.draw() def closeCurrentTabDialogue(self): pass # @@ def closeCurrentTab(self): self.closeTab(self.editor.tab) def closeTab(self, tab): if not tab in self.editor.tabs: self.editor.commwin.set('Error: tab not found') return if len(self.editor.tabs) == 1: msg = 'There is only one tab open. Ctrl+Q to quit.' self.editor.commwin.set(msg, 1) return i = self.editor.tabs.index(tab) self.editor.tabs.remove(tab) if len(self.editor.tabs): newtab = self.editor.tabs[max(0, i - 1)] self.editor.setTab(newtab) # The following two must be used in that order self.editor.getTabPos() # @@ move? self.editor.mainwin.draw() else: self.newTab() def selectAll(self): # Put [[0, 0], [len, llinelen]] in selected region eline = len(self.editor.tab.lines) - 1 elinelen = len(self.editor.tab.lines[eline]) self.editor.tab.selection = [[0, 0], [eline, elinelen]] # move to it self.editor.mainwin.moveTo(eline, elinelen) def selectBlock(self): # @@ doesn't work if the last line is non empty # and it tries to select the document-as-block y, x, q, p = self.editor.mainwin.position() upper, lower = 0, len(self.editor.tab.lines) - 1 for pos in xrange(q - 1, 0 - 1, -1): if not self.editor.tab.lines[pos].strip(' \t\r\n'): upper = pos + 1 break for pos in xrange(q + 1, len(self.editor.tab.lines)): if not self.editor.tab.lines[pos].strip(' \t\r\n'): lower = pos break self.editor.tab.selection = [[upper, 0], [lower, 0]] self.editor.mainwin.moveTo(lower, 0) self.editor.mainwin.updateSelection() def justify(self): if not self.editor.tab.selection: self.selectBlock() text = self.cutText() # @@ justify the text here r_whitespace = re.compile(r'[ \t\r\n]+') text = r_whitespace.sub(' ', text.strip(' \t\r\n')) text = '\n'.join(textwrap.wrap(text, self.editor.maxx - 1)) + '\n' self.paste(text) # ET VOILA def cutText(self): clipcache = self.editor.clipboard self.cut() text = self.editor.clipboard self.editor.clipboard = clipcache return text def copy(self): if not self.editor.tab.selection: y, x, q, p = self.editor.mainwin.position() self.editor.tab.selection = [[q, p], [q, p]] return self.editor.clipboard = self.editor.tab.getSelection() self.editor.mainwin.selectTo() length = len(self.editor.clipboard) # @@ nice-formatting on the numbers self.editor.commwin.set('Copied %s bytes to clipboard' % length) self.editor.tab.prevcommand = ('copy',) def selectFrom(self): y, x, q, p = self.editor.mainwin.position() self.editor.tab.selection = [[q, p], [q, p]] def cut(self, append=False): if not self.editor.tab.selection: self.selectFrom() return if append: self.editor.clipboard += self.editor.tab.getSelection() else: self.editor.clipboard = self.editor.tab.getSelection() self.editor.tab.deleteSelection() top, bot = self.editor.tab.getTopAndBot() (topq, topp) = top if topp > self.editor.maxx: self.editor.tab.setStartchar(topq, self.editor.maxx) # @@ might still need more else: self.editor.tab.setStartchar(topq, 0) self.editor.mainwin.moveTo(*top) self.editor.mainwin.selectTo() length = len(self.editor.clipboard) # @@ nice-formatting on the numbers self.editor.commwin.set('Cut %s bytes to clipboard' % length) self.editor.tab.prevcommand = ('cut',) def cutLine(self): y, x, q, p = self.editor.mainwin.position() self.editor.mainwin.startOfLine() self.editor.mainwin.selectFrom() self.editor.mainwin.endOfLine() moved = self.editor.mainwin.moveRight() self.editor.mainwin.updateSelection() if (hasattr(self.editor.tab, 'prevcommand') and self.editor.tab.prevcommand == ('cutLine', q)): self.cut(append=True) else: self.cut() self.editor.tab.prevcommand = ('cutLine', q) return moved def moveLineUp(self): # @@ doesn't work on the last line... # If we're selecting stuff, don't invoke this function # Rationale being that people might be on the ctrl button still if self.editor.tab.selection: return x = self.editor.mainwin.position()[1] moved = self.cutLine() self.editor.mainwin.moveUp() self.editor.mainwin.startOfLine() self.paste() if moved: self.editor.mainwin.moveUp() y = self.editor.mainwin.position()[0] self.editor.moveCursor(y, x) def moveLineDown(self): # @@ does this work on the first (e.g. empty) line? test # If we're selecting stuff, don't invoke this function # Rationale being that people might be on the ctrl button still if self.editor.tab.selection: return y, x, q, p = self.editor.mainwin.position() # @@ tab.isLastLine if q >= (len(self.editor.tab.lines) - 1): return self.cutLine() self.editor.mainwin.moveDown() self.editor.mainwin.startOfLine() self.paste() self.editor.mainwin.moveUp() ny = self.editor.mainwin.position()[0] self.editor.moveCursor(ny, x) def paste(self, text=None): if text is None: clipboard = self.editor.clipboard else: clipboard = text if not clipboard: self.editor.commwin.set('Error: nothing to paste', 1) return length = len(clipboard) y, x, q, p = self.editor.mainwin.position() (nq, np) = self.editor.tab.pasteSelection(clipboard, q, p) if q != nq: # ooh, if only it were p/np... # if self.editor.tab.getStartchar(q): self.editor.tab.setStartchar(q, 0) self.editor.mainwin.moveTo(nq, np) # self.editor.mainwin.draw() # self.editor.moveCursor(ny - self.editor.tab.startline, # min(nx, self.editor.maxx)) self.editor.commwin.set('Pasted %s bytes from clipboard' % length) self.editor.tab.prevcommand = ('paste') def findDialogue(self): self.dialogue('Find: ', self.find) def find(self, pattern): try: regexp = re.compile(pattern) except: regexp = None else: self.editor.commwin.set('Finding pattern: %r' % regexp.pattern) # except sre_constants.error: pass if regexp: def search(line, start=0): m = regexp.search(line[start:]) if m: return start + m.start() return -1 else: def search(line, start=0): m = line[start:].find(pattern) if m < 0: return -1 return start + m y, x, q, p = self.editor.mainwin.position() for i in xrange(q, len(self.editor.tab.lines)): line = self.editor.tab.lines[i] if i == q: start = x + 1 else: start = 0 m = search(line, start=start) if m < 0: continue if i < (self.editor.maxy - 3): self.editor.tab.startline = 0 self.editor.mainwin.draw() self.editor.moveCursor(i, m) else: self.editor.mainwin.moveTo(i, m) break else: for i in xrange(0, q + 1): line = self.editor.tab.lines[i] if i == q: line = line[:x] else: start = 0 m = search(line, start=start) if m < 0: continue if i < (self.editor.maxy - 3): self.editor.tab.startline = 0 self.editor.mainwin.draw() self.editor.moveCursor(i, m) else: self.editor.mainwin.moveTo(i, m) break def grepDialogue(self): self.dialogue('Grep: ', self.grep) def grep(self, pattern): # @@ Only supports valid regexps for now try: regexp = re.compile(pattern) except Exception, e: self.editor.commwin.set('Invalid regexp: %r, %s' % (pattern, e), 1) return else: self.editor.commwin.set('Grepping pattern: %r' % regexp.pattern) def search(line, start=0): m = regexp.search(line[start:]) if m: return start + m.start() return -1 self.editor.listwin.reset() moveTo = self.editor.mainwin.moveTo y, x, q, p = self.editor.mainwin.position() # for i in xrange(q, len(self.editor.tab.lines)): for i in xrange(0, len(self.editor.tab.lines)): line = self.editor.tab.lines[i] pos = search(line) if pos < 0: continue self.editor.listwin.append(line, (moveTo, (i, pos))) if not self.editor.listwin.items: self.editor.listwin.reset() self.editor.commwin.set('Sorry, no results found.') else: self.editor.setFocus(self.editor.listwin) self.editor.listwin.draw() def chooseTab(self): # Public stuff: tab # Private stuff: tab if not self.editor.tabs: self.editor.commwin.set('Error: No tabs to choose from') return self.editor.listwin.reset() titles = {} for tab in self.editor.tabs: title = tab.getTitle() if titles.has_key(title): titles[title] += 1 title += ' [%s]' % titles[title] else: titles[title] = 1 def setTab(tab=tab): self.editor.setTab(tab) self.editor.getTabPos() # @@ self.editor.mainwin.draw() self.editor.commwin.default() args = (' * ' + title, (setTab, None)) kargs = {'default': (tab is self.editor.tab)} self.editor.listwin.append(*args, **kargs) self.editor.setFocus(self.editor.listwin) self.editor.listwin.draw() def commandMenu(self): import inspect # self.editor.commwin.set('Creating command menu') # @ hmm? useful how? self.editor.listwin.reset() for methodname in dir(self): if methodname.startswith('_'): continue method = getattr(self, methodname) # is __call__-sniffing really necessary? if hasattr(method, 'func_code') and hasattr(method, '__call__'): args, varargs, varkw = inspect.getargs(method.func_code) if hasattr(method, '__doc__') and method.__doc__: methodname += ' ' + method.__doc__ if len(args) == 1: self.editor.listwin.append(' * ' + methodname, (method, None)) if self.editor.listwin.items: self.editor.setFocus(self.editor.listwin) self.editor.listwin.draw() else: # This shouldn't happen self.editor.listwin.reset() self.editor.commwin.set('Error: there were no commands!', 1) return def smartComment(self): # @@ smartComment regions # @@ can this handle comments bigger than one in length? # comment = '#' comment = self.editor.config.CommentString y, x, q, p = self.editor.mainwin.position() line = self.editor.tab.lines[q] # Check to see if there's a comment in the line above pos = None r_ws = re.compile(r'^[ \t]*') if q: # If there's a line above: lineabove = self.editor.tab.lines[q - 1] epos = r_ws.match(lineabove).end() pos = lineabove.find(comment) if pos < 0: pos = None if pos != epos: pos = None # If there is a comment above, put the new one in the same place # Otherwise, use the end of the whitespace: if pos is None: pos = r_ws.match(line).end() # self.editor.commwin.set('Pos is: %s' % pos, 1) # @@ DEBUG self.editor.tab.lines[q] = line[:pos] + comment + ' ' + line[pos:] self.editor.mainwin.draw() nq = min(q + len(comment + ' '), self.editor.maxx) self.editor.moveCursor(y, nq) def browseDirectoryDialogue(self): self.dialogue('Browse directory: ', self.browseDirectory) def browseDirectory(self, directory): if not os.path.isdir(directory): msg = 'Error: %r is not a directory' % directory self.editor.commwin.set(msg, 1) return files = os.listdir(directory) if directory != '/': files.append('..') files.sort() self.editor.listwin.reset() for fn in files: filename = os.path.normpath(os.path.join(directory, fn)) if os.path.isfile(filename): self.editor.listwin.append(fn, (self.open, (filename,))) elif os.path.isdir(filename): args = (self.browseDirectory, (filename,)) self.editor.listwin.append(fn + '/', args) self.editor.setFocus(self.editor.listwin) self.editor.listwin.draw() def help(self): # @@ actually load this (a bigger version) from the Web? helptext = '\n'.join([ 'emano - The Simple Powerful Term Editor', 'About: http://inamidst.com/proj/emano/', '', Menu.commands ]) helpuri = 'http://inamidst.com/proj/emano/help' helptab = self.editor.newTab() helptab.feedlines(helptext.splitlines() + ['']) helptab.id = helpuri self.editor.setTab(helptab) self.editor.mainwin.draw() self.editor.moveCursor(0, 0) self.editor.commwin.set('Editing %s' % helpuri) def quickReference(self): lines = ['emano - Quick Reference', ''] for line in Menu.commands.splitlines(): if line.startswith(' '): lines.append(' * ' + line.lstrip(' ')) lines += [''] * (self.editor.maxy - len(lines) + 1) position = self.editor.mainwin.position() for y, line in enumerate(lines): if len(line) < self.editor.maxx: line += ' ' * (self.editor.maxx - len(line)) self.editor.screen.addstr(y, 0, line) self.editor.getEvent() self.editor.mainwin.draw() self.editor.moveCursor(*position[:2]) def indentLine(self): # @@ indenting a region y, x, q, p = self.editor.mainwin.position() line = self.editor.tab.lines[q] indent = self.editor.config.IndentString self.editor.tab.lines[q] = indent + line self.editor.mainwin.draw() newp = p + len(indent) if ((x >= (self.editor.maxx - len(indent))) or ((len(self.editor.tab.lines[q]) > self.editor.maxx) and # @@ possible off-by-one error above (x >= (self.editor.maxx - len(indent) - 2)))): self.editor.tab.setStartchar(q, newp - len(indent)) self.editor.mainwin.drawline(y) self.editor.moveCursor(y, len(indent)) else: newx = newp - self.editor.tab.getStartchar(q) # self.editor.mainwin.drawline(y) # @@ shouldn't be needed self.editor.moveCursor(y, newx) def unindentLine(self): # @@ this and indent need fitting for p # @@ strange off-by one error giving a leading single space y, x, q, p = self.editor.mainwin.position() # @@ line = self.editor.tab.lines[q] indent = self.editor.config.IndentString if line.startswith(indent): line = line[len(indent):] self.editor.tab.lines[q] = line self.editor.mainwin.draw() # @@ newp = p - len(indent) # 2 is the width of the continuation marker, so that the cursor isn't # actually under the continuation marker if (x - len(indent) - 2) <= 0: start = max(0, p - self.editor.maxx - len(indent) + 3) self.editor.tab.setStartchar(q, start) self.editor.mainwin.drawline(y) newx = max(0, min(p - len(indent), self.editor.maxx - 3)) self.editor.moveCursor(y, newx) elif self.editor.tab.getStartchar(q) > 0: start = self.editor.tab.getStartchar(q) self.editor.moveCursor(y, p - start - len(indent)) else: self.editor.moveCursor(y, max(0, p - len(indent))) else: self.editor.commwin.set("Can't unindent line with %r" % indent) def insertCodepointDialogue(self): self.dialogue('Codepoint: ', self.insertCodepoint) def insertCodepoint(self, codepoint): if codepoint.startswith('U+'): codepoint = codepoint[2:] char = int(codepoint, 16) self.editor.mainwin.insertChar(char) def codepointName(self): import unicodedata y, x, q, p = self.editor.mainwin.position() try: char = self.editor.tab.lines[q][p] except IndexError: return name = unicodedata.name(char, 'Unknown') codepoint = 'U+%04X' % ord(char) self.editor.commwin.set("Codepoint: %s (%s)" % (name, codepoint)) def khomeSequence(self): msg = '%r' % curses.tigetstr('khome') self.editor.commwin.set('khome: %s' % msg) def termInformation(self): def s(*args): return ' '.join(str(arg) for arg in args) self.editor.screen.keypad(1) lines = [ s('Term name:', curses.termname()), s('Term longname:', curses.longname()), '', s('KEY_LEFT:', c.LEFT, curses.unctrl(curses.KEY_LEFT)), s('KEY_RIGHT:', c.RIGHT, curses.KEY_RIGHT), s('KEY_UP:', c.UP, curses.unctrl(curses.KEY_UP)), s('KEY_DOWN:', c.DOWN, curses.KEY_DOWN), s('KEY_HOME:', c.HOME, curses.unctrl(curses.KEY_HOME)), s('KEY_BACKSPACE (unreliable):', c.BACKSPACE, curses.KEY_BACKSPACE), s('KEY_DL:', c.DELETE, curses.unctrl(curses.KEY_DL)), s('KEY_NPAGE:', c.PAGE_DOWN, curses.KEY_NPAGE), s('KEY_PPAGE:', c.PAGE_UP, curses.unctrl(curses.KEY_PPAGE)), '', s('khome:', curses.tigetstr('khome')), s('cuu1:', curses.tigetstr('cuu1')), s('cud1:', curses.tigetstr('cud1')), s('kcud1:', curses.tigetstr('kcud1')), s('kpp:', curses.tigetstr('kpp')) ] self.editor.screen.keypad(0) lines += [''] * (self.editor.maxy - len(lines) + 1) position = self.editor.mainwin.position() for y, line in enumerate(lines): if len(line) < self.editor.maxx: line += ' ' * (self.editor.maxx - len(line)) self.editor.screen.addstr(y, 0, line) self.editor.getEvent() self.editor.mainwin.draw() self.editor.moveCursor(*position[:2]) def shellCommandDialogue(self): self.dialogue('Command (use with care!): ', self.shellCommand) def shellCommand(self, command): from subprocess import Popen, PIPE if command.endswith(' |'): command = command.rstrip(' |') paste = True else: paste = False kargs = {'shell': True, 'stdout': PIPE, 'stderr': PIPE} try: output = Popen(command, **kargs).communicate()[0] except OSError, e: output = str(e) if paste: self.paste(output) else: self.openLines(output.splitlines(), '\n') def barf(self): raise Exception('KABOOM') def noop(self): # heh, heh self.editor.commwin.set('250 OK', 1) class Editor(object): def __init__(self, screen, filenames=None): self.screen = screen self.tabs = [] self.clipboard = '' class Config(object): def __getattr__(self, attr): if hasattr(self, attr): return object.__getattr__(self, attr) return None self.config = Config() # Internally set configuration options self.config.DefaultInputEncoding = 'utf-8' self.config.CommentString = '#' # @@ what if it contains \r\n? self.config.IndentString = ' ' # Options set from a config file configFile = os.path.join(dotemano, 'config') if os.path.isfile(configFile): f = open(configFile) for line in f: line = line.rstrip('\r\n') if ': ' in line: name, value = line.split(': ', 1) setattr(self.config, name, value) f.close() # Hmm, Maxy and Maxx sound like strippers maxy, maxx = self.screen.getmaxyx() self.maxy = maxy - 2 # @@ why 2? self.maxx = maxx - 1 # @@ why 1? self.menu = Menu(self) # Create the two main windows, and focus! self.mainwin = MainWindow(self) self.commwin = CommandWindow(self) # @@ status and input? self.listwin = ListWindow(self) # @@ for menus, etc. self.setFocus(self.mainwin) if not filenames: self.menu.newTab() else: for s in filenames: if isinstance(s, str): if os.path.exists(s): # @@ self.menu.open(s) else: self.menu.openLines(s.splitlines(), '\n') if not hasattr(self, 'tab'): self.menu.newTab() # @@ messy! self.commwin.default() def newTab(self): tab = Tab(self) self.addTab(tab) return tab def addTab(self, tab): if tab in self.tabs: pass # self.error() self.tabs.append(tab) def setTab(self, tab): if not tab in self.tabs: pass # self.error() self.tab = tab def setTabPos(self): y, x, q, p = self.mainwin.position() self.tab.pos = [q, p] def getTabPos(self): q, p = tuple(self.tab.pos) y = q - self.tab.startline x = p - self.tab.getStartchar(q) self.moveCursor(y, x) def setFocus(self, window): # @@ If focus is already on the window, send a warning # when focussing away from mainwin, store pos # when focussing to mainwin, read pos if hasattr(self, 'focus'): if (self.focus is self.mainwin) and (window is not self.mainwin): self.setTabPos() elif (self.focus is not self.mainwin) and (window is self.mainwin): self.getTabPos() if (self.focus is self.listwin) and (window is not self.listwin): y, x, q, p = self.mainwin.position() self.listwin.pos = q # @@ add p too? elif (self.focus is not self.listwin) and (window is self.listwin): y = self.listwin.pos - self.listwin.startline # @@ uh, hmm? y = min(self.maxy, y) self.moveCursor(y, 0) self.focus = window def moveCursor(self, y, x): if ((y < 0) or (y > self.maxy + 1) or (x < 0) or (x > self.maxx) or ((y > self.maxy) and (self.focus is self.mainwin))): msg = 'Error: tried to move to (%s, %s). KABOOM! Save & restart!' raise ValueError(msg % (y, x)) # self.commwin.set(msg % (y, x), 1) # return self.screen.move(y, x) def error(self, err): # Last line of defence. If this errors out, bye-bye work! if isinstance(err, SystemExit): raise err try: import sys, traceback lines = ["Emano Error!", "", "Emano is experimental, and, as such, breaks occasionally. This ", "is one of those occasions. The error has been caught, so your ", "work should be safe, but think about saving and restarting.", "", "Here's the error: ", ""] for line in traceback.format_exception(*sys.exc_info()): lines.extend(line.rstrip('\r\n').splitlines()) lines.extend(["", "(Ctrl+W to close this tab.)", ""]) self.menu.openLines(lines, '\n') except Exception, e: msg = 'Error: Got %s when reporting an error! Save & restart!' self.commwin.set(msg % e, 2) def getEvent(self): # Get a character event from the term screen or return None try: ch = self.screen.getch() except KeyboardInterrupt: return None # Return the character event if it's not an escape sequence if ch != 27: # ASCII 27 is \E (ESCAPE) return ch # Here we have an escape sequence that we need to munge p = self.screen.getch() q = self.screen.getch() if chr(q).isalpha(): # @@ Heuristic seq = (chr(p) + chr(q)).upper() else: r = self.screen.getch() try: seq = chr(p) + chr(q) + chr(r) except ValueError: seq = '%r %r %r' % (p, q, r) munge = { 'OH': c.HOME, # e.g. xterm '[1~': c.HOME, # e.g. cygwin, linux '[7*': c.HOME, # e.g. rxvt '[H': c.HOME, 'OF': c.END, '[8*': c.END, '[F': c.END, '[Y': c.END, '[4*': c.END, # ? '[5*': c.PAGE_UP, # e.g. xterm, rxvt, cygwin, linux '[V': c.PAGE_UP, # Hurd '[I': c.PAGE_UP, # FreeBSD '[6*': c.PAGE_DOWN, '[U': c.PAGE_DOWN, # Hurd '[G': c.PAGE_DOWN, # FreeBSD '[9': c.DELETE, '[3*': c.DELETE, # ? 'OA': c.UP, # \EOB is kcud1 in xterm, but Ctrl+DOWN (not DOWN) rxvt-cygwin! 'OB': c.DOWN, 'OC': c.RIGHT, 'OD': c.LEFT, # 'O2A': c.UP, # 'O2B': c.DOWN, # 'O2C': c.RIGHT, # 'O2D': c.LEFT, # 'O5A': c.UP, # 'O5B': c.DOWN, # 'O5C': c.RIGHT, # 'O5D': c.LEFT, # 'O6A': c.UP, # 'O6B': c.DOWN, # 'O6C': c.RIGHT, # 'O6D': c.LEFT, '[A': c.UP, # e.g. xterm, rxvt, cygwin, linux # Note that \E[B is also cud1 in cygwin, as well as kcud1 '[B': c.DOWN, # e.g. rxvt, cygwin, linux; but *not* xterm '[C': c.RIGHT, '[D': c.LEFT } # Lookup the sequence in the term mappings # If we can find it return it # Otherwise, just return the sequence as it is try: return munge[seq] except KeyError: if len(seq) == 3: try: return munge[seq[:2] + '*'] except KeyError: pass # @@ Otherwise, try to work it out from terminfo? return seq def insertChar(self, c): self.focus.insertChar(c) if (self.focus is self.mainwin): self.commwin.default() # @@ or, if it ain't got no yo-yo... # if (self.focus is self.commwin): # self.commwin.insertChar(c) # else: self.mainwin.insertChar(c) def event(self, e): DEBUG = False if DEBUG and (self.mode == 'editor'): for char in '{%s}' % curses.unctrl(e): self.insertChar(ord(char)) if isinstance(e, int) and curses.ascii.isprint(e): try: self.insertChar(e) except Exception, err: self.error(err) return True if hasattr(self.focus, 'event'): try: result = self.focus.event(e) except Exception, err: try: self.error(err) except Exception, e: if isinstance(e, SystemExit): raise SystemExit # @@! result = True else: result = False # @@ if not result: # @@ This is debug information if e == -1: raise ValueError("Got a wacky -1") # @@ self.commwin.refresh(augment=(' [%r?]' % e)) DEBUG = False if DEBUG and (self.focus is self.mainwin): y, x = self.screen.getyx() self.draw() self.moveCursor(y, x) return True def run(self): # listen for events while True: e = self.getEvent() n = self.event(e) if not n: break def run(screen, filenames=None): if filenames is None: filenames = [] e = Editor(screen, filenames=filenames) e.run() def testPythonVersion(): if (not hasattr(sys, 'version_info')) or sys.version_info < (2, 4): print textwrap.dedent("""\ Sorry, emano currently requires Python 2.4 or later. You can download the latest version of Python from www.python.org, or 2.4.1 from: http://www.python.org/ftp/python/2.4.1/Python-2.4.1.tgz If you run across any difficulties with this, you can contact me. -- Sean B. Palmer, http://inamidst.com/sbp/contact """) sys.exit(1) if hasattr(os, 'pathconf_names'): # A value of -1 is returned if any of _POSIX_CHOWN_RESTRICTED, _ # POSIX_NO_TRUNC and _POSIX_VDISABLE are turned off. Otherwise, # another value is returned. # --man pathconf(2) # # This option is only meaningful for files that are terminal # devices. If it is enabled, then handling for special control # characters can be disabled individually. # --GNU libc manual on _POSIX_VDISABLE _POSIX_VDISABLE = os.pathconf_names.get('PC_VDISABLE', -1) != -1 else: _POSIX_VDISABLE = False def start(*filenames): stdin = False filenames = list(filenames) for (i, filename) in enumerate(filenames): if filename in ('-', '/dev/stdin'): # Of course, if one day unicode filenames are used # everywhere, this method is basically buggered. if stdin is False: # @@ stdin.read behaves very oddly! stdin = unicode(sys.stdin.read()) filenames[i] = stdin # Check requirements testPythonVersion() # @@ Warn if the terminal is not xterm, rxvt, cygwin, screen, or linux? # This is a temporary measure if not os.path.exists(dotemano): os.mkdir(dotemano) elif not os.path.isdir(dotemano): raise ValueError("%s backup path is not a directory!" % dotemano) # @@ use optparse screen = curses.initscr() screen.border(' ', ' ', ' ', ' ', ' ', ' ', ord('#')) # set ncurses to NOT give flags for special characters # @@ so we can use ^H, presumably. also it's borken anyway screen.keypad(0) curses.nonl() curses.noecho() if _POSIX_VDISABLE: curses.raw() # Allow 8-bit characters to be input curses.meta(1) # @@ Wrap in better try/except/finally here try: run(screen, filenames) finally: curses.echo() if _POSIX_VDISABLE: curses.noraw() curses.endwin() def main(argv=None): from optparse import OptionParser parser = OptionParser(usage='%prog [options] *') # parser.add_option("-o", "--config", dest="config", default=[], # help="set the default comment string", action="append") options, args = parser.parse_args(argv) start(*args) if __name__=="__main__": main()