From 28960a1f8aa6e8c0d18eb397719666dfbda2003d Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 27 Mar 2018 14:04:53 +0200 Subject: [PATCH 1/8] Add the basis of building_menu with quit and persistence --- evennia/contrib/building_menu.py | 539 +++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 evennia/contrib/building_menu.py diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py new file mode 100644 index 0000000000..b1151e1bb6 --- /dev/null +++ b/evennia/contrib/building_menu.py @@ -0,0 +1,539 @@ +""" +Module containing the building menu system. + +Evennia contributor: vincent-lg 2018 + +Building menus are similar to `EvMenu`, except that they have been specifically-designed to edit information as a builder. Creating a building menu in a command allows builders quick-editing of a given object, like a room. Here is an example of output you could obtain when editing the room: + +``` + Editing the room: Limbo + + [T]itle: the limbo room + [D]escription + This is the limbo room. You can easily change this default description, + either by using the |y@desc/edit|n command, or simply by selecting this + menu (enter |yd|n). + [E]xits: + north to A parking(#4) + [Q]uit this menu +``` + +From there, you can open the title sub-menu by pressing t. You can then change the room title by simply entering text, and go back to the main menu entering @ (all this is customizable). Press q to quit this menu. + +The first thing to do is to create a new module and place a class inheriting from `BuildingMenu` in it. + +```python +from evennia.contrib.building_menu import BuildingMenu + +class RoomMenu(BuildingMenu): + # ... to be ocmpleted ... +``` + +Next, override the `init` method. You can add choices (like the title, description, and exits sub-menus as seen above) by using the `add_choice` method. + +``` +class RoomMenu(BuildingMenu): + def init(self, room): + self.add_choice("Title", "t", attr="key") +``` + +That will create the first choice, the title sub-menu. If one opens your menu and enter t, she will be in the title sub-menu. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. + +`add_choice` has a lot of arguments and offer a great deal of flexibility. The most useful ones is probably the usage of callback, as you can set any argument in `add_choice` to be a callback, a function that you have defined above in your module. Here is a very short example of this: + +``` +def show_exits(menu +``` + +""" + +from inspect import getargspec + +from django.conf import settings +from evennia import Command, CmdSet +from evennia.commands import cmdhandler +from evennia.utils.logger import log_err, log_trace +from evennia.utils.ansi import strip_ansi +from evennia.utils.utils import class_from_module + +_MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH +_CMD_NOMATCH = cmdhandler.CMD_NOMATCH +_CMD_NOINPUT = cmdhandler.CMD_NOINPUT + +def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None): + """ + Call the value, if appropriate, or just return it. + + Args: + value (any): the value to obtain. + + Kwargs: + menu (BuildingMenu, optional): the building menu to pass to value + choice (Choice, optional): the choice to pass to value if a callback. + string (str, optional): the raw string to pass to value if a callback. if a callback. + obj (any): the object to pass to value if a callback. + caller (Account or Character, optional): the caller. + + Returns: + The value itself. If the argument is a function, call it with specific + arguments, passing it the menu, choice, string, and object if supported. + + Note: + If `value` is a function, call it with varying arguments. The + list of arguments will depend on the argument names. + - An argument named `menu` will contain the building menu or None. + - The `choice` argument will contain the choice or None. + - The `string` argument will contain the raw string or None. + - The `obj` argument will contain the object or None. + - The `caller` argument will contain the caller or None. + - Any other argument will contain the object (`obj`). + + """ + if callable(value): + # Check the function arguments + kwargs = {} + spec = getargspec(value) + args = spec.args + if spec.keywords: + kwargs.update(dict(menu=menu, choice=choice, string=string, obj=obj, caller=caller)) + else: + if "menu" in args: + kwargs["menu"] = menu + if "choice" in args: + kwargs["choice"] = choice + if "string" in args: + kwargs["string"] = string + if "obj" in args: + kwargs["obj"] = obj + if "caller" in args: + kwargs["caller"] = caller + + # Fill missing arguments + for arg in args: + if arg not in kwargs: + kwargs[arg] = obj + + # Call the function and return its return value + return value(**kwargs) + + return value + + +class Choice(object): + + """A choice object, created by `add_choice`.""" + + def __init__(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None, menu=None, caller=None, obj=None): + """Constructor. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + callback (callable, optional): the function to call before the input + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + brief (str or callable, optional): a brief summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + menu (BuildingMenu, optional): the parent building menu. + caller (Account or Object, optional): the caller. + obj (Object, optional): the object to edit. + + """ + self.title = title + self.key = key + self.aliases = aliases + self.attr = attr + self.callback = callback + self.text = text + self.brief = brief + self.menu = menu + self.caller = caller + self.obj = obj + + def __repr__(self): + return "".format(self.title, self.key) + + def trigger(self, string): + """Call the trigger callback, is specified.""" + if self.callback: + _call_or_get(self.callback, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + +class BuildingMenu(object): + + """ + Class allowing to create and set builder menus. + + A builder menu is a kind of `EvMenu` designed to edit objects by + builders, although it can be used for players in some contexts. You + could, for instance, create a builder menu to edit a room with a + sub-menu for the room's key, another for the room's description, + another for the room's exits, and so on. + + To add choices (sub-menus), you should call `add_choice` (see the + full documentation of this method). With most arguments, you can + specify either a plain string or a callback. This callback will be + called when the operation is to be performed. + + """ + + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): + """Constructor, you shouldn't override. See `init` instead. + + Args: + obj (Object): the object to be edited, like a room. + + """ + self.caller = caller + self.obj = obj + self.title = title + self.choices = [] + self.key = key + self.cmds = {} + + # Options (can be overridden in init) + self.min_shortcut = 1 + + if obj: + self.init(obj) + + # If choices have been added without keys, try to guess them + for choice in self.choices: + if choice.key is None: + title = strip_ansi(choice.title.strip()).lower() + length = self.min_shortcut + i = 0 + while length <= len(title): + while i < len(title) - length + 1: + guess = title[i:i + length] + if guess not in self.cmds: + choice.key = guess + break + + i += 1 + + if choice.key is not None: + break + + length += 1 + + if choice.key is None: + raise ValueError("Cannot guess the key for {}".format(choice)) + else: + self.cmds[chocie.key] = choice + + def init(self, obj): + """Create the sub-menu to edit the specified object. + + Args: + obj (Object): the object to edit. + + Note: + This method is probably to be overridden in your subclasses. Use `add_choice` and its variants to create sub-menus. + + """ + pass + + def add_choice(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None): + """Add a choice, a valid sub-menu, in the current builder menu. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + callback (callable, optional): the function to call before the input + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + brief (str or callable, optional): a brief summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + + Note: + All arguments can be a callable, like a function. This has the + advantage of allowing persistent building menus. If you specify + a callable in any of the arguments, the callable should return + the value expected by the argument (a str more often than + not) and can have the following arguments: + callable(menu) + callable(menu, user) + callable(menu, user, input) + + """ + key = key or "" + key = key.lower() + aliases = aliases or [] + aliases = [a.lower() for a in aliases] + if callback is None: + if attr is None: + raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) + + callback = menu_setattr + + if key and key in self.cmds: + raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + + choice = Choice(title, key, aliases, attr, callback, text, brief, menu=self, caller=self.caller, obj=self.obj) + self.choices.append(choice) + if key: + self.cmds[key] = choice + + for alias in aliases: + self.cmds[alias] = choice + + def add_choice_quit(self, title="quit the menu", key="q", aliases=None): + """ + Add a simple choice just to quit the building menu. + + Args: + title (str, optional): the choice title. + key (str, optional): the choice key. + aliases (list of str, optional): the choice aliases. + + Note: + This is just a shortcut method, calling `add_choice`. + + """ + return self.add_choice(title, key=key, aliases=aliases, callback=menu_quit) + + def _generate_commands(self, cmdset): + """ + Generate commands for the menu, if any is needed. + + Args: + cmdset (CmdSet): the cmdset. + + """ + if self.key is None: + for choice in self.choices: + cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) + cmd.get_help = lambda cmd, caller: _call_or_get(choice.text, menu=self, choice=choice, obj=self.obj, caller=self.caller) + cmdset.add(cmd) + + def _save(self): + """Save the menu in a persistent attribute on the caller.""" + self.caller.ndb._building_menu = self + self.caller.db._building_menu = { + "class": type(self).__module__ + "." + type(self).__name__, + "obj": self.obj, + "key": self.key, + } + + def open(self): + """Open the building menu for the caller.""" + caller = self.caller + self._save() + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + + # Try to find the newly added cmdset (a shortcut would be nice) + for cmdset in self.caller.cmdset.get(): + if isinstance(cmdset, BuildingMenuCmdSet): + self._generate_commands(cmdset) + self.display() + return + + # Display methods. Override for customization + def display_title(self): + """Return the menu title to be displayed.""" + return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) + + def display_choice(self, choice): + """Display the specified choice. + + Args: + choice (Choice): the menu choice. + + """ + title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) + clear_title = title.lower() + pos = clear_title.find(choice.key.lower()) + ret = " " + if pos >= 0: + ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] + else: + ret += "[|y" + choice.key.title() + "|n] " + title + + return ret + + def display(self): + """Display the entire menu.""" + menu = self.display_title() + "\n" + for choice in self.choices: + menu += "\n" + self.display_choice(choice) + + self.caller.msg(menu) + + @staticmethod + def restore(caller, cmdset): + """Restore the building menu for the caller. + + Args: + caller (Account or Character): the caller. + cmdset (CmdSet): the cmdset. + + Note: + This method should be automatically called if a menu is + saved in the caller, but the object itself cannot be found. + + """ + menu = caller.db._buildingmenu + if menu: + class_name = menu.get("class") + if not class_name: + log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) + return + + try: + menu_class = class_from_module(class_name) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) + return False + + # Create the menu + obj = menu.get("obj") + try: + building_menu = menu_class(caller, obj) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(class_name))) + return False + + # If there's no saved key, add the menu commands + building_menu._generate_commands(cmdset) + + return building_menu + + +class MenuCommand(Command): + + """An applicaiton-specific command.""" + + help_category = "Application-specific" + + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + self.choice = kwargs.pop("choice", None) + super(MenuCommand, self).__init__(**kwargs) + + def func(self): + """Function body.""" + if self.choice is None: + log_err("Command: {}, no choice has been specified".format(self.key)) + self.msg("An unexpected error occurred. Closing the menu.") + self.caller.cmdset.delete(BuildingMenuCmdSet) + return + + self.choice.trigger(self.args) + + +class CmdNoInput(MenuCommand): + + """No input has been found.""" + + key = _CMD_NOINPUT + locks = "cmd:all()" + + def func(self): + """Redisplay the screen, if any.""" + if self.menu: + self.menu.display() + else: + log_err("When CMDNOMATCH was called, the building menu couldn't be found") + self.caller.msg("The building menu couldn't be found, remove the CmdSet") + self.caller.cmdset.delete(BuildingMenuCmdSet) + + +class CmdNoMatch(Command): + + """No input has been found.""" + + key = _CMD_NOMATCH + locks = "cmd:all()" + + def func(self): + """Redirect most inputs to the screen, if found.""" + raw_string = self.raw_string.rstrip() + self.msg("No match") + + +class BuildingMenuCmdSet(CmdSet): + + """ + Building menu CmdSet, adding commands specific to the menu. + """ + + key = "building_menu" + priority = 5 + + def at_cmdset_creation(self): + """Populates the cmdset with commands.""" + caller = self.cmdsetobj + + # The caller could recall the menu + menu = caller.ndb._building_menu + if menu: + menu._generate_commands(self) + else: + menu = caller.db._building_menu + if menu: + menu = BuildingMenu.restore(caller, self) + + cmds = [CmdNoInput, CmdNoMatch] + for cmd in cmds: + self.add(cmd(building_menu=menu, choice=None)) + + +# Helper functions +def menu_setattr(menu, choice, obj, string): + """ + Set the value at the specified attribute. + + Args: + menu (BuildingMenu): the menu object. + choice (Chocie): the specific choice. + obj (any): the object to modify. + string (str): the string with the new value. + + Note: + This function is supposed to be used as a default to + `BuildingMenu.add_choice`, when an attribute name is specified + but no function to callback the said value. + + """ + attr = getattr(choice, "attr", None) + if choice is None or string is None or attr is None or menu is None: + log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + return + + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], string) + menu.display() + +def menu_quit(caller): + """ + Quit the menu, closing the CmdSet. + + Args: + caller (Account or Object): the caller. + + """ + if caller is None: + log_err("The function `menu_quit` was called from a building menu without a caller") + + if caller.cmdset.has(BuildingMenuCmdSet): + caller.msg("Closing the building menu.") + caller.cmdset.remove(BuildingMenuCmdSet) + else: + caller.msg("It looks like the building menu has already been closed.") From bfe9dde655c28ee17cc75b54f0df0e36635d4baf Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 27 Mar 2018 20:42:25 +0200 Subject: [PATCH 2/8] Add the setattr choice building menu as a default --- evennia/contrib/building_menu.py | 111 +++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index b1151e1bb6..ed77bb1cb3 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -48,6 +48,7 @@ def show_exits(menu """ from inspect import getargspec +from textwrap import dedent from django.conf import settings from evennia import Command, CmdSet @@ -123,7 +124,8 @@ class Choice(object): """A choice object, created by `add_choice`.""" - def __init__(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None, menu=None, caller=None, obj=None): + def __init__(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None, + menu=None, caller=None, obj=None): """Constructor. Args: @@ -132,9 +134,8 @@ class Choice(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - callback (callable, optional): the function to call before the input - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. + on_select (callable, optional): a callable to call when the choice is selected. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. brief (str or callable, optional): a brief summary of the @@ -150,7 +151,8 @@ class Choice(object): self.key = key self.aliases = aliases self.attr = attr - self.callback = callback + self.on_select = on_select + self.on_nomatch = on_nomatch self.text = text self.brief = brief self.menu = menu @@ -160,10 +162,30 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) - def trigger(self, string): - """Call the trigger callback, is specified.""" - if self.callback: - _call_or_get(self.callback, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + def select(self, string): + """Called when the user opens the choice.""" + if self.on_select: + _call_or_get(self.on_select, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + # Display the text if there is some + if self.text: + self.caller.msg(_call_or_get(self.text, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj)) + + + def nomatch(self, string): + """Called when the user entered something that wasn't a command in a given choice. + + Args: + string (str): the entered string. + + """ + if self.on_nomatch: + _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + def display_text(self): + """Display the choice text to the caller.""" + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + return text.format(obj=self.obj, caller=self.caller) class BuildingMenu(object): @@ -241,7 +263,7 @@ class BuildingMenu(object): """ pass - def add_choice(self, title, key=None, aliases=None, attr=None, callback=None, text=None, brief=None): + def add_choice(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None): """Add a choice, a valid sub-menu, in the current builder menu. Args: @@ -250,7 +272,8 @@ class BuildingMenu(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - callback (callable, optional): the function to call before the input + on_select (callable, optional): a callable to call when the choice is selected. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. is set in `attr`. If `attr` is not set, you should specify a function that both callback and set the value in `obj`. text (str or callable, optional): a text to be displayed when @@ -275,16 +298,21 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if callback is None: + if on_select is None and on_nomatch is None: if attr is None: raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - callback = menu_setattr + if attr and on_nomatch is None: + on_nomatch = menu_setattr + + if isinstance(text, basestring): + text = dedent(text.strip("\n")) if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - choice = Choice(title, key, aliases, attr, callback, text, brief, menu=self, caller=self.caller, obj=self.obj) + choice = Choice(title, key=key, aliases=aliases, attr=attr, on_select=on_select, on_nomatch=on_nomatch, text=text, + brief=brief, menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) if key: self.cmds[key] = choice @@ -305,7 +333,7 @@ class BuildingMenu(object): This is just a shortcut method, calling `add_choice`. """ - return self.add_choice(title, key=key, aliases=aliases, callback=menu_quit) + return self.add_choice(title, key=key, aliases=aliases, on_select=menu_quit) def _generate_commands(self, cmdset): """ @@ -318,7 +346,7 @@ class BuildingMenu(object): if self.key is None: for choice in self.choices: cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) - cmd.get_help = lambda cmd, caller: _call_or_get(choice.text, menu=self, choice=choice, obj=self.obj, caller=self.caller) + cmd.get_help = lambda cmd, caller: choice.display_text() cmdset.add(cmd) def _save(self): @@ -427,13 +455,20 @@ class MenuCommand(Command): def func(self): """Function body.""" - if self.choice is None: + if self.choice is None or self.menu is None: log_err("Command: {}, no choice has been specified".format(self.key)) - self.msg("An unexpected error occurred. Closing the menu.") + self.msg("|rAn unexpected error occurred. Closing the menu.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) return - self.choice.trigger(self.args) + self.menu.key = self.choice.key + self.menu._save() + for cmdset in self.caller.cmdset.get(): + if isinstance(cmdset, BuildingMenuCmdSet): + for command in cmdset: + cmdset.remove(command) + break + self.choice.select(self.raw_string) class CmdNoInput(MenuCommand): @@ -444,16 +479,20 @@ class CmdNoInput(MenuCommand): locks = "cmd:all()" def func(self): - """Redisplay the screen, if any.""" + """Display the menu or choice text.""" if self.menu: - self.menu.display() + choice = self.menu.cmds.get(self.menu.key) + if self.menu.key and choice: + choice.display_text() + else: + self.menu.display() else: - log_err("When CMDNOMATCH was called, the building menu couldn't be found") - self.caller.msg("The building menu couldn't be found, remove the CmdSet") + log_err("When CMDNOINPUT was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) -class CmdNoMatch(Command): +class CmdNoMatch(MenuCommand): """No input has been found.""" @@ -463,7 +502,26 @@ class CmdNoMatch(Command): def func(self): """Redirect most inputs to the screen, if found.""" raw_string = self.raw_string.rstrip() - self.msg("No match") + choice = self.menu.cmds.get(self.menu.key) if self.menu else None + cmdset = None + for cset in self.caller.cmdset.get(): + if isinstance(cset, BuildingMenuCmdSet): + cmdset = cset + break + if self.menu is None: + log_err("When CMDNOMATCH was called, the building menu couldn't be found") + self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") + self.caller.cmdset.delete(BuildingMenuCmdSet) + elif self.args == "/" and self.menu.key: + self.menu.key = None + self.menu._save() + self.menu._generate_commands(cmdset) + self.menu.display() + elif self.menu.key: + choice.nomatch(raw_string) + choice.display_text() + else: + self.menu.display() class BuildingMenuCmdSet(CmdSet): @@ -507,7 +565,7 @@ def menu_setattr(menu, choice, obj, string): Note: This function is supposed to be used as a default to `BuildingMenu.add_choice`, when an attribute name is specified - but no function to callback the said value. + but no function to call `on_nomatch` the said value. """ attr = getattr(choice, "attr", None) @@ -519,7 +577,6 @@ def menu_setattr(menu, choice, obj, string): obj = getattr(obj, part) setattr(obj, attr.split(".")[-1], string) - menu.display() def menu_quit(caller): """ From ec359503ac8fd01d77b8b148fdf24798061a833c Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 28 Mar 2018 14:06:51 +0200 Subject: [PATCH 3/8] Simplify and expand building menus with proper callables and at-a-glance descriptions --- evennia/contrib/building_menu.py | 101 +++++++++++++++++-------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index ed77bb1cb3..af72d909a5 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -11,7 +11,7 @@ Building menus are similar to `EvMenu`, except that they have been specifically- [T]itle: the limbo room [D]escription This is the limbo room. You can easily change this default description, - either by using the |y@desc/edit|n command, or simply by selecting this + either by using the |y@desc/edit|n command, or simply by entering this menu (enter |yd|n). [E]xits: north to A parking(#4) @@ -124,7 +124,7 @@ class Choice(object): """A choice object, created by `add_choice`.""" - def __init__(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None, + def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, menu=None, caller=None, obj=None): """Constructor. @@ -134,15 +134,16 @@ class Choice(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - on_select (callable, optional): a callable to call when the choice is selected. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. - brief (str or callable, optional): a brief summary of the + glance (str or callable, optional): an at-a-glance summary of the sub-menu shown in the main menu. It can be set to display the current value of the attribute in the main menu itself. menu (BuildingMenu, optional): the parent building menu. + on_enter (callable, optional): a callable to call when the choice is entered. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. + on_leave (callable, optional): a callable to call when the caller leaves the choice. caller (Account or Object, optional): the caller. obj (Object, optional): the object to edit. @@ -151,10 +152,11 @@ class Choice(object): self.key = key self.aliases = aliases self.attr = attr - self.on_select = on_select - self.on_nomatch = on_nomatch self.text = text - self.brief = brief + self.glance = glance + self.on_enter = on_enter + self.on_nomatch = on_nomatch + self.on_leave = on_leave self.menu = menu self.caller = caller self.obj = obj @@ -162,15 +164,13 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) - def select(self, string): + def enter(self, string): """Called when the user opens the choice.""" - if self.on_select: - _call_or_get(self.on_select, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + if self.on_enter: + _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) # Display the text if there is some - if self.text: - self.caller.msg(_call_or_get(self.text, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj)) - + self.display_text() def nomatch(self, string): """Called when the user entered something that wasn't a command in a given choice. @@ -184,8 +184,9 @@ class Choice(object): def display_text(self): """Display the choice text to the caller.""" - text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - return text.format(obj=self.obj, caller=self.caller) + if self.text: + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + self.caller.msg(text.format(obj=self.obj, caller=self.caller)) class BuildingMenu(object): @@ -206,6 +207,9 @@ class BuildingMenu(object): """ + keys_go_back = ["@"] + min_shortcut = 1 + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): """Constructor, you shouldn't override. See `init` instead. @@ -220,9 +224,6 @@ class BuildingMenu(object): self.key = key self.cmds = {} - # Options (can be overridden in init) - self.min_shortcut = 1 - if obj: self.init(obj) @@ -263,7 +264,8 @@ class BuildingMenu(object): """ pass - def add_choice(self, title, key=None, aliases=None, attr=None, on_select=None, on_nomatch=None, text=None, brief=None): + def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, + on_enter=None, on_nomatch=None, on_leave=None): """Add a choice, a valid sub-menu, in the current builder menu. Args: @@ -272,16 +274,17 @@ class BuildingMenu(object): the sub-neu. If not set, try to guess it based on the title. aliases (list of str, optional): the allowed aliases for this choice. attr (str, optional): the name of the attribute of 'obj' to set. - on_select (callable, optional): a callable to call when the choice is selected. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. text (str or callable, optional): a text to be displayed when the menu is opened It can be a callable. - brief (str or callable, optional): a brief summary of the + glance (str or callable, optional): an at-a-glance summary of the sub-menu shown in the main menu. It can be set to display the current value of the attribute in the main menu itself. + on_enter (callable, optional): a callable to call when the choice is entered. + on_nomatch (callable, optional): a callable to call when no match is entered in the choice. + is set in `attr`. If `attr` is not set, you should + specify a function that both callback and set the value in `obj`. + on_leave (callable, optional): a callable to call when the caller leaves the choice. Note: All arguments can be a callable, like a function. This has the @@ -298,7 +301,7 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if on_select is None and on_nomatch is None: + if on_enter is None and on_nomatch is None: if attr is None: raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) @@ -311,8 +314,8 @@ class BuildingMenu(object): if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - choice = Choice(title, key=key, aliases=aliases, attr=attr, on_select=on_select, on_nomatch=on_nomatch, text=text, - brief=brief, menu=self, caller=self.caller, obj=self.obj) + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, + menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) if key: self.cmds[key] = choice @@ -320,7 +323,7 @@ class BuildingMenu(object): for alias in aliases: self.cmds[alias] = choice - def add_choice_quit(self, title="quit the menu", key="q", aliases=None): + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ Add a simple choice just to quit the building menu. @@ -328,12 +331,18 @@ class BuildingMenu(object): title (str, optional): the choice title. key (str, optional): the choice key. aliases (list of str, optional): the choice aliases. + on_enter (callable, optional): a different callable to quit the building menu. Note: This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_quit` which simply + closes the menu and displays a message. It also + removes the CmdSet from the caller. If you supply + another callable instead, make sure to do the same. """ - return self.add_choice(title, key=key, aliases=aliases, on_select=menu_quit) + on_enter = on_enter or menu_quit + return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) def _generate_commands(self, cmdset): """ @@ -363,13 +372,7 @@ class BuildingMenu(object): caller = self.caller self._save() self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - - # Try to find the newly added cmdset (a shortcut would be nice) - for cmdset in self.caller.cmdset.get(): - if isinstance(cmdset, BuildingMenuCmdSet): - self._generate_commands(cmdset) - self.display() - return + self.display() # Display methods. Override for customization def display_title(self): @@ -391,6 +394,10 @@ class BuildingMenu(object): ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] else: ret += "[|y" + choice.key.title() + "|n] " + title + if choice.glance: + glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) + glance = glance.format(obj=self.obj, caller=self.caller) + ret += ": " + glance return ret @@ -415,7 +422,7 @@ class BuildingMenu(object): saved in the caller, but the object itself cannot be found. """ - menu = caller.db._buildingmenu + menu = caller.db._building_menu if menu: class_name = menu.get("class") if not class_name: @@ -426,10 +433,11 @@ class BuildingMenu(object): menu_class = class_from_module(class_name) except Exception: log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) - return False + return # Create the menu obj = menu.get("obj") + key = menu.get("key") try: building_menu = menu_class(caller, obj) except Exception: @@ -437,6 +445,7 @@ class BuildingMenu(object): return False # If there's no saved key, add the menu commands + building_menu.key = key building_menu._generate_commands(cmdset) return building_menu @@ -463,12 +472,9 @@ class MenuCommand(Command): self.menu.key = self.choice.key self.menu._save() - for cmdset in self.caller.cmdset.get(): - if isinstance(cmdset, BuildingMenuCmdSet): - for command in cmdset: - cmdset.remove(command) - break - self.choice.select(self.raw_string) + self.caller.cmdset.delete(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.choice.enter(self.raw_string) class CmdNoInput(MenuCommand): @@ -512,10 +518,11 @@ class CmdNoMatch(MenuCommand): log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) - elif self.args == "/" and self.menu.key: + elif raw_string in self.menu.keys_go_back and self.menu.key: self.menu.key = None self.menu._save() - self.menu._generate_commands(cmdset) + self.caller.cmdset.delete(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) self.menu.display() elif self.menu.key: choice.nomatch(raw_string) From 70b1bd1ada3a697c6f49441ff5f2af3c97d9f649 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Wed, 28 Mar 2018 19:20:08 +0200 Subject: [PATCH 4/8] Add the link between building menus and the EvEditor --- evennia/contrib/building_menu.py | 77 +++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index af72d909a5..e07e9e3141 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -53,8 +53,9 @@ from textwrap import dedent from django.conf import settings from evennia import Command, CmdSet from evennia.commands import cmdhandler -from evennia.utils.logger import log_err, log_trace from evennia.utils.ansi import strip_ansi +from evennia.utils.eveditor import EvEditor +from evennia.utils.logger import log_err, log_trace from evennia.utils.utils import class_from_module _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -323,6 +324,26 @@ class BuildingMenu(object): for alias in aliases: self.cmds[alias] = choice + def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): + """ + Add a simple choice to edit a given attribute in the EvEditor. + + Args: + title (str, optional): the choice title. + key (str, optional): the choice key. + aliases (list of str, optional): the choice aliases. + glance (str or callable, optional): the at-a-glance description. + on_enter (callable, optional): a different callable to edit the attribute. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_edit` which opens + an EvEditor to edit the specified attribute. + + """ + on_enter = on_enter or menu_edit + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ Add a simple choice just to quit the building menu. @@ -601,3 +622,57 @@ def menu_quit(caller): caller.cmdset.remove(BuildingMenuCmdSet) else: caller.msg("It looks like the building menu has already been closed.") + +def menu_edit(caller, choice, obj): + """ + Open the EvEditor to edit a specified field. + + Args: + caller (Account or Object): the caller. + choice (Choice): the choice object. + obj (any): the object to edit. + + """ + attr = choice.attr + caller.db._building_menu_to_edit = (obj, attr) + caller.cmdset.remove(BuildingMenuCmdSet) + EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) + +def _menu_loadfunc(caller): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + +def _menu_savefunc(caller, buf): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], buf) + + if caller.ndb._building_menu: + caller.ndb._building_menu.key = None + if caller.db._building_menu: + caller.db._building_menu["key"] = None + + caller.attributes.remove("_building_menu_to_edit") + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.display() + + return True + +def _menu_quitfunc(caller): + caller.attributes.remove("_building_menu_to_edit") + if caller.ndb._building_menu: + caller.ndb._building_menu.key = None + if caller.db._building_menu: + caller.db._building_menu["key"] = None + + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.display() From 0ac83639e0509d840df7a913fbc28be3f6d85492 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Fri, 30 Mar 2018 13:32:30 +0200 Subject: [PATCH 5/8] Update building menus, removing MenuCommand --- evennia/contrib/building_menu.py | 363 ++++++++++++++++++++++--------- 1 file changed, 265 insertions(+), 98 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index e07e9e3141..b8f6515b80 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -165,13 +165,19 @@ class Choice(object): def __repr__(self): return "".format(self.title, self.key) + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + return tuple(self.key.split(self.menu.sep_keys)) + def enter(self, string): """Called when the user opens the choice.""" if self.on_enter: _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) # Display the text if there is some - self.display_text() + if self.caller: + self.caller.msg(self.format_text()) def nomatch(self, string): """Called when the user entered something that wasn't a command in a given choice. @@ -183,11 +189,14 @@ class Choice(object): if self.on_nomatch: _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - def display_text(self): - """Display the choice text to the caller.""" + def format_text(self): + """Format the choice text and return it, or an empty string.""" + text = "" if self.text: text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - self.caller.msg(text.format(obj=self.obj, caller=self.caller)) + text = text.format(obj=self.obj, caller=self.caller) + + return text class BuildingMenu(object): @@ -209,9 +218,11 @@ class BuildingMenu(object): """ keys_go_back = ["@"] + sep_keys = "." + joker_key = "*" min_shortcut = 1 - def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key=None): + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key="", parents=None): """Constructor, you shouldn't override. See `init` instead. Args: @@ -223,6 +234,7 @@ class BuildingMenu(object): self.title = title self.choices = [] self.key = key + self.parents = parents or () self.cmds = {} if obj: @@ -253,6 +265,72 @@ class BuildingMenu(object): else: self.cmds[chocie.key] = choice + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + if not self.key: + return () + + return tuple(self.key.split(self.sep_keys)) + + @property + def current_choice(self): + """Return the current choice or None.""" + menu_keys = self.keys + if not menu_keys: + return None + + for choice in self.choices: + choice_keys = choice.keys + if len(menu_keys) == len(choice_keys): + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if menu_key != choice_key: + common = False + break + + if common: + return choice + + return None + + @property + def relevant_choices(self): + """Only return the relevant choices according to the current meny key. + + The menu key is stored and will be used to determine the + actual position of the caller in the menu. Therefore, this + method compares the menu key (`self.key`) to all the choices' + keys. It also handles the joker key. + + """ + menu_keys = self.keys + relevant = [] + for choice in self.choices: + choice_keys = choice.keys + if not menu_keys and len(choice_keys) == 1: + # First level choice with the menu key empty, that's relevant + relevant.append(choice) + elif len(menu_keys) == len(choice_keys) - 1: + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if menu_key != choice_key: + common = False + break + + if common: + relevant.append(choice) + + return relevant + def init(self, obj): """Create the sub-menu to edit the specified object. @@ -365,20 +443,6 @@ class BuildingMenu(object): on_enter = on_enter or menu_quit return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) - def _generate_commands(self, cmdset): - """ - Generate commands for the menu, if any is needed. - - Args: - cmdset (CmdSet): the cmdset. - - """ - if self.key is None: - for choice in self.choices: - cmd = MenuCommand(key=choice.key, aliases=choice.aliases, building_menu=self, choice=choice) - cmd.get_help = lambda cmd, caller: choice.display_text() - cmdset.add(cmd) - def _save(self): """Save the menu in a persistent attribute on the caller.""" self.caller.ndb._building_menu = self @@ -386,6 +450,7 @@ class BuildingMenu(object): "class": type(self).__module__ + "." + type(self).__name__, "obj": self.obj, "key": self.key, + "parents": self.parents, } def open(self): @@ -395,6 +460,121 @@ class BuildingMenu(object): self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) self.display() + def open_parent_menu(self): + """Open parent menu, using `self.parents`.""" + parents = list(self.parents) + if parents: + parent_class, parent_obj, parent_key = parents[-1] + del parents[-1] + + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + try: + menu_class = class_from_module(parent_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, parent_obj, key=parent_key, parents=tuple(parents)) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) + return + else: + return building_menu.open() + + def open_submenu(self, submenu_class, submenu_obj, parent_key): + """ + Open a sub-menu, closing the current menu and opening the new one. + + Args: + submenu_class (str): the submenu class as a Python path. + submenu_obj (any): the object to give to the submenu. + parent_key (str, optional): the parent key when the submenu is closed. + + Note: + When the user enters `@` in the submenu, she will go back to + the current menu, with the `parent_key` set as its key. + Therefore, you should set it on the key of the choice that + should be opened when the user leaves the submenu. + + Returns: + new_menu (BuildingMenu): the new building menu or None. + + """ + parents = list(self.parents) + parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_key)) + parents = tuple(parents) + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + # Shift to the new menu + try: + menu_class = class_from_module(submenu_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, submenu_obj, parents=parents) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) + return + else: + return building_menu.open() + + def move(self, key=None, back=False, quiet=False, string="" ): + """ + Move inside the menu. + + Args: + key (str): the portion of the key to add to the current + menu key, after a separator (`sep_keys`). If + you wish to go back in the menu tree, don't + provide a `key`, just set `back` to `True`. + back (bool, optional): go back in the menu (`False` by default). + quiet (bool, optional): should the menu or choice be displayed afterward? + + Note: + This method will need to be called directly should you + use more than two levels in your menu. For instance, + in your room menu, if you want to have an "exits" + option, and then be able to enter "north" in this + choice to edit an exit. The specific exit choice + could be a different menu (with a different class), but + it could also be an additional level in your original menu. + If that's the case, you will need to use this method. + + """ + choice = self.current_choice + if choice: + #choice.leave() + pass + + if not back: # Move forward + if not key: + raise ValueError("you are asking to move forward, you should specify a key.") + + if self.key: + self.key += self.sep_keys + self.key += key + else: # Move backward + if not self.keys: + raise ValueError("you already are at the top of the tree, you cannot move backward.") + + self.key = self.sep_keys.join(self.keys[:-1]) + + self._save() + choice = self.current_choice + if choice: + choice.enter(string) + + if not quiet: + self.display() + # Display methods. Override for customization def display_title(self): """Return the menu title to be displayed.""" @@ -423,12 +603,16 @@ class BuildingMenu(object): return ret def display(self): - """Display the entire menu.""" - menu = self.display_title() + "\n" - for choice in self.choices: - menu += "\n" + self.display_choice(choice) + """Display the entire menu or a single choice, depending on the current key..""" + choice = self.current_choice + if self.key and choice: + text = choice.format_text() + else: + text = self.display_title() + "\n" + for choice in self.choices: + text += "\n" + self.display_choice(choice) - self.caller.msg(menu) + self.caller.msg(text) @staticmethod def restore(caller, cmdset): @@ -459,97 +643,75 @@ class BuildingMenu(object): # Create the menu obj = menu.get("obj") key = menu.get("key") + parents = menu.get("parents") try: - building_menu = menu_class(caller, obj) + building_menu = menu_class(caller, obj, key=key, parents=parents) except Exception: log_trace("An error occurred while creating building menu {}".format(repr(class_name))) return False - # If there's no saved key, add the menu commands - building_menu.key = key - building_menu._generate_commands(cmdset) - return building_menu -class MenuCommand(Command): - - """An applicaiton-specific command.""" - - help_category = "Application-specific" - - def __init__(self, **kwargs): - self.menu = kwargs.pop("building_menu", None) - self.choice = kwargs.pop("choice", None) - super(MenuCommand, self).__init__(**kwargs) - - def func(self): - """Function body.""" - if self.choice is None or self.menu is None: - log_err("Command: {}, no choice has been specified".format(self.key)) - self.msg("|rAn unexpected error occurred. Closing the menu.|n") - self.caller.cmdset.delete(BuildingMenuCmdSet) - return - - self.menu.key = self.choice.key - self.menu._save() - self.caller.cmdset.delete(BuildingMenuCmdSet) - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.choice.enter(self.raw_string) - - -class CmdNoInput(MenuCommand): +class CmdNoInput(Command): """No input has been found.""" key = _CMD_NOINPUT locks = "cmd:all()" + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super(Command, self).__init__(**kwargs) + def func(self): """Display the menu or choice text.""" if self.menu: - choice = self.menu.cmds.get(self.menu.key) - if self.menu.key and choice: - choice.display_text() - else: - self.menu.display() + self.menu.display() else: log_err("When CMDNOINPUT was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) -class CmdNoMatch(MenuCommand): +class CmdNoMatch(Command): """No input has been found.""" key = _CMD_NOMATCH locks = "cmd:all()" + def __init__(self, **kwargs): + self.menu = kwargs.pop("building_menu", None) + super(Command, self).__init__(**kwargs) + def func(self): - """Redirect most inputs to the screen, if found.""" + """Call the proper menu or redirect to nomatch.""" raw_string = self.raw_string.rstrip() - choice = self.menu.cmds.get(self.menu.key) if self.menu else None - cmdset = None - for cset in self.caller.cmdset.get(): - if isinstance(cset, BuildingMenuCmdSet): - cmdset = cset - break if self.menu is None: log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") self.caller.cmdset.delete(BuildingMenuCmdSet) - elif raw_string in self.menu.keys_go_back and self.menu.key: - self.menu.key = None - self.menu._save() - self.caller.cmdset.delete(BuildingMenuCmdSet) - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.menu.display() - elif self.menu.key: + return + + choice = self.menu.current_choice + if raw_string in self.menu.keys_go_back: + if self.menu.key: + self.menu.move(back=True) + elif self.menu.parents: + self.menu.open_parent_menu() + else: + self.menu.display() + elif choice: choice.nomatch(raw_string) - choice.display_text() + self.caller.msg(choice.format_text()) else: - self.menu.display() + for choice in self.menu.relevant_choices: + if choice.key.lower() == raw_string.lower() or any(raw_string.lower() == alias for alias in choice.aliases): + self.menu.move(choice.key) + return + + self.msg("|rUnknown command: {}|n.".format(raw_string)) class BuildingMenuCmdSet(CmdSet): @@ -567,16 +729,14 @@ class BuildingMenuCmdSet(CmdSet): # The caller could recall the menu menu = caller.ndb._building_menu - if menu: - menu._generate_commands(self) - else: + if menu is None: menu = caller.db._building_menu if menu: menu = BuildingMenu.restore(caller, self) cmds = [CmdNoInput, CmdNoMatch] for cmd in cmds: - self.add(cmd(building_menu=menu, choice=None)) + self.add(cmd(building_menu=menu)) # Helper functions @@ -638,6 +798,28 @@ def menu_edit(caller, choice, obj): caller.cmdset.remove(BuildingMenuCmdSet) EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) +def open_submenu(caller, menu, choice, obj, parent_key): + """ + Open a sub-menu, closing the current menu and opening the new one + with `parent` set. + + Args: + caller (Account or Object): the caller. + menu (Building): the selected choice. + choice (Chocie): the choice. + obj (any): the object to be edited. + parent_key (any): the parent menu key. + + Note: + You can easily call this function from a different callback to customize its + behavior. + + """ + parent_key = parent_key if isinstance(parent_key, basestring) else None + menu.open_submenu(choice.attr, obj, parent_key) + + +# Private functions for EvEditor def _menu_loadfunc(caller): obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) if obj and attr: @@ -654,25 +836,10 @@ def _menu_savefunc(caller, buf): setattr(obj, attr.split(".")[-1], buf) - if caller.ndb._building_menu: - caller.ndb._building_menu.key = None - if caller.db._building_menu: - caller.db._building_menu["key"] = None - caller.attributes.remove("_building_menu_to_edit") - caller.cmdset.add(BuildingMenuCmdSet) - if caller.ndb._building_menu: - caller.ndb._building_menu.display() - return True def _menu_quitfunc(caller): - caller.attributes.remove("_building_menu_to_edit") - if caller.ndb._building_menu: - caller.ndb._building_menu.key = None - if caller.db._building_menu: - caller.db._building_menu["key"] = None - caller.cmdset.add(BuildingMenuCmdSet) if caller.ndb._building_menu: - caller.ndb._building_menu.display() + caller.ndb._building_menu.move(back=True) From e26f04d5738ba34e2bc369061df9ba53de42ec98 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Sun, 1 Apr 2018 11:48:49 +0200 Subject: [PATCH 6/8] Add unittests for building menus, fixing some errors --- evennia/commands/cmdsethandler.py | 2 - evennia/contrib/building_menu.py | 1387 ++++++++++++++++------------- evennia/contrib/tests.py | 163 +++- 3 files changed, 916 insertions(+), 636 deletions(-) diff --git a/evennia/commands/cmdsethandler.py b/evennia/commands/cmdsethandler.py index 84eea1fdc5..14d195599c 100644 --- a/evennia/commands/cmdsethandler.py +++ b/evennia/commands/cmdsethandler.py @@ -586,11 +586,9 @@ class CmdSetHandler(object): """ if callable(cmdset) and hasattr(cmdset, 'path'): # try it as a callable - print "Try callable", cmdset if must_be_default: return self.cmdset_stack and (self.cmdset_stack[0].path == cmdset.path) else: - print [cset.path for cset in self.cmdset_stack], cmdset.path return any([cset for cset in self.cmdset_stack if cset.path == cmdset.path]) else: diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index b8f6515b80..0d95492039 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -3,10 +3,14 @@ Module containing the building menu system. Evennia contributor: vincent-lg 2018 -Building menus are similar to `EvMenu`, except that they have been specifically-designed to edit information as a builder. Creating a building menu in a command allows builders quick-editing of a given object, like a room. Here is an example of output you could obtain when editing the room: +Building menus are similar to `EvMenu`, except that they have been +specifically designed to edit information as a builder. Creating a +building menu in a command allows builders quick-editing of a +given object, like a room. Here is an example of output you could +obtain when editing the room: ``` - Editing the room: Limbo + Editing the room: Limbo(#2) [T]itle: the limbo room [D]escription @@ -18,32 +22,58 @@ Building menus are similar to `EvMenu`, except that they have been specifically- [Q]uit this menu ``` -From there, you can open the title sub-menu by pressing t. You can then change the room title by simply entering text, and go back to the main menu entering @ (all this is customizable). Press q to quit this menu. +From there, you can open the title choice by pressing t. You can then +change the room title by simply entering text, and go back to the +main menu entering @ (all this is customizable). Press q to quit this menu. -The first thing to do is to create a new module and place a class inheriting from `BuildingMenu` in it. +The first thing to do is to create a new module and place a class +inheriting from `BuildingMenu` in it. ```python from evennia.contrib.building_menu import BuildingMenu -class RoomMenu(BuildingMenu): - # ... to be ocmpleted ... +class RoomBuildingMenu(BuildingMenu): + # ... ``` -Next, override the `init` method. You can add choices (like the title, description, and exits sub-menus as seen above) by using the `add_choice` method. +Next, override the `init` method. You can add choices (like the title, +description, and exits choices as seen above) by using the `add_choice` +method. ``` -class RoomMenu(BuildingMenu): +class RoomBuildingMenu(BuildingMenu): def init(self, room): - self.add_choice("Title", "t", attr="key") + self.add_choice("title", "t", attr="key") ``` -That will create the first choice, the title sub-menu. If one opens your menu and enter t, she will be in the title sub-menu. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. +That will create the first choice, the title choice. If one opens your menu +and enter t, she will be in the title choice. She can change the title +(it will write in the room's `key` attribute) and then go back to the +main menu using `@`. -`add_choice` has a lot of arguments and offer a great deal of flexibility. The most useful ones is probably the usage of callback, as you can set any argument in `add_choice` to be a callback, a function that you have defined above in your module. Here is a very short example of this: +`add_choice` has a lot of arguments and offer a great deal of +flexibility. The most useful ones is probably the usage of callbacks, +as you can set almost any argument in `add_choice` to be a callback, a +function that you have defined above in your module. This function will be +called when the menu element is triggered. +When you wish to create a building menu, you just need to import your +class, create it specifying your intended caller and object to edit, +then call `open`: + +```python +from import RoomBuildingMenu + +class CmdEdit(Command): + + def func(self): + menu = RoomBuildingMenu(self.caller, self.caller.location) + menu.open() ``` -def show_exits(menu -``` + +This is a very short introduction. For more details, see the online tutorial +(https://github.com/evennia/evennia/wiki/Building-menus) or read the +heavily-documented code below. """ @@ -58,37 +88,69 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.logger import log_err, log_trace from evennia.utils.utils import class_from_module +## Constants _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH _CMD_NOMATCH = cmdhandler.CMD_NOMATCH _CMD_NOINPUT = cmdhandler.CMD_NOINPUT +## Private functions +def _menu_loadfunc(caller): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + +def _menu_savefunc(caller, buf): + obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) + if obj and attr: + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) + + setattr(obj, attr.split(".")[-1], buf) + + caller.attributes.remove("_building_menu_to_edit") + return True + +def _menu_quitfunc(caller): + caller.cmdset.add(BuildingMenuCmdSet) + if caller.ndb._building_menu: + caller.ndb._building_menu.move(back=True) + def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=None): """ Call the value, if appropriate, or just return it. Args: - value (any): the value to obtain. + value (any): the value to obtain. It might be a callable (see note). Kwargs: menu (BuildingMenu, optional): the building menu to pass to value - choice (Choice, optional): the choice to pass to value if a callback. - string (str, optional): the raw string to pass to value if a callback. if a callback. - obj (any): the object to pass to value if a callback. - caller (Account or Character, optional): the caller. + if it is a callable. + choice (Choice, optional): the choice to pass to value if a callable. + string (str, optional): the raw string to pass to value if a callback. if a callable. + obj (Object): the object to pass to value if a callable. + caller (Account or Object, optional): the caller to pass to value + if a callable. Returns: - The value itself. If the argument is a function, call it with specific - arguments, passing it the menu, choice, string, and object if supported. + The value itself. If the argument is a function, call it with + specific arguments (see note). Note: If `value` is a function, call it with varying arguments. The - list of arguments will depend on the argument names. + list of arguments will depend on the argument names in your callable. - An argument named `menu` will contain the building menu or None. - The `choice` argument will contain the choice or None. - The `string` argument will contain the raw string or None. - The `obj` argument will contain the object or None. - The `caller` argument will contain the caller or None. - Any other argument will contain the object (`obj`). + Thus, you could define callbacks like this: + def on_enter(menu, caller, obj): + def on_nomatch(string, choice, menu): + def on_leave(caller, room): # note that room will contain `obj` """ if callable(value): @@ -120,538 +182,70 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No return value +## Helper functions, to be used in menu choices -class Choice(object): +def menu_setattr(menu, choice, obj, string): + """ + Set the value at the specified attribute. - """A choice object, created by `add_choice`.""" + Args: + menu (BuildingMenu): the menu object. + choice (Chocie): the specific choice. + obj (Object): the object to modify. + string (str): the string with the new value. - def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, - menu=None, caller=None, obj=None): - """Constructor. - - Args: - title (str): the choice's title. - key (str, optional): the key of the letters to type to access - the sub-neu. If not set, try to guess it based on the title. - aliases (list of str, optional): the allowed aliases for this choice. - attr (str, optional): the name of the attribute of 'obj' to set. - text (str or callable, optional): a text to be displayed when - the menu is opened It can be a callable. - glance (str or callable, optional): an at-a-glance summary of the - sub-menu shown in the main menu. It can be set to - display the current value of the attribute in the - main menu itself. - menu (BuildingMenu, optional): the parent building menu. - on_enter (callable, optional): a callable to call when the choice is entered. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - on_leave (callable, optional): a callable to call when the caller leaves the choice. - caller (Account or Object, optional): the caller. - obj (Object, optional): the object to edit. - - """ - self.title = title - self.key = key - self.aliases = aliases - self.attr = attr - self.text = text - self.glance = glance - self.on_enter = on_enter - self.on_nomatch = on_nomatch - self.on_leave = on_leave - self.menu = menu - self.caller = caller - self.obj = obj - - def __repr__(self): - return "".format(self.title, self.key) - - @property - def keys(self): - """Return a tuple of keys separated by `sep_keys`.""" - return tuple(self.key.split(self.menu.sep_keys)) - - def enter(self, string): - """Called when the user opens the choice.""" - if self.on_enter: - _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - - # Display the text if there is some - if self.caller: - self.caller.msg(self.format_text()) - - def nomatch(self, string): - """Called when the user entered something that wasn't a command in a given choice. - - Args: - string (str): the entered string. - - """ - if self.on_nomatch: - _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) - - def format_text(self): - """Format the choice text and return it, or an empty string.""" - text = "" - if self.text: - text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) - text = text.format(obj=self.obj, caller=self.caller) - - return text - - -class BuildingMenu(object): + Note: + This function is supposed to be used as a default to + `BuildingMenu.add_choice`, when an attribute name is specified + (in the `attr` argument) but no function `on_nomatch` is defined. """ - Class allowing to create and set builder menus. + attr = getattr(choice, "attr", None) if choice else None + if choice is None or string is None or attr is None or menu is None: + log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + return - A builder menu is a kind of `EvMenu` designed to edit objects by - builders, although it can be used for players in some contexts. You - could, for instance, create a builder menu to edit a room with a - sub-menu for the room's key, another for the room's description, - another for the room's exits, and so on. + for part in attr.split(".")[:-1]: + obj = getattr(obj, part) - To add choices (sub-menus), you should call `add_choice` (see the - full documentation of this method). With most arguments, you can - specify either a plain string or a callback. This callback will be - called when the operation is to be performed. + setattr(obj, attr.split(".")[-1], string) + return True + +def menu_quit(caller, menu): + """ + Quit the menu, closing the CmdSet. + + Args: + caller (Account or Object): the caller. + menu (BuildingMenu): the building menu to close. """ + if caller is None or menu is None: + log_err("The function `menu_quit` was called with missing arguments: caller={}, menu={}".format(caller, menu)) - keys_go_back = ["@"] - sep_keys = "." - joker_key = "*" - min_shortcut = 1 + if caller.cmdset.has(BuildingMenuCmdSet): + menu.close() + caller.msg("Closing the building menu.") + else: + caller.msg("It looks like the building menu has already been closed.") - def __init__(self, caller=None, obj=None, title="Building menu: {obj}", key="", parents=None): - """Constructor, you shouldn't override. See `init` instead. +def menu_edit(caller, choice, obj): + """ + Open the EvEditor to edit a specified field. - Args: - obj (Object): the object to be edited, like a room. + Args: + caller (Account or Object): the caller. + choice (Choice): the choice object. + obj (Object): the object to edit. - """ - self.caller = caller - self.obj = obj - self.title = title - self.choices = [] - self.key = key - self.parents = parents or () - self.cmds = {} + """ + attr = choice.attr + caller.db._building_menu_to_edit = (obj, attr) + caller.cmdset.remove(BuildingMenuCmdSet) + EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) - if obj: - self.init(obj) - - # If choices have been added without keys, try to guess them - for choice in self.choices: - if choice.key is None: - title = strip_ansi(choice.title.strip()).lower() - length = self.min_shortcut - i = 0 - while length <= len(title): - while i < len(title) - length + 1: - guess = title[i:i + length] - if guess not in self.cmds: - choice.key = guess - break - - i += 1 - - if choice.key is not None: - break - - length += 1 - - if choice.key is None: - raise ValueError("Cannot guess the key for {}".format(choice)) - else: - self.cmds[chocie.key] = choice - - @property - def keys(self): - """Return a tuple of keys separated by `sep_keys`.""" - if not self.key: - return () - - return tuple(self.key.split(self.sep_keys)) - - @property - def current_choice(self): - """Return the current choice or None.""" - menu_keys = self.keys - if not menu_keys: - return None - - for choice in self.choices: - choice_keys = choice.keys - if len(menu_keys) == len(choice_keys): - # Check all the intermediate keys - common = True - for menu_key, choice_key in zip(menu_keys, choice_keys): - if choice_key == self.joker_key: - continue - - if menu_key != choice_key: - common = False - break - - if common: - return choice - - return None - - @property - def relevant_choices(self): - """Only return the relevant choices according to the current meny key. - - The menu key is stored and will be used to determine the - actual position of the caller in the menu. Therefore, this - method compares the menu key (`self.key`) to all the choices' - keys. It also handles the joker key. - - """ - menu_keys = self.keys - relevant = [] - for choice in self.choices: - choice_keys = choice.keys - if not menu_keys and len(choice_keys) == 1: - # First level choice with the menu key empty, that's relevant - relevant.append(choice) - elif len(menu_keys) == len(choice_keys) - 1: - # Check all the intermediate keys - common = True - for menu_key, choice_key in zip(menu_keys, choice_keys): - if choice_key == self.joker_key: - continue - - if menu_key != choice_key: - common = False - break - - if common: - relevant.append(choice) - - return relevant - - def init(self, obj): - """Create the sub-menu to edit the specified object. - - Args: - obj (Object): the object to edit. - - Note: - This method is probably to be overridden in your subclasses. Use `add_choice` and its variants to create sub-menus. - - """ - pass - - def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, - on_enter=None, on_nomatch=None, on_leave=None): - """Add a choice, a valid sub-menu, in the current builder menu. - - Args: - title (str): the choice's title. - key (str, optional): the key of the letters to type to access - the sub-neu. If not set, try to guess it based on the title. - aliases (list of str, optional): the allowed aliases for this choice. - attr (str, optional): the name of the attribute of 'obj' to set. - text (str or callable, optional): a text to be displayed when - the menu is opened It can be a callable. - glance (str or callable, optional): an at-a-glance summary of the - sub-menu shown in the main menu. It can be set to - display the current value of the attribute in the - main menu itself. - on_enter (callable, optional): a callable to call when the choice is entered. - on_nomatch (callable, optional): a callable to call when no match is entered in the choice. - is set in `attr`. If `attr` is not set, you should - specify a function that both callback and set the value in `obj`. - on_leave (callable, optional): a callable to call when the caller leaves the choice. - - Note: - All arguments can be a callable, like a function. This has the - advantage of allowing persistent building menus. If you specify - a callable in any of the arguments, the callable should return - the value expected by the argument (a str more often than - not) and can have the following arguments: - callable(menu) - callable(menu, user) - callable(menu, user, input) - - """ - key = key or "" - key = key.lower() - aliases = aliases or [] - aliases = [a.lower() for a in aliases] - if on_enter is None and on_nomatch is None: - if attr is None: - raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - - if attr and on_nomatch is None: - on_nomatch = menu_setattr - - if isinstance(text, basestring): - text = dedent(text.strip("\n")) - - if key and key in self.cmds: - raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) - - choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, - menu=self, caller=self.caller, obj=self.obj) - self.choices.append(choice) - if key: - self.cmds[key] = choice - - for alias in aliases: - self.cmds[alias] = choice - - def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): - """ - Add a simple choice to edit a given attribute in the EvEditor. - - Args: - title (str, optional): the choice title. - key (str, optional): the choice key. - aliases (list of str, optional): the choice aliases. - glance (str or callable, optional): the at-a-glance description. - on_enter (callable, optional): a different callable to edit the attribute. - - Note: - This is just a shortcut method, calling `add_choice`. - If `on_enter` is not set, use `menu_edit` which opens - an EvEditor to edit the specified attribute. - - """ - on_enter = on_enter or menu_edit - return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) - - def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): - """ - Add a simple choice just to quit the building menu. - - Args: - title (str, optional): the choice title. - key (str, optional): the choice key. - aliases (list of str, optional): the choice aliases. - on_enter (callable, optional): a different callable to quit the building menu. - - Note: - This is just a shortcut method, calling `add_choice`. - If `on_enter` is not set, use `menu_quit` which simply - closes the menu and displays a message. It also - removes the CmdSet from the caller. If you supply - another callable instead, make sure to do the same. - - """ - on_enter = on_enter or menu_quit - return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) - - def _save(self): - """Save the menu in a persistent attribute on the caller.""" - self.caller.ndb._building_menu = self - self.caller.db._building_menu = { - "class": type(self).__module__ + "." + type(self).__name__, - "obj": self.obj, - "key": self.key, - "parents": self.parents, - } - - def open(self): - """Open the building menu for the caller.""" - caller = self.caller - self._save() - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) - self.display() - - def open_parent_menu(self): - """Open parent menu, using `self.parents`.""" - parents = list(self.parents) - if parents: - parent_class, parent_obj, parent_key = parents[-1] - del parents[-1] - - if self.caller.cmdset.has(BuildingMenuCmdSet): - self.caller.cmdset.remove(BuildingMenuCmdSet) - - try: - menu_class = class_from_module(parent_class) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) - return - - # Create the submenu - try: - building_menu = menu_class(self.caller, parent_obj, key=parent_key, parents=tuple(parents)) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) - return - else: - return building_menu.open() - - def open_submenu(self, submenu_class, submenu_obj, parent_key): - """ - Open a sub-menu, closing the current menu and opening the new one. - - Args: - submenu_class (str): the submenu class as a Python path. - submenu_obj (any): the object to give to the submenu. - parent_key (str, optional): the parent key when the submenu is closed. - - Note: - When the user enters `@` in the submenu, she will go back to - the current menu, with the `parent_key` set as its key. - Therefore, you should set it on the key of the choice that - should be opened when the user leaves the submenu. - - Returns: - new_menu (BuildingMenu): the new building menu or None. - - """ - parents = list(self.parents) - parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_key)) - parents = tuple(parents) - if self.caller.cmdset.has(BuildingMenuCmdSet): - self.caller.cmdset.remove(BuildingMenuCmdSet) - - # Shift to the new menu - try: - menu_class = class_from_module(submenu_class) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) - return - - # Create the submenu - try: - building_menu = menu_class(self.caller, submenu_obj, parents=parents) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) - return - else: - return building_menu.open() - - def move(self, key=None, back=False, quiet=False, string="" ): - """ - Move inside the menu. - - Args: - key (str): the portion of the key to add to the current - menu key, after a separator (`sep_keys`). If - you wish to go back in the menu tree, don't - provide a `key`, just set `back` to `True`. - back (bool, optional): go back in the menu (`False` by default). - quiet (bool, optional): should the menu or choice be displayed afterward? - - Note: - This method will need to be called directly should you - use more than two levels in your menu. For instance, - in your room menu, if you want to have an "exits" - option, and then be able to enter "north" in this - choice to edit an exit. The specific exit choice - could be a different menu (with a different class), but - it could also be an additional level in your original menu. - If that's the case, you will need to use this method. - - """ - choice = self.current_choice - if choice: - #choice.leave() - pass - - if not back: # Move forward - if not key: - raise ValueError("you are asking to move forward, you should specify a key.") - - if self.key: - self.key += self.sep_keys - self.key += key - else: # Move backward - if not self.keys: - raise ValueError("you already are at the top of the tree, you cannot move backward.") - - self.key = self.sep_keys.join(self.keys[:-1]) - - self._save() - choice = self.current_choice - if choice: - choice.enter(string) - - if not quiet: - self.display() - - # Display methods. Override for customization - def display_title(self): - """Return the menu title to be displayed.""" - return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) - - def display_choice(self, choice): - """Display the specified choice. - - Args: - choice (Choice): the menu choice. - - """ - title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) - clear_title = title.lower() - pos = clear_title.find(choice.key.lower()) - ret = " " - if pos >= 0: - ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] - else: - ret += "[|y" + choice.key.title() + "|n] " + title - if choice.glance: - glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) - glance = glance.format(obj=self.obj, caller=self.caller) - ret += ": " + glance - - return ret - - def display(self): - """Display the entire menu or a single choice, depending on the current key..""" - choice = self.current_choice - if self.key and choice: - text = choice.format_text() - else: - text = self.display_title() + "\n" - for choice in self.choices: - text += "\n" + self.display_choice(choice) - - self.caller.msg(text) - - @staticmethod - def restore(caller, cmdset): - """Restore the building menu for the caller. - - Args: - caller (Account or Character): the caller. - cmdset (CmdSet): the cmdset. - - Note: - This method should be automatically called if a menu is - saved in the caller, but the object itself cannot be found. - - """ - menu = caller.db._building_menu - if menu: - class_name = menu.get("class") - if not class_name: - log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) - return - - try: - menu_class = class_from_module(class_name) - except Exception: - log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) - return - - # Create the menu - obj = menu.get("obj") - key = menu.get("key") - parents = menu.get("parents") - try: - building_menu = menu_class(caller, obj, key=key, parents=parents) - except Exception: - log_trace("An error occurred while creating building menu {}".format(repr(class_name))) - return False - - return building_menu +## Building menu commands and CmdSet class CmdNoInput(Command): @@ -687,7 +281,7 @@ class CmdNoMatch(Command): def func(self): """Call the proper menu or redirect to nomatch.""" - raw_string = self.raw_string.rstrip() + raw_string = self.args.rstrip() if self.menu is None: log_err("When CMDNOMATCH was called, the building menu couldn't be found") self.caller.msg("|rThe building menu couldn't be found, remove the CmdSet.|n") @@ -696,15 +290,15 @@ class CmdNoMatch(Command): choice = self.menu.current_choice if raw_string in self.menu.keys_go_back: - if self.menu.key: + if self.menu.keys: self.menu.move(back=True) elif self.menu.parents: self.menu.open_parent_menu() else: self.menu.display() elif choice: - choice.nomatch(raw_string) - self.caller.msg(choice.format_text()) + if choice.nomatch(raw_string): + self.caller.msg(choice.format_text()) else: for choice in self.menu.relevant_choices: if choice.key.lower() == raw_string.lower() or any(raw_string.lower() == alias for alias in choice.aliases): @@ -716,9 +310,7 @@ class CmdNoMatch(Command): class BuildingMenuCmdSet(CmdSet): - """ - Building menu CmdSet, adding commands specific to the menu. - """ + """Building menu CmdSet.""" key = "building_menu" priority = 5 @@ -732,114 +324,657 @@ class BuildingMenuCmdSet(CmdSet): if menu is None: menu = caller.db._building_menu if menu: - menu = BuildingMenu.restore(caller, self) + menu = BuildingMenu.restore(caller) cmds = [CmdNoInput, CmdNoMatch] for cmd in cmds: self.add(cmd(building_menu=menu)) +## Menu classes -# Helper functions -def menu_setattr(menu, choice, obj, string): - """ - Set the value at the specified attribute. +class Choice(object): - Args: - menu (BuildingMenu): the menu object. - choice (Chocie): the specific choice. - obj (any): the object to modify. - string (str): the string with the new value. + """A choice object, created by `add_choice`.""" - Note: - This function is supposed to be used as a default to - `BuildingMenu.add_choice`, when an attribute name is specified - but no function to call `on_nomatch` the said value. + def __init__(self, title, key=None, aliases=None, attr=None, text=None, glance=None, on_enter=None, on_nomatch=None, on_leave=None, + menu=None, caller=None, obj=None): + """Constructor. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the choice. If not set, try to guess it based on the title. + aliases (list of str, optional): the allowed aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + text (str or callable, optional): a text to be displayed for this + choice. It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + menu (BuildingMenu, optional): the parent building menu. + on_enter (callable, optional): a callable to call when the + caller enters into the choice. + on_nomatch (callable, optional): a callable to call when no + match is entered in the choice. + on_leave (callable, optional): a callable to call when the caller + leaves the choice. + caller (Account or Object, optional): the caller. + obj (Object, optional): the object to edit. + + """ + self.title = title + self.key = key + self.aliases = aliases + self.attr = attr + self.text = text + self.glance = glance + self.on_enter = on_enter + self.on_nomatch = on_nomatch + self.on_leave = on_leave + self.menu = menu + self.caller = caller + self.obj = obj + + def __repr__(self): + return "".format(self.title, self.key) + + @property + def keys(self): + """Return a tuple of keys separated by `sep_keys`.""" + return tuple(self.key.split(self.menu.sep_keys)) + + def format_text(self): + """Format the choice text and return it, or an empty string.""" + text = "" + if self.text: + text = _call_or_get(self.text, menu=self.menu, choice=self, string="", caller=self.caller, obj=self.obj) + text = dedent(text.strip("\n")) + text = text.format(obj=self.obj, caller=self.caller) + + return text + + def enter(self, string): + """Called when the user opens the choice. + + Args: + string (str): the entered string. + + """ + if self.on_enter: + _call_or_get(self.on_enter, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + def nomatch(self, string): + """Called when the user entered something in the choice. + + Args: + string (str): the entered string. + + Returns: + to_display (bool): The return value of `nomatch` if set or + `True`. The rule is that if `no_match` returns `True`, + then the choice or menu is displayed. + + """ + if self.on_nomatch: + return _call_or_get(self.on_nomatch, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + return True + + def leave(self, string): + """Called when the user closes the choice. + + Args: + string (str): the entered string. + + """ + if self.on_leave: + _call_or_get(self.on_leave, menu=self.menu, choice=self, string=string, caller=self.caller, obj=self.obj) + + +class BuildingMenu(object): """ - attr = getattr(choice, "attr", None) - if choice is None or string is None or attr is None or menu is None: - log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) - return + Class allowing to create and set building menus to edit specific objects. - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + A building menu is a kind of `EvMenu` designed to edit objects by + builders, although it can be used for players in some contexts. You + could, for instance, create a building menu to edit a room with a + sub-menu for the room's key, another for the room's description, + another for the room's exits, and so on. - setattr(obj, attr.split(".")[-1], string) + To add choices (sub-menus), you should call `add_choice` (see the + full documentation of this method). With most arguments, you can + specify either a plain string or a callback. This callback will be + called when the operation is to be performed. -def menu_quit(caller): - """ - Quit the menu, closing the CmdSet. - - Args: - caller (Account or Object): the caller. + Some methods are provided for frequent needs (see the `add_choice_*` + methods). Some helper functions are defined at the top of this + module in order to be used as arguments to `add_choice` + in frequent cases. """ - if caller is None: - log_err("The function `menu_quit` was called from a building menu without a caller") - if caller.cmdset.has(BuildingMenuCmdSet): - caller.msg("Closing the building menu.") - caller.cmdset.remove(BuildingMenuCmdSet) - else: - caller.msg("It looks like the building menu has already been closed.") + keys_go_back = ["@"] # The keys allowing to go back in the menu tree + sep_keys = "." # The key separator for menus with more than 2 levels + joker_key = "*" # The special key meaning "anything" in a choice key + min_shortcut = 1 # The minimum length of shorcuts when `key` is not set -def menu_edit(caller, choice, obj): - """ - Open the EvEditor to edit a specified field. + def __init__(self, caller=None, obj=None, title="Building menu: {obj}", keys=None, parents=None, persistent=False): + """Constructor, you shouldn't override. See `init` instead. - Args: - caller (Account or Object): the caller. - choice (Choice): the choice object. - obj (any): the object to edit. + Args: + caller (Account or Object): the caller. + obj (Object): the object to be edited, like a room. + title (str, optional): the menu title. + keys (list of str, optional): the starting menu keys (None + to start from the first level). + parents (tuple, optional): information for parent menus, + automatically supplied. + persistent (bool, optional): should this building menu + survive a reload/restart? - """ - attr = choice.attr - caller.db._building_menu_to_edit = (obj, attr) - caller.cmdset.remove(BuildingMenuCmdSet) - EvEditor(caller, loadfunc=_menu_loadfunc, savefunc=_menu_savefunc, quitfunc=_menu_quitfunc, key="editor", persistent=True) + Note: + If some of these options have to be changed, it is + preferable to do so in the `init` method and not to + override `__init__`. For instance: + class RoomBuildingMenu(BuildingMenu): + def init(self, room): + self.title = "Menu for room: {obj.key}(#{obj.id})" + # ... -def open_submenu(caller, menu, choice, obj, parent_key): - """ - Open a sub-menu, closing the current menu and opening the new one - with `parent` set. + """ + self.caller = caller + self.obj = obj + self.title = title + self.keys = keys or [] + self.parents = parents or () + self.persistent = persistent + self.choices = [] + self.cmds = {} - Args: - caller (Account or Object): the caller. - menu (Building): the selected choice. - choice (Chocie): the choice. - obj (any): the object to be edited. - parent_key (any): the parent menu key. + if obj: + self.init(obj) + self._add_keys_choice() - Note: - You can easily call this function from a different callback to customize its - behavior. + @property + def current_choice(self): + """Return the current choice or None. - """ - parent_key = parent_key if isinstance(parent_key, basestring) else None - menu.open_submenu(choice.attr, obj, parent_key) + Returns: + choice (Choice): the current choice or None. + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. -# Private functions for EvEditor -def _menu_loadfunc(caller): - obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) - if obj and attr: - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + """ + menu_keys = self.keys + if not menu_keys: + return None - return getattr(obj, attr.split(".")[-1]) if obj is not None else "" + for choice in self.choices: + choice_keys = choice.keys + if len(menu_keys) == len(choice_keys): + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue -def _menu_savefunc(caller, buf): - obj, attr = caller.attributes.get("_building_menu_to_edit", [None, None]) - if obj and attr: - for part in attr.split(".")[:-1]: - obj = getattr(obj, part) + if not isinstance(menu_key, basestring) or menu_key != choice_key: + common = False + break - setattr(obj, attr.split(".")[-1], buf) + if common: + return choice - caller.attributes.remove("_building_menu_to_edit") - return True + return None -def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet) - if caller.ndb._building_menu: - caller.ndb._building_menu.move(back=True) + @property + def relevant_choices(self): + """Only return the relevant choices according to the current meny key. + + Returns: + relevant (list of Choice object): the relevant choices. + + Note: + We use the menu keys to identify the current position of + the caller in the menu. The menu `keys` hold a list of + keys that should match a choice to be usable. + + """ + menu_keys = self.keys + relevant = [] + for choice in self.choices: + choice_keys = choice.keys + if not menu_keys and len(choice_keys) == 1: + # First level choice with the menu key empty, that's relevant + relevant.append(choice) + elif len(menu_keys) == len(choice_keys) - 1: + # Check all the intermediate keys + common = True + for menu_key, choice_key in zip(menu_keys, choice_keys): + if choice_key == self.joker_key: + continue + + if not isinstance(menu_key, basestring) or menu_key != choice_key: + common = False + break + + if common: + relevant.append(choice) + + return relevant + + def _save(self): + """Save the menu in a attributes on the caller. + + If `persistent` is set to `True`, also save in a persistent attribute. + + """ + self.caller.ndb._building_menu = self + + if self.persistent: + self.caller.db._building_menu = { + "class": type(self).__module__ + "." + type(self).__name__, + "obj": self.obj, + "title": self.title, + "keys": self.keys, + "parents": self.parents, + "persistent": self.persistent, + } + + def _add_keys_choice(self): + """Add the choices' keys if some choices don't have valid keys.""" + # If choices have been added without keys, try to guess them + for choice in self.choices: + if not choice.key: + title = strip_ansi(choice.title.strip()).lower() + length = self.min_shortcut + while length <= len(title): + i = 0 + while i < len(title) - length + 1: + guess = title[i:i + length] + if guess not in self.cmds: + choice.key = guess + break + + i += 1 + + if choice.key: + break + + length += 1 + + if choice.key: + self.cmds[choice.key] = choice + else: + raise ValueError("Cannot guess the key for {}".format(choice)) + + def init(self, obj): + """Create the sub-menu to edit the specified object. + + Args: + obj (Object): the object to edit. + + Note: + This method is probably to be overridden in your subclasses. + Use `add_choice` and its variants to create menu choices. + + """ + pass + + def add_choice(self, title, key=None, aliases=None, attr=None, text=None, glance=None, + on_enter=None, on_nomatch=None, on_leave=None): + """ + Add a choice, a valid sub-menu, in the current builder menu. + + Args: + title (str): the choice's title. + key (str, optional): the key of the letters to type to access + the sub-neu. If not set, try to guess it based on the + choice title. + aliases (list of str, optional): the aliases for this choice. + attr (str, optional): the name of the attribute of 'obj' to set. + This is really useful if you want to edit an + attribute of the object (that's a frequent need). If + you don't want to do so, just use the `on_*` arguments. + text (str or callable, optional): a text to be displayed when + the menu is opened It can be a callable. + glance (str or callable, optional): an at-a-glance summary of the + sub-menu shown in the main menu. It can be set to + display the current value of the attribute in the + main menu itself. + on_enter (callable, optional): a callable to call when the + caller enters into this choice. + on_nomatch (callable, optional): a callable to call when + the caller enters something in this choice. If you + don't set this argument but you have specified + `attr`, then `obj`.`attr` will be set with the value + entered by the user. + on_leave (callable, optional): a callable to call when the + caller leaves the choice. + + Returns: + choice (Choice): the newly-created choice. + + Raises: + ValueError if the choice cannot be added. + + Note: + Most arguments can be callables, like functions. This has the + advantage of allowing great flexibility. If you specify + a callable in most of the arguments, the callable should return + the value expected by the argument (a str more often than + not). For instance, you could set a function to be called + to get the menu text, which allows for some filtering: + def text_exits(menu): + return "Some text to display" + class RoomBuildingMenu(BuildingMenu): + def init(self): + self.add_choice("exits", key="x", text=text_exits) + + The allowed arguments in a callable are specific to the + argument names (they are not sensitive to orders, not all + arguments have to be present). For more information, see + `_call_or_get`. + + """ + key = key or "" + key = key.lower() + aliases = aliases or [] + aliases = [a.lower() for a in aliases] + if on_enter is None and on_nomatch is None: + if attr is None: + raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) + + if attr and on_nomatch is None: + on_nomatch = menu_setattr + + if key and key in self.cmds: + raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, + menu=self, caller=self.caller, obj=self.obj) + self.choices.append(choice) + if key: + self.cmds[key] = choice + + for alias in aliases: + self.cmds[alias] = choice + + return choice + + def add_choice_edit(self, title="description", key="d", aliases=None, attr="db.desc", glance="\n {obj.db.desc}", on_enter=None): + """ + Add a simple choice to edit a given attribute in the EvEditor. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + glance (str or callable, optional): the at-a-glance description. + on_enter (callable, optional): a different callable to edit + the attribute. + + Returns: + choice (Choice): the newly-created choice. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_edit` which opens + an EvEditor to edit the specified attribute. + When the caller closes the editor (with :q), the menu + will be re-opened. + + """ + on_enter = on_enter or menu_edit + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + + def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): + """ + Add a simple choice just to quit the building menu. + + Args: + title (str, optional): the choice's title. + key (str, optional): the choice's key. + aliases (list of str, optional): the choice's aliases. + on_enter (callable, optional): a different callable + to quit the building menu. + + Note: + This is just a shortcut method, calling `add_choice`. + If `on_enter` is not set, use `menu_quit` which simply + closes the menu and displays a message. It also + removes the CmdSet from the caller. If you supply + another callable instead, make sure to do the same. + + """ + on_enter = on_enter or menu_quit + return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) + + def open(self): + """Open the building menu for the caller. + + Note: + This method should be called once when the building menu + has been instanciated. From there, the building menu will + be re-created automatically when the server + reloads/restarts, assuming `persistent` is set to `True`. + + """ + caller = self.caller + self._save() + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.display() + + def open_parent_menu(self): + """Open the parent menu, using `self.parents`. + + Note: + You probably don't need to call this method directly, + since the caller can go back to the parent menu using the + `keys_go_back` automatically. + + """ + parents = list(self.parents) + if parents: + parent_class, parent_obj, parent_keys = parents[-1] + del parents[-1] + + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + try: + menu_class = class_from_module(parent_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(parent_class))) + return + + # Create the parent menu + try: + building_menu = menu_class(self.caller, parent_obj, keys=parent_keys, parents=tuple(parents)) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(parent_class))) + return + else: + return building_menu.open() + + def open_submenu(self, submenu_class, submenu_obj, parent_keys=None): + """ + Open a sub-menu, closing the current menu and opening the new one. + + Args: + submenu_class (str): the submenu class as a Python path. + submenu_obj (Object): the object to give to the submenu. + parent_keys (list of str, optional): the parent keys when + the submenu is closed. + + Note: + When the user enters `@` in the submenu, she will go back to + the current menu, with the `parent_keys` set as its keys. + Therefore, you should set it on the keys of the choice that + should be opened when the user leaves the submenu. + + Returns: + new_menu (BuildingMenu): the new building menu or None. + + """ + parent_keys = parent_keys or [] + parents = list(self.parents) + parents.append((type(self).__module__ + "." + type(self).__name__, self.obj, parent_keys)) + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.remove(BuildingMenuCmdSet) + + # Shift to the new menu + try: + menu_class = class_from_module(submenu_class) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(submenu_class))) + return + + # Create the submenu + try: + building_menu = menu_class(self.caller, submenu_obj, parents=parents) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(submenu_class))) + return + else: + return building_menu.open() + + def move(self, key=None, back=False, quiet=False, string=""): + """ + Move inside the menu. + + Args: + key (any): the portion of the key to add to the current + menu keys. If you wish to go back in the menu + tree, don't provide a `key`, just set `back` to `True`. + back (bool, optional): go back in the menu (`False` by default). + quiet (bool, optional): should the menu or choice be + displayed afterward? + string (str, optional): the string sent by the caller to move. + + Note: + This method will need to be called directly should you + use more than two levels in your menu. For instance, + in your room menu, if you want to have an "exits" + option, and then be able to enter "north" in this + choice to edit an exit. The specific exit choice + could be a different menu (with a different class), but + it could also be an additional level in your original menu. + If that's the case, you will need to use this method. + + """ + choice = self.current_choice + if choice: + choice.leave("") + + if not back: # Move forward + if not key: + raise ValueError("you are asking to move forward, you should specify a key.") + + self.keys.append(key) + else: # Move backward + if not self.keys: + raise ValueError("you already are at the top of the tree, you cannot move backward.") + + del self.keys[-1] + + self._save() + choice = self.current_choice + if choice: + choice.enter(string) + + if not quiet: + self.display() + + def close(self): + """Close the building menu, removing the CmdSet.""" + if self.caller.cmdset.has(BuildingMenuCmdSet): + self.caller.cmdset.delete(BuildingMenuCmdSet) + if self.caller.attributes.has("_building_menu"): + self.caller.attributes.remove("_building_menu") + if self.caller.nattributes.has("_building_menu"): + self.caller.nattributes.remove("_building_menu") + + # Display methods. Override for customization + def display_title(self): + """Return the menu title to be displayed.""" + return _call_or_get(self.title, menu=self, obj=self.obj, caller=self.caller).format(obj=self.obj) + + def display_choice(self, choice): + """Display the specified choice. + + Args: + choice (Choice): the menu choice. + + """ + title = _call_or_get(choice.title, menu=self, choice=choice, obj=self.obj, caller=self.caller) + clear_title = title.lower() + pos = clear_title.find(choice.key.lower()) + ret = " " + if pos >= 0: + ret += title[:pos] + "[|y" + choice.key.title() + "|n]" + title[pos + len(choice.key):] + else: + ret += "[|y" + choice.key.title() + "|n] " + title + + if choice.glance: + glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) + glance = glance.format(obj=self.obj, caller=self.caller) + ret += ": " + glance + + return ret + + def display(self): + """Display the entire menu or a single choice, depending on the keys.""" + choice = self.current_choice + if self.keys and choice: + text = choice.format_text() + else: + text = self.display_title() + "\n" + for choice in self.relevant_choices: + text += "\n" + self.display_choice(choice) + + self.caller.msg(text) + + @staticmethod + def restore(caller): + """Restore the building menu for the caller. + + Args: + caller (Account or Object): the caller. + + Note: + This method should be automatically called if a menu is + saved in the caller, but the object itself cannot be found. + + """ + menu = caller.db._building_menu + if menu: + class_name = menu.get("class") + if not class_name: + log_err("BuildingMenu: on caller {}, a persistent attribute holds building menu data, but no class could be found to restore the menu".format(caller)) + return + + try: + menu_class = class_from_module(class_name) + except Exception: + log_trace("BuildingMenu: attempting to load class {} failed".format(repr(class_name))) + return + + # Create the menu + obj = menu.get("obj") + keys = menu.get("keys") + title = menu.get("title", "") + parents = menu.get("parents") + persistent = menu.get("persistent", False) + try: + building_menu = menu_class(caller, obj, title=title, keys=keys, parents=parents, persistent=persistent) + except Exception: + log_trace("An error occurred while creating building menu {}".format(repr(class_name))) + return + + return building_menu diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 30bf71dcc8..69af89a3b6 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,7 +670,7 @@ class TestGenderSub(CommandTest): char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) txt = "Test |p gender" self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") - + # test health bar contrib from evennia.contrib import health_bar @@ -966,7 +966,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") - + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. @@ -984,7 +984,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") - + # Test range commands def test_turnbattlerangecmd(self): # Start with range module specific commands. @@ -998,7 +998,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + class TestTurnBattleFunc(EvenniaTest): @@ -1080,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1159,7 +1159,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test combat functions in tb_range too. def test_tbrangefunc(self): testroom = create_object(DefaultRoom, key="Test Room") @@ -1264,7 +1264,7 @@ Bar -Qux""" class TestTreeSelectFunc(EvenniaTest): - + def test_tree_functions(self): # Dash counter self.assertTrue(tree_select.dashcount("--test") == 2) @@ -1279,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest): # Option list to menu options test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, - {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) @@ -1414,3 +1414,150 @@ class TestRandomStringGenerator(EvenniaTest): # We can't generate one more with self.assertRaises(random_string_generator.ExhaustedGenerator): SIMPLE_GENERATOR.get() + + +# Tests for the building_menu contrib +from evennia.contrib.building_menu import BuildingMenu, CmdNoInput, CmdNoMatch + +class Submenu(BuildingMenu): + def init(self, exit): + self.add_choice("title", key="t", attr="key") + +class TestBuildingMenu(CommandTest): + + def setUp(self): + super(TestBuildingMenu, self).setUp() + self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") + self.menu.add_choice("title", key="t", attr="key") + self.menu.add_choice_quit() + + def test_quit(self): + """Try to quit the building menu.""" + self.assertFalse(self.char1.cmdset.has("building_menu")) + self.menu.open() + self.assertTrue(self.char1.cmdset.has("building_menu")) + self.call(CmdNoMatch(building_menu=self.menu), "q") + # char1 tries to quit the editor + self.assertFalse(self.char1.cmdset.has("building_menu")) + + def test_setattr(self): + """Test the simple setattr provided by building menus.""" + key = self.room1.key + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.assertIsNotNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIsNone(self.menu.current_choice) + self.assertEqual(self.room1.key, "some new title") + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_add_choice_without_key(self): + """Try to add choices without keys.""" + choices = [] + for i in range(20): + choices.append(self.menu.add_choice("choice", attr="test")) + self.menu._add_keys_choice() + keys = ["c", "h", "o", "i", "e", "ch", "ho", "oi", "ic", "ce", "cho", "hoi", "oic", "ice", "choi", "hoic", "oice", "choic", "hoice", "choice"] + for i in range(20): + self.assertEqual(choices[i].key, keys[i]) + + # Adding another key of the same title would break, no more available shortcut + self.menu.add_choice("choice", attr="test") + with self.assertRaises(ValueError): + self.menu._add_keys_choice() + + def test_callbacks(self): + """Test callbacks in menus.""" + self.room1.key = "room1" + def on_enter(caller, menu): + caller.msg("on_enter:{}".format(menu.title)) + def on_nomatch(caller, string, choice): + caller.msg("on_nomatch:{},{}".format(string, choice.key)) + def on_leave(caller, obj): + caller.msg("on_leave:{}".format(obj.key)) + self.menu.add_choice("test", key="e", on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave) + self.call(CmdNoMatch(building_menu=self.menu), "e", "on_enter:test") + self.call(CmdNoMatch(building_menu=self.menu), "ok", "on_nomatch:ok,e") + self.call(CmdNoMatch(building_menu=self.menu), "@", "on_leave:room1") + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_multi_level(self): + """Test multi-level choices.""" + # Creaste three succeeding menu (t2 is contained in t1, t3 is contained in t2) + def on_nomatch_t1(caller, menu): + menu.move("whatever") # this will be valid since after t1 is a joker + + def on_nomatch_t2(caller, menu): + menu.move("t3") # this time the key matters + + t1 = self.menu.add_choice("what", key="t1", attr="t1", on_nomatch=on_nomatch_t1) + t2 = self.menu.add_choice("and", key="t1.*", attr="t2", on_nomatch=on_nomatch_t2) + t3 = self.menu.add_choice("why", key="t1.*.t3", attr="t3") + self.menu.open() + + # Move into t1 + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "t1") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move into t2 + self.call(CmdNoMatch(building_menu=self.menu), "t2") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move into t3 + self.call(CmdNoMatch(building_menu=self.menu), "t3") + self.assertEqual(self.menu.current_choice, t3) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Move back to t2 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t2) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertIn(t3, self.menu.relevant_choices) + + # Move back into t1 + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertEqual(self.menu.current_choice, t1) + self.assertNotIn(t1, self.menu.relevant_choices) + self.assertIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + + # Moves back to the main menu + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.assertIn(t1, self.menu.relevant_choices) + self.assertNotIn(t2, self.menu.relevant_choices) + self.assertNotIn(t3, self.menu.relevant_choices) + self.assertIsNone(self.menu.current_choice) + self.call(CmdNoMatch(building_menu=self.menu), "q") + + def test_submenu(self): + """Test to add sub-menus.""" + def open_exit(menu): + menu.open_submenu("evennia.contrib.tests.Submenu", self.exit) + return False + + self.menu.add_choice("exit", key="x", on_enter=open_exit) + self.menu.open() + self.call(CmdNoMatch(building_menu=self.menu), "x") + self.menu = self.char1.ndb._building_menu + self.call(CmdNoMatch(building_menu=self.menu), "t") + self.call(CmdNoMatch(building_menu=self.menu), "in") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.call(CmdNoMatch(building_menu=self.menu), "@") + self.menu = self.char1.ndb._building_menu + self.assertEqual(self.char1.ndb._building_menu.obj, self.room1) + self.call(CmdNoMatch(building_menu=self.menu), "q") + self.assertEqual(self.exit.key, "in") From a7b4dc09e94adca873213b48feb0b93ad8420144 Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Mon, 2 Apr 2018 14:53:34 +0200 Subject: [PATCH 7/8] [building menu] The BuildingMenuCmdSet is permanent only if the menu is persistent --- evennia/contrib/building_menu.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index 0d95492039..4a2f204c6c 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -114,7 +114,7 @@ def _menu_savefunc(caller, buf): return True def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet) + caller.cmdset.add(BuildingMenuCmdSet, permanent=calelr.ndb._building_menu and caller.ndb._building_menu.persistent or False) if caller.ndb._building_menu: caller.ndb._building_menu.move(back=True) @@ -767,7 +767,7 @@ class BuildingMenu(object): """ caller = self.caller self._save() - self.caller.cmdset.add(BuildingMenuCmdSet, permanent=True) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=self.persistent) self.display() def open_parent_menu(self): From fa31367a761ff416d5fdb98dae17b22f8fe8661b Mon Sep 17 00:00:00 2001 From: Vincent Le Goff Date: Tue, 4 Sep 2018 20:33:54 +0200 Subject: [PATCH 8/8] Update the building menu, following Griatch's feedback --- evennia/contrib/building_menu.py | 181 +++++++++++++++++++++++++++---- evennia/contrib/tests.py | 7 +- 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/evennia/contrib/building_menu.py b/evennia/contrib/building_menu.py index 4a2f204c6c..75b7fe63c2 100644 --- a/evennia/contrib/building_menu.py +++ b/evennia/contrib/building_menu.py @@ -3,11 +3,39 @@ Module containing the building menu system. Evennia contributor: vincent-lg 2018 -Building menus are similar to `EvMenu`, except that they have been -specifically designed to edit information as a builder. Creating a -building menu in a command allows builders quick-editing of a -given object, like a room. Here is an example of output you could -obtain when editing the room: +Building menus are in-game menus, not unlike `EvMenu` though using a +different approach. Building menus have been specifically designed to edit +information as a builder. Creating a building menu in a command allows +builders quick-editing of a given object, like a room. If you follow the +steps below to add the contrib, you will have access to an `@edit` command +that will edit any default object offering to change its key and description. + +1. Import the `GenericBuildingCmd` class from this contrib in your `mygame/commands/default_cmdset.py` file: + + ```python + from evennia.contrib.building_menu import GenericBuildingCmd + ``` + +2. Below, add the command in the `CharacterCmdSet`: + + ```python + # ... These lines should exist in the file + class CharacterCmdSet(default_cmds.CharacterCmdSet): + key = "DefaultCharacter" + + def at_cmdset_creation(self): + super(CharacterCmdSet, self).at_cmdset_creation() + # ... add the line below + self.add(GenericBuildingCmd()) + ``` + +The `@edit` command will allow you to edit any object. You will need to +specify the object name or ID as an argument. For instance: `@edit here` +will edit the current room. However, building menus can perform much more +than this very simple example, read on for more details. + +Building menus can be set to edit about anything. Here is an example of +output you could obtain when editing the room: ``` Editing the room: Limbo(#2) @@ -51,12 +79,24 @@ and enter t, she will be in the title choice. She can change the title (it will write in the room's `key` attribute) and then go back to the main menu using `@`. -`add_choice` has a lot of arguments and offer a great deal of +`add_choice` has a lot of arguments and offers a great deal of flexibility. The most useful ones is probably the usage of callbacks, as you can set almost any argument in `add_choice` to be a callback, a function that you have defined above in your module. This function will be called when the menu element is triggered. +Notice that in order to edit a description, the best method to call isn't +`add_choice`, but `add_choice_edit`. This is a convenient shortcut +which is available to quickly open an `EvEditor` when entering this choice +and going back to the menu when the editor closes. + +``` +class RoomBuildingMenu(BuildingMenu): + def init(self, room): + self.add_choice("title", "t", attr="key") + self.add_choice_edit("description", key="d", attr="db.desc") +``` + When you wish to create a building menu, you just need to import your class, create it specifying your intended caller and object to edit, then call `open`: @@ -66,6 +106,8 @@ from import RoomBuildingMenu class CmdEdit(Command): + key = "redit" + def func(self): menu = RoomBuildingMenu(self.caller, self.caller.location) menu.open() @@ -114,7 +156,7 @@ def _menu_savefunc(caller, buf): return True def _menu_quitfunc(caller): - caller.cmdset.add(BuildingMenuCmdSet, permanent=calelr.ndb._building_menu and caller.ndb._building_menu.persistent or False) + caller.cmdset.add(BuildingMenuCmdSet, permanent=caller.ndb._building_menu and caller.ndb._building_menu.persistent or False) if caller.ndb._building_menu: caller.ndb._building_menu.move(back=True) @@ -129,7 +171,7 @@ def _call_or_get(value, menu=None, choice=None, string=None, obj=None, caller=No menu (BuildingMenu, optional): the building menu to pass to value if it is a callable. choice (Choice, optional): the choice to pass to value if a callable. - string (str, optional): the raw string to pass to value if a callback. if a callable. + string (str, optional): the raw string to pass to value if a callback. obj (Object): the object to pass to value if a callable. caller (Account or Object, optional): the caller to pass to value if a callable. @@ -202,7 +244,10 @@ def menu_setattr(menu, choice, obj, string): """ attr = getattr(choice, "attr", None) if choice else None if choice is None or string is None or attr is None or menu is None: - log_err("The `menu_setattr` function was called to set the attribute {} of object {} to {}, but the choice {} of menu {} or another information is missing.".format(attr, obj, repr(string), choice, menu)) + log_err(dedent(""" + The `menu_setattr` function was called to set the attribute {} of object {} to {}, + but the choice {} of menu {} or another information is missing. + """.format(attr, obj, repr(string), choice, menu)).strip("\n")).strip() return for part in attr.split(".")[:-1]: @@ -219,6 +264,11 @@ def menu_quit(caller, menu): caller (Account or Object): the caller. menu (BuildingMenu): the building menu to close. + Note: + This callback is used by default when using the + `BuildingMenu.add_choice_quit` method. This method is called + automatically if the menu has no parent. + """ if caller is None or menu is None: log_err("The function `menu_quit` was called with missing arguments: caller={}, menu={}".format(caller, menu)) @@ -231,7 +281,7 @@ def menu_quit(caller, menu): def menu_edit(caller, choice, obj): """ - Open the EvEditor to edit a specified field. + Open the EvEditor to edit a specified attribute. Args: caller (Account or Object): the caller. @@ -437,13 +487,13 @@ class BuildingMenu(object): """ Class allowing to create and set building menus to edit specific objects. - A building menu is a kind of `EvMenu` designed to edit objects by - builders, although it can be used for players in some contexts. You - could, for instance, create a building menu to edit a room with a + A building menu is somewhat similar to `EvMenu`, but designed to edit + objects by builders, although it can be used for players in some contexts. + You could, for instance, create a building menu to edit a room with a sub-menu for the room's key, another for the room's description, another for the room's exits, and so on. - To add choices (sub-menus), you should call `add_choice` (see the + To add choices (simple sub-menus), you should call `add_choice` (see the full documentation of this method). With most arguments, you can specify either a plain string or a callback. This callback will be called when the operation is to be performed. @@ -492,9 +542,13 @@ class BuildingMenu(object): self.persistent = persistent self.choices = [] self.cmds = {} + self.can_quit = False if obj: self.init(obj) + if not parents and not self.can_quit: + # Automatically add the menu to quit + self.add_choice_quit(key=None) self._add_keys_choice() @property @@ -686,16 +740,26 @@ class BuildingMenu(object): key = key.lower() aliases = aliases or [] aliases = [a.lower() for a in aliases] - if on_enter is None and on_nomatch is None: - if attr is None: - raise ValueError("The choice {} has neither attr nor callback, specify one of these as arguments".format(title)) - if attr and on_nomatch is None: on_nomatch = menu_setattr if key and key in self.cmds: raise ValueError("A conflict exists between {} and {}, both use key or alias {}".format(self.cmds[key], title, repr(key))) + if attr: + if glance is None: + glance = "{obj." + attr + "}" + if text is None: + text = """ + ------------------------------------------------------------------------------- + {attr} for {{obj}}(#{{obj.id}}) + + You can change this value simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current value: |c{{{obj_attr}}}|n + """.format(attr=attr, obj_attr="obj." + attr, back="|n or |y".join(self.keys_go_back)) + choice = Choice(title, key=key, aliases=aliases, attr=attr, text=text, glance=glance, on_enter=on_enter, on_nomatch=on_nomatch, on_leave=on_leave, menu=self, caller=self.caller, obj=self.obj) self.choices.append(choice) @@ -731,7 +795,7 @@ class BuildingMenu(object): """ on_enter = on_enter or menu_edit - return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter) + return self.add_choice(title, key=key, aliases=aliases, attr=attr, glance=glance, on_enter=on_enter, text="") def add_choice_quit(self, title="quit the menu", key="q", aliases=None, on_enter=None): """ @@ -753,6 +817,7 @@ class BuildingMenu(object): """ on_enter = on_enter or menu_quit + self.can_quit = True return self.add_choice(title, key=key, aliases=aliases, on_enter=on_enter) def open(self): @@ -767,6 +832,11 @@ class BuildingMenu(object): """ caller = self.caller self._save() + + # Remove the same-key cmdset if exists + if caller.cmdset.has(BuildingMenuCmdSet): + caller.cmdset.remove(BuildingMenuCmdSet) + self.caller.cmdset.add(BuildingMenuCmdSet, permanent=self.persistent) self.display() @@ -923,7 +993,11 @@ class BuildingMenu(object): if choice.glance: glance = _call_or_get(choice.glance, menu=self, choice=choice, caller=self.caller, string="", obj=self.obj) - glance = glance.format(obj=self.obj, caller=self.caller) + try: + glance = glance.format(obj=self.obj, caller=self.caller) + except: + import pdb;pdb.set_trace() + ret += ": " + glance return ret @@ -978,3 +1052,70 @@ class BuildingMenu(object): return return building_menu + + +# Generic building menu and command +class GenericBuildingMenu(BuildingMenu): + + """A generic building menu, allowing to edit any object. + + This is more a demonstration menu. By default, it allows to edit the + object key and description. Nevertheless, it will be useful to demonstrate + how building menus are meant to be used. + + """ + + def init(self, obj): + """Build the meny, adding the 'key' and 'description' choices. + + Args: + obj (Object): any object to be edited, like a character or room. + + Note: + The 'quit' choice will be automatically added, though you can + call `add_choice_quit` to add this choice with different options. + + """ + self.add_choice("key", key="k", attr="key", glance="{obj.key}", text=""" + ------------------------------------------------------------------------------- + Editing the key of {{obj.key}}(#{{obj.id}}) + + You can change the simply by entering it. + Use |y{back}|n to go back to the main menu. + + Current key: |c{{obj.key}}|n + """.format(back="|n or |y".join(self.keys_go_back))) + self.add_choice_edit("description", key="d", attr="db.desc") + + +class GenericBuildingCmd(Command): + + """ + Generic building command. + + Syntax: + @edit [object] + + Open a building menu to edit the specified object. This menu allows to + change the object's key and description. + + Examples: + @edit here + @edit self + @edit #142 + + """ + + key = "@edit" + + def func(self): + if not self.args.strip(): + self.msg("You should provide an argument to this function: the object to edit.") + return + + obj = self.caller.search(self.args.strip(), global_search=True) + if not obj: + return + + menu = GenericBuildingMenu(self.caller, obj) + menu.open() diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index b651198ccf..6f73e23837 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1712,7 +1712,6 @@ class TestBuildingMenu(CommandTest): super(TestBuildingMenu, self).setUp() self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") self.menu.add_choice("title", key="t", attr="key") - self.menu.add_choice_quit() def test_quit(self): """Try to quit the building menu.""" @@ -1774,9 +1773,9 @@ class TestBuildingMenu(CommandTest): def on_nomatch_t2(caller, menu): menu.move("t3") # this time the key matters - t1 = self.menu.add_choice("what", key="t1", attr="t1", on_nomatch=on_nomatch_t1) - t2 = self.menu.add_choice("and", key="t1.*", attr="t2", on_nomatch=on_nomatch_t2) - t3 = self.menu.add_choice("why", key="t1.*.t3", attr="t3") + t1 = self.menu.add_choice("what", key="t1", on_nomatch=on_nomatch_t1) + t2 = self.menu.add_choice("and", key="t1.*", on_nomatch=on_nomatch_t2) + t3 = self.menu.add_choice("why", key="t1.*.t3") self.menu.open() # Move into t1