diff --git a/Options.py b/Options.py index d4e42fc02d..9d12875e54 100644 --- a/Options.py +++ b/Options.py @@ -1018,6 +1018,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys): supports_weighting = False display_name = "Plando Texts" + visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler + def __init__(self, value: typing.Iterable[PlandoText]) -> None: self.value = list(deepcopy(value)) super().__init__() @@ -1144,6 +1146,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect entrances: typing.ClassVar[typing.AbstractSet[str]] exits: typing.ClassVar[typing.AbstractSet[str]] + visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler + duplicate_exits: bool = False """Whether or not exits should be allowed to be duplicate.""" @@ -1435,6 +1439,7 @@ class DeathLink(Toggle): class ItemLinks(OptionList): """Share part of your item pool with other players.""" display_name = "Item Links" + visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler rich_text_doc = True default = [] schema = Schema([ diff --git a/OptionsCreator.py b/OptionsCreator.py new file mode 100644 index 0000000000..e9e6819b25 --- /dev/null +++ b/OptionsCreator.py @@ -0,0 +1,661 @@ +from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel, + ToggleButton, MarkupDropdown, ResizableTextField) +from kivy.uix.behaviors.button import ButtonBehavior +from kivymd.uix.behaviors import RotateBehavior +from kivymd.uix.anchorlayout import MDAnchorLayout +from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader +from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText +from kivymd.uix.slider import MDSlider +from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText +from kivymd.uix.menu import MDDropdownMenu +from kivymd.uix.button import MDButton, MDButtonText, MDIconButton +from kivymd.uix.dialog import MDDialog +from kivy.core.text.markup import MarkupLabel +from kivy.utils import escape_markup +from kivy.lang.builder import Builder +from kivy.properties import ObjectProperty +from textwrap import dedent +from copy import deepcopy +import Utils +import typing +import webbrowser +import re +from urllib.parse import urlparse +from worlds.AutoWorld import AutoWorldRegister, World +from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed, + OptionCounter, Visibility) + + +def validate_url(x): + try: + result = urlparse(x) + return all([result.scheme, result.netloc]) + except AttributeError: + return False + + +def filter_tooltip(tooltip): + if tooltip is None: + tooltip = "No tooltip available." + tooltip = dedent(tooltip).strip().replace("\n", "
").replace("&", "&") \ + .replace("[", "&bl;").replace("]", "&br;") + tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip) + tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip) + return escape_markup(tooltip) + + +def option_can_be_randomized(option: typing.Type[Option]): + # most options can be randomized, so we should just check for those that cannot + if not option.supports_weighting: + return False + elif issubclass(option, FreeText) and not issubclass(option, TextChoice): + return False + return True + + +def check_random(value: typing.Any): + if not isinstance(value, str): + return value # cannot be random if evaluated + if value.startswith("random-"): + return "random" + return value + + +class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon): + pass + + +class WorldButton(ToggleButton): + world_cls: typing.Type[World] + + +class VisualRange(MDBoxLayout): + option: typing.Type[Range] + name: str + tag: MDLabel = ObjectProperty(None) + slider: MDSlider = ObjectProperty(None) + + def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs): + self.option = option + self.name = name + super().__init__(*args, **kwargs) + + def update_points(*update_args): + pass + + self.slider._update_points = update_points + + +class VisualChoice(MDButton): + option: typing.Type[Choice] + name: str + text: MDButtonText = ObjectProperty(None) + + def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs): + self.option = option + self.name = name + super().__init__(*args, **kwargs) + + +class VisualNamedRange(MDBoxLayout): + option: typing.Type[NamedRange] + name: str + range: VisualRange = ObjectProperty(None) + choice: MDButton = ObjectProperty(None) + + def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs): + self.option = option + self.name = name + super().__init__(*args, **kwargs) + self.range = range_widget + self.add_widget(self.range) + + +class VisualFreeText(ResizableTextField): + option: typing.Type[FreeText] | typing.Type[TextChoice] + name: str + + def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs): + self.option = option + self.name = name + super().__init__(*args, **kwargs) + + +class VisualTextChoice(MDBoxLayout): + option: typing.Type[TextChoice] + name: str + choice: VisualChoice = ObjectProperty(None) + text: VisualFreeText = ObjectProperty(None) + + def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice, + text: VisualFreeText, **kwargs): + self.option = option + self.name = name + super(MDBoxLayout, self).__init__(*args, **kwargs) + self.choice = choice + self.text = text + self.add_widget(self.choice) + self.add_widget(self.text) + + +class VisualToggle(MDBoxLayout): + button: MDIconButton = ObjectProperty(None) + option: typing.Type[Toggle] + name: str + + def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs): + self.option = option + self.name = name + super().__init__(*args, **kwargs) + + +class CounterItemValue(ResizableTextField): + pat = re.compile('[^0-9]') + + def insert_text(self, substring, from_undo=False): + return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo) + + +class VisualListSetCounter(MDDialog): + button: MDIconButton = ObjectProperty(None) + option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter] + scrollbox: ScrollBox = ObjectProperty(None) + add: MDIconButton = ObjectProperty(None) + save: MDButton = ObjectProperty(None) + input: ResizableTextField = ObjectProperty(None) + dropdown: MDDropdownMenu + valid_keys: typing.Iterable[str] + + def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList], + name: str, valid_keys: typing.Iterable[str], **kwargs): + self.option = option + self.name = name + self.valid_keys = valid_keys + super().__init__(*args, **kwargs) + self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2), + width=self.input.width, position="bottom") + self.input.bind(text=self.on_text) + self.input.bind(on_text_validate=self.validate_add) + + def validate_add(self, instance): + if self.valid_keys: + if self.input.text not in self.valid_keys: + MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24), + pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + return + + if not issubclass(self.option, OptionList): + if any(self.input.text == child.text.text for child in self.scrollbox.layout.children): + MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24), + pos_hint={"center_x": 0.5}, size_hint_x=0.5).open() + return + + self.add_set_item(self.input.text) + self.input.set_text(self.input, "") + + def remove_item(self, button: MDIconButton): + list_item = button.parent + self.scrollbox.layout.remove_widget(list_item) + + def add_set_item(self, key: str, value: int | None = None): + text = MDListItemSupportingText(text=key, id="value") + if issubclass(self.option, OptionCounter): + value_txt = CounterItemValue(text=str(value) if value else "1") + item = MDListItem(text, + value_txt, + MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False) + item.value = value_txt + else: + item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False) + item.text = text + self.scrollbox.layout.add_widget(item) + + def on_text(self, instance, value): + if not self.valid_keys: + return + if len(value) >= 3: + self.dropdown.items.clear() + + def on_press(txt): + split_text = MarkupLabel(text=txt, markup=True).markup + self.input.set_text(self.input, "".join(text_frag for text_frag in split_text + if not text_frag.startswith("["))) + self.input.focus = True + self.dropdown.dismiss() + + lowered = value.lower() + for item_name in self.valid_keys: + try: + index = item_name.lower().index(lowered) + except ValueError: + pass # substring not found + else: + text = escape_markup(item_name) + text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):] + self.dropdown.items.append({ + "text": text, + "on_release": lambda txt=text: on_press(txt), + "markup": True + }) + if not self.dropdown.parent: + self.dropdown.open() + else: + self.dropdown.dismiss() + + +class OptionsCreator(ThemedApp): + base_title: str = "Archipelago Options Creator" + container: ContainerLayout + main_layout: MainLayout + scrollbox: ScrollBox + main_panel: MainLayout + player_options: MainLayout + option_layout: MainLayout + name_input: ResizableTextField + game_label: MDLabel + current_game: str + options: typing.Dict[str, typing.Any] + + def __init__(self): + self.title = self.base_title + " " + Utils.__version__ + self.icon = r"data/icon.png" + self.current_game = "" + 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"])], + Utils.get_file_safe_name(f"{self.name_input.text}.yaml")) + 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() + 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() + 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() + 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() + + def create_range(self, option: typing.Type[Range], name: str): + def update_text(range_box: VisualRange): + self.options[name] = int(range_box.slider.value) + range_box.tag.text = str(int(range_box.slider.value)) + return + + box = VisualRange(option=option, name=name) + box.slider.bind(on_touch_move=lambda _, _1: update_text(box)) + self.options[name] = option.default + return box + + def create_named_range(self, option: typing.Type[NamedRange], name: str): + def set_to_custom(range_box: VisualNamedRange): + if (not self.options[name] == range_box.range.slider.value) \ + and (not self.options[name] in option.special_range_names or + range_box.range.slider.value != option.special_range_names[self.options[name]]): + # we should validate the touch here, + # but this is much cheaper + self.options[name] = int(range_box.range.slider.value) + range_box.range.tag.text = str(int(range_box.range.slider.value)) + set_button_text(range_box.choice, "Custom") + + def set_button_text(button: MDButton, text: str): + button.text.text = text + + def set_value(text: str, range_box: VisualNamedRange): + range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start), + option.range_end) + range_box.range.tag.text = str(int(range_box.range.slider.value)) + set_button_text(range_box.choice, text) + self.options[name] = text.lower() + range_box.range.slider.dropdown.dismiss() + + def open_dropdown(button): + # for some reason this fixes an issue causing some to not open + box.range.slider.dropdown.open() + + box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name)) + box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box)) + items = [ + { + "text": choice.title(), + "on_release": lambda text=choice.title(): set_value(text, box) + } + for choice in option.special_range_names + ] + box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items) + box.choice.bind(on_release=open_dropdown) + self.options[name] = option.default + return box + + def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str): + text = VisualFreeText(option=option, name=name) + + def set_value(instance): + self.options[name] = instance.text + + text.bind(on_text_validate=set_value) + return text + + def create_choice(self, option: typing.Type[Choice], name: str): + def set_button_text(button: VisualChoice, text: str): + button.text.text = text + + def set_value(text, value): + set_button_text(main_button, text) + self.options[name] = value + dropdown.dismiss() + + def open_dropdown(button): + # for some reason this fixes an issue causing some to not open + dropdown.open() + + default_random = option.default == "random" + main_button = VisualChoice(option=option, name=name) + main_button.bind(on_release=open_dropdown) + + items = [ + { + "text": option.get_option_name(choice), + "on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val]) + } + for choice in option.name_lookup + ] + dropdown = MDDropdownMenu(caller=main_button, items=items) + self.options[name] = option.name_lookup[option.default] if not default_random else option.default + return main_button + + def create_text_choice(self, option: typing.Type[TextChoice], name: str): + def set_button_text(button: MDButton, text: str): + for child in button.children: + if isinstance(child, MDButtonText): + child.text = text + + box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name), + text=self.create_free_text(option, name)) + + def set_value(instance): + set_button_text(box.choice, "Custom") + self.options[name] = instance.text + + box.text.bind(on_text_validate=set_value) + return box + + def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget: + def set_value(instance: MDIconButton): + if instance.icon == "checkbox-outline": + instance.icon = "checkbox-blank-outline" + else: + instance.icon = "checkbox-outline" + self.options[name] = bool(not self.options[name]) + + self.options[name] = bool(option.default) + checkbox = VisualToggle(option=option, name=name) + checkbox.button.bind(on_release=set_value) + + return checkbox + + def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter], + name: str, world: typing.Type[World]): + + valid_keys = sorted(option.valid_keys) + if option.verify_item_name: + valid_keys += list(world.item_name_to_id.keys()) + if option.verify_location_name: + valid_keys += list(world.location_name_to_id.keys()) + + if not issubclass(option, OptionCounter): + def apply_changes(button): + self.options[name].clear() + for list_item in dialog.scrollbox.layout.children: + self.options[name].append(getattr(list_item.text, "text")) + dialog.dismiss() + else: + def apply_changes(button): + self.options[name].clear() + for list_item in dialog.scrollbox.layout.children: + self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text")) + dialog.dismiss() + + dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys) + dialog.ids.container.spacing = dp(30) + dialog.scrollbox.layout.theme_bg_color = "Custom" + dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor + dialog.scrollbox.layout.spacing = dp(5) + dialog.scrollbox.layout.padding = [0, dp(5), 0, 0] + + if name not in self.options: + # convert from non-mutable to mutable + # We use list syntax even for sets, set behavior is enforced through GUI + if issubclass(option, OptionCounter): + self.options[name] = deepcopy(option.default) + else: + self.options[name] = sorted(option.default) + + if issubclass(option, OptionCounter): + for value in sorted(self.options[name]): + dialog.add_set_item(value, self.options[name].get(value, None)) + else: + for value in sorted(self.options[name]): + dialog.add_set_item(value) + + dialog.save.bind(on_release=apply_changes) + dialog.open() + + def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | + typing.Type[OptionCounter], name: str, world: typing.Type[World]): + main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world)) + return main_button + + def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget: + option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)]) + + tooltip = filter_tooltip(option.__doc__) + option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}") + label_box = MDBoxLayout(orientation="horizontal") + label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center") + label_anchor.add_widget(option_label) + label_box.add_widget(label_anchor) + + option_base.add_widget(label_box) + if issubclass(option, NamedRange): + option_base.add_widget(self.create_named_range(option, name)) + elif issubclass(option, Range): + option_base.add_widget(self.create_range(option, name)) + elif issubclass(option, Toggle): + option_base.add_widget(self.create_toggle(option, name)) + elif issubclass(option, TextChoice): + option_base.add_widget(self.create_text_choice(option, name)) + elif issubclass(option, Choice): + option_base.add_widget(self.create_choice(option, name)) + elif issubclass(option, FreeText): + option_base.add_widget(self.create_free_text(option, name)) + elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)): + option_base.add_widget(self.create_option_set_list_counter(option, name, world)) + else: + option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n" + "Please edit your yaml manually to set this option.")) + + if option_can_be_randomized(option): + def randomize_option(instance: Widget, value: str): + value = value == "down" + if value: + self.options[name] = "random-" + str(self.options[name]) + else: + self.options[name] = self.options[name].replace("random-", "") + if self.options[name].isnumeric() or self.options[name] in ("True", "False"): + self.options[name] = eval(self.options[name]) + + base_object = instance.parent.parent + label_object = instance.parent + for child in base_object.children: + if child is not label_object: + child.disabled = value + + default_random = option.default == "random" + random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100), + state="down" if default_random else "normal") + random_toggle.bind(state=randomize_option) + label_box.add_widget(random_toggle) + if default_random: + randomize_option(random_toggle, "down") + + return option_base + + def create_options_panel(self, world_button: WorldButton): + self.option_layout.clear_widgets() + self.options.clear() + cls: typing.Type[World] = world_button.world_cls + + self.current_game = cls.game + if not cls.web.options_page: + self.current_game = "None" + return + elif isinstance(cls.web.options_page, str): + self.current_game = "None" + if validate_url(cls.web.options_page): + webbrowser.open(cls.web.options_page) + MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() + world_button.state = "normal" + else: + # attach onto archipelago.gg and see if we pass + new_url = "https://archipelago.gg/" + cls.web.options_page + if validate_url(new_url): + webbrowser.open(new_url) + MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), + pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() + else: + MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24), + pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() + world_button.state = "normal" + # else just fall through + else: + expansion_box = ScrollBox() + expansion_box.layout.orientation = "vertical" + expansion_box.layout.spacing = dp(3) + expansion_box.scroll_type = ["bars"] + expansion_box.do_scroll_x = False + group_names = ["Game Options", *(group.name for group in cls.web.option_groups)] + groups = {name: [] for name in group_names} + for name, option in cls.options_dataclass.type_hints.items(): + group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options") + groups[group].append((name, option)) + + for group, options in groups.items(): + if not options: + continue # Game Options can be empty if every other option is in another group + group_item = MDExpansionPanel(size_hint_y=None) + group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group), + TrailingPressedIconButton(icon="chevron-right", + on_release=lambda x, + item=group_item: + self.tap_expansion_chevron( + item, x)), + md_bg_color=self.theme_cls.surfaceContainerLowestColor, + theme_bg_color="Custom", + on_release=lambda x, item=group_item: + self.tap_expansion_chevron(item, x))) + group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom", + md_bg_color=self.theme_cls.surfaceContainerLowestColor, + padding=[dp(12), dp(100), dp(12), 0], + spacing=dp(3)) + group_item.add_widget(group_header) + group_item.add_widget(group_content) + group_box = ScrollBox() + group_box.layout.orientation = "vertical" + group_box.layout.spacing = dp(3) + for name, option in options: + if name and option is not Removed and option.visibility & Visibility.simple_ui: + group_content.add_widget(self.create_option(option, name, cls)) + expansion_box.layout.add_widget(group_item) + self.option_layout.add_widget(expansion_box) + self.game_label.text = f"Game: {self.current_game}" + + @staticmethod + def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem): + if isinstance(chevron, MDListItem): + chevron = next((child for child in chevron.ids.trailing_container.children + if isinstance(child, TrailingPressedIconButton)), None) + panel.open() if not panel.is_open else panel.close() + if chevron: + panel.set_chevron_down( + chevron + ) if not panel.is_open else panel.set_chevron_up(chevron) + + def build(self): + self.set_colors() + self.options = {} + self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv")) + self.root = self.container + self.main_layout = self.container.ids.main + self.scrollbox = self.container.ids.scrollbox + + def world_button_action(world_btn: WorldButton): + if self.current_game != world_btn.world_cls.game: + old_button = next((button for button in self.scrollbox.layout.children + if button.world_cls.game == self.current_game), None) + if old_button: + old_button.state = "normal" + else: + world_btn.state = "down" + self.create_options_panel(world_btn) + + for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]): + if world == "Archipelago": + continue + world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150), + pos_hint={"x": 0.03, "center_y": 0.5}) + world_text.text_size = (world_text.width, None) + world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)), + texture_size=lambda *x, text=world_text: text.setter("height")(text, + world_text.texture_size[1])) + world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom", + radius=(dp(5), dp(5), dp(5), dp(5))) + world_button.bind(on_release=world_button_action) + world_button.world_cls = cls + self.scrollbox.layout.add_widget(world_button) + self.main_panel = self.container.ids.player_layout + self.player_options = self.container.ids.player_options + self.game_label = self.container.ids.game + self.name_input = self.container.ids.player_name + self.option_layout = self.container.ids.options + + def set_height(instance, value): + instance.height = value[1] + + self.game_label.bind(texture_size=set_height) + + # Uncomment to re-enable the Kivy console/live editor + # Ctrl-E to enable it, make sure numlock/capslock is disabled + # from kivy.modules.console import create_console + # from kivy.core.window import Window + # create_console(Window, self.container) + + return self.container + + +def launch(): + OptionsCreator().run() + + +if __name__ == "__main__": + Utils.init_logging("OptionsCreator") + launch() diff --git a/Utils.py b/Utils.py index a1c239aced..f4e5f842a7 100644 --- a/Utils.py +++ b/Utils.py @@ -751,6 +751,11 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: res.put(open_filename(*args)) +def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: + if is_kivy_running(): + raise RuntimeError("kivy should not be running in multiprocess") + res.put(save_filename(*args)) + def _run_for_stdout(*args: str): env = os.environ if "LD_LIBRARY_PATH" in env: @@ -801,6 +806,51 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin initialfile=suggest or None) +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) + 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) + + # fall back to tk + try: + import tkinter + import tkinter.filedialog + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed. ' + f'This attempt was made because save_filename was used for "{title}".') + raise e + else: + if is_macos and is_kivy_running(): + # on macOS, mixing kivy and tk does not work, so spawn a new process + # FIXME: performance of this is pretty bad, and we should (also) look into alternatives + from multiprocessing import Process, Queue + res: "Queue[typing.Optional[str]]" = Queue() + Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start() + return res.get() + try: + root = tkinter.Tk() + 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) + + def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: if is_kivy_running(): raise RuntimeError("kivy should not be running in multiprocess") diff --git a/data/client.kv b/data/client.kv index 08f4c8d718..cf8e88446d 100644 --- a/data/client.kv +++ b/data/client.kv @@ -224,6 +224,7 @@ height: self.content.texture_size[1] + 80 : layout: layout + box_height: dp(100) bar_width: "12dp" scroll_wheel_distance: 40 do_scroll_x: False @@ -234,4 +235,11 @@ orientation: "vertical" spacing: 10 size_hint_y: None - height: self.minimum_height + height: max(self.minimum_height, root.box_height) + +: + valign: "middle" + halign: "center" + text_size: self.width, None + height: self.texture_size[1] + diff --git a/data/optionscreator.kv b/data/optionscreator.kv new file mode 100644 index 0000000000..ad5e6f3799 --- /dev/null +++ b/data/optionscreator.kv @@ -0,0 +1,174 @@ +: + id: this + spacing: 15 + orientation: "horizontal" + slider: slider + tag: tag + MDLabel: + id: tag + text: str(this.option.default) if this.option.default != "random" else this.option.range_start + MDSlider: + id: slider + min: this.option.range_start + max: this.option.range_end + value: min(max(this.option.default, this.option.range_start), this.option.range_end) if this.option.default != "random" else this.option.range_start + step: 1 + step_point_size: 0 + MDSliderHandle: + + MDSliderValueLabel: + +: + id: this + text: text + MDButtonText: + id: text + text: this.option.get_option_name(this.option.default if this.option.default != "random" else list(this.option.options.values())[0]) + theme_text_color: "Primary" + +: + id: this + orientation: "horizontal" + spacing: "10dp" + padding: (0, 0, "10dp", 0) + choice: choice + + MDButton: + id: choice + text: text + MDButtonText: + id: text + text: this.option.special_range_names.get(list(this.option.special_range_names.values()).index(this.option.default)) if this.option.default in this.option.special_range_names else "Custom" + +: + multiline: False + font_size: "15sp" + text: self.option.default if isinstance(self.option.default, str) else "" + theme_height: "Custom" + height: "30dp" + + +: + id: this + orientation: "horizontal" + spacing: "5dp" + padding: (0, 0, "10dp", 0) + +: + id: this + button: button + MDIconButton: + id: button + icon: "checkbox-outline" if this.option.default else "checkbox-blank-outline" + +: + height: "20dp" + +: + height: "30dp" + +: + id: this + scrollbox: scrollbox + add: add + save: save + input: input + focus_behavior: False + + MDDialogHeadlineText: + text: getattr(this.option, "display_name", this.name) + + MDDialogSupportingText: + text: "Add or Remove Entries" + + MDDialogContentContainer: + orientation: "vertical" + spacing: 10 + + MDBoxLayout: + orientation: "horizontal" + VisualListSetEntry: + id: input + height: "20dp" + + MDIconButton: + id: add + icon: "plus" + theme_height: "Custom" + height: "20dp" + on_press: root.validate_add(input) + + ScrollBox: + id: scrollbox + size_hint_y: None + adapt_minimum: False + + MDButton: + id: save + MDButtonText: + text: "Save Changes" + +ContainerLayout: + md_bg_color: app.theme_cls.backgroundColor + + MainLayout: + id: main + cols: 3 + padding: 3, 5, 0, 3 + spacing: "2dp" + + ScrollBox: + id: scrollbox + size_hint_x: None + width: "150dp" + + MDDivider: + orientation: "vertical" + width: "4dp" + + MainLayout: + id: player_layout + rows: 2 + spacing: "20dp" + + MDBoxLayout: + id: player_options + orientation: "horizontal" + height: "75dp" + size_hint_y: None + padding: ["10dp", "30dp", "10dp", 0] + spacing: "10dp" + + ResizableTextField: + id: player_name + multiline: False + + MDTextFieldHintText: + text: "Player Name" + + MDTextFieldMaxLengthText: + max_text_length: 16 + + MDBoxLayout: + orientation: "vertical" + spacing: "15dp" + + MDLabel: + id: game + text: "Game: None" + pos_hint: {"center_x": 0.5, "center_y": 0.5} + + MDButton: + pos_hint: {"center_x": 0.5, "center_y": 0.5} + on_press: app.export_options(self) + theme_width: "Custom" + size_hint_y: 1 + size_hint_x: 1 + + MDButtonText: + pos_hint: {"center_x": 0.5, "center_y": 0.5} + text: "Export Options" + + MainLayout: + cols: 1 + id: options diff --git a/kvui.py b/kvui.py index 1989dd164a..403872d63b 100644 --- a/kvui.py +++ b/kvui.py @@ -127,7 +127,7 @@ class ImageButton(MDIconButton): val = kwargs.pop(kwarg, "None") if val != "None": image_args[kwarg.replace("image_", "")] = val - super().__init__() + super().__init__(**kwargs) self.image = ApAsyncImage(**image_args) def set_center(button, center): @@ -143,6 +143,7 @@ class ImageButton(MDIconButton): class ScrollBox(MDScrollView): layout: MDBoxLayout = ObjectProperty(None) + box_height: int = NumericProperty(dp(100)) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -153,6 +154,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior): def __init__(self, *args, **kwargs): super(ToggleButton, self).__init__(*args, **kwargs) self.bind(state=self._update_bg) + self._update_bg(self, self.state) def _update_bg(self, _, state: str): if self.disabled: @@ -170,7 +172,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior): child.text_color = self.theme_cls.onPrimaryColor child.icon_color = self.theme_cls.onPrimaryColor else: - self.md_bg_color = self.theme_cls.surfaceContainerLowestColor + self.md_bg_color = self.theme_cls.surfaceContainerLowColor for child in self.children: if child.theme_text_color == "Primary": child.theme_text_color = "Custom" @@ -184,7 +186,6 @@ class ToggleButton(MDButton, ToggleButtonBehavior): class ResizableTextField(MDTextField): """ Resizable MDTextField that manually overrides the builtin sizing. - Note that in order to use this, the sizing must be specified from within a .kv rule. """ def __init__(self, *args, **kwargs): @@ -248,7 +249,7 @@ Factory.register("HoverBehavior", HoverBehavior) class ToolTip(MDTooltipPlain): - pass + markup = True class ServerToolTip(ToolTip): @@ -283,6 +284,8 @@ class TooltipLabel(HovererableLabel, MDTooltip): def on_mouse_pos(self, window, pos): if not self.get_root_window(): return # Abort if not displayed + if self.disabled: + return super().on_mouse_pos(window, pos) if self.refs and self.hovered: diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 3db227fd48..a078ce55ac 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -225,6 +225,8 @@ components: List[Component] = [ description="Host a generated multiworld on your computer."), Component('Generate', 'Generate', cli=True, description="Generate a multiworld with the YAMLs in the players folder."), + Component("Options Creator", "OptionsCreator", "ArchipelagoOptionsCreator", component_type=Type.TOOL, + description="Visual creator for Archipelago option files."), Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"), description="Install an APWorld to play games not included with Archipelago by default."), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, @@ -242,7 +244,7 @@ components: List[Component] = [ Component('Zillion Client', 'ZillionClient', file_identifier=SuffixIdentifier('.apzl')), - #MegaMan Battle Network 3 + # MegaMan Battle Network 3 Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')), Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL), diff --git a/worlds/mm2/options.py b/worlds/mm2/options.py index f333348982..c1b291a911 100644 --- a/worlds/mm2/options.py +++ b/worlds/mm2/options.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from Options import Choice, Toggle, DeathLink, DefaultOnToggle, TextChoice, Range, OptionDict, PerGameCommonOptions +from Options import (Choice, Toggle, DeathLink, DefaultOnToggle, TextChoice, Range, OptionDict, PerGameCommonOptions, + Visibility) from schema import Schema, And, Use, Optional bosses = { @@ -178,6 +179,7 @@ class WeaknessPlando(OptionDict): And(str, Use(str.title), lambda s: s in weapons_to_id): And(int, lambda i: i in range(-1, 15)) } }) + visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler default = {}