Source code for simvx.core.ui.tooltip

"""TooltipManager: hover-triggered floating tooltip, rendered via the overlay layer."""

from __future__ import annotations

import logging

from ..math.types import Vec2
from .core import Control
from .theme import get_theme

log = logging.getLogger(__name__)

__all__ = ["TooltipManager"]


# ============================================================================
# _TooltipOverlay: the on-top Control the manager registers as a "none" overlay
# ============================================================================


class _TooltipOverlay(Control):
    """Internal floating Control that paints the tip box in absolute screen space.

    Registered by :class:`TooltipManager` as a ``modality="none"`` overlay so the
    single overlay collector draws it on top of all other UI on BOTH the immediate
    and retained (web) render paths. It carries no layout rect of its own: its
    ``on_draw`` reads the manager's text / position / screen bounds and draws the
    box at absolute coordinates, exactly as the old ``TooltipManager.draw`` did.
    """

    def __init__(self, manager: TooltipManager):
        super().__init__()
        self.name = "TooltipOverlay"
        self._manager = manager

    def on_draw(self, renderer):
        self._manager._paint(renderer)


# ============================================================================
# TooltipManager: utility class, NOT a Control subclass
# ============================================================================


[docs] class TooltipManager: """Manages tooltip display for the UI system. Not a Control subclass. Intended to be owned by SceneTree or the application layer and updated each frame. The actual rendering is routed through an internal :class:`_TooltipOverlay` registered on the overlay layer, so tooltips paint through the single collector on every backend (no manual ``draw`` call). Example: tip = TooltipManager(root=ui_root) # In the frame loop: control = tree._find_control_at_point(mouse_pos) tip.update(dt, mouse_pos, control) # No draw() call: the overlay layer renders the tip box on top. """ _FONT_SIZE = 12.0 _PADDING_X = 8.0 _PADDING_Y = 4.0 _OFFSET_X = 15.0 _OFFSET_Y = 15.0 def __init__( self, show_delay: float = 0.5, screen_width: float = 800, screen_height: float = 600, root: Control | None = None, ): self._hover_control: Control | None = None self._hover_time: float = 0.0 self._show_delay: float = show_delay self._visible: bool = False self._text: str = "" self._position: Vec2 = Vec2() self._screen_width: float = screen_width self._screen_height: float = screen_height # The overlay Control the manager opens/closes as the tip shows/hides. It is # parented under ``root`` (an enclosing UI Control) so it can join a tree and # register an overlay entry; created lazily on first show when no root yet. self._overlay: _TooltipOverlay | None = None self._root: Control | None = None if root is not None: self.attach(root)
[docs] def attach(self, root: Control) -> None: """Parent the tooltip overlay Control under ``root`` so it can register. The overlay needs a Control ancestor in the scene tree to obtain a tree reference (the overlay registry is tree-owned). Call this once the UI root exists if it was not supplied to ``__init__``. """ if self._overlay is None: self._overlay = _TooltipOverlay(self) if self._root is not None and self._overlay.parent is self._root: return self._root = root root.add_child(self._overlay)
[docs] def set_text(self, text: str) -> None: """Override the displayed tooltip text (kept for the manager's public API).""" self._text = text
[docs] def update(self, dt: float, mouse_pos, control_at_pos: Control | None): """Update tooltip state. Call once per frame. Args: dt: Frame delta time in seconds. mouse_pos: Current mouse position (Vec2 or tuple). control_at_pos: The topmost Control under the cursor, or None. """ mx = mouse_pos.x if hasattr(mouse_pos, "x") else mouse_pos[0] my = mouse_pos.y if hasattr(mouse_pos, "y") else mouse_pos[1] # Check if control has a non-empty tooltip attribute tooltip_text = "" if control_at_pos is not None: tooltip_text = getattr(control_at_pos, "tooltip", "") if tooltip_text: if control_at_pos is self._hover_control: # Same control: accumulate hover time self._hover_time += dt else: # New control: reset timer self._hover_control = control_at_pos self._hover_time = 0.0 self._set_visible(False) if self._hover_time >= self._show_delay: self._text = tooltip_text self._position = Vec2(mx + self._OFFSET_X, my + self._OFFSET_Y) self._set_visible(True) else: # No control or no tooltip: hide self._hover_control = None self._hover_time = 0.0 self._text = "" self._set_visible(False)
def _set_visible(self, visible: bool) -> None: """Toggle the tip and open / close its overlay registration accordingly.""" self._visible = visible overlay = self._overlay if overlay is None: return if visible: if not overlay.is_overlay_open: overlay.show_overlay("none") elif overlay.is_overlay_open: overlay.close_overlay() def _paint(self, renderer): """Draw the tooltip box at its clamped absolute position. Invoked by ``_TooltipOverlay.on_draw`` through the overlay collector; this is the single source of the tip's geometry (was ``TooltipManager.draw``). """ if not self._visible or not self._text: return theme = get_theme() scale = self._FONT_SIZE / 16.0 text_w = renderer.text_width(self._text, scale) box_w = text_w + self._PADDING_X * 2 box_h = self._FONT_SIZE + self._PADDING_Y * 2 # Clamp to screen bounds so tooltip doesn't overflow tx = self._position.x ty = self._position.y if tx + box_w > self._screen_width: tx = self._screen_width - box_w if ty + box_h > self._screen_height: ty = self._screen_height - box_h if tx < 0: tx = 0 if ty < 0: ty = 0 # Background renderer.draw_rect((tx, ty), (box_w, box_h), colour=theme.bg_darker, filled=True) # Border renderer.draw_rect((tx, ty), (box_w, box_h), colour=theme.border_light) # Text renderer.draw_text( self._text, (tx + self._PADDING_X, ty + self._PADDING_Y), colour=theme.text_bright, scale=scale )