"""
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.
- in-built help
To use the editor, just import EvEditor from this module and initialize it:
```python
from evennia.utils.eveditor import EvEditor
# set up an editor to edit the caller's 'desc' Attribute
def _loadfunc(caller):
return caller.db.desc
def _savefunc(caller, buffer):
caller.db.desc = buffer.strip()
return True
def _quitfunc(caller):
caller.msg("Custom quit message")
# start the editor
EvEditor(caller, loadfunc=None, savefunc=None, quitfunc=None, key="",
persistent=True, code=False)
```
The editor can also be used to format Python code and be made to
survive a reload. See the `EvEditor` class for more details.
"""
import re
from django.conf import settings
from django.utils.translation import gettext as _
from evennia import CmdSet
from evennia.commands import cmdhandler
from evennia.utils import dedent, fill, is_iter, justify, logger, to_str, utils
from evennia.utils.ansi import raw
# 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*")
_COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
# use NAWS in the future?
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
# -------------------------------------------------------------
#
# texts
#
# -------------------------------------------------------------
_HELP_TEXT = _(
f"""
<txt> - any non-command is appended to the end of the buffer.
: <l> - view buffer or only line(s) <l>
:: <l> - raw-view buffer or only line(s) <l>
::: - escape - enter ':' as the only character on the line.
:h - this help.
:w - save the buffer (don't quit)
:wq - save buffer and quit
:q - quit (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 state
:dd <l> - delete last line or line(s) <l>
:dw <l> <w> - delete word or regex <w> in entire buffer or on line <l>
:DD - clear entire buffer
:y <l> - yank (copy) line(s) <l> to the copy buffer
:x <l> - cut line(s) <l> and store it in the copy buffer
:p <l> - put (paste) previously copied line(s) directly before <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>
:j <l> <a> = <w> - justify buffer or line <l>. <a> is f, c, l or r. <w> is
width. <a> and <w> are optional and default to l (left)
and {_DEFAULT_WIDTH} respectively
:f <l> = <w> - flood-fill entire buffer or line <l> to width <w>.
Equivalent to :j <l> l. <w> is optional, as for :j
: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)
"""
)
_HELP_LEGEND = _(
"""
Legend:
<l> - line number, like '5' or range, like '3:7'.
<w> - a single word, or multiple words with quotes around them.
<txt> - longer string, usually not needing quotes.
"""
)
_HELP_CODE = _(
"""
:! - Execute code buffer without saving
:< - Decrease the level of automatic indentation for the next lines
:> - Increase the level of automatic indentation for the next lines
:= - Switch automatic indentation on/off
""".lstrip(
"\n"
)
)
_ERROR_LOADFUNC = _(
"""
{error}
|rBuffer load function error. Could not load initial data.|n
"""
)
_ERROR_SAVEFUNC = _(
"""
{error}
|rSave function returned an error. Buffer not saved.|n
"""
)
_ERROR_NO_SAVEFUNC = _("|rNo save function defined. Buffer cannot be saved.|n")
_MSG_SAVE_NO_CHANGE = _("No changes need saving")
_DEFAULT_NO_QUITFUNC = _("Exited editor.")
_ERROR_QUITFUNC = _(
"""
{error}
|rQuit function gave an error. Skipping.|n
"""
)
_ERROR_PERSISTENT_SAVING = _(
"""
{error}
|rThe editor state could not be saved for persistent mode. Switching
to non-persistent mode (which means the editor session won't survive
an eventual server reload - so save often!)|n
"""
)
_TRACE_PERSISTENT_SAVING = _(
"EvEditor persistent-mode error. Commonly, this is because one or "
"more of the EvEditor callbacks could not be pickled, for example "
"because it's a class method or is defined inside another function."
)
_MSG_NO_UNDO = _("Nothing to undo.")
_MSG_NO_REDO = _("Nothing to redo.")
_MSG_UNDO = _("Undid one step.")
_MSG_REDO = _("Redid one step.")
# -------------------------------------------------------------
#
# Handle yes/no quit question
#
# -------------------------------------------------------------
[docs]class CmdSaveYesNo(_COMMAND_DEFAULT_CLASS):
"""
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"
[docs] 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._eveditor.quit())
else:
# answered yes (default)
self.caller.ndb._eveditor.save_buffer()
self.caller.ndb._eveditor.quit()
[docs]class SaveYesNoCmdSet(CmdSet):
"""
Stores the yesno question
"""
key = "quitsave_yesno"
priority = 150 # override other cmdsets.
mergetype = "Replace"
[docs] def at_cmdset_creation(self):
"""at cmdset creation"""
self.add(CmdSaveYesNo())
# -------------------------------------------------------------
#
# Editor commands
#
# -------------------------------------------------------------
[docs]class CmdEditorBase(_COMMAND_DEFAULT_CLASS):
"""
Base parent for editor commands
"""
locks = "cmd:all()"
help_entry = "LineEditor"
editor = None
[docs] 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.
"""
editor = self.caller.ndb._eveditor
if not editor:
# this will completely replace the editor
_load_editor(self.caller)
editor = self.caller.ndb._eveditor
self.editor = editor
linebuffer = self.editor.get_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 arglist[0].count(":") == 1:
part1, part2 = arglist[0].split(":")
lstart = min(max(1, int(part1)), nlines) - 1 if utils.value_is_integer(part1) else 0
lend = (
min(max(lstart + 1, int(part2)), nlines)
if utils.value_is_integer(part2)
else nlines
)
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 = (
"line %i" % (lstart + 1)
if not linerange or lstart + 1 == lend
else "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
[docs] def insert_raw_string_into_buffer(self):
"""
Insert a line into the buffer. Used by both CmdLineInput and CmdEditorGroup.
"""
caller = self.caller
editor = caller.ndb._eveditor
buf = editor.get_buffer()
# add a line of text to buffer
line = self.raw_string.strip("\r\n")
if editor._codefunc and editor._indent >= 0:
# if automatic indentation is active, add spaces
line = editor.deduce_indent(line, buf)
buf = line if not buf else buf + "\n%s" % line
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.get_buffer().split("\n"))
if editor._codefunc:
# display the current level of identation
indent = editor._indent
if indent < 0:
indent = "off"
self.caller.msg("|b%02i|||n (|g%s|n) %s" % (cline, indent, raw(line)))
else:
self.caller.msg("|b%02i|||n %s" % (cline, raw(line)))
def _load_editor(caller):
"""
Load persistent editor from storage.
"""
saved_options = caller.attributes.get("_eveditor_saved")
saved_buffer, saved_undo = caller.attributes.get("_eveditor_buffer_temp", (None, None))
unsaved = caller.attributes.get("_eveditor_unsaved", False)
indent = caller.attributes.get("_eveditor_indent", 0)
if saved_options:
eveditor = EvEditor(caller, **saved_options[0])
if saved_buffer:
# we have to re-save the buffer data so we can handle subsequent restarts
caller.attributes.add("_eveditor_buffer_temp", (saved_buffer, saved_undo))
setattr(eveditor, "_buffer", saved_buffer)
setattr(eveditor, "_undo_buffer", saved_undo)
setattr(eveditor, "_undo_pos", len(saved_undo) - 1)
setattr(eveditor, "_unsaved", unsaved)
setattr(eveditor, "_indent", indent)
for key, value in saved_options[1].items():
setattr(eveditor, key, value)
else:
# something went wrong. Cleanup.
caller.cmdset.remove(EvEditorCmdSet)
[docs]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",
":j",
":r",
":I",
":A",
":s",
":S",
":f",
":fi",
":fd",
":echo",
":!",
":<",
":>",
":=",
]
arg_regex = r"\s.*?|$"
[docs] 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 = caller.ndb._eveditor
linebuffer = self.linebuffer
lstart, lend = self.lstart, self.lend
# preserve the cmdname including case (otherwise uu and UU would be the same)
cmd = self.raw_string[: len(self.cmdstring)]
echo_mode = self.editor._echo_mode
if cmd == ":":
# Echo buffer
if self.linerange:
buf = linebuffer[lstart:lend]
editor.display_buffer(buf=buf, offset=lstart)
else:
editor.display_buffer()
elif cmd == "::":
# Echo buffer without the line numbers and syntax parsing
if self.linerange:
buf = linebuffer[lstart:lend]
editor.display_buffer(buf=buf, offset=lstart, linenums=False, options={"raw": True})
else:
editor.display_buffer(linenums=False, options={"raw": True})
elif cmd == ":::":
# Insert single colon alone on a line
editor.update_buffer([":"] if lstart == 0 else linebuffer + [":"])
if echo_mode:
caller.msg(_("Single ':' added to buffer."))
elif cmd == ":h":
# help entry
editor.display_help()
elif cmd == ":w":
# save without quitting
editor.save_buffer()
elif cmd == ":wq":
# save and quit
editor.save_buffer()
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:
editor.quit()
elif cmd == ":q!":
# force quit, not checking saving
editor.quit()
elif cmd == ":u":
# undo
editor.update_undo(-1)
elif cmd == ":uu":
# redo
editor.update_undo(1)
elif cmd == ":UU":
# reset buffer
editor.update_buffer(editor._pristine_buffer)
caller.msg(_("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)
caller.msg(_("Deleted {string}.").format(string=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:
caller.msg(_("You must give a search word to delete."))
else:
if not self.linerange:
lstart = 0
lend = self.cline + 1
caller.msg(
_("Removed {arg1} for lines {l1}-{l2}.").format(
arg1=self.arg1, l1=lstart + 1, l2=lend + 1
)
)
else:
caller.msg(
_("Removed {arg1} for {line}.").format(arg1=self.arg1, line=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("")
# Reset indentation level to 0
if editor._codefunc:
if editor._indent >= 0:
editor._indent = 0
if editor._persistent:
caller.attributes.add("_eveditor_indent", 0)
caller.msg(_("Cleared {nlines} lines from buffer.").format(nlines=self.nlines))
elif cmd == ":y":
# :y <l> - yank line(s) to copy buffer
cbuf = linebuffer[lstart:lend]
editor._copy_buffer = cbuf
caller.msg(_("{line}, {cbuf} yanked.").format(line=self.lstr.capitalize(), cbuf=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)
caller.msg(_("{line}, {cbuf} cut.").format(line=self.lstr.capitalize(), cbuf=cbuf))
elif cmd == ":p":
# :p <l> paste line(s) from copy buffer
if not editor._copy_buffer:
caller.msg(_("Copy buffer is empty."))
else:
buf = linebuffer[:lstart] + editor._copy_buffer + linebuffer[lstart:]
editor.update_buffer(buf)
caller.msg(
_("Pasted buffer {cbuf} to {line}.").format(
cbuf=editor._copy_buffer, line=self.lstr
)
)
elif cmd == ":i":
# :i <l> <txt> - insert new line
new_lines = self.args.split("\n")
if not new_lines:
caller.msg(_("You need to enter a new line and where to insert it."))
else:
buf = linebuffer[:lstart] + new_lines + linebuffer[lstart:]
editor.update_buffer(buf)
caller.msg(
_("Inserted {num} new line(s) at {line}.").format(
num=len(new_lines), line=self.lstr
)
)
elif cmd == ":r":
# :r <l> <txt> - replace lines
new_lines = self.args.split("\n")
if not new_lines:
caller.msg(_("You need to enter a replacement string."))
else:
buf = linebuffer[:lstart] + new_lines + linebuffer[lend:]
editor.update_buffer(buf)
caller.msg(
_("Replaced {num} line(s) at {line}.").format(
num=len(new_lines), line=self.lstr
)
)
elif cmd == ":I":
# :I <l> <txt> - insert text at beginning of line(s) <l>
if not self.raw_string and not editor._codefunc:
caller.msg(_("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)
caller.msg(_("Inserted text at beginning of {line}.").format(line=self.lstr))
elif cmd == ":A":
# :A <l> <txt> - append text after end of line(s)
if not self.args:
caller.msg(_("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)
caller.msg(_("Appended text to end of {line}.").format(line=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:
caller.msg(_("You must give a search word and something to replace it with."))
else:
if not self.linerange:
lstart = 0
lend = self.cline + 1
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(" ", " +")
try:
sarea = re.sub(
regex % (regarg, regarg, regarg, regarg, regarg),
self.arg2.strip("'").strip('"'),
sarea,
re.MULTILINE,
)
except re.error as e:
caller.msg(_("Invalid regular expression."))
else:
if not self.linerange:
caller.msg(
_("Search-replaced {arg1} -> {arg2} for lines {l1}-{l2}.").format(
arg1=raw(self.arg1), arg2=raw(self.arg2), l1=lstart + 1, l2=lend
)
)
else:
caller.msg(
_("Search-replaced {arg1} -> {arg2} for {line}.").format(
arg1=raw(self.arg1), arg2=raw(self.arg2), line=self.lstr
)
)
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.
# :f <l> =<w> flood-fill buffer or <l> lines of buffer to width <w>.
width = _DEFAULT_WIDTH
if self.arg1:
value = self.arg1.lstrip("=")
if not value.isdigit():
self.caller.msg("Width must be a number.")
return
width = int(value)
if not self.linerange:
lstart = 0
lend = self.cline + 1
caller.msg(_("Flood filled lines {l1}-{l2}.").format(l1=lstart + 1, l2=lend))
else:
caller.msg(_("Flood filled {line}.").format(line=self.lstr))
fbuf = "\n".join(linebuffer[lstart:lend])
fbuf = fill(fbuf, width=width)
buf = linebuffer[:lstart] + fbuf.split("\n") + linebuffer[lend:]
editor.update_buffer(buf)
elif cmd == ":j":
# :j <l> <a> =<w> justify buffer of <l> to width <w> with <a> as align (one of
# f(ull), c(enter), r(ight) or l(left). Default is full.
align_map = {
"full": "f",
"f": "f",
"center": "c",
"c": "c",
"right": "r",
"r": "r",
"left": "l",
"l": "l",
}
align_name = {"f": "Full", "c": "Center", "l": "Left", "r": "Right"}
# shift width arg right if no alignment specified
if self.arg1.startswith("="):
self.arg2 = self.arg1
self.arg1 = None
if self.arg1 and self.arg1.lower() not in align_map:
self.caller.msg(
_("Valid justifications are")
+ " [f]ull (default), [c]enter, [r]right or [l]eft"
)
return
align = align_map[self.arg1.lower()] if self.arg1 else "f"
width = _DEFAULT_WIDTH
if self.arg2:
value = self.arg2.lstrip("=")
if not value.isdigit():
self.caller.msg("Width must be a number.")
return
width = int(value)
if not self.linerange:
lstart = 0
lend = self.cline + 1
self.caller.msg(
_("{align}-justified lines {l1}-{l2}.").format(
align=align_name[align], l1=lstart + 1, l2=lend
)
)
else:
self.caller.msg(
_("{align}-justified {line}.").format(align=align_name[align], line=self.lstr)
)
jbuf = "\n".join(linebuffer[lstart:lend])
jbuf = justify(jbuf, width=width, align=align)
buf = linebuffer[:lstart] + jbuf.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
caller.msg(_("Indented lines {l1}-{l2}.").format(l1=lstart + 1, l2=lend))
else:
caller.msg(_("Indented {line}.").format(line=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
caller.msg(
_("Removed left margin (dedented) lines {l1}-{l2}.").format(
l1=lstart + 1, l2=lend
)
)
else:
caller.msg(_("Removed left margin (dedented) {line}.").format(line=self.lstr))
fbuf = "\n".join(linebuffer[lstart:lend])
fbuf = 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
caller.msg(_("Echo mode set to {mode}").format(mode=editor._echo_mode))
elif cmd == ":!":
if editor._codefunc:
editor._codefunc(caller, editor._buffer)
else:
caller.msg(_("This command is only available in code editor mode."))
elif cmd == ":<":
# :<
if editor._codefunc:
editor.decrease_indent()
indent = editor._indent
if indent >= 0:
caller.msg(
_("Decreased indentation: new indentation is {indent}.").format(
indent=indent
)
)
else:
caller.msg(_("|rManual indentation is OFF.|n Use := to turn it on."))
else:
caller.msg(_("This command is only available in code editor mode."))
elif cmd == ":>":
# :>
if editor._codefunc:
editor.increase_indent()
indent = editor._indent
if indent >= 0:
caller.msg(
_("Increased indentation: new indentation is {indent}.").format(
indent=indent
)
)
else:
caller.msg(_("|rManual indentation is OFF.|n Use := to turn it on."))
else:
caller.msg(_("This command is only available in code editor mode."))
elif cmd == ":=":
# :=
if editor._codefunc:
editor.swap_autoindent()
indent = editor._indent
if indent >= 0:
caller.msg(_("Auto-indentation turned on."))
else:
caller.msg(_("Auto-indentation turned off."))
else:
caller.msg(_("This command is only available in code editor mode."))
else:
# no match - insert as line in buffer
self.insert_raw_string_into_buffer()
[docs]class EvEditorCmdSet(CmdSet):
"""CmdSet for the editor commands"""
key = "editorcmdset"
priority = 150 # override other cmdsets.
mergetype = "Replace"
[docs] def at_cmdset_creation(self):
self.add(CmdLineInput())
self.add(CmdEditorGroup())
# -------------------------------------------------------------
#
# Main Editor object
#
# -------------------------------------------------------------
[docs]class EvEditor:
"""
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.
"""
[docs] def __init__(
self,
caller,
loadfunc=None,
savefunc=None,
quitfunc=None,
key="",
persistent=False,
codefunc=False,
):
"""
Launches a full in-game line editor, mimicking the functionality of VIM.
Args:
caller (Object): Who is using the editor.
loadfunc (callable, optional): This will be called as
`loadfunc(caller)` when the editor is first started. Its
return will be used as the editor's starting buffer.
savefunc (callable, optional): This will be called as
`savefunc(caller, buffer)` 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.
quitfunc (callable, optional): This will optionally be
called as `quitfunc(caller)` 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.
persistent (bool, optional): Make the editor survive a reboot. Note
that if this is set, all callables must be possible to pickle
codefunc (bool, optional): If given, will run the editor in code mode.
This will be called as `codefunc(caller, buf)`.
Notes:
In persistent mode, all the input callables (savefunc etc)
must be possible to be *pickled*, this excludes e.g.
callables that are class methods or functions defined
dynamically or as part of another function. In
non-persistent mode no such restrictions exist.
"""
self._key = key
self._caller = caller
self._caller.ndb._eveditor = self
self._buffer = ""
self._unsaved = False
self._persistent = persistent
self._indent = 0
if loadfunc:
self._loadfunc = loadfunc
else:
self._loadfunc = lambda caller: self._buffer
self.load_buffer()
if savefunc:
self._savefunc = savefunc
else:
self._savefunc = lambda caller, buffer: caller.msg(_ERROR_NO_SAVEFUNC)
if quitfunc:
self._quitfunc = quitfunc
else:
self._quitfunc = lambda caller: caller.msg(_DEFAULT_NO_QUITFUNC)
self._codefunc = codefunc
# 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 = []
if persistent:
# save in tuple {kwargs, other options}
try:
caller.attributes.add(
"_eveditor_saved",
(
dict(
loadfunc=loadfunc,
savefunc=savefunc,
quitfunc=quitfunc,
codefunc=codefunc,
key=key,
persistent=persistent,
),
dict(_pristine_buffer=self._pristine_buffer, _sep=self._sep),
),
)
caller.attributes.add("_eveditor_buffer_temp", (self._buffer, self._undo_buffer))
caller.attributes.add("_eveditor_unsaved", False)
caller.attributes.add("_eveditor_indent", 0)
except Exception as err:
caller.msg(_ERROR_PERSISTENT_SAVING.format(error=err))
logger.log_trace(_TRACE_PERSISTENT_SAVING)
persistent = False
# Create the commands we need
caller.cmdset.add(EvEditorCmdSet, persistent=persistent)
# echo inserted text back to caller
self._echo_mode = True
# show the buffer ui
self.display_buffer()
[docs] def load_buffer(self):
"""
Load the buffer using the load function hook.
"""
try:
self._buffer = self._loadfunc(self._caller)
if not isinstance(self._buffer, str):
self._caller.msg(
f"|rBuffer is of type |w{type(self._buffer)})|r. "
"Continuing, it is converted to a string "
"(and will be saved as such)!|n"
)
self._buffer = to_str(self._buffer)
except Exception as e:
from evennia.utils import logger
logger.log_trace()
self._caller.msg(_ERROR_LOADFUNC.format(error=e))
[docs] def get_buffer(self):
"""
Return:
buffer (str): The current buffer.
"""
return self._buffer
[docs] def update_buffer(self, buf):
"""
This should be called when the buffer has been changed
somehow. It will handle unsaved flag and undo updating.
Args:
buf (str): The text to update the buffer with.
"""
if is_iter(buf):
buf = "\n".join(buf)
if buf != self._buffer:
self._buffer = buf
self.update_undo()
self._unsaved = True
if self._persistent:
self._caller.attributes.add(
"_eveditor_buffer_temp", (self._buffer, self._undo_buffer)
)
self._caller.attributes.add("_eveditor_unsaved", True)
self._caller.attributes.add("_eveditor_indent", self._indent)
[docs] def quit(self):
"""
Cleanly exit the editor.
"""
try:
self._quitfunc(self._caller)
except Exception as e:
self._caller.msg(_ERROR_QUITFUNC.format(error=e))
self._caller.nattributes.remove("_eveditor")
self._caller.attributes.remove("_eveditor_buffer_temp")
self._caller.attributes.remove("_eveditor_saved")
self._caller.attributes.remove("_eveditor_unsaved")
self._caller.attributes.remove("_eveditor_indent")
self._caller.cmdset.remove(EvEditorCmdSet)
[docs] def save_buffer(self):
"""
Saves the content of the buffer.
"""
if self._unsaved or self._codefunc:
# always save code - this allows us to tie execution to
# saving if we want.
try:
if self._savefunc(self._caller, self._buffer):
# Save codes should return a true value to indicate
# save worked. The saving function is responsible for
# any status messages.
self._unsaved = False
except Exception as e:
self._caller.msg(_ERROR_SAVEFUNC.format(error=e))
else:
self._caller.msg(_MSG_SAVE_NO_CHANGE)
[docs] def update_undo(self, step=None):
"""
This updates the undo position.
Args:
step (int, optional): The amount of steps
to progress the undo position to. This
may be a negative value for undo and
a positive value for redo.
"""
if step and step < 0:
# undo
if self._undo_pos <= 0:
self._caller.msg(_MSG_NO_UNDO)
else:
self._undo_pos = max(0, self._undo_pos + step)
self._buffer = self._undo_buffer[self._undo_pos]
self._caller.msg(_MSG_UNDO)
elif step and step > 0:
# redo
if self._undo_pos >= len(self._undo_buffer) - 1 or self._undo_pos + 1 >= self._undo_max:
self._caller.msg(_MSG_NO_REDO)
else:
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]
self._caller.msg(_MSG_REDO)
if not self._undo_buffer or (
self._undo_buffer and self._buffer != self._undo_buffer[self._undo_pos]
):
# save undo state
self._undo_buffer = self._undo_buffer[: self._undo_pos + 1] + [self._buffer]
self._undo_pos = len(self._undo_buffer) - 1
[docs] def display_buffer(self, buf=None, offset=0, linenums=True, options={"raw": False}):
"""
This displays the line editor buffer, or selected parts of it.
Args:
buf (str, optional): The buffer or part of buffer to display.
offset (int, optional): If `buf` is set and is not the full buffer,
`offset` should define the actual starting line number, to
get the linenum display right.
linenums (bool, optional): Show line numbers in buffer.
options: raw (bool, optional): Tell protocol to not parse
formatting information.
"""
if buf is None:
buf = self._buffer
if 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 [{name}]").format(name=self._key)
+ sep * (_DEFAULT_WIDTH - 24 - len(self._key))
)
footer = (
"|n"
+ sep * 10
+ "[l:%02i w:%03i c:%04i]" % (nlines, nwords, nchars)
+ sep * 12
+ _("(:h for help)")
+ sep * (_DEFAULT_WIDTH - 54)
)
if linenums:
main = "\n".join(
"|b%02i|||n %s" % (iline + 1 + offset, raw(line))
for iline, line in enumerate(lines)
)
else:
main = "\n".join([raw(line) for line in lines])
string = "%s\n%s\n%s" % (header, main, footer)
self._caller.msg(string, options=options)
[docs] def display_help(self):
"""
Shows the help entry for the editor.
"""
string = self._sep * _DEFAULT_WIDTH + _HELP_TEXT
if self._codefunc:
string += _HELP_CODE
string += _HELP_LEGEND + self._sep * _DEFAULT_WIDTH
self._caller.msg(string)
[docs] def deduce_indent(self, line, buffer):
"""
Try to deduce the level of indentation of the given line.
"""
keywords = {
"elif ": ["if "],
"else:": ["if ", "try"],
"except": ["try:"],
"finally:": ["try:"],
}
opening_tags = ("if ", "try:", "for ", "while ")
# If the line begins by one of the given keywords
indent = self._indent
if any(line.startswith(kw) for kw in keywords.keys()):
# Get the keyword and matching begin tags
keyword = [kw for kw in keywords if line.startswith(kw)][0]
begin_tags = keywords[keyword]
for oline in reversed(buffer.splitlines()):
if any(oline.lstrip(" ").startswith(tag) for tag in begin_tags):
# This line begins with a begin tag, takes the identation
indent = (len(oline) - len(oline.lstrip(" "))) / 4
break
self._indent = indent + 1
if self._persistent:
self._caller.attributes.add("_eveditor_indent", self._indent)
elif any(line.startswith(kw) for kw in opening_tags):
self._indent = indent + 1
if self._persistent:
self._caller.attributes.add("_eveditor_indent", self._indent)
line = " " * 4 * indent + line
return line
[docs] def decrease_indent(self):
"""Decrease automatic indentation by 1 level."""
if self._codefunc and self._indent > 0:
self._indent -= 1
if self._persistent:
self._caller.attributes.add("_eveditor_indent", self._indent)
[docs] def increase_indent(self):
"""Increase automatic indentation by 1 level."""
if self._codefunc and self._indent >= 0:
self._indent += 1
if self._persistent:
self._caller.attributes.add("_eveditor_indent", self._indent)
[docs] def swap_autoindent(self):
"""Swap automatic indentation on or off."""
if self._codefunc:
if self._indent >= 0:
self._indent = -1
else:
self._indent = 0
if self._persistent:
self._caller.attributes.add("_eveditor_indent", self._indent)