Source code for elide.dialog

# This file is part of Elide, frontend to Lisien, a framework for life simulation games.
# Copyright (c) Zachary Spector, public@zacharyspector.com
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""Generic dialog boxes and menus, for in front of a Board"""

from functools import partial

from kivy.app import App
from kivy.clock import Clock, mainthread
from kivy.core.text import DEFAULT_FONT
from kivy.logger import Logger
from kivy.properties import (
	DictProperty,
	ListProperty,
	NumericProperty,
	ObjectProperty,
	StringProperty,
	VariableListProperty,
)
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.scrollview import ScrollView
from kivy.uix.widget import Widget

from .util import load_string_once


[docs] class Box(Widget): padding = VariableListProperty(6) border = VariableListProperty(4) font_size = StringProperty("15sp") font_name = StringProperty(DEFAULT_FONT) background = StringProperty() background_color = VariableListProperty([1, 1, 1, 1]) foreground_color = VariableListProperty([0, 0, 0, 1])
[docs] class ScrollableLabel(ScrollView): font_size = StringProperty("15sp") font_name = StringProperty(DEFAULT_FONT) color = VariableListProperty([0, 0, 0, 1]) line_spacing = NumericProperty(0) text = StringProperty()
[docs] class MessageBox(Box): """Looks like a TextInput but doesn't accept any input. Does support styled text with BBcode. """ line_spacing = NumericProperty(0) text = StringProperty()
[docs] class DialogMenu(Box): """Some buttons that make the game do things. Set ``options`` to a list of pairs of ``(text, function)`` and the menu will be populated with buttons that say ``text`` that call ``function`` when pressed. """ options = ListProperty() """List of pairs of (button_text, callable)""" def _set_sv_size(self, *_): self._sv.width = self.width - self.padding[0] - self.padding[2] self._sv.height = self.height - self.padding[1] - self.padding[3] def _set_sv_pos(self, *_): self._sv.x = self.x + self.padding[0] self._sv.y = self.y + self.padding[3] @mainthread def on_options(self, *_): if not hasattr(self, "_sv"): self._sv = ScrollView(size=self.size, pos=self.pos) self.bind(size=self._set_sv_size, pos=self._set_sv_pos) layout = BoxLayout(orientation="vertical") self._sv.add_widget(layout) self.add_widget(self._sv) else: layout = self._sv.children[0] layout.clear_widgets() for txt, part in self.options: if not callable(part): raise TypeError("Menu options must be callable") butn = Button( text=txt, on_release=part, font_name=self.font_name, font_size=self.font_size, valign="center", halign="center", ) butn.bind(size=butn.setter("text_size")) layout.add_widget(butn)
[docs] class Dialog(BoxLayout): """MessageBox with a DialogMenu beneath it. Set the properties ``message_kwargs`` and ``menu_kwargs``, respectively, to control them -- but you probably want to do that by returning a pair of dicts from an action in lisien. """ message_kwargs = DictProperty({}) menu_kwargs = DictProperty({}) def __init__(self, **kwargs): super().__init__(**kwargs) self.bind( message_kwargs=self._propagate_msg_kwargs, menu_kwargs=self._propagate_menu_kwargs, ) self._propagate_msg_kwargs() self._propagate_menu_kwargs() def _propagate_msg_kwargs(self, *_): if "msg" not in self.ids: Clock.schedule_once(self._propagate_msg_kwargs, 0) return kw = dict(self.message_kwargs) kw.setdefault( "background", "atlas://data/images/defaulttheme/textinput" ) for k, v in kw.items(): setattr(self.ids.msg, k, v) def _propagate_menu_kwargs(self, *_): if "menu" not in self.ids: Clock.schedule_once(self._propagate_menu_kwargs, 0) return kw = dict(self.menu_kwargs) kw.setdefault( "background", "atlas://data/images/defaulttheme/vkeyboard_background", ) for k, v in kw.items(): setattr(self.ids.menu, k, v)
[docs] class DialogLayout(FloatLayout): """A layout, normally empty, that can generate dialogs To make dialogs, set my ``todo`` property to a list. It may contain: * Strings, which will be displayed with an "OK" button to dismiss them * Lists of pairs of strings and callables, which generate buttons with the string on them that, when clicked, call the callable * Lists of pairs of dictionaries, which are interpreted as keyword arguments to :class:`MessageBox` and :class:`DialogMenu` In place of a callable you can use the name of a function in my ``usermod``, a Python module given by name. I'll import it when I need it. Needs to be instantiated with a lisien ``engine`` -- probably an :class:`EngineProxy`. """ dialog = ObjectProperty() todo = ListProperty() usermod = StringProperty("user") userpkg = StringProperty(None, allownone=True) def __init__(self, **kwargs): super().__init__(**kwargs) self.dialog = Dialog() self._finalize() def _finalize(self, *_): app = App.get_running_app() if not hasattr(app, "engine"): Clock.schedule_once(self._finalize, 0) return engine = app.engine todo = engine.universal.get("last_result") if isinstance(todo, list): self.todo = todo else: self.todo = [] self.idx = engine.universal.get("last_result_idx", 0) engine.universal.connect(self._pull) if self.todo: self.advance_dialog() def _pull(self, *_, key, value): if key == "last_result": self.todo = value if value and isinstance(value, list) else [] elif key == "last_result_idx": self.idx = value if value and isinstance(value, int) else 0 def on_idx(self, *_): lidx = self.engine.universal.get("last_result_idx") if lidx is not None and lidx != self.idx: self.engine.universal["last_result_idx"] = self.idx Logger.debug(f"DialogLayout.idx = {self.idx}")
[docs] def advance_dialog(self, after_ok=None, *args): """Try to display the next dialog described in my ``todo``. :param after_ok: An optional callable. Will be called after the user clicks a dialog option--as well as after any game-specific code for that option has run. """ self.clear_widgets() try: Logger.debug(f"About to update dialog: {self.todo[0]}") self._update_dialog(self.todo.pop(0), after_ok) except IndexError: if after_ok is not None: after_ok()
@mainthread def _update_dialog(self, diargs, after_ok, **kwargs): if diargs is None: Logger.debug("DialogLayout: null dialog") return if isinstance(diargs, tuple) and diargs[0] == "stop": if len(diargs) == 1: Logger.debug("DialogLayout: null dialog") return diargs = diargs[1] dia = self.dialog # Simple text dialogs just tell the player something and let them click OK if isinstance(diargs, str): dia.message_kwargs = {"text": diargs} dia.menu_kwargs = { "options": [("OK", partial(self.ok, cb2=after_ok))] } # List dialogs are for when you need the player to make a choice and don't care much # about presentation elif isinstance(diargs, list): dia.message_kwargs = {"text": "Select from the following:"} dia.menu_kwargs = { "options": list( map(partial(self._munge_menu_option, after_ok), diargs) ) } # For real control of the dialog, you need a pair of dicts -- # the 0th describes the message shown to the player, the 1th # describes the menu below elif isinstance(diargs, tuple): if len(diargs) != 2: # TODO more informative error raise TypeError("Need a tuple of length 2") msgkwargs, mnukwargs = diargs if isinstance(msgkwargs, dict): dia.message_kwargs = msgkwargs elif isinstance(msgkwargs, str): dia.message_kwargs["text"] = msgkwargs else: raise TypeError("Message must be dict or str") if isinstance(mnukwargs, dict): mnukwargs["options"] = list( map( partial(self._munge_menu_option, after_ok), mnukwargs["options"], ) ) dia.menu_kwargs = mnukwargs elif isinstance(mnukwargs, (list, tuple)): dia.menu_kwargs["options"] = list( map(partial(self._munge_menu_option, after_ok), mnukwargs) ) else: raise TypeError("Menu must be dict or list") else: raise TypeError( "Don't know how to turn {} into a dialog".format(type(diargs)) ) self.add_widget(dia)
[docs] def ok(self, *_, cb=None, cb2=None): """Clear dialog widgets, call ``cb``s if provided, and advance the dialog queue""" self.clear_widgets() if cb: cb() if cb2: cb2()
def _lookup_func(self, funcname): from importlib import import_module if not hasattr(self, "_usermod"): self._usermod = import_module(self.usermod, self.userpkg) return getattr(self.usermod, funcname) def _munge_menu_option(self, after_ok, option): if not isinstance(option, tuple): raise TypeError name, func = option if func is None: return name, partial(self.ok, cb2=after_ok) if callable(func): return name, partial(self.ok, cb=func, cb2=after_ok) if isinstance(func, tuple): fun = func[0] if isinstance(fun, str): fun = self._lookup_func(fun) args = func[1] if len(func) == 3: kwargs = func[2] func = partial(fun, *args, **kwargs) else: func = partial(fun, *args) if isinstance(func, str): func = self._lookup_func(func) return name, partial(self.ok, cb=func, cb2=after_ok)
load_string_once(""" <Box>: canvas.before: Color: rgba: self.background_color BorderImage: border: self.border pos: self.pos size: self.size source: self.background Color: rgba: 1, 1, 1, 1 <ScrollableLabel>: Label: size_hint_y: None height: self.texture_size[1] text_size: self.width, None text: root.text color: root.color <MessageBox>: ScrollableLabel: x: root.x + root.padding[0] y: root.y + root.padding[3] width: root.width - root.padding[2] height: root.height - root.padding[1] text: root.text color: root.foreground_color <Dialog>: orientation: 'vertical' pos_hint: {'x': 0, 'y': 0} size_hint: 1, 0.3 MessageBox: id: msg DialogMenu: id: menu """)