d87761d451
Continues the HC chrome rollout started by `c9af1ea` (which wired just the modal scaffold). Tags four more static-border surfaces so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast mode is on: - **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption popup. Border legibility matters because tooltips are usually brief — if the player has to squint to find the panel edge, the tooltip dismisses before they've parsed it. - **Onboarding banner key chips** (`onboarding_plugin.rs:388`). The first-run UI's "press H or ?" key chips. First-run onboarding has the highest stakes for accessibility — a low-vision player who can't see the chips can't discover the help system. - **Help panel key chips** (`help_plugin.rs:265`). Same treatment as the onboarding chips: keyboard-shortcut chips inside the F1 cheat sheet. - **Stats panel cells** (`stats_plugin.rs:1019`). The S-key overlay's individual stat cells. A dense grid of bordered numbers is exactly the kind of surface where HC's `#505050 → #a0a0a0` boost makes the layout legible. Each tagging is one line on the spawn tuple plus an import. The existing `update_high_contrast_borders` system in `settings_plugin` (added in `c9af1ea`) handles all tagged entities uniformly — no system changes needed. ### Skipped on this pass Sites with dynamic hover/focus paint systems (HUD action buttons, modal buttons, radial menu rim) intentionally not tagged because their existing paint cycles would race the HC system. Wiring HC into those needs a different shape — either fold HC into the dynamic-paint logic, or have HC consult the hover/focus state. Future scope. Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home menu element borders, `settings_plugin.rs:1952/2093/2214/2274` settings panel rows) are likely fine to tag but I'm capping this commit at four to keep it reviewable. Pattern is established; future commits can extend. 1194 passing / 0 failing across the workspace (unchanged — no new tests; the system-level test in `c9af1ea`'s scaffolding covers all tagged entities uniformly). Workspace clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
606 lines
24 KiB
Rust
606 lines
24 KiB
Rust
//! 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::settings_plugin::SettingsResource;
|
||
use crate::ui_theme::{
|
||
BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, 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;
|
||
|
||
/// Pure helper: returns `true` once `elapsed_secs` has met or exceeded
|
||
/// the player-configured `delay_secs`, so the tooltip should be revealed.
|
||
///
|
||
/// Treating "elapsed >= delay" as the show condition (rather than
|
||
/// strictly greater than) is what makes a `delay_secs == 0.0` setting
|
||
/// behave as advertised: on the very first tick after hover starts,
|
||
/// `elapsed_secs` is `0.0` and the tooltip appears immediately. With a
|
||
/// strict `>` the zero-delay case would still wait one tick.
|
||
///
|
||
/// Extracted so the comparison can be unit-tested without spinning up
|
||
/// a Bevy `App` — `Time<Virtual>` clamps each tick to 250 ms under
|
||
/// `MinimalPlugins`, which makes precise sub-second timing assertions
|
||
/// awkward.
|
||
pub(crate) fn tooltip_should_show(elapsed_secs: f32, delay_secs: f32) -> bool {
|
||
elapsed_secs >= delay_secs
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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),
|
||
HighContrastBorder::with_default(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>,
|
||
settings: Option<Res<SettingsResource>>,
|
||
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;
|
||
};
|
||
|
||
// Player-configurable dwell delay; falls back to the design-token
|
||
// default when `SettingsResource` is absent (test harnesses running
|
||
// `UiTooltipPlugin` under `MinimalPlugins` without `SettingsPlugin`).
|
||
let delay_secs = settings
|
||
.as_ref()
|
||
.map(|s| s.0.tooltip_delay_secs)
|
||
.unwrap_or(MOTION_TOOLTIP_DELAY_SECS);
|
||
let elapsed = time.elapsed().saturating_sub(started_at);
|
||
if !tooltip_should_show(elapsed.as_secs_f32(), delay_secs) {
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// Test 5: `tooltip_should_show` is the pure helper that the system
|
||
/// uses to gate the reveal — exercising it directly avoids the
|
||
/// `Time<Virtual>` 250 ms clamp that makes precise sub-second
|
||
/// timing assertions in `MinimalPlugins` fiddly. The four cases
|
||
/// below cover the boundary semantics:
|
||
///
|
||
/// * `delay = 0.0` ("Instant") must show on the first tick.
|
||
/// * `elapsed < delay` must NOT show.
|
||
/// * `elapsed == delay` must show (boundary inclusive).
|
||
/// * `elapsed > delay` must show.
|
||
#[test]
|
||
fn tooltip_should_show_respects_delay() {
|
||
// delay == 0 ("Instant"): any elapsed (including zero) shows.
|
||
assert!(tooltip_should_show(0.0, 0.0), "instant delay must show on first tick");
|
||
assert!(tooltip_should_show(0.5, 0.0));
|
||
|
||
// Standard non-zero delay.
|
||
assert!(!tooltip_should_show(0.4, 0.5), "elapsed < delay must hide");
|
||
assert!(tooltip_should_show(0.5, 0.5), "elapsed == delay must show (boundary)");
|
||
assert!(tooltip_should_show(0.6, 0.5), "elapsed > delay must show");
|
||
|
||
// Larger delay (max-end of the slider).
|
||
assert!(!tooltip_should_show(1.0, 1.5));
|
||
assert!(tooltip_should_show(1.5, 1.5));
|
||
}
|
||
}
|