Cherry-pick EvMenu list_node decorator from olc branch

This commit is contained in:
Griatch 2018-04-19 22:47:13 +02:00
parent 0350b6c3c6
commit bee7fa174d

View file

@ -43,13 +43,18 @@ command definition too) with function definitions:
def node_with_other_name(caller, input_string): def node_with_other_name(caller, input_string):
# code # code
return text, options return text, options
def another_node(caller, input_string, **kwargs):
# code
return text, options
``` ```
Where caller is the object using the menu and input_string is the Where caller is the object using the menu and input_string is the
command entered by the user on the *previous* node (the command command entered by the user on the *previous* node (the command
entered to get to this node). The node function code will only be entered to get to this node). The node function code will only be
executed once per node-visit and the system will accept nodes with executed once per node-visit and the system will accept nodes with
both one or two arguments interchangeably. both one or two arguments interchangeably. It also accepts nodes
that takes **kwargs.
The menu tree itself is available on the caller as The menu tree itself is available on the caller as
`caller.ndb._menutree`. This makes it a convenient place to store `caller.ndb._menutree`. This makes it a convenient place to store
@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called.
the callable. Those kwargs will also be passed into the next node if possible. the callable. Those kwargs will also be passed into the next node if possible.
Such a callable should return either a str or a (str, dict), where the Such a callable should return either a str or a (str, dict), where the
string is the name of the next node to go to and the dict is the new, string is the name of the next node to go to and the dict is the new,
(possibly modified) kwarg to pass into the next node. (possibly modified) kwarg to pass into the next node. If the callable returns
None or the empty string, the current node will be revisited.
- `exec` (str, callable or tuple, optional): This takes the same input as `goto` above - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above
and runs before it. If given a node name, the node will be executed but will not and runs before it. If given a node name, the node will be executed but will not
be considered the next node. If node/callback returns str or (str, dict), these will be considered the next node. If node/callback returns str or (str, dict), these will
replace the `goto` step (`goto` callbacks will not fire), with the string being the replace the `goto` step (`goto` callbacks will not fire), with the string being the
next node name and the optional dict acting as the kwargs-input for the next node. next node name and the optional dict acting as the kwargs-input for the next node.
If an exec callable returns the empty string (only), the current node is re-run.
If key is not given, the option will automatically be identified by If key is not given, the option will automatically be identified by
its number 1..N. its number 1..N.
@ -167,7 +174,7 @@ from evennia import Command, CmdSet
from evennia.utils import logger from evennia.utils import logger
from evennia.utils.evtable import EvTable from evennia.utils.evtable import EvTable
from evennia.utils.ansi import strip_ansi from evennia.utils.ansi import strip_ansi
from evennia.utils.utils import mod_import, make_iter, pad, m_len from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter
from evennia.commands import cmdhandler from evennia.commands import cmdhandler
# read from protocol NAWS later? # read from protocol NAWS later?
@ -182,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT
# i18n # i18n
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") _ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or "
"caused an error. Make another choice.")
_ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_GENERAL = _("Error in menu node '{nodename}'.")
_ERR_NO_OPTION_DESC = _("No description.") _ERR_NO_OPTION_DESC = _("No description.")
_HELP_FULL = _("Commands: <menu option>, help, quit") _HELP_FULL = _("Commands: <menu option>, help, quit")
@ -573,6 +581,7 @@ class EvMenu(object):
except EvMenuError: except EvMenuError:
errmsg = _ERR_GENERAL.format(nodename=callback) errmsg = _ERR_GENERAL.format(nodename=callback)
self.caller.msg(errmsg, self._session) self.caller.msg(errmsg, self._session)
logger.log_trace()
raise raise
return ret return ret
@ -606,9 +615,11 @@ class EvMenu(object):
nodetext, options = ret, None nodetext, options = ret, None
except KeyError: except KeyError:
self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session)
logger.log_trace()
raise EvMenuError raise EvMenuError
except Exception: except Exception:
self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session)
logger.log_trace()
raise raise
# store options to make them easier to test # store options to make them easier to test
@ -665,24 +676,27 @@ class EvMenu(object):
if isinstance(ret, basestring): if isinstance(ret, basestring):
# only return a value if a string (a goto target), ignore all other returns # only return a value if a string (a goto target), ignore all other returns
if not ret:
# an empty string - rerun the same node
return self.nodename
return ret, kwargs return ret, kwargs
return None return None
def goto(self, nodename, raw_string, **kwargs): def extract_goto_exec(self, nodename, option_dict):
""" """
Run a node by name, optionally dynamically generating that name first. Helper: Get callables and their eventual kwargs.
Args: Args:
nodename (str or callable): Name of node or a callable nodename (str): The current node name (used for error reporting).
to be called as `function(caller, raw_string)` or `function(caller)` option_dict (dict): The seleted option's dict.
to return the actual goto string.
raw_string (str): The raw default string entered on the Returns:
previous node (only used if the node accepts it as an goto (str, callable or None): The goto directive in the option.
argument) goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty.
execute (callable or None): Executable given by the `exec` directive.
exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty.
""" """
def _extract_goto_exec(option_dict):
"Helper: Get callables and their eventual kwargs"
goto_kwargs, exec_kwargs = {}, {} goto_kwargs, exec_kwargs = {}, {}
goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) goto, execute = option_dict.get("goto", None), option_dict.get("exec", None)
if goto and isinstance(goto, (tuple, list)): if goto and isinstance(goto, (tuple, list)):
@ -705,6 +719,20 @@ class EvMenu(object):
execute = execute[0] execute = execute[0]
return goto, goto_kwargs, execute, exec_kwargs return goto, goto_kwargs, execute, exec_kwargs
def goto(self, nodename, raw_string, **kwargs):
"""
Run a node by name, optionally dynamically generating that name first.
Args:
nodename (str or callable): Name of node or a callable
to be called as `function(caller, raw_string)` or `function(caller)`
to return the actual goto string.
raw_string (str): The raw default string entered on the
previous node (only used if the node accepts it as an
argument)
"""
if callable(nodename): if callable(nodename):
# run the "goto" callable, if possible # run the "goto" callable, if possible
inp_nodename = nodename inp_nodename = nodename
@ -714,6 +742,9 @@ class EvMenu(object):
raise EvMenuError( raise EvMenuError(
"{}: goto callable must return str or (str, dict)".format(inp_nodename)) "{}: goto callable must return str or (str, dict)".format(inp_nodename))
nodename, kwargs = nodename[:2] nodename, kwargs = nodename[:2]
if not nodename:
# no nodename return. Re-run current node
nodename = self.nodename
try: try:
# execute the found node, make use of the returns. # execute the found node, make use of the returns.
nodetext, options = self._execute_node(nodename, raw_string, **kwargs) nodetext, options = self._execute_node(nodename, raw_string, **kwargs)
@ -746,12 +777,12 @@ class EvMenu(object):
desc = dic.get("desc", dic.get("text", None)) desc = dic.get("desc", dic.get("text", None))
if "_default" in keys: if "_default" in keys:
keys = [key for key in keys if key != "_default"] keys = [key for key in keys if key != "_default"]
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
self.default = (goto, goto_kwargs, execute, exec_kwargs) self.default = (goto, goto_kwargs, execute, exec_kwargs)
else: else:
# use the key (only) if set, otherwise use the running number # use the key (only) if set, otherwise use the running number
keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) keys = list(make_iter(dic.get("key", str(inum + 1).strip())))
goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic)
if keys: if keys:
display_options.append((keys[0], desc)) display_options.append((keys[0], desc))
for key in keys: for key in keys:
@ -945,14 +976,164 @@ class EvMenu(object):
node (str): The formatted node to display. node (str): The formatted node to display.
""" """
screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0]
nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) nodetext_width_max = max(m_len(line) for line in nodetext.split("\n"))
options_width_max = max(m_len(line) for line in optionstext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n"))
total_width = max(options_width_max, nodetext_width_max) total_width = min(screen_width, max(options_width_max, nodetext_width_max))
separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator1 = "_" * total_width + "\n\n" if nodetext_width_max else ""
separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else ""
return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext
# -----------------------------------------------------------
#
# List node (decorator turning a node into a list with
# look/edit/add functionality for the elements)
#
# -----------------------------------------------------------
def list_node(option_generator, select=None, pagesize=10):
"""
Decorator for making an EvMenu node into a multi-page list node. Will add new options,
prepending those options added in the node.
Args:
option_generator (callable or list): A list of strings indicating the options, or a callable
that is called as option_generator(caller) to produce such a list.
select (callable, option): Will be called as select(caller, menuchoice)
where menuchoice is the chosen option as a string. Should return the target node to
goto after this selection (or None to repeat the list-node). Note that if this is not
given, the decorated node must itself provide a way to continue from the node!
pagesize (int): How many options to show per page.
Example:
@list_node(['foo', 'bar'], select)
def node_index(caller):
text = "describing the list"
return text, []
Notes:
All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept
**kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named
options (descs) visible on the current node page.
"""
def decorator(func):
def _select_parser(caller, raw_string, **kwargs):
"""
Parse the select action
"""
available_choices = kwargs.get("available_choices", [])
try:
index = int(raw_string.strip()) - 1
selection = available_choices[index]
except Exception:
caller.msg("|rInvalid choice.|n")
else:
if select:
try:
return select(caller, selection)
except Exception:
logger.log_trace()
return None
def _list_node(caller, raw_string, **kwargs):
option_list = option_generator(caller) \
if callable(option_generator) else option_generator
npages = 0
page_index = 0
page = []
options = []
if option_list:
nall_options = len(option_list)
pages = [option_list[ind:ind + pagesize]
for ind in range(0, nall_options, pagesize)]
npages = len(pages)
page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0)))
page = pages[page_index]
text = ""
extra_text = None
# dynamic, multi-page option list. Each selection leads to the `select`
# callback being called with a result from the available choices
options.extend([{"desc": opt,
"goto": (_select_parser,
{"available_choices": page})} for opt in page])
if npages > 1:
# if the goto callable returns None, the same node is rerun, and
# kwargs not used by the callable are passed on to the node. This
# allows us to call ourselves over and over, using different kwargs.
options.append({"key": ("|Wcurrent|n", "c"),
"desc": "|W({}/{})|n".format(page_index + 1, npages),
"goto": (lambda caller: None,
{"optionpage_index": page_index})})
if page_index > 0:
options.append({"key": ("|wp|Wrevious page|n", "p"),
"goto": (lambda caller: None,
{"optionpage_index": page_index - 1})})
if page_index < npages - 1:
options.append({"key": ("|wn|Wext page|n", "n"),
"goto": (lambda caller: None,
{"optionpage_index": page_index + 1})})
# add data from the decorated node
decorated_options = []
try:
text, decorated_options = func(caller, raw_string)
except TypeError:
try:
text, decorated_options = func(caller)
except Exception:
raise
except Exception:
logger.log_trace()
else:
if isinstance(decorated_options, {}):
decorated_options = [decorated_options]
else:
decorated_options = make_iter(decorated_options)
extra_options = []
for eopt in decorated_options:
cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None
if cback:
signature = eopt[cback]
if callable(signature):
# callable with no kwargs defined
eopt[cback] = (signature, {"available_choices": page})
elif is_iter(signature):
if len(signature) > 1 and isinstance(signature[1], dict):
signature[1]["available_choices"] = page
eopt[cback] = signature
elif signature:
# a callable alone in a tuple (i.e. no previous kwargs)
eopt[cback] = (signature[0], {"available_choices": page})
else:
# malformed input.
logger.log_err("EvMenu @list_node decorator found "
"malformed option to decorate: {}".format(eopt))
extra_options.append(eopt)
options.extend(extra_options)
text = text + "\n\n" + extra_text if extra_text else text
return text, options
return _list_node
return decorator
# ------------------------------------------------------------------------------------------------- # -------------------------------------------------------------------------------------------------
# #
# Simple input shortcuts # Simple input shortcuts