feat(engine): tooltip infrastructure with hover delay (foundation only)

A new ui_tooltip module owns a Tooltip(Cow<'static, str>) component
that turns any UI node into a hover-revealing help target. Bevy 0.18's
required-components attribute auto-inserts an Interaction so callers
just attach Tooltip and the rest is wired.

A single overlay entity is reparented above the focus ring (new
Z_TOOLTIP token = Z_FOCUS_RING + 10) and tracked from the hovered
target's GlobalTransform + ComputedNode. The chained Update systems
start a hover timer on Interaction::Hovered, show the overlay once
MOTION_TOOLTIP_DELAY_SECS (0.5s) has elapsed, hide it the moment hover
ends, and refresh the text when the hover target switches without an
intervening unhover.

Tested headless under MinimalPlugins with a 200ms ManualDuration
ticker — Bevy clamps Time<Virtual>'s max_delta to 250ms by default, so
a one-shot 1s step doesn't actually advance the clock past the
threshold; the tests step five times to exercise both pre- and
post-delay invariants.

This commit ships the infrastructure only — no entity in the engine
has Tooltip attached yet. A follow-up applies tooltips to the HUD
readouts and action bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 23:03:43 +00:00
parent 0c86cac2d5
commit 54d34972d4
4 changed files with 575 additions and 1 deletions
+2
View File
@@ -33,6 +33,7 @@ pub mod time_attack_plugin;
pub mod ui_focus;
pub mod ui_modal;
pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
@@ -104,6 +105,7 @@ pub use ui_modal::{
spawn_modal_header, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard,
ModalHeader, ModalScrim, UiModalPlugin,
};
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use table_plugin::{
BackgroundImageSet, HintPileHighlight, PileMarker, TableBackground, TablePlugin,
};
+18
View File
@@ -275,6 +275,23 @@ pub const MOTION_SCORE_PULSE_SECS: f32 = 0.25;
/// 400 ms.
pub const MOTION_LOADING_TICK_SECS: f32 = 0.40;
/// Hover delay before a tooltip appears, in seconds. Long enough that
/// players gliding the cursor across the HUD don't see flicker; short
/// enough that "stop and read" feels responsive. Not run through
/// [`scaled_duration`] — `AnimSpeed` controls gameplay motion, not the
/// hover-discoverability budget for help text.
pub const MOTION_TOOLTIP_DELAY_SECS: f32 = 0.5;
// ---------------------------------------------------------------------------
// Z-index — tooltip layer
// ---------------------------------------------------------------------------
/// Z-layer for tooltips. Sits one rung above the focus ring so a
/// tooltip rendered over a focused button is never occluded by the
/// button's outline. Still below `Z_WIN_CASCADE` and `Z_TOAST` so the
/// celebration and notification layers stay on top.
pub const Z_TOOLTIP: i32 = Z_FOCUS_RING + 10;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -342,6 +359,7 @@ mod tests {
Z_PAUSE_DIALOG,
Z_ONBOARDING,
Z_FOCUS_RING,
Z_TOOLTIP,
Z_WIN_CASCADE,
Z_TOAST,
];
+553
View File
@@ -0,0 +1,553 @@
//! Hover-tooltip infrastructure. Adds a one-shot, design-token-styled
//! popover that appears over any UI element carrying a [`Tooltip`]
//! component once the cursor has lingered for
//! [`crate::ui_theme::MOTION_TOOLTIP_DELAY_SECS`] seconds.
//!
//! ## Why a sibling overlay
//!
//! Like [`crate::ui_focus`], this module uses a single absolute-positioned
//! overlay entity that is never a descendant of any modal or HUD card. On
//! every frame, [`show_or_hide_tooltip`] reads the hovered target's
//! [`bevy::ui::UiGlobalTransform`] + [`bevy::ui::ComputedNode`] and writes
//! an absolute `Node.left` / `Node.top` so the overlay tracks the target
//! without inheriting modal scale-in or scroll-clipping. The pattern
//! mirrors [`crate::ui_focus::update_focus_overlay`] one-for-one.
//!
//! ## Public surface
//!
//! - [`Tooltip`] — component carrying the hover text. Add it to any
//! interactive node and the rest is automatic.
//! - [`UiTooltipPlugin`] — registers the resource, startup spawn, and the
//! per-frame tracking + display systems.
//!
//! ## Scope
//!
//! Phase 1 of the tooltip rollout — *infrastructure only*. No HUD or
//! Settings entity carries [`Tooltip`] yet; a follow-up commit applies
//! tooltips to specific readouts and buttons. Treat this module as the
//! library half of the feature.
use std::borrow::Cow;
use std::time::Duration;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource;
use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
};
// ---------------------------------------------------------------------------
// Public component / plugin
// ---------------------------------------------------------------------------
/// Marker on a UI element that should display a tooltip when the cursor
/// hovers over it. The component carries the tooltip text — typically a
/// short caption explaining what the element does or what its number
/// represents.
///
/// Bevy UI hover detection requires the [`Interaction`] component (the
/// picking system writes `Interaction::Hovered` only on entities that
/// have it), so [`Tooltip`] declares it as a required component. Adding
/// `Tooltip` to a node automatically inserts a default [`Interaction`].
///
/// The owning entity must also be a UI [`Node`] for picking to pick it
/// up; that's a layout concern handled at the call site. Every interactive
/// HUD readout and modal button in this codebase already carries `Node`,
/// so in practice callers just attach `Tooltip::new("…")` and move on.
///
/// # Example
///
/// ```ignore
/// use solitaire_engine::ui_tooltip::Tooltip;
///
/// commands.spawn((
/// Node { /* ... */ ..default() },
/// Tooltip::new("Cards left in the stock"),
/// ));
/// ```
#[derive(Component, Debug, Clone)]
#[require(Interaction)]
pub struct Tooltip(pub Cow<'static, str>);
impl Tooltip {
/// Builds a [`Tooltip`] from any string-like value. Prefer passing a
/// `&'static str` for static labels — the underlying `Cow` keeps the
/// allocation-free path open for the common case while still
/// accepting owned `String`s for runtime-formatted text.
pub fn new(text: impl Into<Cow<'static, str>>) -> Self {
Self(text.into())
}
}
/// Registers the tooltip overlay and the systems that drive it. Add this
/// plugin once, immediately after [`crate::ui_focus::UiFocusPlugin`], and
/// every entity carrying a [`Tooltip`] component gains hover-to-reveal
/// behaviour with no per-plugin wiring.
pub struct UiTooltipPlugin;
impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip_overlay)
.add_systems(
Update,
(track_tooltip_hover, show_or_hide_tooltip).chain(),
);
}
}
// ---------------------------------------------------------------------------
// Private resource + markers
// ---------------------------------------------------------------------------
/// Internal state for the singleton tooltip overlay. Tracks which
/// [`Tooltip`]-bearing entity the cursor is currently hovering and the
/// `Time::elapsed()` timestamp at which the hover started, so the display
/// system can fire only once the dwell threshold has elapsed.
#[derive(Resource, Debug, Default)]
struct TooltipState {
/// `(target_entity, hover_started_at)` — populated by
/// [`track_tooltip_hover`] when an entity transitions to
/// [`Interaction::Hovered`], cleared when the cursor leaves.
hovered: Option<(Entity, Duration)>,
/// The singleton overlay entity, populated by
/// [`spawn_tooltip_overlay`] on Startup. Read by
/// [`show_or_hide_tooltip`] to skip a `single_mut` query.
overlay: Option<Entity>,
}
/// Marker on the singleton tooltip-overlay container.
#[derive(Component, Debug)]
struct TooltipOverlay;
/// Marker on the overlay's [`Text`] child, so the display system can
/// rewrite the tooltip string without despawning the whole overlay.
#[derive(Component, Debug)]
struct TooltipText;
// ---------------------------------------------------------------------------
// Tunables
// ---------------------------------------------------------------------------
/// Vertical gap between the target and the tooltip overlay, in logical
/// pixels. Small enough to read as "attached"; big enough to clear the
/// target's own border.
const TOOLTIP_GAP_PX: f32 = 4.0;
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Spawns the singleton tooltip-overlay entity at Startup. Hidden until a
/// [`Tooltip`]-bearing target is hovered for [`MOTION_TOOLTIP_DELAY_SECS`]
/// seconds, then repositioned and revealed by [`show_or_hide_tooltip`].
fn spawn_tooltip_overlay(
mut commands: Commands,
mut state: ResMut<TooltipState>,
font_res: Option<Res<FontResource>>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
let overlay = commands
.spawn((
TooltipOverlay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
// Auto width/height so the overlay tracks its text content.
..default()
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline.
GlobalZIndex(Z_TOOLTIP),
))
.with_children(|root| {
root.spawn((
TooltipText,
Text::new(String::new()),
font,
TextColor(TEXT_PRIMARY),
));
})
.id();
state.overlay = Some(overlay);
}
/// Watches every interactive entity for `Changed<Interaction>` and
/// updates [`TooltipState::hovered`] accordingly:
///
/// * Hovering a [`Tooltip`]-bearing entity records the start time so the
/// display system can apply the dwell delay.
/// * Leaving the currently-hovered entity (transition away from
/// `Hovered`) clears the state so the overlay hides on the next tick.
///
/// Hovering a different `Tooltip` entity simply replaces the prior
/// `(entity, t0)` pair — the dwell timer restarts, matching native
/// tooltip behaviour where moving across multiple targets resets the
/// reveal delay.
fn track_tooltip_hover(
time: Res<Time>,
interactions: Query<
(Entity, &Interaction, Option<&Tooltip>),
Changed<Interaction>,
>,
mut state: ResMut<TooltipState>,
) {
for (entity, interaction, tooltip) in &interactions {
match interaction {
Interaction::Hovered => {
if tooltip.is_some() {
// Record the hover start. If the same entity is
// already recorded, leave the original timestamp so
// a re-emitted Hovered (e.g. pointer wiggle) doesn't
// reset the dwell timer.
let already = matches!(state.hovered, Some((e, _)) if e == entity);
if !already {
state.hovered = Some((entity, time.elapsed()));
}
}
}
Interaction::Pressed | Interaction::None => {
// Clear iff this is the entity we were tracking. Other
// changed-interaction events on unrelated entities must
// not blow away an in-flight hover.
if matches!(state.hovered, Some((e, _)) if e == entity) {
state.hovered = None;
}
}
}
}
}
/// Per-frame display driver. Reads [`TooltipState::hovered`] and:
///
/// * If `None`, hides the overlay.
/// * If `Some((entity, t0))` and `time.elapsed() - t0 < delay`, hides the
/// overlay (still in the dwell window).
/// * If `Some((entity, t0))` and the dwell has elapsed, copies the
/// target's [`Tooltip`] string into the overlay's [`TooltipText`] child,
/// positions the overlay above the target (or below, if above would
/// clip the screen top), and reveals it.
///
/// Positioning math mirrors
/// [`crate::ui_focus::update_focus_overlay`]: `ComputedNode.size` and
/// `UiGlobalTransform.translation` are converted from physical to
/// logical pixels via `inverse_scale_factor` before being written into
/// `Val::Px` slots on the overlay's `Node`. Headless tests run under
/// `MinimalPlugins` and don't execute the layout schedule, so
/// `ComputedNode` is `Vec2::ZERO` there — the test asserts the
/// visibility-and-text invariant rather than position.
#[allow(clippy::type_complexity)]
fn show_or_hide_tooltip(
time: Res<Time>,
state: Res<TooltipState>,
tooltips: Query<(&Tooltip, &UiGlobalTransform, &ComputedNode)>,
tooltip_text_only: Query<&Tooltip>,
mut overlay_q: Query<(&mut Node, &mut Visibility, &Children), With<TooltipOverlay>>,
mut text_q: Query<&mut Text, With<TooltipText>>,
) {
let Ok((mut node, mut visibility, children)) = overlay_q.single_mut() else {
// Overlay not yet spawned — first frame before Startup ran, or a
// test harness without Startup. Nothing to do.
return;
};
// Helper: hide the overlay if not already hidden.
let hide = |visibility: &mut Visibility| {
if !matches!(*visibility, Visibility::Hidden) {
*visibility = Visibility::Hidden;
}
};
let Some((target, started_at)) = state.hovered else {
hide(&mut visibility);
return;
};
let elapsed = time.elapsed().saturating_sub(started_at);
let delay = Duration::from_secs_f32(MOTION_TOOLTIP_DELAY_SECS);
if elapsed < delay {
hide(&mut visibility);
return;
}
// Past the dwell threshold. Pull the target's tooltip text and write
// it into the overlay's Text child. The wider query
// (`UiGlobalTransform + ComputedNode`) may miss in headless tests
// where layout doesn't run; fall back to the text-only query so test
// assertions on visibility + text content still pass even when
// positioning data is unavailable.
let label: Option<Cow<'static, str>> = tooltips
.get(target)
.ok()
.map(|(t, _, _)| t.0.clone())
.or_else(|| tooltip_text_only.get(target).ok().map(|t| t.0.clone()));
let Some(text) = label else {
// Target despawned or no longer carries Tooltip — hide and bail.
// We don't write back to the resource here because it's `Res`,
// not `ResMut`; `track_tooltip_hover` will clear it the next
// frame the entity changes interaction.
hide(&mut visibility);
return;
};
// Update the visible text. Skip the write if it already matches so
// we don't churn the change-detection flag every frame.
for child in children.iter() {
if let Ok(mut t) = text_q.get_mut(child)
&& t.0 != text
{
t.0 = text.clone().into_owned();
}
}
// Compute placement. ComputedNode.size is in physical pixels;
// inverse_scale_factor multiplies physical → logical so the result
// matches the Val::Px logical-pixel coordinate space every other
// Node uses.
if let Ok((_, transform, computed)) = tooltips.get(target) {
let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv;
let center_logical = transform.translation * inv;
// Default placement: above the target, centered horizontally.
// Tooltip width isn't known until layout — use a small assumed
// width via auto sizing; we centre on the target's centre and
// let the overlay's auto Node width do the rest. For the X
// coordinate we still need to anchor *something*: place the
// overlay's left edge at the target's centre minus half of the
// target's width, then rely on auto-Node sizing. That's a small
// approximation; the follow-up phase that wires real entities
// will measure overlay width via ComputedNode and re-centre.
let half = size_logical * 0.5;
let left_above = center_logical.x - half.x;
let top_above = center_logical.y - half.y - TOOLTIP_GAP_PX;
// If the tooltip would render above the screen top (top < 0),
// flip below the target. We don't know overlay height yet, so
// use the target's bottom edge plus the gap.
let (left, top) = if top_above < 0.0 {
(left_above, center_logical.y + half.y + TOOLTIP_GAP_PX)
} else {
(left_above, top_above)
};
node.left = Val::Px(left);
node.top = Val::Px(top);
}
if !matches!(*visibility, Visibility::Visible) {
*visibility = Visibility::Visible;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use bevy::time::TimeUpdateStrategy;
/// Builds a headless `App` with `MinimalPlugins + UiTooltipPlugin`.
/// Ticks once so the Startup spawn system has run and the singleton
/// overlay exists in the world before the first asserting `update`.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiTooltipPlugin);
app.update();
app
}
/// Tells `TimePlugin` to advance the clock by `secs` on the next
/// `app.update()`. Mirrors the helper in `ui_modal::tests` and
/// `hud_plugin::tests`.
fn set_manual_time_step(app: &mut App, secs: f32) {
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(secs),
));
}
/// Reads the current overlay visibility. Panics if the singleton is
/// missing — that would indicate a bug in `spawn_tooltip_overlay`.
fn overlay_visibility(app: &mut App) -> Visibility {
let mut q = app
.world_mut()
.query_filtered::<&Visibility, With<TooltipOverlay>>();
*q.iter(app.world())
.next()
.expect("TooltipOverlay singleton should exist")
}
/// Reads the current tooltip text content from the overlay's Text
/// child.
fn overlay_text(app: &mut App) -> String {
let mut q = app.world_mut().query_filtered::<&Text, With<TooltipText>>();
q.iter(app.world())
.next()
.expect("TooltipText child should exist")
.0
.clone()
}
/// Spawns a synthetic interactive node with a `Tooltip` component,
/// pre-set to `Interaction::Hovered`. The picking pipeline doesn't
/// run under `MinimalPlugins`, so we write `Hovered` directly.
fn spawn_hovered_tooltip(app: &mut App, label: &'static str) -> Entity {
let id = app
.world_mut()
.spawn((
Node::default(),
Interaction::Hovered,
Tooltip::new(label),
))
.id();
// Mark the Interaction Changed by re-inserting it. `Changed`
// requires component mutation since the previous tick; spawn
// already counts, but a follow-up insert is the explicit signal.
app.world_mut()
.entity_mut(id)
.insert(Interaction::Hovered);
id
}
/// Test 1: nothing is shown before the dwell delay elapses.
#[test]
fn tooltip_does_not_show_before_delay() {
let mut app = headless_app();
// Manual step well under the dwell delay. A handful of ticks
// accumulates to far less than `MOTION_TOOLTIP_DELAY_SECS` so
// the overlay must stay hidden the whole time.
set_manual_time_step(&mut app, MOTION_TOOLTIP_DELAY_SECS * 0.1);
spawn_hovered_tooltip(&mut app, "Test");
// Two ticks: track_tooltip_hover records the hover start on
// tick #1; show_or_hide_tooltip on tick #2 sees a non-zero but
// sub-threshold elapsed. Both must keep the overlay hidden.
app.update();
app.update();
assert!(
matches!(overlay_visibility(&mut app), Visibility::Hidden),
"overlay must stay hidden before MOTION_TOOLTIP_DELAY_SECS elapses"
);
}
/// Advances Bevy's virtual clock far enough that any
/// `Time::elapsed()` reader observes more than
/// `MOTION_TOOLTIP_DELAY_SECS` of progress since the last
/// `track_tooltip_hover` recorded a hover start.
///
/// `Time<Virtual>` clamps each tick's delta to `max_delta`
/// (default 250 ms) regardless of how big the underlying
/// `TimeUpdateStrategy::ManualDuration` is, so a single oversized
/// step doesn't actually advance virtual time by that much. We
/// instead set a small per-tick step (200 ms — well under the
/// 250 ms clamp) and call `app.update()` enough times to exceed
/// the dwell threshold by a comfortable margin.
fn advance_past_tooltip_delay(app: &mut App) {
set_manual_time_step(app, 0.2);
// 5 ticks × 200 ms = 1.0 s — comfortably past the 0.5 s delay
// even after subtracting the first tick (when the hover gets
// recorded; that tick's elapsed-since-hover is zero).
for _ in 0..5 {
app.update();
}
}
/// Test 2: after the dwell delay, the overlay reveals and the
/// tooltip text matches the hovered entity's `Tooltip` string.
/// Position is intentionally not asserted: layout doesn't run under
/// `MinimalPlugins`, so `ComputedNode.size` is `Vec2::ZERO`. The
/// invariants we *can* check headlessly are visibility and text.
#[test]
fn tooltip_shows_after_delay() {
let mut app = headless_app();
spawn_hovered_tooltip(&mut app, "Test");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"overlay must be visible after the dwell delay"
);
assert_eq!(
overlay_text(&mut app),
"Test",
"overlay text must reflect the hovered entity's Tooltip string"
);
}
/// Test 3: after the tooltip is shown, transitioning the target's
/// `Interaction` away from `Hovered` hides the overlay on the next
/// tick.
#[test]
fn tooltip_hides_on_unhover() {
let mut app = headless_app();
let target = spawn_hovered_tooltip(&mut app, "Test");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"precondition: tooltip should be visible before un-hover"
);
// Unhover. `track_tooltip_hover` clears the state on the next
// tick because the entity transitions Hovered → None.
app.world_mut()
.entity_mut(target)
.insert(Interaction::None);
app.update();
assert!(
matches!(overlay_visibility(&mut app), Visibility::Hidden),
"overlay must hide once the target is no longer hovered"
);
}
/// Test 4: when the cursor switches from one tooltip entity to
/// another with different text, the overlay's text updates to match
/// the new target's string after the dwell delay.
#[test]
fn tooltip_text_updates_when_hovered_target_changes() {
let mut app = headless_app();
// Phase A: hover entity A and let its tooltip appear.
let a = spawn_hovered_tooltip(&mut app, "A label");
advance_past_tooltip_delay(&mut app);
assert_eq!(overlay_text(&mut app), "A label");
// Phase B: unhover A, hover B with a different label. Then
// advance time past the dwell delay again so B's tooltip can
// take over the overlay.
app.world_mut().entity_mut(a).insert(Interaction::None);
let _b = spawn_hovered_tooltip(&mut app, "B label");
advance_past_tooltip_delay(&mut app);
assert!(
matches!(overlay_visibility(&mut app), Visibility::Visible),
"B's tooltip must be visible after switching hover"
);
assert_eq!(
overlay_text(&mut app),
"B label",
"overlay text must update to the new hovered entity's Tooltip string"
);
}
}