forked from mirror/Archipelago
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
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -65,6 +65,8 @@ Output Logs/
|
||||
/datapackage
|
||||
/datapackage_export.json
|
||||
/custom_worlds
|
||||
# stubgen output
|
||||
/out/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -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):
|
||||
|
||||
27
Utils.py
27
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:
|
||||
|
||||
82
typings/kivy/clock.pyi
Normal file
82
typings/kivy/clock.pyi
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user