From ced35c5b78a5d2d23fce8c7938dbe95fe3a0f07d Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:51:35 -0600 Subject: [PATCH] CommonClient: Add a hints tab (#2392) Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> --- CommonClient.py | 7 +- data/client.kv | 75 +++++++++++++- kvui.py | 263 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 262 insertions(+), 83 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index a5e9b455..0952b08a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -758,6 +758,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} ctx.hint_points = args.get("hint_points", 0) ctx.consume_players_package(args["players"]) + ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") msgs = [] if ctx.locations_checked: msgs.append({"cmd": "LocationChecks", @@ -836,10 +837,14 @@ async def process_server_cmd(ctx: CommonContext, args: dict): elif cmd == "Retrieved": ctx.stored_data.update(args["keys"]) + if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: + ctx.ui.update_hints() elif cmd == "SetReply": ctx.stored_data[args["key"]] = args["value"] - if args["key"].startswith("EnergyLink"): + if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: + ctx.ui.update_hints() + elif args["key"].startswith("EnergyLink"): ctx.current_energy_link_value = args["value"] if ctx.ui: ctx.ui.set_new_energy_link_value() diff --git a/data/client.kv b/data/client.kv index f0e36169..3b48d216 100644 --- a/data/client.kv +++ b/data/client.kv @@ -17,6 +17,12 @@ color: "FFFFFF" : tab_width: root.width / app.tab_count +: + text_size: self.width, None + size_hint_y: None + height: self.texture_size[1] + font_size: dp(20) + markup: True : canvas.before: Color: @@ -24,11 +30,6 @@ Rectangle: size: self.size pos: self.pos - text_size: self.width, None - size_hint_y: None - height: self.texture_size[1] - font_size: dp(20) - markup: True : messages: 1000 # amount of messages stored in client logs. cols: 1 @@ -44,6 +45,70 @@ height: self.minimum_height orientation: 'vertical' spacing: dp(3) +: + canvas.before: + Color: + rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1) + Rectangle: + size: self.size + pos: self.pos + height: self.minimum_height + receiving_text: "Receiving Player" + item_text: "Item" + finding_text: "Finding Player" + location_text: "Location" + entrance_text: "Entrance" + found_text: "Found?" + TooltipLabel: + id: receiving + text: root.receiving_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: item + text: root.item_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: finding + text: root.finding_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: location + text: root.location_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: entrance + text: root.entrance_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} + TooltipLabel: + id: found + text: root.found_text + halign: 'center' + valign: 'center' + pos_hint: {"center_y": 0.5} +: + cols: 1 + viewclass: 'HintLabel' + scroll_y: self.height + scroll_type: ["content", "bars"] + bar_width: dp(12) + effect_cls: "ScrollEffect" + SelectableRecycleBoxLayout: + default_size: None, dp(20) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + spacing: dp(3) : text: "Server:" size_hint_x: None diff --git a/kvui.py b/kvui.py index 71bf80c8..22e179d5 100644 --- a/kvui.py +++ b/kvui.py @@ -5,12 +5,13 @@ import typing 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 try: ctypes.windll.shcore.SetProcessDpiAwareness(0) except FileNotFoundError: # shcore may not be found on <= Windows 7 - pass # TODO: remove silent except when Python 3.8 is phased out. + pass # TODO: remove silent except when Python 3.8 is phased out. os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1" @@ -18,14 +19,15 @@ 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") 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 +Config.set("kivy", "exit_on_escape", "0") +Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers from kivy.app import App from kivy.core.window import Window @@ -58,7 +60,6 @@ from kivy.uix.popup import Popup fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) - from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType from Utils import async_start @@ -77,8 +78,8 @@ class HoverBehavior(object): border_point = ObjectProperty(None) def __init__(self, **kwargs): - self.register_event_type('on_enter') - self.register_event_type('on_leave') + 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) @@ -106,7 +107,7 @@ class HoverBehavior(object): self.dispatch("on_leave") -Factory.register('HoverBehavior', HoverBehavior) +Factory.register("HoverBehavior", HoverBehavior) class ToolTip(Label): @@ -121,6 +122,60 @@ class HovererableLabel(HoverBehavior, Label): pass +class TooltipLabel(HovererableLabel): + tooltip = None + + def create_tooltip(self, text, x, y): + text = text.replace("
", "\n").replace("&", "&").replace("&bl;", "[").replace("&br;", "]") + if self.tooltip: + # update + self.tooltip.children[0].text = text + else: + self.tooltip = FloatLayout() + tooltip_label = ToolTip(text=text) + self.tooltip.add_widget(tooltip_label) + fade_in_animation.start(self.tooltip) + App.get_running_app().root.add_widget(self.tooltip) + + # handle left-side boundary to not render off-screen + x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2) + + # position float layout + self.tooltip.x = x - self.tooltip.width / 2 + self.tooltip.y = y - self.tooltip.height / 2 + 48 + + def remove_tooltip(self): + if self.tooltip: + App.get_running_app().root.remove_widget(self.tooltip) + self.tooltip = None + + 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() + + class ServerLabel(HovererableLabel): def __init__(self, *args, **kwargs): super(HovererableLabel, self).__init__(*args, **kwargs) @@ -189,11 +244,10 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, """ Adds selection and focus behaviour to the view. """ -class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): +class SelectableLabel(RecycleDataViewBehavior, TooltipLabel): """ Add selection support to the Label """ index = None selected = BooleanProperty(False) - tooltip = None def refresh_view_attrs(self, rv, index, data): """ Catch and handle the view changes """ @@ -201,56 +255,6 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): return super(SelectableLabel, self).refresh_view_attrs( rv, index, data) - def create_tooltip(self, text, x, y): - text = text.replace("
", "\n").replace('&', '&').replace('&bl;', '[').replace('&br;', ']') - if self.tooltip: - # update - self.tooltip.children[0].text = text - else: - self.tooltip = FloatLayout() - tooltip_label = ToolTip(text=text) - self.tooltip.add_widget(tooltip_label) - fade_in_animation.start(self.tooltip) - App.get_running_app().root.add_widget(self.tooltip) - - # handle left-side boundary to not render off-screen - x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2) - - # position float layout - self.tooltip.x = x - self.tooltip.width / 2 - self.tooltip.y = y - self.tooltip.height / 2 + 48 - - def remove_tooltip(self): - if self.tooltip: - App.get_running_app().root.remove_widget(self.tooltip) - self.tooltip = None - - 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() - def on_touch_down(self, touch): """ Add selection on touch down """ if super(SelectableLabel, self).on_touch_down(touch): @@ -274,7 +278,7 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): elif not cmdinput.text and text.startswith("Missing: "): cmdinput.text = text.replace("Missing: ", "!hint_location ") - Clipboard.copy(text.replace('&', '&').replace('&bl;', '[').replace('&br;', ']')) + 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): @@ -282,9 +286,68 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel): self.selected = is_selected +class HintLabel(RecycleDataViewBehavior, BoxLayout): + selected = BooleanProperty(False) + striped = BooleanProperty(False) + index = None + no_select = [] + + def __init__(self): + super(HintLabel, self).__init__() + self.receiving_text = "" + self.item_text = "" + self.finding_text = "" + self.location_text = "" + self.entrance_text = "" + self.found_text = "" + for child in self.children: + child.bind(texture_size=self.set_height) + + 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 + if "select" in data and not data["select"] and index not in self.no_select: + self.no_select.append(index) + self.striped = data["striped"] + 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.found_text = data["found"]["text"] + self.height = self.minimum_height + 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 not in self.no_select: + if self.collide_point(*touch.pos): + if 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.found_text.lower(), ")"]) + temp = MarkupLabel(text).markup + text = "".join( + part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) + Clipboard.copy(escape_markup(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. """ + if self.index not in self.no_select: + self.selected = is_selected + + class ConnectBarTextInput(TextInput): def insert_text(self, substring, from_undo=False): - s = substring.replace('\n', '').replace('\r', '') + s = substring.replace("\n", "").replace("\r", "") return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) @@ -302,7 +365,7 @@ class MessageBox(Popup): def __init__(self, title, text, error=False, **kwargs): label = MessageBox.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=(None, None), width=max(100, int(label.width)+40), + super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40), separator_color=separator_color, **kwargs) self.height += max(0, label.height - 18) @@ -358,11 +421,14 @@ class GameManager(App): # top part server_label = ServerLabel() self.connect_layout.add_widget(server_label) - self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None, + self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", + size_hint_y=None, height=dp(30), multiline=False, write_tab=False) + def connect_bar_validate(sender): if not self.ctx.server: self.connect_button_action(sender) + self.server_connect_bar.bind(on_text_validate=connect_bar_validate) self.connect_layout.add_widget(self.server_connect_bar) self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None) @@ -383,20 +449,22 @@ class GameManager(App): bridge_logger = logging.getLogger(logger_name) panel = TabbedPanelItem(text=display_name) self.log_panels[display_name] = panel.content = UILog(bridge_logger) - self.tabs.add_widget(panel) + if len(self.logging_pairs) > 1: + # show Archipelago tab if other logging is present + self.tabs.add_widget(panel) + + hint_panel = TabbedPanelItem(text="Hints") + self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser) + self.tabs.add_widget(hint_panel) + + if len(self.logging_pairs) == 1: + self.tabs.default_tab_text = "Archipelago" self.main_area_container = GridLayout(size_hint_y=1, rows=1) self.main_area_container.add_widget(self.tabs) self.grid.add_widget(self.main_area_container) - if len(self.logging_pairs) == 1: - # Hide Tab selection if only one tab - self.tabs.clear_tabs() - self.tabs.do_default_tab = False - self.tabs.current_tab.height = 0 - self.tabs.tab_height = 0 - # bottom part bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None) @@ -422,7 +490,7 @@ class GameManager(App): return self.container def update_texts(self, dt): - if hasattr(self.tabs.content.children[0], 'fix_heights'): + if hasattr(self.tabs.content.children[0], "fix_heights"): self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream if self.ctx.server: self.title = self.base_title + " " + Utils.__version__ + \ @@ -499,6 +567,10 @@ class GameManager(App): 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[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"] + self.log_panels["Hints"].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 @@ -513,12 +585,12 @@ class LogtoUI(logging.Handler): 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] + 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): + if getattr(record, "skip_gui", False): pass # skip output - elif getattr(record, 'compact_gui', False): + elif getattr(record, "compact_gui", False): self.on_log(self.format_compact(record)) else: self.on_log(self.format(record)) @@ -552,6 +624,44 @@ class UILog(RecycleView): element.height = element.texture_size[1] +class HintLog(RecycleView): + 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]"}, + "found": {"text": "[u]Status[/u]"}, + "striped": True, + "select": False, + } + + def __init__(self, parser): + super(HintLog, self).__init__() + self.data = [self.header] + self.parser = parser + + def refresh_hints(self, hints): + self.data = [self.header] + striped = False + for hint in hints: + self.data.append({ + "striped": striped, + "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"]})}, + "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"]})}, + "entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text", + "color": "blue", "text": hint["entrance"] + if hint["entrance"] else "Vanilla"})}, + "found": { + "text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red", + "text": "Found" if hint["found"] else "Not Found"})}, + }) + striped = not striped + + class E(ExceptionHandler): logger = logging.getLogger("Client") @@ -599,7 +709,7 @@ class KivyJSONtoTextParser(JSONtoTextParser): f"Type: {SlotType(slot_info.type).name}" if slot_info.group_members: text += f"
Members:
" + \ - '
'.join(self.ctx.player_names[player] for player in slot_info.group_members) + "
".join(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) @@ -627,4 +737,3 @@ 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) -