use std::time::Duration;
use bevy::{picking::hover::Hovered, prelude::*, window::PrimaryWindow};
use crate::{
popover::{self, PopoverPlacement, PopoverProps},
tokens,
};
const SHORT_HOVER_DELAY: Duration = Duration::from_millis(300);
const FULL_HOVER_DELAY: Duration = Duration::from_millis(1200);
const TOOLTIP_MAX_WIDTH: f32 = 360.0;
const TOOLTIP_PADDING: f32 = 10.0;
#[derive(Component, Clone, Debug, Default)]
pub struct Tooltip {
pub title: String,
pub keybind: String,
pub description: String,
pub footer: String,
}
impl Tooltip {
pub fn title(title: impl Into<String>) -> Self {
Self {
title: title.into(),
keybind: String::new(),
description: String::new(),
footer: String::new(),
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub fn with_keybind(mut self, keybind: impl Into<String>) -> Self {
self.keybind = keybind.into();
self
}
#[must_use]
pub fn with_footer(mut self, footer: impl Into<String>) -> Self {
self.footer = footer.into();
self
}
}
pub struct TooltipPlugin;
impl Plugin for TooltipPlugin {
fn build(&self, app: &mut App) {
app.world_mut().register_component::<Tooltip>();
app.init_resource::<TooltipState>()
.add_systems(Update, tick_tooltip);
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum TooltipStage {
#[default]
None,
Title,
Full,
}
#[derive(Resource, Default)]
struct TooltipState {
pending: Option<(Entity, Duration)>,
active: Option<Entity>,
stage: TooltipStage,
}
fn tick_tooltip(
time: Res<Time>,
targets: Query<(Entity, &Tooltip, &Hovered)>,
window: Single<&Window, With<PrimaryWindow>>,
mut state: ResMut<TooltipState>,
mut commands: Commands,
) {
let hovered = targets
.iter()
.find_map(|(entity, tip, hover)| hover.get().then_some((entity, tip)));
let Some((entity, tip)) = hovered else {
state.pending = None;
if let Some(active) = state.active.take() {
commands.entity(active).try_despawn();
}
state.stage = TooltipStage::None;
return;
};
if state.pending.is_none_or(|(prev, _)| prev != entity) {
state.pending = Some((entity, Duration::ZERO));
if let Some(active) = state.active.take() {
commands.entity(active).try_despawn();
}
state.stage = TooltipStage::None;
}
let Some((_, elapsed)) = state.pending.as_mut() else {
return;
};
*elapsed += time.delta();
let elapsed = *elapsed;
match state.stage {
TooltipStage::None if elapsed >= SHORT_HOVER_DELAY => {
let cursor_pos = window.cursor_position();
let popover_entity = commands
.spawn((
popover::popover(
PopoverProps::new(entity)
.with_position(cursor_pos)
.with_placement(PopoverPlacement::BottomStart)
.with_padding(TOOLTIP_PADDING)
.with_gap(tokens::SPACING_XS)
.with_z_index(300)
.with_node(Node {
flex_direction: FlexDirection::Column,
max_width: Val::Px(TOOLTIP_MAX_WIDTH),
..Default::default()
}),
),
bevy::picking::Pickable::IGNORE,
))
.id();
spawn_title(&mut commands, popover_entity, tip);
state.active = Some(popover_entity);
state.stage = TooltipStage::Title;
}
TooltipStage::Title if elapsed >= FULL_HOVER_DELAY => {
if let Some(popover) = state.active {
spawn_body(&mut commands, popover, tip);
state.stage = TooltipStage::Full;
}
}
_ => {}
}
}
fn spawn_title(commands: &mut Commands, popover: Entity, tip: &Tooltip) {
if tip.title.is_empty() {
return;
}
if tip.keybind.is_empty() {
commands.spawn((
Text::new(tip.title.clone()),
TextFont {
font_size: tokens::FONT_SM,
weight: FontWeight::MEDIUM,
..default()
},
TextColor(tokens::TEXT_PRIMARY),
bevy::picking::Pickable::IGNORE,
ChildOf(popover),
));
return;
}
commands
.spawn((
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(tokens::SPACING_MD),
..default()
},
bevy::picking::Pickable::IGNORE,
ChildOf(popover),
))
.with_child((
Text::new(tip.title.clone()),
TextFont {
font_size: tokens::FONT_SM,
weight: FontWeight::MEDIUM,
..default()
},
TextColor(tokens::TEXT_PRIMARY),
bevy::picking::Pickable::IGNORE,
))
.with_child((
Text::new(tip.keybind.clone()),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
bevy::picking::Pickable::IGNORE,
));
}
fn spawn_body(commands: &mut Commands, popover: Entity, tip: &Tooltip) {
if !tip.description.is_empty() {
commands.spawn((
Text::new(tip.description.clone()),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_PRIMARY),
bevy::picking::Pickable::IGNORE,
ChildOf(popover),
));
}
if !tip.footer.is_empty() {
commands.spawn((
Text::new(tip.footer.clone()),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
bevy::picking::Pickable::IGNORE,
ChildOf(popover),
));
}
}