From 503999cb326f24f6a64363c79994d249360ab75b Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:46:24 -0500 Subject: [PATCH] Core: KivyMD and Launcher overhaul (#3934) Shifts the contents of `kvui.py`, and thus all CommonClient-based clients as well as Launcher, to using KivyMD. KivyMD is an extension for Kivy that is almost fully compatible with pre-existing Kivy components, while providing Material Design support for theming and overall visual design as well as useful pre-existing built in components such as Snackbars, Tooltips, and a built-in File Manager (not currently being used). As a part of this shift, the launcher was completely overhauled, adding the ability to filter the list of components down to each type of component, the ability to define favorite components and filter to them, and add shortcuts for launcher components to the desktop. An optional description field was added to Component for display within the new launcher. The theme (Light/Dark) and primary palette have also been exposed to users via client/user.kv. --- Launcher.py | 287 +++++++++++++--------- LinksAwakeningClient.py | 16 +- WargrooveClient.py | 8 +- data/client.kv | 57 ++++- data/launcher.kv | 142 +++++++++++ kvui.py | 464 ++++++++++++++++++++++++----------- requirements.txt | 3 + setup.py | 7 +- worlds/AutoSNIClient.py | 3 +- worlds/LauncherComponents.py | 5 +- worlds/sc2/ClientGui.py | 2 +- 11 files changed, 710 insertions(+), 284 deletions(-) create mode 100644 data/launcher.kv diff --git a/Launcher.py b/Launcher.py index 22c0944a..609c1094 100644 --- a/Launcher.py +++ b/Launcher.py @@ -1,5 +1,5 @@ """ -Archipelago launcher for bundled app. +Archipelago Launcher * if run with APBP as argument, launch corresponding client. * if run with executable as argument, run it passing argv[2:] as arguments @@ -8,7 +8,7 @@ Archipelago launcher for bundled app. Scroll down to components= to add components to the launcher as well as setup.py """ - +import os import argparse import itertools import logging @@ -20,10 +20,11 @@ import urllib.parse import webbrowser from os.path import isfile from shutil import which -from typing import Callable, Optional, Sequence, Tuple, Union +from typing import Callable, Optional, Sequence, Tuple, Union, Any if __name__ == "__main__": import ModuleUpdate + ModuleUpdate.update() import settings @@ -105,7 +106,8 @@ components.extend([ Component("Generate Template Options", func=generate_yamls), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), - Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Unrated/18+ Discord Server", icon="discord", + func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Browse Files", func=browse_files), ]) @@ -114,7 +116,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: url = urllib.parse.urlparse(path) queries = urllib.parse.parse_qs(url.query) launch_args = (path, *launch_args) - client_component = None + client_component = [] text_client_component = None if "game" in queries: game = queries["game"][0] @@ -122,49 +124,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: game = "Archipelago" for component in components: if component.supports_uri and component.game_name == game: - client_component = component + client_component.append(component) elif component.display_name == "Text Client": text_client_component = component + from kvui import MDButton, MDButtonText + from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText + from kivymd.uix.divider import MDDivider + if client_component is None: run_component(text_client_component, *launch_args) return + else: + popup_text = MDDialogSupportingText(text="Select client to open and connect with.") + component_buttons = [MDDivider()] + for component in [text_client_component, *client_component]: + component_buttons.append(MDButton( + MDButtonText(text=component.display_name), + on_release=lambda *args, comp=component: run_component(comp, *launch_args), + style="text" + )) + component_buttons.append(MDDivider()) - from kvui import App, Button, BoxLayout, Label, Window + MDDialog( + # Headline + MDDialogHeadlineText(text="Connect to Multiworld"), + # Text + popup_text, + # Content + MDDialogContentContainer( + *component_buttons, + orientation="vertical" + ), - class Popup(App): - def __init__(self): - self.title = "Connect to Multiworld" - self.icon = r"data/icon.png" - super().__init__() - - def build(self): - layout = BoxLayout(orientation="vertical") - layout.add_widget(Label(text="Select client to open and connect with.")) - button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4)) - - text_client_button = Button( - text=text_client_component.display_name, - on_release=lambda *args: run_component(text_client_component, *launch_args) - ) - button_row.add_widget(text_client_button) - - game_client_button = Button( - text=client_component.display_name, - on_release=lambda *args: run_component(client_component, *launch_args) - ) - button_row.add_widget(game_client_button) - - layout.add_widget(button_row) - - return layout - - def _stop(self, *largs): - # see run_gui Launcher _stop comment for details - self.root_window.close() - super()._stop(*largs) - - Popup().run() + ).open() def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: @@ -220,100 +213,163 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +def create_shortcut(button: Any, component: Component) -> None: + from pyshortcuts import make_shortcut + script = sys.argv[0] + wkdir = Utils.local_path() + + script = f"{script} \"{component.display_name}\"" + make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"), + startmenu=False, terminal=False, working_dir=wkdir) + button.menu.dismiss() + + refresh_components: Optional[Callable[[], None]] = None -def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage +def run_gui(path: str, args: Any) -> None: + from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, MDButton, MDLabel, MDButtonText, ScrollBox, ApAsyncImage) + from kivy.properties import ObjectProperty from kivy.core.window import Window - from kivy.uix.relativelayout import RelativeLayout + from kivy.metrics import dp + from kivymd.uix.button import MDIconButton + from kivymd.uix.card import MDCard + from kivymd.uix.menu import MDDropdownMenu + from kivymd.uix.relativelayout import MDRelativeLayout + from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText - class Launcher(App): + from kivy.lang.builder import Builder + + class LauncherCard(MDCard): + component: Component | None + image: str + context_button: MDIconButton = ObjectProperty(None) + + def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs): + self.component = component + self.image = image_path + super().__init__(args, kwargs) + + + class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" - container: ContainerLayout - grid: GridLayout - _tool_layout: Optional[ScrollBox] = None - _client_layout: Optional[ScrollBox] = None + top_screen: MDFloatLayout = ObjectProperty(None) + navigation: MDGridLayout = ObjectProperty(None) + grid: MDGridLayout = ObjectProperty(None) + button_layout: ScrollBox = ObjectProperty(None) + cards: list[LauncherCard] + current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None): + def __init__(self, ctx=None, path=None, args=None): self.title = self.base_title + " " + Utils.__version__ self.ctx = ctx self.icon = r"data/icon.png" + self.favorites = [] + self.launch_uri = path + self.launch_args = args + self.cards = [] + self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) + persistent = Utils.persistent_load() + if "launcher" in persistent: + if "favorites" in persistent["launcher"]: + self.favorites.extend(persistent["launcher"]["favorites"]) + if "filter" in persistent["launcher"]: + if persistent["launcher"]["filter"]: + filters = [] + for filter in persistent["launcher"]["filter"].split(", "): + if filter == "favorites": + filters.append(filter) + else: + filters.append(Type[filter]) + self.current_filter = filters super().__init__() - def _refresh_components(self) -> None: + def set_favorite(self, caller): + if caller.component.display_name in self.favorites: + self.favorites.remove(caller.component.display_name) + caller.icon = "star-outline" + else: + self.favorites.append(caller.component.display_name) + caller.icon = "star" - def build_button(component: Component) -> Widget: + def build_card(self, component: Component) -> LauncherCard: + """ + Builds a card widget for a given component. + + :param component: The component associated with the button. + + :return: The created Card Widget. """ - Builds a button widget for a given component. + button_card = LauncherCard(component=component, + image_path=icon_paths[component.icon]) - Args: - component (Component): The component associated with the button. + def open_menu(caller): + caller.menu.open() - Returns: - None. The button is added to the parent grid layout. + menu_items = [ + { + "text": "Add shortcut on desktop", + "leading_icon": "laptop", + "on_release": lambda: create_shortcut(button_card.context_button, component) + } + ] + button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items) + button_card.context_button.bind(on_release=open_menu) - """ - button = Button(text=component.display_name, size_hint_y=None, height=40) - button.component = component - button.bind(on_release=self.component_action) - if component.icon != "icon": - image = ApAsyncImage(source=icon_paths[component.icon], - size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout(size_hint_y=None, height=40) - box_layout.add_widget(button) - box_layout.add_widget(image) - return box_layout - return button + return button_card + + def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None: + if not type_filter: + type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC] + favorites = "favorites" in type_filter # clear before repopulating - assert self._tool_layout and self._client_layout, "must call `build` first" - tool_children = reversed(self._tool_layout.layout.children) + assert self.button_layout, "must call `build` first" + tool_children = reversed(self.button_layout.layout.children) for child in tool_children: - self._tool_layout.layout.remove_widget(child) - client_children = reversed(self._client_layout.layout.children) - for child in client_children: - self._client_layout.layout.remove_widget(child) + self.button_layout.layout.remove_widget(child) - _tools = {c.display_name: c for c in components if c.type == Type.TOOL} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} - _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + cards = [card for card in self.cards if card.component.type in type_filter + or favorites and card.component.display_name in self.favorites] - for (tool, client) in itertools.zip_longest(itertools.chain( - _tools.items(), _miscs.items(), _adjusters.items() - ), _clients.items()): - # column 1 - if tool: - self._tool_layout.layout.add_widget(build_button(tool[1])) - # column 2 - if client: - self._client_layout.layout.add_widget(build_button(client[1])) + self.current_filter = type_filter + + for card in cards: + self.button_layout.layout.add_widget(card) + + def filter_clients(self, caller): + self._refresh_components(caller.type) def build(self): - self.container = ContainerLayout() - self.grid = GridLayout(cols=2) - self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) - self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) - self._tool_layout = ScrollBox() - self._tool_layout.layout.orientation = "vertical" - self.grid.add_widget(self._tool_layout) - self._client_layout = ScrollBox() - self._client_layout.layout.orientation = "vertical" - self.grid.add_widget(self._client_layout) - - self._refresh_components() + self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) + self.grid = self.top_screen.ids.grid + self.navigation = self.top_screen.ids.navigation + self.button_layout = self.top_screen.ids.button_layout + self.set_colors() + self.top_screen.md_bg_color = self.theme_cls.backgroundColor global refresh_components refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) - return self.container + for component in components: + self.cards.append(self.build_card(component)) + + self._refresh_components(self.current_filter) + + return self.top_screen + + def on_start(self): + if self.launch_uri: + handle_uri(self.launch_uri, self.launch_args) + self.launch_uri = None + self.launch_args = None @staticmethod def component_action(button): + MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() if button.component.func: button.component.func() else: @@ -333,7 +389,13 @@ def run_gui(): self.root_window.close() super()._stop(*largs) - Launcher().run() + def on_stop(self): + Utils.persistent_store("launcher", "favorites", self.favorites) + Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter + for filter in self.current_filter)) + super().on_stop() + + Launcher(path=path, args=args).run() # avoiding Launcher reference leak # and don't try to do something with widgets after window closed @@ -360,16 +422,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): path = args.get("Patch|Game|Component|url", None) if path is not None: - if path.startswith("archipelago://"): - handle_uri(path, args.get("args", ())) - return - file, component = identify(path) - if file: - args['file'] = file - if component: - args['component'] = component - if not component: - logging.warning(f"Could not identify Component responsible for {path}") + if not path.startswith("archipelago://"): + file, component = identify(path) + if file: + args['file'] = file + if component: + args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {path}") if args["update_settings"]: update_settings() @@ -378,7 +438,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: - run_gui() + run_gui(path, args.get("args", ())) if __name__ == '__main__': @@ -400,6 +460,7 @@ if __name__ == '__main__': main(parser.parse_args()) from worlds.LauncherComponents import processes + for process in processes: # we await all child processes to close before we tear down the process host # this makes it feel like each one is its own program, as the Launcher is closed now diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index 26a0d553..bdfaa746 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -529,9 +529,7 @@ class LinksAwakeningContext(CommonContext): def run_gui(self) -> None: import webbrowser - import kvui - from kvui import Button, GameManager - from kivy.uix.image import Image + from kvui import GameManager, ImageButton class LADXManager(GameManager): logging_pairs = [ @@ -544,16 +542,10 @@ class LinksAwakeningContext(CommonContext): b = super().build() if self.ctx.magpie_enabled: - button = Button(text="", size=(30, 30), size_hint_x=None, - on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) - image = Image(size=(16, 16), texture=magpie_logo()) - button.add_widget(image) - - def set_center(_, center): - image.center = center - button.bind(center=set_center) - + button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) self.connect_layout.add_widget(button) + return b self.ui = LADXManager(self) diff --git a/WargrooveClient.py b/WargrooveClient.py index f900e05e..595a221c 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -214,17 +214,11 @@ class WargrooveContext(CommonContext): def run_gui(self): """Import kivy UI system and start running it as self.ui_task.""" from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.uix.tabbedpanel import TabbedPanelItem + from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.lang import Builder - from kivy.uix.button import Button from kivy.uix.togglebutton import ToggleButton from kivy.uix.boxlayout import BoxLayout - from kivy.uix.gridlayout import GridLayout - from kivy.uix.image import AsyncImage, Image - from kivy.uix.stacklayout import StackLayout from kivy.uix.label import Label - from kivy.properties import ColorProperty - from kivy.uix.image import Image import pkgutil class TrackerLayout(BoxLayout): diff --git a/data/client.kv b/data/client.kv index f0f31769..0974258d 100644 --- a/data/client.kv +++ b/data/client.kv @@ -14,23 +14,50 @@ salmon: "FA8072" # typically trap item white: "FFFFFF" # not used, if you want to change the generic text color change color in Label orange: "FF7700" # Used for command echo -