Moved lineditor into utils/eveditor.py. The line editor is accessed by simply importing the class (same as EvTable, EvMenu etc), so the utils.get_line_editor function was removed.

This commit is contained in:
Griatch 2015-07-08 15:26:12 +02:00
parent adae4f2ec4
commit cc2b094bc6
5 changed files with 143 additions and 112 deletions

773
evennia/utils/eveditor.py Normal file
View file

@ -0,0 +1,773 @@
"""
EvEditor (Evennia Line Editor)
This implements an advanced line editor for editing longer texts
in-game. The editor mimics the command mechanisms of the "VI" editor
(a famous line-by-line editor) as far as reasonable.
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.
To use the editor, just import EvEditor from this module
and initialize it:
from evennia.utils.eveditor import EvEditor
EvEditor(caller,
loadfunc=None, loadfunc_args=None,
savefunc=None, savefunc_args=None,
quitfunc=None, quitfunc_args=None,
key=""):
Where the load/save/quitfunc are callbacks (with matching arguments)
to trigger when the editor loads, saves and quits respectively.
"""
import re
from django.conf import settings
from evennia import Command, CmdSet, utils
from evennia.commands import cmdhandler
# we use cmdhandler instead of evennia.syscmdkeys to
# avoid some cases of loading before evennia init'd
_CMD_NOMATCH = cmdhandler.CMD_NOMATCH
_CMD_NOINPUT = cmdhandler.CMD_NOINPUT
_RE_GROUP = re.compile(r"\".*?\"|\'.*?\'|\S*")
# use NAWS in the future?
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
#------------------------------------------------------------
#
# texts
#
#------------------------------------------------------------
_HELP_TEXT = \
"""
<txt> - any non-command is appended to the end of the buffer.
: <l> - view buffer or only line <l>
:: <l> - 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 <l> - delete line <n>
:dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
:DD - clear buffer
:y <l> - yank (copy) line <l> to the copy buffer
:x <l> - cut line <l> and store it in the copy buffer
:p <l> - put (paste) previously copied line directly after <l>
:i <l> <txt> - insert new text <txt> at line <l>. Old line will move down
:r <l> <txt> - replace line <l> with text <txt>
:I <l> <txt> - insert text at the beginning of line <l>
:A <l> <txt> - append text after the end of line <l>
:s <l> <w> <txt> - search/replace word or regex <w> in buffer or on line <l>
:f <l> - flood-fill entire buffer or line <l>
:fi <l> - indent entire buffer or line <l>
:fd <l> - de-indent entire buffer or line <l>
:echo - turn echoing of the input on/off (helpful for some clients)
Legend:
<l> - line numbers, or range lstart:lend, e.g. '3:7'.
<w> - one word or several enclosed in quotes.
<txt> - longer string, usually not needed to be enclosed in quotes.
"""
#------------------------------------------------------------
#
# Handle yes/no quit question
#
#------------------------------------------------------------
class CmdSaveYesNo(Command):
"""
Save the editor state on quit. This catches
nomatches (defaults to Yes), and avoid saves only if
command was given specifically as "no" or "n".
"""
key = _CMD_NOMATCH
aliases = _CMD_NOINPUT
locks = "cmd:all()"
help_cateogory = "LineEditor"
def func(self):
"Implement the yes/no choice."
# this is only called from inside the lineeditor
# so caller.ndb._lineditor must be set.
self.caller.cmdset.remove(SaveYesNoCmdSet)
if self.raw_string.strip().lower() in ("no", "n"):
# answered no
self.caller.msg(self.caller.ndb._lineeditor.quit())
else:
# answered yes (default)
self.caller.ndb._lineeditor.save_buffer()
self.caller.ndb._lineeditor.quit()
class SaveYesNoCmdSet(CmdSet):
"Stores the yesno question"
key = "quitsave_yesno"
priority = 1
mergetype = "Replace"
def at_cmdset_creation(self):
"at cmdset creation"
self.add(CmdSaveYesNo())
#------------------------------------------------------------
#
# Editor commands
#
#------------------------------------------------------------
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 <l>:<l>.
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 <lstart>:<lend>. 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:
# need to do it here or we will be off one line
cline = len(self.editor.buffer.split('\n'))
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, 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:
caller.cmdset.add(SaveYesNoCmdSet)
caller.msg("Save before quitting? {lcyes{lt[Y]{le/{lcno{ltN{le")
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 <l> - delete line <l>
buf = linebuffer[:lstart] + linebuffer[lend:]
editor.update_buffer(buf)
string = "Deleted %s." % (self.lstr)
elif cmd == ":dw":
# :dw <w> - delete word in entire buffer
# :dw <l> <w> delete word only on line(s) <l>
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 <l> - 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 <l> - 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 <l> 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 <l> <txt> - 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 <l> <txt> - 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 <l> <txt> - insert text at beginning of line(s) <l>
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 <l> <txt> - 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 <li> <w> <txt> - 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 <l> flood-fill buffer or <l> lines of buffer.
width = _DEFAULT_WIDTH
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 <l> indent buffer or lines <l> 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 <l> indent buffer or lines <l> 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 EvEditorCmdSet(CmdSet):
"CmdSet for the editor commands"
key = "editorcmdset"
mergetype = "Replace"
#------------------------------------------------------------
#
# Main Editor object
#
#------------------------------------------------------------
class EvEditor(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=""):
"""
Args:
caller (Object): Who is using the editor.
loadfunc (callable, optional): This will be called as
`func(*loadfunc_args)` when the editor is first started,
e.g. for pre-loading text into it.
loadfunc_args (tuple, optional): Optional tuple of
arguments to supply to `loadfunc`.
savefunc (callable, optional): 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 (tuple, optional): Optional tuple of
arguments to supply to `savefunc`.
quitfunc (callable, optional): This will optionally be
called as `func(*quitfunc_args)` when the editor is
exited. If defined, it should handle all wanted feedback
to the user.
quitfunc_args (tuple, optional): Optional tuple of arguments to
supply to `quitfunc`.
key (str, optional): An optional key for naming this
session and make it unique from other editing sessions.
"""
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 = EvEditorCmdSet()
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.remove(EvEditorCmdSet)
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 * (_DEFAULT_WIDTH-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 * _DEFAULT_WIDTH + _HELP_TEXT + self.sep * _DEFAULT_WIDTH
return string
#------------------------------------------------------------------
#
# Editor access command for editing a given attribute on an object.
#
#------------------------------------------------------------------
class CmdEditor(Command):
"""
Start editor
Usage:
@editor <obj>/<attr>
This will start Evennia's powerful line editor to edit an
Attribute. The editor 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 <obj>/<attrname>")
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.attributes.get(self.attrname)
if target is not 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.attributes.add(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. We need to
# store it on the command object since we set up callback functions
# to refer to it that way.
self.editor = EvEditor(self.caller,
loadfunc=load_attr,
savefunc=save_attr,
quitfunc=quit_hook,
key=editor_key)