import os import logging import sys import typing import re import io import pkgutil from collections import deque assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility" if sys.platform == "win32": import ctypes # kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout # by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's ctypes.windll.shcore.SetProcessDpiAwareness(0) os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1" os.environ["KIVY_NO_ARGS"] = "1" os.environ["KIVY_LOG_ENABLE"] = "0" import Utils if Utils.is_frozen(): os.environ["KIVY_DATA_DIR"] = Utils.local_path("data") import platformdirs os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy") os.makedirs(os.environ["KIVY_HOME"], exist_ok=True) from kivy.config import Config Config.set("input", "mouse", "mouse,disable_multitouch") Config.set("kivy", "exit_on_escape", "0") Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers from kivymd.uix.divider import MDDivider from kivy.core.window import Window from kivy.core.clipboard import Clipboard from kivy.core.text.markup import MarkupLabel from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData from kivy.base import ExceptionHandler, ExceptionManager from kivy.clock import Clock from kivy.factory import Factory from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty from kivy.metrics import dp, sp from kivy.uix.widget import Widget from kivy.uix.layout import Layout from kivy.utils import escape_markup from kivy.lang import Builder from kivy.uix.recycleview.views import RecycleDataViewBehavior from kivy.uix.behaviors import FocusBehavior, ToggleButtonBehavior from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.recycleview.layout import LayoutSelectionBehavior from kivy.animation import Animation from kivy.uix.popup import Popup from kivy.uix.image import AsyncImage from kivymd.app import MDApp from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupportingText, MDDialogButtonContainer from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem from kivymd.uix.screen import MDScreen from kivymd.uix.screenmanager import MDScreenManager from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText from kivymd.uix.button import MDButton, MDButtonText, MDButtonIcon, MDIconButton from kivymd.uix.label import MDLabel, MDIcon from kivymd.uix.recycleview import MDRecycleView from kivymd.uix.textfield.textfield import MDTextField from kivymd.uix.progressindicator import MDLinearProgressIndicator from kivymd.uix.scrollview import MDScrollView from kivymd.uix.tooltip import MDTooltip, MDTooltipPlain fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType, HintStatus from Utils import async_start, get_input_text_from_response if typing.TYPE_CHECKING: import CommonClient context_type = CommonClient.CommonContext else: context_type = object remove_between_brackets = re.compile(r"\[.*?]") class ThemedApp(MDApp): def set_colors(self): text_colors = KivyJSONtoTextParser.TextColors() self.theme_cls.theme_style = text_colors.theme_style self.theme_cls.primary_palette = text_colors.primary_palette self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast class ImageIcon(MDButtonIcon, AsyncImage): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.image = ApAsyncImage(**kwargs) self.add_widget(self.image) def add_widget(self, widget, index=0, canvas=None): return super(MDIcon, self).add_widget(widget) class ImageButton(MDIconButton): def __init__(self, **kwargs): image_args = dict() for kwarg in ("fit_mode", "image_size", "color", "source", "texture"): val = kwargs.pop(kwarg, "None") if val != "None": image_args[kwarg.replace("image_", "")] = val super().__init__() self.image = ApAsyncImage(**image_args) def set_center(button, center): self.image.center_x = self.center_x self.image.center_y = self.center_y self.bind(center=set_center) self.add_widget(self.image) def add_widget(self, widget, index=0, canvas=None): return super(MDIcon, self).add_widget(widget) class ScrollBox(MDScrollView): layout: MDBoxLayout = ObjectProperty(None) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # thanks kivymd class ToggleButton(MDButton, ToggleButtonBehavior): def __init__(self, *args, **kwargs): super(ToggleButton, self).__init__(*args, **kwargs) self.bind(state=self._update_bg) def _update_bg(self, _, state: str): if self.disabled: return if self.theme_bg_color == "Primary": self.theme_bg_color = "Custom" if state == "down": self.md_bg_color = self.theme_cls.primaryColor for child in self.children: if child.theme_text_color == "Primary": child.theme_text_color = "Custom" if child.theme_icon_color == "Primary": child.theme_icon_color = "Custom" child.text_color = self.theme_cls.onPrimaryColor child.icon_color = self.theme_cls.onPrimaryColor else: self.md_bg_color = self.theme_cls.surfaceContainerLowestColor for child in self.children: if child.theme_text_color == "Primary": child.theme_text_color = "Custom" if child.theme_icon_color == "Primary": child.theme_icon_color = "Custom" child.text_color = self.theme_cls.primaryColor child.icon_color = self.theme_cls.primaryColor # thanks kivymd 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): # cursed rules override rules = Builder.match(self) textfield = next((rule for rule in rules if rule.name == f""), None) if textfield: subclasses = rules[rules.index(textfield) + 1:] for subclass in subclasses: height_rule = subclass.properties.get("height", None) if height_rule: height_rule.ignore_prev = True super().__init__(*args, **kwargs) def on_release(self: MDButton, *args): super(MDButton, self).on_release(args) self.on_leave() MDButton.on_release = on_release # I was surprised to find this didn't already exist in kivy :( class HoverBehavior(object): """originally from https://stackoverflow.com/a/605348110""" hovered = BooleanProperty(False) border_point = ObjectProperty(None) def __init__(self, **kwargs): self.register_event_type("on_enter") self.register_event_type("on_leave") Window.bind(mouse_pos=self.on_mouse_pos) Window.bind(on_cursor_leave=self.on_cursor_leave) super(HoverBehavior, self).__init__(**kwargs) def on_mouse_pos(self, window, pos): if not self.get_root_window(): return # Abort if not displayed # to_widget translates window pos to within widget pos inside = self.collide_point(*self.to_widget(*pos)) if self.hovered == inside: return # We have already done what was needed self.border_point = pos self.hovered = inside if inside: self.dispatch("on_enter") else: self.dispatch("on_leave") def on_cursor_leave(self, *args): # if the mouse left the window, it is obviously no longer inside the hover label. self.hovered = BooleanProperty(False) self.border_point = ObjectProperty(None) self.dispatch("on_leave") Factory.register("HoverBehavior", HoverBehavior) class ToolTip(MDTooltipPlain): pass class ServerToolTip(ToolTip): pass class HovererableLabel(HoverBehavior, MDLabel): pass class TooltipLabel(HovererableLabel, MDTooltip): tooltip_display_delay = 0.1 def create_tooltip(self, text, x, y): text = text.replace("
", "\n").replace("&", "&").replace("&bl;", "[").replace("&br;", "]") # position float layout center_x, center_y = self.to_window(self.center_x, self.center_y) self.shift_y = y - center_y shift_x = center_x - x if shift_x > 0: self.shift_left = shift_x else: self.shift_right = shift_x if self._tooltip: # update self._tooltip.text = text else: self._tooltip = ToolTip(text=text, pos_hint={}) self.display_tooltip() def on_mouse_pos(self, window, pos): if not self.get_root_window(): return # Abort if not displayed super().on_mouse_pos(window, pos) if self.refs and self.hovered: tx, ty = self.to_widget(*pos, relative=True) # Why TF is Y flipped *within* the texture? ty = self.texture_size[1] - ty hit = False for uid, zones in self.refs.items(): for zone in zones: x, y, w, h = zone if x <= tx <= w and y <= ty <= h: self.create_tooltip(uid.split("|", 1)[1], *pos) hit = True break if not hit: self.remove_tooltip() def on_enter(self): pass def on_leave(self): self.remove_tooltip() self._tooltip = None class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout): tooltip_display_delay = 0.1 text: str = StringProperty("Server:") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_widget(MDIcon(icon="information", font_size=sp(15))) self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5}, font_size=sp(15))) self._tooltip = ServerToolTip(text="Test") def on_enter(self): self._tooltip.text = self.get_text() self.display_tooltip() def on_leave(self): self.animation_tooltip_dismiss() @property def ctx(self) -> context_type: return MDApp.get_running_app().ctx def get_text(self): if self.ctx.server: ctx = self.ctx text = f"Connected to: {ctx.server_address}." if ctx.slot is not None: text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team}, " \ f"named {ctx.player_names[ctx.slot]}." if ctx.items_received: text += f"\nYou have received {len(ctx.items_received)} items. " \ f"You can list them in order with /received." if ctx.total_locations: text += f"\nYou have checked {len(ctx.checked_locations)} " \ f"out of {ctx.total_locations} locations. " \ f"You can get more info on missing checks with /missing." if ctx.permissions: text += "\nPermissions:" for permission_name, permission_data in ctx.permissions.items(): text += f"\n {permission_name}: {permission_data}" if ctx.hint_cost is not None and ctx.total_locations: min_cost = int(ctx.server_version >= (0, 3, 9)) text += f"\nA new !hint costs {ctx.hint_cost}% of checks made. " \ f"For you this means every " \ f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \ "location checks." \ f"\nYou currently have {ctx.hint_points} points." elif ctx.hint_cost == 0: text += "\n!hint is free to use." if ctx.stored_data and "_read_race_mode" in ctx.stored_data: text += "\nRace mode is enabled." \ if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled." else: text += f"\nYou are not authenticated yet." return text else: return "No current server connection. \nPlease connect to an Archipelago server." class MainLayout(MDGridLayout): pass class ContainerLayout(MDFloatLayout): pass class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, RecycleBoxLayout): """ Adds selection and focus behaviour to the view. """ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel): """ Add selection support to the Label """ index = None selected = BooleanProperty(False) def refresh_view_attrs(self, rv, index, data): """ Catch and handle the view changes """ self.index = index return super(SelectableLabel, self).refresh_view_attrs( rv, index, data) def on_size(self, instance_label, size: list) -> None: super().on_size(instance_label, size) if self.parent: self.width = self.parent.width def on_touch_down(self, touch): """ Add selection on touch down """ if super(SelectableLabel, self).on_touch_down(touch): return True if self.collide_point(*touch.pos): if self.selected: self.parent.clear_selection() else: # Not a fan of the following few lines, but they work. temp = MarkupLabel(text=self.text).markup text = "".join(part for part in temp if not part.startswith("[")) cmdinput = MDApp.get_running_app().textinput if not cmdinput.text: input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command) if input_text is not None: cmdinput.text = input_text Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]")) return self.parent.select_with_touch(self.index, touch) def apply_selection(self, rv, index, is_selected): """ Respond to the selection of items in the view. """ self.selected = is_selected class MarkupDropdownTextItem(MDDropdownTextItem): def __init__(self): super().__init__() for child in self.children: if child.__class__ == MDLabel: child.markup = True # Currently, this only lets us do markup on text that does not have any icons # Create new TextItems as needed class MarkupDropdown(MDDropdownMenu): def on_items(self, instance, value: list) -> None: """ The method sets the class that will be used to create the menu item. """ items = [] viewclass = "MarkupDropdownTextItem" for data in value: if "viewclass" not in data: if ( "leading_icon" not in data and "trailing_icon" not in data and "trailing_text" not in data ): viewclass = "MarkupDropdownTextItem" elif ( "leading_icon" in data and "trailing_icon" not in data and "trailing_text" not in data ): viewclass = "MDDropdownLeadingIconItem" elif ( "leading_icon" not in data and "trailing_icon" in data and "trailing_text" not in data ): viewclass = "MDDropdownTrailingIconItem" elif ( "leading_icon" not in data and "trailing_icon" in data and "trailing_text" in data ): viewclass = "MDDropdownTrailingIconTextItem" elif ( "leading_icon" in data and "trailing_icon" in data and "trailing_text" in data ): viewclass = "MDDropdownLeadingTrailingIconTextItem" elif ( "leading_icon" in data and "trailing_icon" in data and "trailing_text" not in data ): viewclass = "MDDropdownLeadingTrailingIconItem" elif ( "leading_icon" not in data and "trailing_icon" not in data and "trailing_text" in data ): viewclass = "MDDropdownTrailingTextItem" elif ( "leading_icon" in data and "trailing_icon" not in data and "trailing_text" in data ): viewclass = "MDDropdownLeadingIconTrailingTextItem" data["viewclass"] = viewclass if "height" not in data: data["height"] = dp(48) items.append(data) self._items = items # Update items in view if hasattr(self, "menu"): self.menu.data = self._items class AutocompleteHintInput(ResizableTextField): min_chars = NumericProperty(3) def __init__(self, **kwargs): super().__init__(**kwargs) self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width) self.bind(on_text_validate=self.on_message) self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x)) def on_message(self, instance): MDApp.get_running_app().commandprocessor("!hint "+instance.text) def on_text(self, instance, value): if len(value) >= self.min_chars: self.dropdown.items.clear() ctx: context_type = MDApp.get_running_app().ctx if not ctx.game: return item_names = ctx.item_names._game_store[ctx.game].values() def on_press(text): split_text = MarkupLabel(text=text).markup self.set_text(self, "".join(text_frag for text_frag in split_text if not text_frag.startswith("["))) self.dropdown.dismiss() self.focus = True lowered = value.lower() for item_name in item_names: 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() status_icons = { HintStatus.HINT_NO_PRIORITY: "information", HintStatus.HINT_PRIORITY: "exclamation-thick", HintStatus.HINT_AVOID: "alert" } class HintLabel(RecycleDataViewBehavior, MDBoxLayout): selected = BooleanProperty(False) striped = BooleanProperty(False) index = None dropdown: MDDropdownMenu def __init__(self): super(HintLabel, self).__init__() self.receiving_text = "" self.item_text = "" self.finding_text = "" self.location_text = "" self.entrance_text = "" self.status_text = "" self.hint = {} ctx = MDApp.get_running_app().ctx menu_items = [] for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID): name = status_names[status] status_button = MDDropDownItem(MDDropDownItemText(text=name), size_hint_y=None, height=dp(50)) status_button.status = status menu_items.append({ "text": name, "leading_icon": status_icons[status], "on_release": lambda x=status: select(self, x) }) self.dropdown = MDDropdownMenu(caller=self.ids["status"], items=menu_items) def select(instance, data): ctx.update_hint(self.hint["location"], self.hint["finding_player"], data) self.dropdown.bind(on_release=self.dropdown.dismiss) def set_height(self, instance, value): self.height = max([child.texture_size[1] for child in self.children]) def refresh_view_attrs(self, rv, index, data): self.index = index self.striped = data.get("striped", False) self.receiving_text = data["receiving"]["text"] self.item_text = data["item"]["text"] self.finding_text = data["finding"]["text"] self.location_text = data["location"]["text"] self.entrance_text = data["entrance"]["text"] self.status_text = data["status"]["text"] self.hint = data["status"]["hint"] return super(HintLabel, self).refresh_view_attrs(rv, index, data) def on_touch_down(self, touch): """ Add selection on touch down """ if super(HintLabel, self).on_touch_down(touch): return True if self.index: # skip header if self.collide_point(*touch.pos): status_label = self.ids["status"] if status_label.collide_point(*touch.pos): if self.hint["status"] == HintStatus.HINT_FOUND: return ctx = MDApp.get_running_app().ctx if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint # open a dropdown self.dropdown.open() elif self.selected: self.parent.clear_selection() else: text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ", self.finding_text, "\'s World", (" at " + self.entrance_text) if self.entrance_text != "Vanilla" else "", ". (", self.status_text.lower(), ")")) temp = MarkupLabel(text).markup text = "".join(part for part in temp if not part.startswith("[")) Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]")) return self.parent.select_with_touch(self.index, touch) else: parent = self.parent parent.clear_selection() parent: HintLog = parent.parent # find correct column for child in self.children: if child.collide_point(*touch.pos): key = child.sort_key if key == "status": parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]] else: parent.hint_sorter = lambda element: ( remove_between_brackets.sub("", element[key]["text"]).lower() ) if key == parent.sort_key: # second click reverses order parent.reversed = not parent.reversed else: parent.sort_key = key parent.reversed = False MDApp.get_running_app().update_hints() def apply_selection(self, rv, index, is_selected): """ Respond to the selection of items in the view. """ if self.index: self.selected = is_selected class ConnectBarTextInput(ResizableTextField): def insert_text(self, substring, from_undo=False): s = substring.replace("\n", "").replace("\r", "") return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) def is_command_input(string: str) -> bool: return len(string) > 0 and string[0] in "/!" class CommandPromptTextInput(ResizableTextField): MAXIMUM_HISTORY_MESSAGES = 50 def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._command_history_index = -1 self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES) def update_history(self, new_entry: str) -> None: self._command_history_index = -1 if is_command_input(new_entry): self._command_history.appendleft(new_entry) def keyboard_on_key_down( self, window, keycode: typing.Tuple[int, str], text: typing.Optional[str], modifiers: typing.List[str] ) -> bool: """ :param window: The kivy window object :param keycode: A tuple of (keycode, keyname). Keynames are always lowercase :param text: The text printed by this key, not accounting for modifiers, or `None` if no text. Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters. :param modifiers: A list of string modifiers, like `ctrl` or `numlock` """ if keycode[1] == 'up': self._change_to_history_text_if_available(self._command_history_index + 1) return True if keycode[1] == 'down': self._change_to_history_text_if_available(self._command_history_index - 1) return True return super().keyboard_on_key_down(window, keycode, text, modifiers) def _change_to_history_text_if_available(self, new_index: int) -> None: if new_index < -1: return if new_index >= len(self._command_history): return self._command_history_index = new_index if new_index == -1: self.text = "" return self.text = self._command_history[self._command_history_index] class MessageBoxLabel(MDLabel): def __init__(self, **kwargs): super().__init__(**kwargs) self._label.refresh() class MessageBox(Popup): def __init__(self, title, text, error=False, **kwargs): label = MessageBoxLabel(text=text) separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40), separator_color=separator_color, **kwargs) self.height += max(0, label.height - 18) class MDNavigationItemBase(MDNavigationItem): text = StringProperty(None) class ButtonsPrompt(MDDialog): def __init__(self, title: str, text: str, response: typing.Callable[[str], None], *prompts: str, **kwargs) -> None: """ Customizable popup box that lets you create any number of buttons. The text of the pressed button is returned to the callback. :param title: The title of the popup. :param text: The message prompt in the popup. :param response: A callable that will get called when the user presses a button. The prompt will not close itself so should be done here if you want to close it when certain buttons are pressed. :param prompts: Any number of strings to be used for the buttons. """ layout = MDBoxLayout(orientation="vertical") label = MessageBoxLabel(text=text) layout.add_widget(label) def on_release(button: MDButton, *args) -> None: response(button.text) buttons = [MDDivider()] for prompt in prompts: button = MDButton( MDButtonText(text=prompt, pos_hint={"center_x": 0.5, "center_y": 0.5}), on_release=on_release, style="text", theme_width="Custom", size_hint_x=1, ) button.text = prompt buttons.extend([button, MDDivider()]) super().__init__( MDDialogHeadlineText(text=title), MDDialogSupportingText(text=text), MDDialogButtonContainer(*buttons, orientation="vertical"), **kwargs, ) class MDScreenManagerBase(MDScreenManager): current_tab: MDNavigationItemBase local_screen_names: list[str] def __init__(self, **kwargs): super().__init__(**kwargs) self.local_screen_names = [] def add_widget(self, widget: Widget, *args, **kwargs) -> None: super().add_widget(widget, *args, **kwargs) if "index" in kwargs: self.local_screen_names.insert(kwargs["index"], widget.name) else: self.local_screen_names.append(widget.name) def switch_screens(self, new_tab: MDNavigationItemBase) -> None: """ Called whenever the user clicks a tab to switch to a different screen. :param new_tab: The new screen to switch to's tab. """ name = new_tab.text if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name): self.transition.direction = "left" else: self.transition.direction = "right" self.current = name self.current_tab = new_tab class CommandButton(MDButton, MDTooltip): def __init__(self, *args, manager: "GameManager", **kwargs): super().__init__(*args, **kwargs) self.manager = manager self._tooltip = ToolTip(text="Test") def on_enter(self): self._tooltip.text = self.manager.commandprocessor.get_help_text() self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here self.display_tooltip() def on_leave(self): self.animation_tooltip_dismiss() class GameManager(ThemedApp): logging_pairs = [ ("Client", "Archipelago"), ] base_title: str = "Archipelago Client" last_autofillable_command: str main_area_container: MDGridLayout """ subclasses can add more columns beside the tabs """ tabs: MDNavigationBar screens: MDScreenManagerBase def __init__(self, ctx: context_type): self.title = self.base_title self.ctx = ctx self.commandprocessor = ctx.command_processor(ctx) self.icon = r"data/icon.png" self.json_to_kivy_parser = KivyJSONtoTextParser(ctx) self.log_panels: typing.Dict[str, Widget] = {} # keep track of last used command to autofill on click self.last_autofillable_command = "hint" autofillable_commands = ("hint_location", "hint", "getitem") original_say = ctx.on_user_say def intercept_say(text): text = original_say(text) if text: for command in autofillable_commands: if text.startswith("!" + command): self.last_autofillable_command = command break return text ctx.on_user_say = intercept_say super(GameManager, self).__init__() @property def tab_count(self): if hasattr(self, "tabs"): return max(1, len(self.tabs.children)) return 1 def on_start(self): def on_start(*args): self.root.md_bg_color = self.theme_cls.backgroundColor super().on_start() Clock.schedule_once(on_start) def build(self) -> Layout: self.set_colors() self.container = ContainerLayout() self.grid = MainLayout() self.grid.cols = 1 self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10)) # top part server_label = ServerLabel(width=dp(75)) self.connect_layout.add_widget(server_label) self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", pos_hint={"center_x": 0.5, "center_y": 0.5}) def connect_bar_validate(sender): if not self.ctx.server: self.connect_button_action(sender) self.server_connect_bar.height = dp(30) self.server_connect_bar.bind(on_text_validate=connect_bar_validate) self.connect_layout.add_widget(self.server_connect_bar) self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None, radius=5, pos_hint={"center_y": 0.55}) self.server_connect_button.bind(on_press=self.connect_button_action) self.server_connect_button.height = self.server_connect_bar.height self.connect_layout.add_widget(self.server_connect_button) self.grid.add_widget(self.connect_layout) self.progressbar = MDLinearProgressIndicator(size_hint_y=None, height=3) self.grid.add_widget(self.progressbar) # middle part self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5}) self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True) # bind the method to the bar for back compatibility self.tabs.remove_tab = self.remove_client_tab self.screens.current_tab = self.add_client_tab( "All" if len(self.logging_pairs) > 1 else "Archipelago", UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)), ) self.log_panels["All"] = self.screens.current_tab.content self.screens.current_tab.active = True for logger_name, display_name in self.logging_pairs: bridge_logger = logging.getLogger(logger_name) self.log_panels[display_name] = UILog(bridge_logger) if len(self.logging_pairs) > 1: self.add_client_tab(display_name, self.log_panels[display_name]) self.hint_log = HintLog(self.json_to_kivy_parser) hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log)) self.log_panels["Hints"] = hint_panel.content self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) tab_container = MDGridLayout(size_hint_y=1, cols=1) tab_container.add_widget(self.tabs) tab_container.add_widget(self.screens) self.main_area_container.add_widget(tab_container) self.grid.add_widget(self.main_area_container) # bottom part bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10)) info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5, style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.575}) info_button.bind(on_release=self.command_button_action) bottom_layout.add_widget(info_button) self.textinput = CommandPromptTextInput(size_hint_y=None, multiline=False, write_tab=False) self.textinput.bind(on_text_validate=self.on_message) info_button.height = self.textinput.height self.textinput.text_validate_unfocus = False bottom_layout.add_widget(self.textinput) self.grid.add_widget(bottom_layout) self.commandprocessor("/help") Clock.schedule_interval(self.update_texts, 1 / 30) self.container.add_widget(self.grid) # If the address contains a port, select it; otherwise, select the host. s = self.server_connect_bar.text host_start = s.find("@") + 1 ipv6_end = s.find("]", host_start) + 1 port_start = s.find(":", ipv6_end if ipv6_end > 0 else host_start) + 1 self.server_connect_bar.focus = True self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s)) # Uncomment to enable the kivy live editor console # Press Ctrl-E (with numlock/capslock) disabled to open # from kivy.core.window import Window # from kivy.modules import console # console.create_console(Window, self.container) return self.container def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase: """ Adds a new tab to the client window with a given title, and provides a given Widget as its content. Returns the new tab widget, with the provided content being placed on the tab as content. :param title: The title of the tab. :param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the returned tab as tab.content. :param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end. :return: The new tab. """ if self.tabs.children: self.tabs.add_widget(MDDivider(orientation="vertical")) new_tab = MDNavigationItemBase(text=title) new_tab.content = content new_screen = MDScreen(name=title) new_screen.add_widget(content) if -1 < index <= len(self.tabs.children): remapped_index = len(self.tabs.children) - index self.tabs.add_widget(new_tab, index=remapped_index) self.screens.add_widget(new_screen, index=index) else: self.tabs.add_widget(new_tab) self.screens.add_widget(new_screen) return new_tab def remove_client_tab(self, tab: MDNavigationItemBase) -> None: """ Called to remove a tab and its screen. :param tab: The tab to remove. """ tab_index = self.tabs.children.index(tab) # if the tab is currently active we need to swap before removing it if tab == self.screens.current_tab: if not tab_index: # account for the divider swap_index = tab_index + 2 else: swap_index = tab_index - 2 self.tabs.children[swap_index].on_release() # self.screens.switch_screens(self.tabs.children[swap_index]) # get the divider to the left if we can if not tab_index: divider_index = tab_index + 1 else: divider_index = tab_index - 1 self.tabs.remove_widget(self.tabs.children[divider_index]) self.tabs.remove_widget(tab) self.screens.remove_widget(self.screens.get_screen(tab.text)) def update_texts(self, dt): if hasattr(self.screens.current_tab.content, "fix_heights"): getattr(self.screens.current_tab.content, "fix_heights")() if self.ctx.server: self.title = self.base_title + " " + Utils.__version__ + \ f" | Connected to: {self.ctx.server_address} " \ f"{'.'.join(str(e) for e in self.ctx.server_version)}" self.server_connect_button._button_text.text = "Disconnect" self.server_connect_bar.readonly = True self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations) self.progressbar.value = len(self.ctx.checked_locations) else: self.server_connect_button._button_text.text = "Connect" self.server_connect_bar.readonly = False self.title = self.base_title + " " + Utils.__version__ self.progressbar.value = 0 def command_button_action(self, button): if self.ctx.server: logging.getLogger("Client").info("/help for client commands and !help for server commands.") else: logging.getLogger("Client").info("/help for client commands and once you are connected, " "!help for server commands.") def connect_button_action(self, button): self.ctx.username = None self.ctx.password = None if self.ctx.server: async_start(self.ctx.disconnect()) else: async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", ""))) def on_stop(self): # "kill" input tasks for x in range(self.ctx.input_requests): self.ctx.input_queue.put_nowait("") self.ctx.input_requests = 0 self.ctx.exit_event.set() def on_message(self, textinput: CommandPromptTextInput): try: input_text = textinput.text.strip() textinput.text = "" textinput.update_history(input_text) if self.ctx.input_requests > 0: self.ctx.input_requests -= 1 self.ctx.input_queue.put_nowait(input_text) elif is_command_input(input_text): self.ctx.on_ui_command(input_text) self.commandprocessor(input_text) elif input_text: self.commandprocessor(input_text) except Exception as e: logging.getLogger("Client").exception(e) def print_json(self, data: typing.List[JSONMessagePart]): text = self.json_to_kivy_parser(data) self.log_panels["Archipelago"].on_message_markup(text) self.log_panels["All"].on_message_markup(text) def focus_textinput(self): if hasattr(self, "textinput"): self.textinput.focus = True def update_address_bar(self, text: str): if hasattr(self, "server_connect_bar"): self.server_connect_bar.text = text else: logging.getLogger("Client").info("Could not update address bar as the GUI is not yet initialized.") def enable_energy_link(self): if not hasattr(self, "energy_link_label"): self.energy_link_label = MDLabel(text="Energy Link: Standby", size_hint_x=None, width=150, halign="center") self.connect_layout.add_widget(self.energy_link_label) def set_new_energy_link_value(self): if hasattr(self, "energy_link_label"): self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J" def update_hints(self): hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []) self.hint_log.refresh_hints(hints) # default F1 keybind, opens a settings menu, that seems to break the layout engine once closed def open_settings(self, *largs): pass class LogtoUI(logging.Handler): def __init__(self, on_log): super(LogtoUI, self).__init__(logging.INFO) self.on_log = on_log @staticmethod def format_compact(record: logging.LogRecord) -> str: if isinstance(record.msg, Exception): return str(record.msg) return (f"{record.exc_info[1]}\n" if record.exc_info else "") + str(record.msg).split("\n")[0] def handle(self, record: logging.LogRecord) -> None: if getattr(record, "skip_gui", False): pass # skip output elif getattr(record, "compact_gui", False): self.on_log(self.format_compact(record)) else: self.on_log(self.format(record)) class UILog(MDRecycleView): messages: typing.ClassVar[int] # comes from kv file adaptive_height = True def __init__(self, *loggers_to_handle, **kwargs): super(UILog, self).__init__(**kwargs) self.data = [] for logger in loggers_to_handle: logger.addHandler(LogtoUI(self.on_log)) def on_log(self, record: str) -> None: self.data.append({"text": escape_markup(record)}) self.clean_old() def on_message_markup(self, text): self.data.append({"text": text}) self.clean_old() def clean_old(self): if len(self.data) > self.messages: self.data.pop(0) def fix_heights(self): """Workaround fix for divergent texture and layout heights""" for element in self.children[0].children: if element.height != element.texture_size[1]: element.height = element.texture_size[1] class HintLayout(MDBoxLayout): orientation = "vertical" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40)) boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(40), width=dp(75), halign="center", valign="center")) boxlayout.add_widget(AutocompleteHintInput()) self.add_widget(boxlayout) def fix_heights(self): for child in self.children: fix_func = getattr(child, "fix_heights", None) if fix_func: fix_func() status_names: typing.Dict[HintStatus, str] = { HintStatus.HINT_FOUND: "Found", HintStatus.HINT_UNSPECIFIED: "Unspecified", HintStatus.HINT_NO_PRIORITY: "No Priority", HintStatus.HINT_AVOID: "Avoid", HintStatus.HINT_PRIORITY: "Priority", } status_colors: typing.Dict[HintStatus, str] = { HintStatus.HINT_FOUND: "green", HintStatus.HINT_UNSPECIFIED: "white", HintStatus.HINT_NO_PRIORITY: "cyan", HintStatus.HINT_AVOID: "salmon", HintStatus.HINT_PRIORITY: "plum", } status_sort_weights: dict[HintStatus, int] = { HintStatus.HINT_FOUND: 0, HintStatus.HINT_UNSPECIFIED: 1, HintStatus.HINT_NO_PRIORITY: 2, HintStatus.HINT_AVOID: 3, HintStatus.HINT_PRIORITY: 4, } class HintLog(MDRecycleView): header = { "receiving": {"text": "[u]Receiving Player[/u]"}, "item": {"text": "[u]Item[/u]"}, "finding": {"text": "[u]Finding Player[/u]"}, "location": {"text": "[u]Location[/u]"}, "entrance": {"text": "[u]Entrance[/u]"}, "status": {"text": "[u]Status[/u]", "hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}}, "striped": True, } data: list[typing.Any] sort_key: str = "" reversed: bool = True def __init__(self, parser): super(HintLog, self).__init__() self.data = [self.header] self.parser = parser def refresh_hints(self, hints): if not hints: # Fix the scrolling looking visually wrong in some edge cases self.scroll_y = 1.0 data = [] ctx = MDApp.get_running_app().ctx for hint in hints: if not hint.get("status"): # Allows connecting to old servers hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED hint_status_node = self.parser.handle_node({"type": "color", "color": status_colors.get(hint["status"], "red"), "text": status_names.get(hint["status"], "Unknown")}) if hint["status"] != HintStatus.HINT_FOUND and ctx.slot_concerns_self(hint["receiving_player"]): hint_status_node = f"[u]{hint_status_node}[/u]" data.append({ "receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})}, "item": {"text": self.parser.handle_node({ "type": "item_id", "text": hint["item"], "flags": hint["item_flags"], "player": hint["receiving_player"], })}, "finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})}, "location": {"text": self.parser.handle_node({ "type": "location_id", "text": hint["location"], "player": hint["finding_player"], })}, "entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text", "color": "blue", "text": hint["entrance"] if hint["entrance"] else "Vanilla"})}, "status": { "text": hint_status_node, "hint": hint, }, }) data.sort(key=self.hint_sorter, reverse=self.reversed) for i in range(0, len(data), 2): data[i]["striped"] = True data.insert(0, self.header) self.data = data @staticmethod def hint_sorter(element: dict) -> str: return element["status"]["hint"]["status"] # By status by default def fix_heights(self): """Workaround fix for divergent texture and layout heights""" for element in self.children[0].children: max_height = max(child.texture_size[1] for child in element.children) element.height = max_height class ApAsyncImage(AsyncImage): def is_uri(self, filename: str) -> bool: if filename.startswith("ap:"): return True else: return super().is_uri(filename) class ImageLoaderPkgutil(ImageLoaderBase): def load(self, filename: str) -> typing.List[ImageData]: # take off the "ap:" prefix module, path = filename[3:].split("/", 1) data = pkgutil.get_data(module, path) return self._bytes_to_data(data) @staticmethod def _bytes_to_data(data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]: loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory()) return loader.load(loader, io.BytesIO(data)) # grab the default loader method so we can override it but use it as a fallback _original_image_loader_load = ImageLoader.load def load_override(filename: str, default_load=_original_image_loader_load, **kwargs): if filename.startswith("ap:"): return ImageLoaderPkgutil(filename) else: return default_load(filename, **kwargs) ImageLoader.load = load_override class E(ExceptionHandler): logger = logging.getLogger("Client") def handle_exception(self, inst): self.logger.exception("Uncaught Exception:", exc_info=inst) return ExceptionManager.PASS class KivyJSONtoTextParser(JSONtoTextParser): # dummy class to absorb kvlang definitions class TextColors(Widget): white: str = StringProperty("FFFFFF") black: str = StringProperty("000000") red: str = StringProperty("EE0000") green: str = StringProperty("00FF7F") yellow: str = StringProperty("FAFAD2") blue: str = StringProperty("6495ED") magenta: str = StringProperty("EE00EE") cyan: str = StringProperty("00EEEE") slateblue: str = StringProperty("6D8BE8") plum: str = StringProperty("AF99EF") salmon: str = StringProperty("FA8072") orange: str = StringProperty("FF7700") # KivyMD parameters theme_style: str = StringProperty("Dark") primary_palette: str = StringProperty("Lightsteelblue") dynamic_scheme_name: str = StringProperty("VIBRANT") dynamic_scheme_contrast: int = NumericProperty(0) def __init__(self, *args, **kwargs): # we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries colors = self.TextColors() color_codes = self.color_codes.copy() for name, code in color_codes.items(): color_codes[name] = getattr(colors, name, code) self.color_codes = color_codes super().__init__(*args, **kwargs) def __call__(self, *args, **kwargs): self.ref_count = 0 return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs) def _handle_item_name(self, node: JSONMessagePart): flags = node.get("flags", 0) item_types = [] if flags & 0b001: # advancement item_types.append("progression") if flags & 0b010: # useful item_types.append("useful") if flags & 0b100: # trap item_types.append("trap") if not item_types: item_types.append("normal") node.setdefault("refs", []).append("Item Class: " + ", ".join(item_types)) return super(KivyJSONtoTextParser, self)._handle_item_name(node) def _handle_player_id(self, node: JSONMessagePart): player = int(node["text"]) slot_info = self.ctx.slot_info.get(player, None) if slot_info: text = f"Game: {slot_info.game}
" \ f"Type: {SlotType(slot_info.type).name}" if slot_info.group_members: text += f"
Members:
" + "
".join( escape_markup(self.ctx.player_names[player]) for player in slot_info.group_members ) node.setdefault("refs", []).append(text) return super(KivyJSONtoTextParser, self)._handle_player_id(node) def _handle_color(self, node: JSONMessagePart): colors = node["color"].split(";") node["text"] = escape_markup(node["text"]) for color in colors: color_code = self.color_codes.get(color, None) if color_code: node["text"] = f"[color={color_code}]{node['text']}[/color]" return self._handle_text(node) return self._handle_text(node) def _handle_text(self, node: JSONMessagePart): # All other text goes through _handle_color, and we don't want to escape markup twice, # or mess up text that already has intentional markup applied to it if node.get("type", "text") == "text": node["text"] = escape_markup(node["text"]) for ref in node.get("refs", []): node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]" self.ref_count += 1 return super(KivyJSONtoTextParser, self)._handle_text(node) ExceptionManager.add_handler(E()) Builder.load_file(Utils.local_path("data", "client.kv")) user_file = Utils.user_path("data", "user.kv") if os.path.exists(user_file): logging.info("Loading user.kv into builder.") Builder.load_file(user_file)