""" Evennia Line Editor Contribution - Griatch 2011 This implements an advanced line editor for editing longer texts in-game. The editor mimics the command mechanisms of the VI editor as far as possible. Features of the editor: undo/redo edit/replace on any line of the buffer search&replace text anywhere in buffer formatting of buffer, or selection, to certain width + indentations allow to echo the input or not depending on your client. """ import re from ev import Command, CmdSet, utils from ev import syscmdkeys from contrib.menusystem import prompt_yesno CMD_NOMATCH = syscmdkeys.CMD_NOMATCH CMD_NOINPUT = syscmdkeys.CMD_NOINPUT RE_GROUP = re.compile(r"\".*?\"|\'.*?\'|\S*") class CmdEditorBase(Command): """ Base parent for editor commands """ locks = "cmd:all()" help_entry = "LineEditor" code = None editor = None def parse(self): """ Handles pre-parsing Editor commands are on the form :cmd [li] [w] [txt] Where all arguments are optional. li - line number (int), starting from 1. This could also be a range given as : w - word(s) (string), could be encased in quotes. txt - extra text (string), could be encased in quotes """ linebuffer = [] if self.editor: linebuffer = self.editor.buffer.split("\n") nlines = len(linebuffer) # The regular expression will split the line by whitespaces, # stripping extra whitespaces, except if the text is # surrounded by single- or double quotes, in which case they # will be kept together and extra whitespace preserved. You # can input quotes on the line by alternating single and # double quotes. arglist = [part for part in RE_GROUP.findall(self.args) if part] temp = [] for arg in arglist: # we want to clean the quotes, but only one type, in case we are nesting. if arg.startswith('"'): arg.strip('"') elif arg.startswith("'"): arg.strip("'") temp.append(arg) arglist = temp # A dumb split, without grouping quotes words = self.args.split() # current line number cline = nlines - 1 # the first argument could also be a range of line numbers, on the # form :. Either of the ends could be missing, to # mean start/end of buffer respectively. lstart, lend = cline, cline + 1 linerange = False if arglist and ':' in arglist[0]: part1, part2 = arglist[0].split(':') if part1 and part1.isdigit(): lstart = min(max(0, int(part1)) - 1, nlines) linerange = True if part2 and part2.isdigit(): lend = min(lstart + 1, int(part2)) + 1 linerange = True elif arglist and arglist[0].isdigit(): lstart = min(max(0, int(arglist[0]) - 1), nlines) lend = lstart + 1 linerange = True if linerange: arglist = arglist[1:] # nicer output formatting of the line range. lstr = "" if not linerange or lstart + 1 == lend: lstr = "line %i" % (lstart + 1) else: lstr = "lines %i-%i" % (lstart + 1, lend) # arg1 and arg2 is whatever arguments. Line numbers or -ranges are never included here. args = " ".join(arglist) arg1, arg2 = "", "" if len(arglist) > 1: arg1, arg2 = arglist[0], " ".join(arglist[1:]) else: arg1 = " ".join(arglist) # store for use in func() self.linebuffer = linebuffer self.nlines = nlines self.arglist = arglist self.cline = cline self.lstart = lstart self.lend = lend self.linerange = linerange self.lstr = lstr self.words = words self.args = args self.arg1 = arg1 self.arg2 = arg2 def func(self): "Implements the Editor commands" pass class CmdLineInput(CmdEditorBase): """ No command match - Inputs line of text into buffer. """ key = CMD_NOMATCH aliases = [CMD_NOINPUT] def func(self): "Adds the line without any formatting changes." # add a line of text if not self.editor.buffer: buf = self.args else: buf = self.editor.buffer + "\n%s" % self.args self.editor.update_buffer(buf) if self.editor.echo_mode: cline = len(self.editor.buffer.split('\n')) # need to do it here or we will be off one line self.caller.msg("{b%02i|{n %s" % (cline, self.args)) class CmdEditorGroup(CmdEditorBase): """ Commands for the editor """ key = ":editor_command_group" aliases = [":","::", ":::", ":h", ":w", ":wq", ":q", ":q!", ":u", ":uu", ":UU", ":dd", ":dw", ":DD", ":y", ":x", ":p", ":i", ":r", ":I", ":A", ":s", ":S", ":f", ":fi", ":fd", ":echo"] arg_regex = r"\s.*?|$" def func(self): """ This command handles all the in-editor :-style commands. Since each command is small and very limited, this makes for a more efficient presentation. """ caller = self.caller editor = self.editor linebuffer = self.linebuffer lstart, lend = self.lstart, self.lend cmd = self.cmdstring echo_mode = self.editor.echo_mode string = "" if cmd == ":": # Echo buffer if self.linerange: buf = linebuffer[lstart:lend] string = editor.display_buffer(buf=buf, offset=lstart) else: string = editor.display_buffer() elif cmd == "::": # Echo buffer without the line numbers and syntax parsing if self.linerange: buf = linebuffer[lstart:lend] string = editor.display_buffer(buf=buf, offset=lstart, linenums=False) else: string = editor.display_buffer(linenums=False) self.caller.msg(string, data={"raw":True}) return elif cmd == ":::": # Insert single colon alone on a line editor.update_buffer(editor.buffer + "\n:") if echo_mode: string = "Single ':' added to buffer." elif cmd == ":h": # help entry string = editor.display_help() elif cmd == ":w": # save without quitting string = editor.save_buffer() elif cmd == ":wq": # save and quit string = editor.save_buffer() string += " " + editor.quit() elif cmd == ":q": # quit. If not saved, will ask if self.editor.unsaved: prompt_yesno(caller, "Save before quitting?", yescode = "self.caller.ndb._lineeditor.save_buffer(quitting=True)\nself.caller.ndb._lineeditor.quit()", nocode = "self.caller.msg(self.caller.ndb._lineeditor.quit())", default="Y") else: string = editor.quit() elif cmd == ":q!": # force quit, not checking saving string = editor.quit() elif cmd == ":u": # undo string = editor.update_undo(-1) elif cmd == ":uu": # redo string = editor.update_undo(1) elif cmd == ":UU": # reset buffer editor.update_buffer(editor.pristine_buffer) string = "Reverted all changes to the buffer back to original state." elif cmd == ":dd": # :dd - delete line buf = linebuffer[:lstart] + linebuffer[lend:] editor.update_buffer(buf) string = "Deleted %s." % (self.lstr) elif cmd == ":dw": # :dw - delete word in entire buffer # :dw delete word only on line(s) if not self.arg1: string = "You must give a search word to delete." else: if not self.linerange: lstart = 0 lend = self.cline + 1 string = "Removed %s for lines %i-%i." % (self.arg1, lstart + 1 , lend + 1) else: string = "Removed %s for %s." % (self.arg1, self.lstr) sarea = "\n".join(linebuffer[lstart:lend]) sarea = re.sub(r"%s" % self.arg1.strip("\'").strip('\"'), "", sarea, re.MULTILINE) buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] editor.update_buffer(buf) elif cmd == ":DD": # clear buffer editor.update_buffer("") string = "Cleared %i lines from buffer." % self.nlines elif cmd == ":y": # :y - yank line(s) to copy buffer cbuf = linebuffer[lstart:lend] editor.copy_buffer = cbuf string = "%s, %s yanked." % (self.lstr.capitalize(), cbuf) elif cmd == ":x": # :x - cut line to copy buffer cbuf = linebuffer[lstart:lend] editor.copy_buffer = cbuf buf = linebuffer[:lstart] + linebuffer[lend:] editor.update_buffer(buf) string = "%s, %s cut." % (self.lstr.capitalize(), cbuf) elif cmd == ":p": # :p paste line(s) from copy buffer if not editor.copy_buffer: string = "Copy buffer is empty." else: buf = linebuffer[:lstart] + editor.copy_buffer + linebuffer[lstart:] editor.update_buffer(buf) string = "Copied buffer %s to %s." % (editor.copy_buffer, self.lstr) elif cmd == ":i": # :i - insert new line new_lines = self.args.split('\n') if not new_lines: string = "You need to enter a new line and where to insert it." else: buf = linebuffer[:lstart] + new_lines + linebuffer[lstart:] editor.update_buffer(buf) string = "Inserted %i new line(s) at %s." % (len(new_lines), self.lstr) elif cmd == ":r": # :r - replace lines new_lines = self.args.split('\n') if not new_lines: string = "You need to enter a replacement string." else: buf = linebuffer[:lstart] + new_lines + linebuffer[lend:] editor.update_buffer(buf) string = "Replaced %i line(s) at %s." % (len(new_lines), self.lstr) elif cmd == ":I": # :I - insert text at beginning of line(s) if not self.args: string = "You need to enter text to insert." else: buf = linebuffer[:lstart] + ["%s%s" % (self.args, line) for line in linebuffer[lstart:lend]] + linebuffer[lend:] editor.update_buffer(buf) string = "Inserted text at beginning of %s." % self.lstr elif cmd == ":A": # :A - append text after end of line(s) if not self.args: string = "You need to enter text to append." else: buf = linebuffer[:lstart] + ["%s%s" % (line, self.args) for line in linebuffer[lstart:lend]] + linebuffer[lend:] editor.update_buffer(buf) string = "Appended text to end of %s." % self.lstr elif cmd == ":s": # :s
  • - search and replace words in entire buffer or on certain lines if not self.arg1 or not self.arg2: string = "You must give a search word and something to replace it with." else: if not self.linerange: lstart = 0 lend = self.cline + 1 string = "Search-replaced %s -> %s for lines %i-%i." % (self.arg1, self.arg2, lstart + 1 , lend) else: string = "Search-replaced %s -> %s for %s." % (self.arg1, self.arg2, self.lstr) sarea = "\n".join(linebuffer[lstart:lend]) regex = r"%s|^%s(?=\s)|(?<=\s)%s(?=\s)|^%s$|(?<=\s)%s$" regarg = self.arg1.strip("\'").strip('\"') if " " in regarg: regarg = regarg.replace(" ", " +") sarea = re.sub(regex % (regarg, regarg, regarg, regarg, regarg), self.arg2.strip("\'").strip('\"'), sarea, re.MULTILINE) buf = linebuffer[:lstart] + sarea.split("\n") + linebuffer[lend:] editor.update_buffer(buf) elif cmd == ":f": # :f flood-fill buffer or lines of buffer. width = 78 if not self.linerange: lstart = 0 lend = self.cline + 1 string = "Flood filled lines %i-%i." % (lstart + 1 , lend) else: string = "Flood filled %s." % self.lstr fbuf = "\n".join(linebuffer[lstart:lend]) fbuf = utils.fill(fbuf, width=width) buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] editor.update_buffer(buf) elif cmd == ":fi": # :fi indent buffer or lines of buffer. indent = " " * 4 if not self.linerange: lstart = 0 lend = self.cline + 1 string = "Indented lines %i-%i." % (lstart + 1 , lend) else: string = "Indented %s." % self.lstr fbuf = [indent + line for line in linebuffer[lstart:lend]] buf = linebuffer[:lstart] + fbuf + linebuffer[lend:] editor.update_buffer(buf) elif cmd == ":fd": # :fi indent buffer or lines of buffer. if not self.linerange: lstart = 0 lend = self.cline + 1 string = "Removed left margin (dedented) lines %i-%i." % (lstart + 1 , lend) else: string = "Removed left margin (dedented) %s." % self.lstr fbuf = "\n".join(linebuffer[lstart:lend]) fbuf = utils.dedent(fbuf) buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:] editor.update_buffer(buf) elif cmd == ":echo": # set echoing on/off editor.echo_mode = not editor.echo_mode string = "Echo mode set to %s" % editor.echo_mode caller.msg(string) class EditorCmdSet(CmdSet): "CmdSet for the editor commands" key = "editorcmdset" mergetype = "Replace" class LineEditor(object): """ This defines a line editor object. It creates all relevant commands and tracks the current state of the buffer. It also cleans up after itself. """ def __init__(self, caller, loadfunc=None, loadfunc_args=None, savefunc=None, savefunc_args=None, quitfunc=None, quitfunc_args=None, key=""): """ caller - who is using the editor loadfunc - this will be called as func(*loadfunc_args) when the editor is first started, e.g. for pre-loading text into it. loadfunc_args - optional tuple of arguments to supply to loadfunc. savefunc - this will be called as func(*savefunc_args) when the save-command is given and is used to actually determine where/how result is saved. It should return True if save was successful and also handle any feedback to the user. savefunc_args - optional tuple of arguments to supply to savefunc. quitfunc - this will optionally e called as func(*quitfunc_args) when the editor is exited. If defined, it should handle all wanted feedback to the user. quitfunc_args - optional tuple of arguments to supply to quitfunc. key = an optional key for naming this session (such as which attribute is being edited) """ self.key = key self.caller = caller self.caller.ndb._lineeditor = self self.buffer = "" self.unsaved = False if loadfunc: # execute command for loading initial data try: args = loadfunc_args or () self.buffer = loadfunc(*args) except Exception, e: caller.msg("%s\n{rBuffer load function error. Could not load initial data.{n" % e) if not savefunc: # If no save function is defined, save an error-reporting function err = "{rNo save function defined. Buffer cannot be saved.{n" caller.msg(err) savefunc = lambda: self.caller.msg(err) self.savefunc = savefunc self.savefunc_args = savefunc_args or () self.quitfunc = quitfunc self.quitfunc_args = quitfunc_args or () # Create the commands we need cmd1 = CmdLineInput() cmd1.editor = self cmd1.obj = self cmd2 = CmdEditorGroup() cmd2.obj = self cmd2.editor = self # Populate cmdset and add it to caller editor_cmdset = EditorCmdSet() editor_cmdset.add(cmd1) editor_cmdset.add(cmd2) self.caller.cmdset.add(editor_cmdset) # store the original version self.pristine_buffer = self.buffer self.sep = "-" # undo operation buffer self.undo_buffer = [self.buffer] self.undo_pos = 0 self.undo_max = 20 # copy buffer self.copy_buffer = [] # echo inserted text back to caller self.echo_mode = False # show the buffer ui self.caller.msg(self.display_buffer()) def update_buffer(self, buf): """ This should be called when the buffer has been changed somehow. It will handle unsaved flag and undo updating. """ if utils.is_iter(buf): buf = "\n".join(buf) if buf != self.buffer: self.buffer = buf self.update_undo() self.unsaved = True def quit(self): "Cleanly exit the editor." if self.quitfunc: # call quit function hook if available try: self.quitfunc(*self.quitfunc_args) except Exception, e: self.caller.msg("%s\n{Quit function gave an error. Skipping.{n" % e) del self.caller.ndb._lineeditor self.caller.cmdset.delete(EditorCmdSet) if self.quitfunc: # if quitfunc is defined, it should manage exit messages. return "" return "Exited editor." def save_buffer(self): """ Saves the content of the buffer. The 'quitting' argument is a bool indicating whether or not the editor intends to exit after saving. """ if self.unsaved: try: if self.savefunc(*self.savefunc_args): # Save codes should return a true value to indicate save worked. # The saving function is responsible for any status messages. self.unsaved = False return "" except Exception, e: return "%s\n{rSave function gave an error. Buffer not saved." % e else: return "No changes need saving." def update_undo(self, step=None): """ This updates the undo position. """ if step and step < 0: if self.undo_pos <= 0: return "Nothing to undo." self.undo_pos = max(0, self.undo_pos + step) self.buffer = self.undo_buffer[self.undo_pos] return "Undo." elif step and step > 0: if self.undo_pos >= len(self.undo_buffer) - 1 or self.undo_pos + 1 >= self.undo_max: return "Nothing to redo." self.undo_pos = min(self.undo_pos + step, min(len(self.undo_buffer), self.undo_max) - 1) self.buffer = self.undo_buffer[self.undo_pos] return "Redo." if not self.undo_buffer or (self.undo_buffer and self.buffer != self.undo_buffer[self.undo_pos]): self.undo_buffer = self.undo_buffer[:self.undo_pos + 1] + [self.buffer] self.undo_pos = len(self.undo_buffer) - 1 def display_buffer(self, buf=None, offset=0, linenums=True): """ This displays the line editor buffer, or selected parts of it. If buf is set and is not the full buffer, offset should define the starting line number, to get the linenum display right. """ if buf == None: buf = self.buffer if utils.is_iter(buf): buf = "\n".join(buf) lines = buf.split('\n') nlines = len(lines) nwords = len(buf.split()) nchars = len(buf) sep = self.sep header = "{n" + sep * 10 + "Line Editor [%s]" % self.key + sep * (78-25-len(self.key)) footer = "{n" + sep * 10 + "[l:%02i w:%03i c:%04i]" % (nlines, nwords, nchars) + sep * 12 + "(:h for help)" + sep * 23 if linenums: main = "\n".join("{b%02i|{n %s" % (iline + 1 + offset, line) for iline, line in enumerate(lines)) else: main = "\n".join(lines) string = "%s\n%s\n%s" % (header, main, footer) return string def display_help(self): """ Shows the help entry for the editor. """ string = self.sep*78 + """ - any non-command is appended to the end of the buffer. : - view buffer or only line :: - view buffer without line numbers or other parsing ::: - print a ':' as the only character on the line... :h - this help. :w - saves the buffer (don't quit) :wq - save buffer and quit :q - quits (will be asked to save if buffer was changed) :q! - quit without saving, no questions asked :u - (undo) step backwards in undo history :uu - (redo) step forward in undo history :UU - reset all changes back to initial :dd - delete line :dw - delete word or regex in entire buffer or on line :DD - clear buffer :y - yank (copy) line to the copy buffer :x - cut line and store it in the copy buffer :p - put (paste) previously copied line directly after :i - insert new text at line . Old line will be shifted down :r - replace line with text :I - insert text at the beginning of line :A - append text after the end of line :s - search/replace word or regex in buffer or on line :f - flood-fill entire buffer or line :fi - indent entire buffer or line :fd - de-indent entire buffer or line :echo - turn echoing of the input on/off (helpful for some clients) Legend: - line numbers, or range lstart:lend, e.g. '3:7'. - one word or several enclosed in quotes. - longer string, usually not needed to be enclosed in quotes. """ + self.sep * 78 return string # # Editor access command for editing a given attribute on an object. # class CmdEditor(Command): """ start editor Usage: @editor / This will start Evennia's powerful line editor, which has a host of commands on its own. Use :h for a list of commands. """ key = "@editor" aliases = ["@edit"] locks = "cmd:perm(editor) or perm(Builders)" help_category = "Building" def func(self): "setup and start the editor" if not self.args or not '/' in self.args: self.caller.msg("Usage: @editor /") return self.objname, self.attrname = [part.strip() for part in self.args.split("/", 1)] self.obj = self.caller.search(self.objname) if not self.obj: return # hook save/load functions def load_attr(): "inital loading of buffer data from given attribute." target = self.obj.get_attribute(self.attrname) if target != None and not isinstance(target, basestring): typ = type(target).__name__ self.caller.msg("{RWARNING! Saving this buffer will overwrite the current attribute (of type %s) with a string!{n" % typ) return target and str(target) or "" def save_attr(): "Save line buffer to given attribute name. This should return True if successful and also report its status." self.obj.set_attribute(self.attrname, self.editor.buffer) self.caller.msg("Saved.") return True def quit_hook(): "Example quit hook. Since it's given, it's responsible for giving feedback messages." self.caller.msg("Exited Editor.") editor_key = "%s/%s" % (self.objname, self.attrname) # start editor, it will handle things from here. self.editor = LineEditor(self.caller, loadfunc=load_attr, savefunc=save_attr, quitfunc=quit_hook, key=editor_key)