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:" + + '" + ) + + +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:" - + '" - ) - - 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