use std::time::{Duration, Instant};
use astrelis_core::alloc::HashMap;
use astrelis_core::math::Vec2;
use astrelis_render::Color;
use crate::overlay::{OverlayConfig, OverlayId, OverlayManager, OverlayPosition, ZLayer};
use crate::tree::{NodeId, UiTree};
use crate::widgets::Container;
#[derive(Debug, Clone)]
pub struct TooltipConfig {
pub show_delay: Duration,
pub hide_delay: Duration,
pub cursor_offset: Vec2,
pub max_width: f32,
pub background_color: Color,
pub text_color: Color,
pub border_radius: f32,
pub padding: f32,
pub follow_cursor: bool,
}
impl Default for TooltipConfig {
fn default() -> Self {
Self {
show_delay: Duration::from_millis(500),
hide_delay: Duration::from_millis(100),
cursor_offset: Vec2::new(12.0, 12.0),
max_width: 300.0,
background_color: Color::rgba(0.15, 0.15, 0.15, 0.95),
text_color: Color::WHITE,
border_radius: 4.0,
padding: 8.0,
follow_cursor: true,
}
}
}
#[derive(Debug, Clone)]
pub enum TooltipContent {
Text(String),
RichText {
text: String,
font_size: f32,
color: Color,
},
CustomWidget(NodeId),
Builder(TooltipBuilder),
}
impl TooltipContent {
pub fn text(text: impl Into<String>) -> Self {
Self::Text(text.into())
}
pub fn rich_text(text: impl Into<String>, font_size: f32, color: Color) -> Self {
Self::RichText {
text: text.into(),
font_size,
color,
}
}
}
#[derive(Debug, Clone)]
pub struct TooltipBuilder {
pub id: &'static str,
pub data: Option<String>,
}
impl TooltipBuilder {
pub fn new(id: &'static str) -> Self {
Self { id, data: None }
}
pub fn with_data(mut self, data: impl Into<String>) -> Self {
self.data = Some(data.into());
self
}
}
#[derive(Debug, Clone)]
struct TooltipRegistration {
_widget_node: NodeId,
content: TooltipContent,
config_override: Option<TooltipConfigOverride>,
}
#[derive(Debug, Clone, Default)]
pub struct TooltipConfigOverride {
pub show_delay: Option<Duration>,
pub hide_delay: Option<Duration>,
pub cursor_offset: Option<Vec2>,
pub position: Option<TooltipPosition>,
}
#[derive(Debug, Clone)]
pub enum TooltipPosition {
FollowCursor { offset: Vec2 },
BelowWidget { offset: Vec2 },
AboveWidget { offset: Vec2 },
RightOfWidget { offset: Vec2 },
LeftOfWidget { offset: Vec2 },
}
impl Default for TooltipPosition {
fn default() -> Self {
Self::FollowCursor {
offset: Vec2::new(12.0, 12.0),
}
}
}
#[derive(Debug)]
struct ActiveTooltip {
widget_node: NodeId,
overlay_id: OverlayId,
_content_node: NodeId,
_shown_at: Instant,
}
#[derive(Debug)]
struct HoverState {
widget: NodeId,
started_at: Instant,
ready_to_show: bool,
}
#[derive(Debug)]
struct LeaveState {
left_at: Instant,
widget: NodeId,
}
pub struct TooltipManager {
config: TooltipConfig,
registrations: HashMap<NodeId, TooltipRegistration>,
active: Option<ActiveTooltip>,
hover_state: Option<HoverState>,
leave_state: Option<LeaveState>,
mouse_position: Vec2,
enabled: bool,
}
impl TooltipManager {
pub fn new(config: TooltipConfig) -> Self {
Self {
config,
registrations: HashMap::new(),
active: None,
hover_state: None,
leave_state: None,
mouse_position: Vec2::ZERO,
enabled: true,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.hover_state = None;
self.leave_state = None;
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn config(&self) -> &TooltipConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut TooltipConfig {
&mut self.config
}
pub fn register(&mut self, widget: NodeId, content: TooltipContent) {
self.registrations.insert(
widget,
TooltipRegistration {
_widget_node: widget,
content,
config_override: None,
},
);
}
pub fn register_with_config(
&mut self,
widget: NodeId,
content: TooltipContent,
config: TooltipConfigOverride,
) {
self.registrations.insert(
widget,
TooltipRegistration {
_widget_node: widget,
content,
config_override: Some(config),
},
);
}
pub fn unregister(&mut self, widget: NodeId) {
self.registrations.remove(&widget);
}
pub fn has_tooltip(&self, widget: NodeId) -> bool {
self.registrations.contains_key(&widget)
}
pub fn set_mouse_position(&mut self, pos: Vec2) {
self.mouse_position = pos;
}
pub fn update(
&mut self,
overlays: &mut OverlayManager,
tree: &mut UiTree,
hovered: Option<NodeId>,
delta_time: f32,
) {
if !self.enabled {
if let Some(active) = self.active.take() {
overlays.hide(tree, active.overlay_id);
}
return;
}
let now = Instant::now();
let hover_changed = match (&self.hover_state, hovered) {
(Some(state), Some(hovered)) => state.widget != hovered,
(Some(_), None) => true,
(None, Some(_)) => true,
(None, None) => false,
};
if hover_changed {
if let Some(state) = self.hover_state.take() {
self.leave_state = Some(LeaveState {
left_at: now,
widget: state.widget,
});
}
if let Some(hovered) = hovered
&& self.registrations.contains_key(&hovered)
{
self.hover_state = Some(HoverState {
widget: hovered,
started_at: now,
ready_to_show: false,
});
}
}
if let Some(state) = &mut self.hover_state
&& !state.ready_to_show
{
let reg = self.registrations.get(&state.widget);
let show_delay = reg
.and_then(|r| r.config_override.as_ref())
.and_then(|c| c.show_delay)
.unwrap_or(self.config.show_delay);
if now.duration_since(state.started_at) >= show_delay {
state.ready_to_show = true;
}
}
if let Some(leave) = &self.leave_state {
let hide_delay = self.config.hide_delay;
if now.duration_since(leave.left_at) >= hide_delay {
if let Some(active) = &self.active
&& active.widget_node == leave.widget
{
overlays.hide(tree, active.overlay_id);
self.active = None;
}
self.leave_state = None;
}
}
if let Some(state) = &self.hover_state
&& state.ready_to_show
&& self.active.is_none()
{
self.show_tooltip(overlays, tree, state.widget);
}
if let Some(active) = &self.active {
let should_hide = match &self.hover_state {
Some(state) => state.widget != active.widget_node,
None => true,
};
if should_hide && self.leave_state.is_none() {
overlays.hide(tree, active.overlay_id);
self.active = None;
}
}
if self.config.follow_cursor
&& let Some(_active) = &self.active
{
overlays.set_mouse_position(self.mouse_position);
overlays.update_positions(tree);
}
let _ = delta_time; }
fn show_tooltip(&mut self, overlays: &mut OverlayManager, tree: &mut UiTree, widget: NodeId) {
let Some(registration) = self.registrations.get(&widget) else {
return;
};
let content_node = self.create_tooltip_node(tree, ®istration.content);
let position = self.calculate_position(registration, widget, tree);
let overlay_id = overlays.show(
tree,
content_node,
OverlayConfig {
layer: ZLayer::Tooltip,
position,
close_on_outside_click: false,
close_on_escape: false,
trap_focus: false,
show_backdrop: false,
backdrop_color: Color::TRANSPARENT,
animate_in: false,
animate_out: false,
auto_dismiss: None,
},
);
self.active = Some(ActiveTooltip {
widget_node: widget,
overlay_id,
_content_node: content_node,
_shown_at: Instant::now(),
});
}
fn create_tooltip_node(&self, tree: &mut UiTree, content: &TooltipContent) -> NodeId {
match content {
TooltipContent::Text(text) => {
let mut container = Container::new();
container.style.background_color = Some(self.config.background_color);
container.style.border_radius = self.config.border_radius;
let padding = taffy::LengthPercentage::Length(self.config.padding);
container.style.layout.padding = taffy::Rect {
left: padding,
right: padding,
top: padding,
bottom: padding,
};
let container_id = tree.add_widget(Box::new(container));
let text_widget = crate::widgets::Text::new(text.clone())
.color(self.config.text_color)
.size(14.0);
let text_id = tree.add_widget(Box::new(text_widget));
tree.add_child(container_id, text_id);
container_id
}
TooltipContent::RichText {
text,
font_size,
color,
} => {
let mut container = Container::new();
container.style.background_color = Some(self.config.background_color);
container.style.border_radius = self.config.border_radius;
let padding = taffy::LengthPercentage::Length(self.config.padding);
container.style.layout.padding = taffy::Rect {
left: padding,
right: padding,
top: padding,
bottom: padding,
};
let container_id = tree.add_widget(Box::new(container));
let text_widget = crate::widgets::Text::new(text.clone())
.color(*color)
.size(*font_size);
let text_id = tree.add_widget(Box::new(text_widget));
tree.add_child(container_id, text_id);
container_id
}
TooltipContent::CustomWidget(node_id) => {
*node_id
}
TooltipContent::Builder(_builder) => {
let container = Container::new();
tree.add_widget(Box::new(container))
}
}
}
fn calculate_position(
&self,
registration: &TooltipRegistration,
widget: NodeId,
_tree: &UiTree,
) -> OverlayPosition {
let tooltip_pos = registration
.config_override
.as_ref()
.and_then(|c| c.position.clone());
match tooltip_pos {
Some(TooltipPosition::FollowCursor { offset }) => OverlayPosition::AtCursor { offset },
Some(TooltipPosition::BelowWidget { offset }) => OverlayPosition::AnchorTo {
anchor_node: widget,
alignment: crate::overlay::AnchorAlignment::BelowLeft,
offset,
},
Some(TooltipPosition::AboveWidget { offset }) => OverlayPosition::AnchorTo {
anchor_node: widget,
alignment: crate::overlay::AnchorAlignment::AboveLeft,
offset,
},
Some(TooltipPosition::RightOfWidget { offset }) => OverlayPosition::AnchorTo {
anchor_node: widget,
alignment: crate::overlay::AnchorAlignment::RightTop,
offset,
},
Some(TooltipPosition::LeftOfWidget { offset }) => OverlayPosition::AnchorTo {
anchor_node: widget,
alignment: crate::overlay::AnchorAlignment::LeftTop,
offset,
},
None => {
if self.config.follow_cursor {
OverlayPosition::AtCursor {
offset: self.config.cursor_offset,
}
} else {
OverlayPosition::AnchorTo {
anchor_node: widget,
alignment: crate::overlay::AnchorAlignment::BelowLeft,
offset: Vec2::new(0.0, 4.0),
}
}
}
}
}
pub fn active_widget(&self) -> Option<NodeId> {
self.active.as_ref().map(|a| a.widget_node)
}
pub fn active_overlay(&self) -> Option<OverlayId> {
self.active.as_ref().map(|a| a.overlay_id)
}
pub fn hide(&mut self, overlays: &mut OverlayManager, tree: &mut UiTree) {
if let Some(active) = self.active.take() {
overlays.hide(tree, active.overlay_id);
}
self.hover_state = None;
self.leave_state = None;
}
pub fn clear(&mut self) {
self.registrations.clear();
}
}
impl Default for TooltipManager {
fn default() -> Self {
Self::new(TooltipConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tooltip_content() {
let text = TooltipContent::text("Hello");
assert!(matches!(text, TooltipContent::Text(_)));
let rich = TooltipContent::rich_text("Hello", 16.0, Color::RED);
assert!(matches!(rich, TooltipContent::RichText { .. }));
}
#[test]
fn test_tooltip_config_default() {
let config = TooltipConfig::default();
assert_eq!(config.show_delay, Duration::from_millis(500));
assert_eq!(config.hide_delay, Duration::from_millis(100));
assert!(config.follow_cursor);
}
#[test]
fn test_tooltip_manager_registration() {
let mut manager = TooltipManager::new(TooltipConfig::default());
let node = NodeId(1);
assert!(!manager.has_tooltip(node));
manager.register(node, TooltipContent::text("Test tooltip"));
assert!(manager.has_tooltip(node));
manager.unregister(node);
assert!(!manager.has_tooltip(node));
}
#[test]
fn test_tooltip_position() {
let pos = TooltipPosition::default();
assert!(matches!(pos, TooltipPosition::FollowCursor { .. }));
let below = TooltipPosition::BelowWidget {
offset: Vec2::new(0.0, 4.0),
};
assert!(matches!(below, TooltipPosition::BelowWidget { .. }));
}
#[test]
fn test_tooltip_manager_enable_disable() {
let mut manager = TooltipManager::new(TooltipConfig::default());
assert!(manager.is_enabled());
manager.set_enabled(false);
assert!(!manager.is_enabled());
manager.set_enabled(true);
assert!(manager.is_enabled());
}
#[test]
fn test_tooltip_builder() {
let builder = TooltipBuilder::new("custom").with_data("some data");
assert_eq!(builder.id, "custom");
assert_eq!(builder.data, Some("some data".to_string()));
}
}