CommonClient: Add a hints tab (#2392)

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
This commit is contained in:
Silvris 2023-11-07 14:51:35 -06:00 committed by GitHub
parent 779a312650
commit ced35c5b78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 262 additions and 83 deletions

View File

@ -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()

View File

@ -17,6 +17,12 @@
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
<TooltipLabel>:
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
<SelectableLabel>:
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
<UILog>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
@ -44,6 +45,70 @@
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
<HintLabel>:
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}
<HintLog>:
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)
<ServerLabel>:
text: "Server:"
size_hint_x: None

263
kvui.py
View File

@ -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("<br>", "\n").replace("&amp;", "&").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("<br>", "\n").replace('&amp;', '&').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('&amp;', '&').replace('&bl;', '[').replace('&br;', ']'))
Clipboard.copy(text.replace("&amp;", "&").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("&amp;", "&").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"<br>Members:<br> " + \
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
"<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)
@ -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)