diff --git a/gui/components/tooltip_formatting.py b/gui/components/tooltip_formatting.py
new file mode 100644
index 00000000..4bb8d7d1
--- /dev/null
+++ b/gui/components/tooltip_formatting.py
@@ -0,0 +1,141 @@
+from logic.requirements import (
+ TOD,
+ Requirement,
+ RequirementType,
+ evaluate_requirement_at_time,
+)
+from logic.tooltips.tooltips import pretty_name, sort_requirement
+from PySide6.QtGui import QFontMetrics, QTextDocumentFragment
+from PySide6.QtWidgets import QToolTip
+
+
+def get_tooltip_text(tracker_label, req: Requirement) -> str:
+ sort_requirement(req)
+ match req.type:
+ case RequirementType.AND:
+ # Computed requirements have a top-level AND requirement
+ # We display them as a list of bullet points to the user
+ # This fetches a list of the terms ANDed together
+ text = [format_requirement(tracker_label, a) for a in req.args]
+ case _:
+ # The requirement is just one term, so format the requirement
+ text = [format_requirement(tracker_label, req)]
+
+ tooltip_font_metrics = QFontMetrics(QToolTip.font())
+ # Find the width of the longest requirement description, adding a 16px buffer for the bullet point
+ max_line_width = (
+ max(
+ [
+ tooltip_font_metrics.horizontalAdvance(
+ QTextDocumentFragment.fromHtml(line).toPlainText()
+ )
+ for line in text + ["Item Requirements:"]
+ ]
+ )
+ + 16
+ )
+ # Set the tooltip's min and max width to ensure the tooltip is the right size and line-breaks properly
+ tracker_label.setStyleSheet(
+ tracker_label.styleSheet()
+ .replace("MINWIDTH", str(min(max_line_width, tracker_label.width() - 3)))
+ .replace("MAXWIDTH", str(tracker_label.width() - 3))
+ )
+ return (
+ "Item Requirements:"
+ + '
- '
+ + "
- ".join(text)
+ + "
"
+ )
+
+
+def format_requirement(
+ tracker_label,
+ req: Requirement,
+ is_top_level=True,
+) -> str:
+ match req.type:
+ case RequirementType.IMPOSSIBLE:
+ return 'Impossible (please discover an entrance first)'
+ case RequirementType.NOTHING:
+ return 'Nothing'
+ case RequirementType.ITEM:
+ # Determine if the user has marked this item
+ color = (
+ "dodgerblue"
+ if evaluate_requirement_at_time(
+ req, tracker_label.recent_search, TOD.ALL, tracker_label.world
+ )
+ else "red"
+ )
+ # Get a pretty name for the item if it is the first stage of a progressive item
+ name = pretty_name(req.args[0].name, 1)
+ return f'{name}'
+ case RequirementType.COUNT:
+ # Determine if the user has enough of this item marked
+ color = (
+ "dodgerblue"
+ if evaluate_requirement_at_time(
+ req, tracker_label.recent_search, TOD.ALL, tracker_label.world
+ )
+ else "red"
+ )
+ # Get a pretty name for the progressive item
+ name = pretty_name(req.args[1].name, req.args[0])
+ return f'{name}'
+ case RequirementType.WALLET_CAPACITY:
+ # Determine if the user has enough wallet capacity for this requirement
+ color = (
+ "dodgerblue"
+ if evaluate_requirement_at_time(
+ req, tracker_label.recent_search, TOD.ALL, tracker_label.world
+ )
+ else "red"
+ )
+ # TODO: Properly expand into wallet combinations
+ return f'Wallet >= {req.args[0]}'
+ case RequirementType.GRATITUDE_CRYSTALS:
+ # Determine if the user has enough gratitude crystals marked
+ color = (
+ "dodgerblue"
+ if evaluate_requirement_at_time(
+ req, tracker_label.recent_search, TOD.ALL, tracker_label.world
+ )
+ else "red"
+ )
+ return (
+ f'{req.args[0]} Gratitude Crystals'
+ )
+ case RequirementType.TRACKER_NOTE:
+ color = (
+ "dodgerblue"
+ if evaluate_requirement_at_time(
+ req.args[1],
+ tracker_label.recent_search,
+ TOD.ALL,
+ tracker_label.world,
+ )
+ else "red"
+ )
+ return f'{req.args[2]}'
+ case RequirementType.OR:
+ # Recursively join requirements with "or"
+ # Only include parentheses if not at the top level (where they'd be redundant)
+ return (
+ ("" if is_top_level else "(")
+ + " or ".join(
+ [format_requirement(tracker_label, a, False) for a in req.args]
+ )
+ + ("" if is_top_level else ")")
+ )
+ case RequirementType.AND:
+ # Recursively join requirements with "and"
+ # Only include parentheses if not at the top level (where they'd be redundant)
+ return (
+ ("" if is_top_level else "(")
+ + " and ".join(
+ [format_requirement(tracker_label, a, False) for a in req.args]
+ )
+ + ("" if is_top_level else ")")
+ )
+ case _:
+ raise ValueError("unreachable")
diff --git a/gui/components/tracker_entrance_label.py b/gui/components/tracker_entrance_label.py
index c31d0b09..4b2841e8 100644
--- a/gui/components/tracker_entrance_label.py
+++ b/gui/components/tracker_entrance_label.py
@@ -1,16 +1,22 @@
-from PySide6.QtWidgets import QLabel
+import platform
+from PySide6.QtWidgets import QLabel, QToolTip
from PySide6.QtGui import QCursor, QMouseEvent
from PySide6 import QtCore
-from PySide6.QtCore import Signal
+from PySide6.QtCore import Signal, QPoint
from logic.entrance import Entrance
from logic.search import Search
from logic.requirements import *
+from constants.guiconstants import TRACKER_LOCATION_TOOLTIP_STYLESHEET
+from .tooltip_formatting import get_tooltip_text
+
class TrackerEntranceLabel(QLabel):
- default_stylesheet = "border-width: 1px; border-color: gray; color: COLOR;"
+ default_stylesheet = (
+ "QLabel { border-width: 1px; border-color: gray; color: COLOR; }"
+ )
choose_target = Signal(Entrance, str)
disconnect_entrance = Signal(Entrance, str)
@@ -19,18 +25,21 @@ def __init__(
entrance_: Entrance,
parent_area_name_: str,
recent_search_: Search,
+ world_,
show_full_connection_: bool,
) -> None:
super().__init__()
self.entrance = entrance_
self.parent_area_name = parent_area_name_
self.recent_search = recent_search_
+ self.world = world_
self.show_full_connection = show_full_connection_
self.setCursor(QCursor(QtCore.Qt.CursorShape.PointingHandCursor))
self.setMargin(10)
self.setMinimumHeight(30)
self.setMaximumWidth(273)
self.setWordWrap(True)
+ self.setMouseTracking(True)
self.update_text()
def update_text(self, recent_search_: Search | None = None) -> None:
@@ -48,6 +57,11 @@ def update_text(self, recent_search_: Search | None = None) -> None:
f"{first_part}{original_connected} -> {connected_area.name if connected_area else '?'}"
)
+ self.update_color(recent_search_)
+
+ def update_color(self, recent_search_: Search | None = None) -> None:
+ if recent_search_ is not None:
+ self.recent_search = recent_search_
# Set the color as blue if accessible, or red if not
color = "red"
if (
@@ -65,6 +79,7 @@ def update_text(self, recent_search_: Search | None = None) -> None:
self.setStyleSheet(
TrackerEntranceLabel.default_stylesheet.replace("COLOR", color)
+ + TRACKER_LOCATION_TOOLTIP_STYLESHEET
)
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
@@ -74,3 +89,15 @@ def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
self.disconnect_entrance.emit(self.entrance, self.parent_area_name)
self.update_text()
return super().mouseReleaseEvent(ev)
+
+ def mouseMoveEvent(self, ev: QMouseEvent) -> None:
+ coords = self.mapToGlobal(QPoint(-2, self.height() - 15))
+ # For whatever reason, MacOS calculates this position differently,
+ # so we must offset the height to compensate
+ if platform.system() == "Darwin":
+ coords.setY(coords.y() - 18)
+ QToolTip.showText(
+ coords, get_tooltip_text(self, self.entrance.computed_requirement), self
+ )
+
+ return super().mouseMoveEvent(ev)
diff --git a/gui/components/tracker_location_label.py b/gui/components/tracker_location_label.py
index c6236126..22b39444 100644
--- a/gui/components/tracker_location_label.py
+++ b/gui/components/tracker_location_label.py
@@ -1,4 +1,3 @@
-from collections import Counter
import platform
from PySide6.QtWidgets import QLabel, QToolTip
from PySide6.QtGui import (
@@ -8,25 +7,17 @@
QPixmap,
QFontMetrics,
QPainter,
- QTextDocumentFragment,
)
from PySide6 import QtCore
from PySide6.QtCore import Signal, QPoint
from constants.guiconstants import TRACKER_LOCATION_TOOLTIP_STYLESHEET
from constants.itemnames import PROGRESSIVE_SWORD
-from constants.trackerprettyitems import PRETTY_ITEM_NAMES
-from logic.item import Item
from logic.location import Location
-from logic.requirements import (
- TOD,
- Requirement,
- RequirementType,
- evaluate_requirement_at_time,
-)
from logic.search import Search
from filepathconstants import TRACKER_ASSETS_PATH
+from .tooltip_formatting import get_tooltip_text
class TrackerLocationLabel(QLabel):
@@ -181,168 +172,8 @@ def mouseMoveEvent(self, ev: QMouseEvent) -> None:
# so we must offset the height to compensate
if platform.system() == "Darwin":
coords.setY(coords.y() - 18)
- QToolTip.showText(coords, self.get_tooltip_text(), self)
-
- return super().mouseMoveEvent(ev)
-
- def get_tooltip_text(self) -> str:
- req = self.location.computed_requirement
- sort_requirement(req)
- match req.type:
- case RequirementType.AND:
- # Computed requirements have a top-level AND requirement
- # We display them as a list of bullet points to the user
- # This fetches a list of the terms ANDed together
- text = [self.format_requirement(a) for a in req.args]
- case _:
- # The requirement is just one term, so format the requirement
- text = [self.format_requirement(req)]
-
- tooltip_font_metrics = QFontMetrics(QToolTip.font())
- # Find the width of the longest requirement description, adding a 16px buffer for the bullet point
- max_line_width = (
- max(
- [
- tooltip_font_metrics.horizontalAdvance(
- QTextDocumentFragment.fromHtml(line).toPlainText()
- )
- for line in text + ["Item Requirements:"]
- ]
- )
- + 16
- )
- # Set the tooltip's min and max width to ensure the tooltip is the right size and line-breaks properly
- self.setStyleSheet(
- self.styleSheet()
- .replace("MINWIDTH", str(min(max_line_width, self.width() - 3)))
- .replace("MAXWIDTH", str(self.width() - 3))
+ QToolTip.showText(
+ coords, get_tooltip_text(self, self.location.computed_requirement), self
)
- return (
- "Item Requirements:"
- + '- '
- + "
- ".join(text)
- + "
"
- )
-
- def format_requirement(self, req: Requirement, is_top_level=True) -> str:
- match req.type:
- case RequirementType.IMPOSSIBLE:
- return 'Impossible (please discover an entrance first)'
- case RequirementType.NOTHING:
- return 'Nothing'
- case RequirementType.ITEM:
- # Determine if the user has marked this item
- color = (
- "dodgerblue"
- if evaluate_requirement_at_time(
- req, self.recent_search, TOD.ALL, self.world
- )
- else "red"
- )
- # Get a pretty name for the item if it is the first stage of a progressive item
- name = pretty_name(req.args[0].name, 1)
- return f'{name}'
- case RequirementType.COUNT:
- # Determine if the user has enough of this item marked
- color = (
- "dodgerblue"
- if evaluate_requirement_at_time(
- req, self.recent_search, TOD.ALL, self.world
- )
- else "red"
- )
- # Get a pretty name for the progressive item
- name = pretty_name(req.args[1].name, req.args[0])
- return f'{name}'
- case RequirementType.WALLET_CAPACITY:
- # Determine if the user has enough wallet capacity for this requirement
- color = (
- "dodgerblue"
- if evaluate_requirement_at_time(
- req, self.recent_search, TOD.ALL, self.world
- )
- else "red"
- )
- # TODO: Properly expand into wallet combinations
- return f'Wallet >= {req.args[0]}'
- case RequirementType.GRATITUDE_CRYSTALS:
- # Determine if the user has enough gratitude crystals marked
- color = (
- "dodgerblue"
- if evaluate_requirement_at_time(
- req, self.recent_search, TOD.ALL, self.world
- )
- else "red"
- )
- return f'{req.args[0]} Gratitude Crystals'
- case RequirementType.TRACKER_NOTE:
- color = (
- "dodgerblue"
- if evaluate_requirement_at_time(
- req.args[1], self.recent_search, TOD.ALL, self.world
- )
- else "red"
- )
- return f'{req.args[2]}'
- case RequirementType.OR:
- # Recursively join requirements with "or"
- # Only include parentheses if not at the top level (where they'd be redundant)
- return (
- ("" if is_top_level else "(")
- + " or ".join([self.format_requirement(a, False) for a in req.args])
- + ("" if is_top_level else ")")
- )
- case RequirementType.AND:
- # Recursively join requirements with "and"
- # Only include parentheses if not at the top level (where they'd be redundant)
- return (
- ("" if is_top_level else "(")
- + " and ".join(
- [self.format_requirement(a, False) for a in req.args]
- )
- + ("" if is_top_level else ")")
- )
- case _:
- raise ValueError("unreachable")
-
-
-def num_terms(req: Requirement):
- if req.type == RequirementType.AND or req.type == RequirementType.OR:
- return sum(map(num_terms, req.args))
- return 1
-
-
-def sort_requirement(req: Requirement):
- def by_length(req: Requirement):
- if req.type == RequirementType.AND or req.type == RequirementType.OR:
- return num_terms(req)
- return -1
- def by_item(req: Requirement):
- if req.type == RequirementType.ITEM:
- return pretty_name(req.args[0].name, 1)
- elif req.type == RequirementType.COUNT:
- return pretty_name(req.args[1].name, req.args[0])
- elif req.type == RequirementType.AND or req.type == RequirementType.OR:
- return by_item(req.args[0])
- elif req.type == RequirementType.TRACKER_NOTE:
- return req.args[2]
- return ""
-
- def sort_key(req: Requirement):
- return (by_length(req), by_item(req))
-
- if req.type == RequirementType.AND or req.type == RequirementType.OR:
- for expr in req.args:
- sort_requirement(expr)
- req.args.sort(key=sort_key)
-
-
-def pretty_name(item, count):
- if (pretty_name := PRETTY_ITEM_NAMES.get((item, count), None)) is not None:
- return pretty_name
-
- if count > 1:
- return f"{item} x {count}"
- else:
- return item
+ return super().mouseMoveEvent(ev)
diff --git a/gui/tracker.py b/gui/tracker.py
index dacee797..dc007d42 100644
--- a/gui/tracker.py
+++ b/gui/tracker.py
@@ -1153,7 +1153,11 @@ def show_area_entrances(self, area_name: str) -> None:
]
)
entrance_label = TrackerEntranceLabel(
- entrance, area_name, area_button.recent_search, show_full_connection
+ entrance,
+ area_name,
+ area_button.recent_search,
+ self.world,
+ show_full_connection,
)
entrance_label.choose_target.connect(self.show_target_selection_info)
entrance_label.disconnect_entrance.connect(
diff --git a/logic/tooltips/tooltips.py b/logic/tooltips/tooltips.py
index be72e605..0fd6de0a 100644
--- a/logic/tooltips/tooltips.py
+++ b/logic/tooltips/tooltips.py
@@ -4,7 +4,15 @@
from constants.configconstants import TRACKER_NOTE_EVENTS
from ..item_pool import get_complete_item_pool
from ..search import Search, SearchMode
-from ..requirements import Requirement, RequirementType, ALL_TODS, visit_requirement
+from ..requirements import (
+ Requirement,
+ RequirementType,
+ ALL_TODS,
+ evaluate_requirement_at_time,
+ visit_requirement,
+)
+from typing import Callable
+from constants.trackerprettyitems import PRETTY_ITEM_NAMES
from ..world import World, TOD, LocationAccess
from ..entrance import Entrance
from ..area import EventAccess, Area
@@ -393,3 +401,45 @@ def evaluate_partial_requirement(
case RequirementType.NIGHT:
return DNF.true() if time & TOD.NIGHT else DNF.false()
+
+
+def num_terms(req: Requirement):
+ if req.type == RequirementType.AND or req.type == RequirementType.OR:
+ return sum(map(num_terms, req.args))
+ return 1
+
+
+def sort_requirement(req: Requirement):
+ def by_length(req: Requirement):
+ if req.type == RequirementType.AND or req.type == RequirementType.OR:
+ return num_terms(req)
+ return -1
+
+ def by_item(req: Requirement):
+ if req.type == RequirementType.ITEM:
+ return pretty_name(req.args[0].name, 1)
+ elif req.type == RequirementType.COUNT:
+ return pretty_name(req.args[1].name, req.args[0])
+ elif req.type == RequirementType.AND or req.type == RequirementType.OR:
+ return by_item(req.args[0])
+ elif req.type == RequirementType.TRACKER_NOTE:
+ return req.args[2]
+ return ""
+
+ def sort_key(req: Requirement):
+ return (by_length(req), by_item(req))
+
+ if req.type == RequirementType.AND or req.type == RequirementType.OR:
+ for expr in req.args:
+ sort_requirement(expr)
+ req.args.sort(key=sort_key)
+
+
+def pretty_name(item, count):
+ if (pretty_name := PRETTY_ITEM_NAMES.get((item, count), None)) is not None:
+ return pretty_name
+
+ if count > 1:
+ return f"{item} x {count}"
+ else:
+ return item