Source code for evennia.utils.eveditor

"""
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 CmdLineInput(CmdEditorBase): """ No command match - Inputs line of text into buffer. """ key = _CMD_NOMATCH aliases = _CMD_NOINPUT
[docs] def func(self): """ Adds the line without any formatting changes. If the editor handles code, it might add automatic indentation. """ self.insert_raw_string_into_buffer()
[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 "l" 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)