mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 07:03:44 -08:00
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
|
||||||
/datapackage_export.json
|
/datapackage_export.json
|
||||||
/custom_worlds
|
/custom_worlds
|
||||||
|
# stubgen output
|
||||||
|
/out/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
||||||
ToggleButton, MarkupDropdown, ResizableTextField)
|
ToggleButton, MarkupDropdown, ResizableTextField)
|
||||||
|
from kivy.clock import Clock
|
||||||
from kivy.uix.behaviors.button import ButtonBehavior
|
from kivy.uix.behaviors.button import ButtonBehavior
|
||||||
from kivymd.uix.behaviors import RotateBehavior
|
from kivymd.uix.behaviors import RotateBehavior
|
||||||
from kivymd.uix.anchorlayout import MDAnchorLayout
|
from kivymd.uix.anchorlayout import MDAnchorLayout
|
||||||
@@ -269,34 +270,53 @@ class OptionsCreator(ThemedApp):
|
|||||||
self.options = {}
|
self.options = {}
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def export_options(self, button: Widget):
|
@staticmethod
|
||||||
if 0 < len(self.name_input.text) < 17 and self.current_game:
|
def show_result_snack(text: str) -> None:
|
||||||
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
|
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"))
|
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 = {
|
options = {
|
||||||
"name": self.name_input.text,
|
"name": self.name_input.text,
|
||||||
"description": f"YAML generated by Archipelago {Utils.__version__}.",
|
"description": f"YAML generated by Archipelago {Utils.__version__}.",
|
||||||
"game": self.current_game,
|
"game": self.current_game,
|
||||||
self.current_game: {k: check_random(v) for k, v in self.options.items()}
|
self.current_game: {k: check_random(v) for k, v in self.options.items()}
|
||||||
}
|
}
|
||||||
try:
|
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
|
||||||
with open(file_name, 'w') as f:
|
self.container.disabled = True
|
||||||
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()
|
|
||||||
elif not self.name_input.text:
|
elif not self.name_input.text:
|
||||||
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
|
self.show_result_snack("Name must not be empty.")
|
||||||
size_hint_x=0.5).open()
|
|
||||||
elif not self.current_game:
|
elif not self.current_game:
|
||||||
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
|
self.show_result_snack("You must select a game to play.")
|
||||||
size_hint_x=0.5).open()
|
|
||||||
else:
|
else:
|
||||||
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
|
self.show_result_snack("Name cannot be longer than 16 characters.")
|
||||||
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
|
||||||
|
|
||||||
def create_range(self, option: typing.Type[Range], name: str):
|
def create_range(self, option: typing.Type[Range], name: str):
|
||||||
def update_text(range_box: VisualRange):
|
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:
|
except tkinter.TclError:
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
try:
|
||||||
initialfile=suggest or None)
|
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 = "") \
|
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file save dialog for {title}.")
|
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:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
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")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
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
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -856,8 +859,14 @@ def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
|||||||
except tkinter.TclError:
|
except tkinter.TclError:
|
||||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
try:
|
||||||
initialfile=suggest or None)
|
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:
|
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