Source code for elide.menu

# 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/>.
import os
import shutil
import zipfile
from functools import partial

from kivy import Logger
from kivy.app import App
from kivy.clock import Clock, mainthread, triggered
from kivy.properties import ObjectProperty, OptionProperty, StringProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.dropdown import DropDown
from kivy.uix.filechooser import FileChooserIconView
from kivy.uix.label import Label
from kivy.uix.modalview import ModalView
from kivy.uix.recycleview import RecycleView
from kivy.uix.screenmanager import Screen
from kivy.uix.textinput import TextInput

from .gen import GridGeneratorDialog
from .util import devour, load_kv, logwrap










[docs] class GeneratorButton(Button): pass
[docs] class WorldStartConfigurator(BoxLayout): """Give options for how to initialize the world state""" generator_type = OptionProperty("none", options=["none", "grid"]) dismiss = ObjectProperty() init_board = ObjectProperty() def __init__(self, **kwargs): super().__init__(**kwargs) binds = App.get_running_app()._bindings self.grid_config = GridGeneratorDialog() self.generator_dropdown = DropDown() def select_txt(btn): self.generator_dropdown.select(btn.text) for opt in ["None", "Grid"]: self.generator_dropdown.add_widget( GeneratorButton(text=opt, on_release=select_txt) ) binds_on_select = binds[ "WorldStartConfigurator", "generator_dropdown", "on_select" ] while binds_on_select: self.unbind_uid("on_select", binds_on_select.pop()) binds["WorldStartConfigurator", "generator_dropdown", "on_select"].add( self.fbind("on_select", self.select_generator_type) ) @logwrap(section="WorldStartConfigurator") def select_generator_type(self, instance, value): self.ids.drop.text = value if value == "none": self.ids.controls.clear_widgets() self.generator_type = "none" elif value == "Grid": self.ids.controls.clear_widgets() self.ids.controls.add_widget(self.grid_config) self.grid_config.size = self.ids.controls.size self.grid_config.pos = self.ids.controls.pos self.generator_type = "grid"
[docs] class GamePickerModal(ModalView): headline = StringProperty() def __init__(self, **kwargs): load_kv("menu.kv") super().__init__(**kwargs) @logwrap(section="GamePickerModal") def _decompress_and_start(self, game_file_path, game, *_): app = App.get_running_app() game_name = ( os.path.basename(game_file_path) .removesuffix(".lisien") .removesuffix(".zip") ) app.game_name = game_name play_dir = str(app.play_path) if os.path.exists(play_dir): # Likely left over from a failed run of Elide shutil.rmtree(play_dir) if hasattr(self, "_decompress"): Logger.debug( f"GamePickerModal: unpacking {game_file_path} to {play_dir}" ) self._decompress(game_file_path, play_dir) archive_path = None else: archive_path = game_file_path Clock.schedule_once( partial( app.start_game, name=game, archive_path=archive_path, ), 0.001, ) self.dismiss(force=True)
[docs] class GameExporterModal(GamePickerModal): path = StringProperty()
[docs] def on_open(self): app = App.get_running_app() self.path = str(app.games_path)
@triggered() @logwrap(section="GameExporterModal") def pick(self, game, *_): import shutil from tempfile import TemporaryDirectory from lisien.engine import Engine app = App.get_running_app() with TemporaryDirectory() as td: shutil.unpack_archive( str(os.path.join(app.games_path, game + ".zip")), td ) connect_string = None if "world.sqlite3" in os.listdir(td): connect_string = f"sqlite:///{td}/world.sqlite3" Logger.debug(f"GameExporterModal: about to export {game}") with Engine( td, workers=0, keyframe_on_close=False, connect_string=connect_string, ) as eng: if hasattr(app, "_ss"): eng._shared_storage = app._ss eng.export(game) self.dismiss() @logwrap(section="GameExporterModal") def regen(self): if "game_list" not in self.ids: return self.ids.game_list.regen()
[docs] class GameImporterModal(GamePickerModal): @triggered() @logwrap(section="GameImporterModal") def pick(self, selection, *_): if not selection: return if len(selection) > 1: raise RuntimeError( "That file picker is supposed to be single select" ) uri = selection[0] if isinstance(uri, str): uri_s = uri else: uri_s = uri.toString() if os.path.isdir(uri_s): return try: game_name = os.path.basename(uri).removesuffix(".lisien") self._decompress_and_start(uri, game_name) except ( NotADirectoryError, FileNotFoundError, FileExistsError, zipfile.error, ) as err: Logger.error(repr(err)) modal = ModalView() error_box = BoxLayout(orientation="vertical") error_box.add_widget(Label(text=repr(err), font_size=80)) error_box.add_widget(Button(text="OK", on_release=modal.dismiss)) modal.add_widget(error_box) modal.open()
[docs] @logwrap(section="GameImporterModal") def on_pre_open(self, *_): try: from android.storage import primary_external_storage_path Logger.error( "GameImporterModal: running on Android, where it won't work" ) return except ImportError: path = App.get_running_app().prefix self._file_chooser = FileChooserIconView(path=path) self.ids.chooser_goes_here.add_widget(self._file_chooser)
[docs] class GameLoaderModal(GamePickerModal): path = StringProperty()
[docs] def on_open(self, *_): app = App.get_running_app() self.path = str(app.games_path)
@triggered() @logwrap(section="GameLoaderModal") def pick(self, game, *_): app = App.get_running_app() games_path = str(app.games_path) if os.path.isfile(games_path): raise RuntimeError( "You put a file where I want to keep the games directory", app.games_dir, ) if not os.path.exists(games_path): os.makedirs(app.games_path) if game + ".zip" in os.listdir(games_path): game_file_path = str(os.path.join(games_path, game + ".zip")) if not zipfile.is_zipfile(game_file_path): raise RuntimeError("Game format invalid", game_file_path) else: raise RuntimeError("Invalid game name", game) self.clear_widgets() self.add_widget(Label(text="Please wait...", font_size=80)) Clock.schedule_once( partial(self._decompress_and_start, game_file_path, game), 0.05 ) _decompress = staticmethod(shutil.unpack_archive)
[docs] class GameList(RecycleView): picker = ObjectProperty() path = StringProperty() name = StringProperty() def on_open(self, *_): app = App.get_running_app() self.path = str(app.games_path) def __init__(self, **kwargs): super().__init__(**kwargs) app = App.get_running_app() binds = app._bindings self._trigger_regen = Clock.create_trigger(self.regen) for att in ("picker", "path"): assert not binds["GameList", id(self), att] binds["GameList", id(self), att].add( self.fbind(att, self._trigger_regen) ) app._unbinders.append(self.unbind_all) def unbind_all(self): binds = App.get_running_app()._bindings for att in ("picker", "path"): for uid in devour(binds["GameList", id(self), att]): self.unbind_uid(att, uid) @logwrap(section="GameList") def regen(self, *_): if not self.picker: Logger.debug("GameList: awaiting picker") Clock.schedule_once(self.regen, 0) return if not os.path.isdir(self.path): Logger.error( f"GameList: Can't list games at non-directory {self.path}" ) return Logger.debug(f"GameList: listing games in {self.path}") self.data = [ { "text": game[:-4], "on_release": partial(self.picker.pick, game[:-4]), } for game in filter( lambda game: game[-4:] == ".zip" and not game.startswith("."), os.listdir(self.path or "."), ) ] Logger.debug(f"GameList: generated {len(self.data)} entries")
[docs] class NewGameModal(ModalView): path = StringProperty() @triggered() @logwrap(section="NewGameModal") def validate_and_start(self, *_): game_name = self.ids.game_name.text self.ids.game_name.text = "" if not game_name: self.ids.game_name.hint_text = "Must be nonempty" return app = App.get_running_app() if os.path.isdir(app.games_dir): games = [ fn.removesuffix(".zip") for fn in os.listdir(app.games_dir) ] else: os.makedirs(app.games_dir) games = [] if game_name in games: self.ids.game_name.hint_text = "Name already taken" return game_archive_path = os.path.join(app.games_dir, game_name + ".zip") game_dir_path = os.path.join(app.prefix, game_name) can_start = False try: zipfile.ZipFile(game_archive_path, "w").close() os.makedirs(game_dir_path) can_start = True except Exception as ex: self.ids.game_name.hint_text = repr(ex) finally: if os.path.isfile(game_archive_path): os.remove(game_archive_path) if can_start and ( self.ids.worldstart.generator_type.lower() == "none" or self.ids.worldstart.grid_config.validate() ): self.clear_widgets() self.add_widget(Label(text="Please wait...", font_size=80)) self.canvas.ask_update() if os.path.exists(app.prefix) and any( fn not in {".", ".."} for fn in os.listdir(app.prefix) ): app.close_game() Clock.schedule_once(partial(self._really_start, game_name), 0.05)
[docs] def on_dismiss(self, *_): binds = App.get_running_app()._bindings world_on_select = binds[ "WorldStartConfigurator", "generator_dropdown", "on_select" ] while world_on_select: self.ids.worldstart.unbind_uid("on_select", world_on_select.pop())
@logwrap(section="NewGameModal") def _really_start(self, game_name, *_): app = App.get_running_app() worldstart = self.ids.worldstart if worldstart.generator_type == "grid": app.start_game( name=game_name, cb=lambda: worldstart.grid_config.generate(app.engine), ) else: app.start_game(name=game_name) app.select_character(app.engine.character["physical"]) self.dismiss()
def trigger(func: callable) -> callable: return triggered()(func)