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 = {}