From 4165f58414893c241c3b619966bce05a5e94148b Mon Sep 17 00:00:00 2001
From: Fabian Dill <Berserker66@users.noreply.github.com>
Date: Tue, 24 May 2022 00:20:02 +0200
Subject: [PATCH] Clients: now featuring tooltips and some general cleanup
 (#564)

* Clients: now featuring tooltips and some general cleanup

* Clients: fade in tooltip over 0.25 seconds

* Clients: reset slot and team when disconnecting

* Clients: allow joining multiworld via link (TextClient only for now)
---
 CommonClient.py                    |  19 ++++-
 FF1Client.py                       |   2 +-
 NetUtils.py                        |   1 +
 Starcraft2Client.py                |   6 +-
 WebHostLib/templates/hostRoom.html |   8 +-
 WebHostLib/templates/macros.html   |   2 +-
 data/client.kv                     |   6 +-
 inno_setup.iss                     |   4 +
 kvui.py                            | 115 ++++++++++++++++++++++++++---
 9 files changed, 145 insertions(+), 18 deletions(-)

diff --git a/CommonClient.py b/CommonClient.py
index 2c2236b3..e7d079ee 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -17,7 +17,7 @@ if __name__ == "__main__":
     Utils.init_logging("TextClient", exception_logger="Client")
 
 from MultiServer import CommandProcessor
-from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
+from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
 from Utils import Version, stream_input
 from worlds import network_data_package, AutoWorldRegister
 import os
@@ -125,6 +125,7 @@ class CommonContext():
     input_task: typing.Optional[asyncio.Task] = None
     keep_alive_task: typing.Optional[asyncio.Task] = None
     items_handling: typing.Optional[int] = None
+    slot_info: typing.Dict[int, NetworkSlot]
     current_energy_link_value: int = 0  # to display in UI, gets set by server
 
     def __init__(self, server_address, password):
@@ -136,6 +137,7 @@ class CommonContext():
         self.server_version = Version(0, 0, 0)
         self.hint_cost: typing.Optional[int] = None
         self.games: typing.Dict[int, str] = {}
+        self.slot_info = {}
         self.permissions = {
             "forfeit": "disabled",
             "collect": "disabled",
@@ -187,6 +189,8 @@ class CommonContext():
 
     def reset_server_state(self):
         self.auth = None
+        self.slot = None
+        self.team = None
         self.items_received = []
         self.locations_info = {}
         self.server_version = Version(0, 0, 0)
@@ -511,6 +515,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
     elif cmd == 'Connected':
         ctx.team = args["team"]
         ctx.slot = args["slot"]
+        # int keys get lost in JSON transfer
+        ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
         ctx.consume_players_package(args["players"])
         msgs = []
         if ctx.locations_checked:
@@ -643,6 +649,7 @@ if __name__ == '__main__':
 
     async def main(args):
         ctx = TextContext(args.connect, args.password)
+        ctx.auth = args.name
         ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
 
         if gui_enabled:
@@ -656,8 +663,16 @@ if __name__ == '__main__':
     import colorama
 
     parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
+    parser.add_argument('--name', default=None, help="Slot Name to connect as.")
+    parser.add_argument("url", nargs="?", help="Archipelago connection url")
+    args = parser.parse_args()
+
+    if args.url:
+        url = urllib.parse.urlparse(args.url)
+        args.connect = url.netloc
+        args.name = urllib.parse.unquote(url.username)
+        args.password = urllib.parse.unquote(url.password)
 
-    args, rest = parser.parse_known_args()
     colorama.init()
 
     asyncio.run(main(args))
diff --git a/FF1Client.py b/FF1Client.py
index 2b406ceb..f6bd0829 100644
--- a/FF1Client.py
+++ b/FF1Client.py
@@ -263,7 +263,7 @@ if __name__ == '__main__':
     import colorama
 
     parser = get_base_parser()
-    args, rest = parser.parse_known_args()
+    args = parser.parse_args()
     colorama.init()
 
     asyncio.run(main(args))
diff --git a/NetUtils.py b/NetUtils.py
index c5811756..9b207715 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -111,6 +111,7 @@ def get_any_version(data: dict) -> Version:
 whitelist = {
     "NetworkPlayer": NetworkPlayer,
     "NetworkItem": NetworkItem,
+    "NetworkSlot": NetworkSlot
 }
 
 custom_hooks = {
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index 971d92b1..51a04ea4 100644
--- a/Starcraft2Client.py
+++ b/Starcraft2Client.py
@@ -47,6 +47,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
                     if not self.ctx.sc2_run_task.done():
                         sc2_logger.warning("Starcraft 2 Client is still running!")
                     self.ctx.sc2_run_task.cancel()  # doesn't actually close the game, just stops the python task
+                if self.ctx.slot is None:
+                    sc2_logger.warning("Launching Mission without Archipelago authentication, "
+                                       "checks will not be registered to server.")
                 self.ctx.sc2_run_task = asyncio.create_task(starcraft_launch(self.ctx, mission_number),
                                                             name="Starcraft 2 Launch")
             else:
@@ -131,10 +134,11 @@ class Context(CommonContext):
 async def main():
     multiprocessing.freeze_support()
     parser = get_base_parser()
-    parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
+    parser.add_argument('--name', default=None, help="Slot Name to connect as.")
     args = parser.parse_args()
 
     ctx = Context(args.connect, args.password)
+    ctx.auth = args.name
     if ctx.server_task is None:
         ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
 
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html
index 75307989..13770e85 100644
--- a/WebHostLib/templates/hostRoom.html
+++ b/WebHostLib/templates/hostRoom.html
@@ -20,7 +20,13 @@
         later,
         you can simply refresh this page and the server will be started again.<br>
         {% if room.last_port %}
-            You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
+            You can connect to this room by using <span class="interactive"
+            data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.
+            Clicking here opens the Text Client, if installed properly.">
+            <a href="archipelago://{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">
+                '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
+            </a>
+            </span>
             in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
         {{ macros.list_patches_room(room) }}
         {% if room.owner == session["_id"] %}
diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html
index 663f3581..70b41fad 100644
--- a/WebHostLib/templates/macros.html
+++ b/WebHostLib/templates/macros.html
@@ -22,7 +22,7 @@
             {% for patch in room.seed.slots|list|sort(attribute="player_id") %}
                 <tr>
                     <td>{{ patch.player_id }}</td>
-                    <td>{{ patch.player_name }}</td>
+                    <td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
                     <td>{{ patch.game }}</td>
                     <td>
                         {% if patch.game == "Minecraft" %}
diff --git a/data/client.kv b/data/client.kv
index 09be0188..4f924896 100644
--- a/data/client.kv
+++ b/data/client.kv
@@ -30,7 +30,7 @@
     size_hint_x: 1
     size_hint_y: 1
     pos: (0, 0)
-<ServerToolTip>:
+<ToolTip>:
     size: self.texture_size
     size_hint: None, None
     font_size: dp(18)
@@ -51,4 +51,6 @@
             rgba: 0.235, 0.678, 0.843, 1
         Line:
             width: 1
-            rectangle: self.x-2, self.y-2, self.width+4, self.height+4
\ No newline at end of file
+            rectangle: self.x-2, self.y-2, self.width+4, self.height+4
+<ServerToolTip>:
+    pos_hint: {'center_y': 0.5, 'center_x': 0.5}
diff --git a/inno_setup.iss b/inno_setup.iss
index 7f5d11f0..47809c87 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -167,6 +167,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata";                     ValueData: "Arc
 Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon";         ValueData: "{app}\ArchipelagoServer.exe,0";                         ValueType: string;  ValueName: ""; Components: server
 Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command";  ValueData: """{app}\ArchipelagoServer.exe"" ""%1""";                ValueType: string;  ValueName: ""; Components: server
 
+Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text
+Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text
+Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text
+Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text
 
 [Code]
 const
diff --git a/kvui.py b/kvui.py
index c1130103..8f681d69 100644
--- a/kvui.py
+++ b/kvui.py
@@ -36,9 +36,12 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior
 from kivy.uix.behaviors import FocusBehavior
 from kivy.uix.recycleboxlayout import RecycleBoxLayout
 from kivy.uix.recycleview.layout import LayoutSelectionBehavior
+from kivy.animation import Animation
+
+fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
 
 import Utils
-from NetUtils import JSONtoTextParser, JSONMessagePart
+from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
 
 if typing.TYPE_CHECKING:
     import CommonClient
@@ -50,7 +53,7 @@ else:
 
 # I was surprised to find this didn't already exist in kivy :(
 class HoverBehavior(object):
-    """from https://stackoverflow.com/a/605348110"""
+    """originally from https://stackoverflow.com/a/605348110"""
     hovered = BooleanProperty(False)
     border_point = ObjectProperty(None)
 
@@ -61,11 +64,11 @@ class HoverBehavior(object):
         Window.bind(on_cursor_leave=self.on_cursor_leave)
         super(HoverBehavior, self).__init__(**kwargs)
 
-    def on_mouse_pos(self, *args):
+    def on_mouse_pos(self, window, pos):
         if not self.get_root_window():
-            return  # do proceed if I'm not displayed <=> If have no parent
-        pos = args[1]
-        # Next line to_widget allow to compensate for relative layout
+            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
@@ -87,11 +90,19 @@ class HoverBehavior(object):
 Factory.register('HoverBehavior', HoverBehavior)
 
 
-class ServerToolTip(Label):
+class ToolTip(Label):
+    pass
+
+
+class ServerToolTip(ToolTip):
     pass
 
 
 class HovererableLabel(HoverBehavior, Label):
+    pass
+
+
+class ServerLabel(HovererableLabel):
     def __init__(self, *args, **kwargs):
         super(HovererableLabel, self).__init__(*args, **kwargs)
         self.layout = FloatLayout()
@@ -101,6 +112,7 @@ class HovererableLabel(HoverBehavior, Label):
     def on_enter(self):
         self.popuplabel.text = self.get_text()
         App.get_running_app().root.add_widget(self.layout)
+        fade_in_animation.start(self.layout)
 
     def on_leave(self):
         App.get_running_app().root.remove_widget(self.layout)
@@ -109,8 +121,6 @@ class HovererableLabel(HoverBehavior, Label):
     def ctx(self) -> context_type:
         return App.get_running_app().ctx
 
-
-class ServerLabel(HovererableLabel):
     def get_text(self):
         if self.ctx.server:
             ctx = self.ctx
@@ -158,10 +168,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
     """ Adds selection and focus behaviour to the view. """
 
 
-class SelectableLabel(RecycleDataViewBehavior, Label):
+class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
     """ 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 """
@@ -169,6 +180,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
         return super(SelectableLabel, self).refresh_view_attrs(
             rv, index, data)
 
+    def create_tooltip(self, text, x, y):
+        text = text.replace("<br>", "\n")
+        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):
@@ -416,6 +477,34 @@ class E(ExceptionHandler):
 
 
 class KivyJSONtoTextParser(JSONtoTextParser):
+    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)
+        if flags & 0b001:  # advancement
+            itemtype = "progression"
+        elif flags & 0b010:  # never_exclude
+            itemtype = "useful"
+        elif flags & 0b100:  # trap
+            itemtype = "trap"
+        else:
+            itemtype = "normal"
+        node.setdefault("refs", []).append("Item Class: " + itemtype)
+        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}<br>" \
+                   f"Type: {SlotType(slot_info.type).name}"
+            if slot_info.group_members:
+                text += f"<br>Members:<br> " + \
+                        '<br> '.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)
 
     def _handle_color(self, node: JSONMessagePart):
         colors = node["color"].split(";")
@@ -427,6 +516,12 @@ class KivyJSONtoTextParser(JSONtoTextParser):
                 return self._handle_text(node)
         return self._handle_text(node)
 
+    def _handle_text(self, node: JSONMessagePart):
+        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())