diff --git a/solitaire_app/src/main.rs b/solitaire_app/src/main.rs index 0cf609c..50bd2fb 100644 --- a/solitaire_app/src/main.rs +++ b/solitaire_app/src/main.rs @@ -11,7 +11,7 @@ use solitaire_engine::{ FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, - WeeklyGoalsPlugin, WinSummaryPlugin, + UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, }; fn main() { @@ -100,6 +100,7 @@ fn main() { .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) + .add_plugins(UiTooltipPlugin) .run(); } diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index a443f8b..1c45088 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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, }; diff --git a/solitaire_engine/src/ui_theme.rs b/solitaire_engine/src/ui_theme.rs index 8af7645..332de4b 100644 --- a/solitaire_engine/src/ui_theme.rs +++ b/solitaire_engine/src/ui_theme.rs @@ -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, ]; diff --git a/solitaire_engine/src/ui_tooltip.rs b/solitaire_engine/src/ui_tooltip.rs new file mode 100644 index 0000000..1eb7e69 --- /dev/null +++ b/solitaire_engine/src/ui_tooltip.rs @@ -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>) -> 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; + +// --------------------------------------------------------------------------- +// 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), + 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