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
)