From 4cb518930c515a6fe74e4ca3deb8c2fce68cd2cb Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:23:14 +0000 Subject: [PATCH] Fix, OptionsCreator: export options on Linux (#5774) * Core/Utils: Use correct env for save_filename from AppImage * OptionsCreator: run export on a separate thread Running a blocking call from kivy misbehaves on Linux. This also changes '*.yaml' to '.yaml' for Utils.save_filename, which is the correct way to call it. * Core/Utils: destroy Tk root after save/open_filename This allows using those functions from multiple threads. Note that pure Tk apps should not use those functions from Utils. * OptionsCreator: show snack when save_filename fails * OptionsCreator: disable window while exporting * OptionsCreator: fixing typing of added stuff --- .gitignore | 2 ++ OptionsCreator.py | 56 +++++++++++++++++++---------- Utils.py | 27 +++++++++----- mypy.ini | 2 ++ typings/kivy/clock.pyi | 82 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 mypy.ini create mode 100644 typings/kivy/clock.pyi diff --git a/.gitignore b/.gitignore index f4415ad740..cbc33e5858 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ Output Logs/ /datapackage /datapackage_export.json /custom_worlds +# stubgen output +/out/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/OptionsCreator.py b/OptionsCreator.py index 103dc763cf..4e56b680b8 100644 --- a/OptionsCreator.py +++ b/OptionsCreator.py @@ -6,6 +6,7 @@ if __name__ == "__main__": from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel, ToggleButton, MarkupDropdown, ResizableTextField) +from kivy.clock import Clock from kivy.uix.behaviors.button import ButtonBehavior from kivymd.uix.behaviors import RotateBehavior from kivymd.uix.anchorlayout import MDAnchorLayout @@ -269,34 +270,53 @@ class OptionsCreator(ThemedApp): self.options = {} super().__init__() - def export_options(self, button: Widget): - if 0 < len(self.name_input.text) < 17 and self.current_game: - file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])], + @staticmethod + def show_result_snack(text: str) -> None: + MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + + def on_export_result(self, text: str | None) -> None: + self.container.disabled = False + if text is not None: + Clock.schedule_once(lambda _: self.show_result_snack(text), 0) + + def export_options_background(self, options: dict[str, typing.Any]) -> None: + try: + file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])], Utils.get_file_safe_name(f"{self.name_input.text}.yaml")) + except Exception: + self.on_export_result("Could not open dialog. Already open?") + raise + + if not file_name: + self.on_export_result(None) # No file selected. No need to show a message for this. + return + + try: + with open(file_name, 'w') as f: + f.write(Utils.dump(options, sort_keys=False)) + f.close() + self.on_export_result("File saved successfully.") + except Exception: + self.on_export_result("Could not save file.") + raise + + def export_options(self, button: Widget) -> None: + if 0 < len(self.name_input.text) < 17 and self.current_game: + import threading options = { "name": self.name_input.text, "description": f"YAML generated by Archipelago {Utils.__version__}.", "game": self.current_game, self.current_game: {k: check_random(v) for k, v in self.options.items()} } - try: - with open(file_name, 'w') as f: - f.write(Utils.dump(options, sort_keys=False)) - f.close() - MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() - except FileNotFoundError: - MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start() + self.container.disabled = True elif not self.name_input.text: - MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + self.show_result_snack("Name must not be empty.") elif not self.current_game: - MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5}, - size_hint_x=0.5).open() + self.show_result_snack("You must select a game to play.") else: - MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24), - pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + self.show_result_snack("Name cannot be longer than 16 characters.") def create_range(self, option: typing.Type[Range], name: str): def update_text(range_box: VisualRange): diff --git a/Utils.py b/Utils.py index db6ae371e7..0dca6b9592 100644 --- a/Utils.py +++ b/Utils.py @@ -811,29 +811,32 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin except tkinter.TclError: return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), - initialfile=suggest or None) + try: + return tkinter.filedialog.askopenfilename( + title=title, + filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None, + ) + finally: + root.destroy() def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ -> typing.Optional[str]: logging.info(f"Opening file save dialog for {title}.") - def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None - if is_linux: # prefer native dialog from shutil import which kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters) + return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) selection = (f"--filename={suggest}",) if suggest else () - return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection) + return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection) # fall back to tk try: @@ -856,8 +859,14 @@ def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin except tkinter.TclError: return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() - return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), - initialfile=suggest or None) + try: + return tkinter.filedialog.asksaveasfilename( + title=title, + filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None, + ) + finally: + root.destroy() def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000000..3a8372455c --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +mypy_path = typings diff --git a/typings/kivy/clock.pyi b/typings/kivy/clock.pyi new file mode 100644 index 0000000000..74b2c26799 --- /dev/null +++ b/typings/kivy/clock.pyi @@ -0,0 +1,82 @@ +from _typeshed import Incomplete +from kivy._clock import ( + ClockEvent as ClockEvent, + ClockNotRunningError as ClockNotRunningError, + CyClockBase as CyClockBase, + CyClockBaseFree as CyClockBaseFree, + FreeClockEvent as FreeClockEvent, +) + +__all__ = [ + "Clock", + "ClockNotRunningError", + "ClockEvent", + "FreeClockEvent", + "CyClockBase", + "CyClockBaseFree", + "triggered", + "ClockBaseBehavior", + "ClockBaseInterruptBehavior", + "ClockBaseInterruptFreeBehavior", + "ClockBase", + "ClockBaseInterrupt", + "ClockBaseFreeInterruptAll", + "ClockBaseFreeInterruptOnly", + "mainthread", +] + +class ClockBaseBehavior: + MIN_SLEEP: float + SLEEP_UNDERSHOOT: Incomplete + def __init__(self, async_lib: str = "asyncio", **kwargs) -> None: ... + def init_async_lib(self, lib) -> None: ... + @property + def frametime(self): ... + @property + def frames(self): ... + @property + def frames_displayed(self): ... + def usleep(self, microseconds) -> None: ... + def idle(self): ... + async def async_idle(self): ... + def tick(self) -> None: ... + async def async_tick(self) -> None: ... + def pre_idle(self) -> None: ... + def post_idle(self, ts, current): ... + def tick_draw(self) -> None: ... + def get_fps(self): ... + def get_rfps(self): ... + def get_time(self): ... + def get_boottime(self): ... + time: Incomplete + def handle_exception(self, e) -> None: ... + +class ClockBaseInterruptBehavior(ClockBaseBehavior): + interupt_next_only: bool + def __init__(self, interupt_next_only: bool = False, **kwargs) -> None: ... + def init_async_lib(self, lib) -> None: ... + def usleep(self, microseconds) -> None: ... + async def async_usleep(self, microseconds) -> None: ... + def on_schedule(self, event) -> None: ... + def idle(self): ... + async def async_idle(self): ... + +class ClockBaseInterruptFreeBehavior(ClockBaseInterruptBehavior): + def __init__(self, **kwargs) -> None: ... + def on_schedule(self, event): ... + +class ClockBase(ClockBaseBehavior, CyClockBase): + def __init__(self, **kwargs) -> None: ... + def usleep(self, microseconds) -> None: ... + +class ClockBaseInterrupt(ClockBaseInterruptBehavior, CyClockBase): ... +class ClockBaseFreeInterruptAll(ClockBaseInterruptFreeBehavior, CyClockBaseFree): ... + +class ClockBaseFreeInterruptOnly(ClockBaseInterruptFreeBehavior, CyClockBaseFree): + def idle(self): ... + async def async_idle(self): ... + +def mainthread(func): ... +def triggered(timeout: int = 0, interval: bool = False): ... + +Clock: ClockBase