//! 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>) -> 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::() .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, } /// 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` 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, font_res: Option>, ) { 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` 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