# 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 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 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
""")