use crate::core::HorizontalAlignment;
use crate::core::ObjectId;
use crate::core::{Color, Font, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::style::animation::{MotionSlot, PropertyDriver};
use crate::widget::capability::coercion::{expect_bool, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const DEFAULT_SHOW_DELAY_MS: u64 = 500;
const DEFAULT_HIDE_DELAY_MS: u64 = 200;
const DEFAULT_PADDING: i32 = 6;
const DEFAULT_FONT_SIZE: f32 = 12.0;
const DEFAULT_MAX_WIDTH: u32 = 300;
const DEFAULT_BG_COLOR: Color = Color::rgba(40, 40, 40, 220);
const DEFAULT_TEXT_COLOR: Color = Color::WHITE;
const TIMER_SHOW_ID: u32 = 1;
const TIMER_HIDE_ID: u32 = 2;
pub struct Tooltip {
base: BaseWidget,
text: String,
target_widget: Option<ObjectId>,
show_delay: u64,
hide_delay: u64,
visible: bool,
background_color: Color,
text_color: Color,
padding: i32,
font_size: f32,
max_width: u32,
hovering: bool,
fade: PropertyDriver,
show_elapsed_ms: u64,
hide_elapsed_ms: u64,
show_pending: bool,
hide_pending: bool,
}
impl Tooltip {
pub fn new(text: &str, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Tooltip, geometry, "Tooltip"),
text: text.to_string(),
target_widget: None,
show_delay: DEFAULT_SHOW_DELAY_MS,
hide_delay: DEFAULT_HIDE_DELAY_MS,
visible: false,
background_color: DEFAULT_BG_COLOR,
text_color: DEFAULT_TEXT_COLOR,
padding: DEFAULT_PADDING,
font_size: DEFAULT_FONT_SIZE,
max_width: DEFAULT_MAX_WIDTH,
hovering: false,
fade: PropertyDriver::at(0.0, MotionSlot::Fast),
show_elapsed_ms: 0,
hide_elapsed_ms: 0,
show_pending: false,
hide_pending: false,
}
}
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.base.request_redraw();
}
pub fn text(&self) -> &str {
&self.text
}
pub fn show(&mut self) {
self.show_pending = false;
self.hide_pending = false;
self.show_elapsed_ms = 0;
self.hide_elapsed_ms = 0;
self.visible = true;
self.fade.jump_to(1.0);
self.base.request_redraw();
}
pub fn hide(&mut self) {
self.hide_pending = false;
self.show_pending = false;
self.show_elapsed_ms = 0;
self.hide_elapsed_ms = 0;
self.visible = false;
self.fade.jump_to(0.0);
self.base.request_redraw();
}
pub fn fade_progress(&self) -> f32 {
self.fade.value()
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
let mut owes_frame = false;
if self.show_pending {
self.show_elapsed_ms = self.show_elapsed_ms.saturating_add(u64::from(delta_ms));
if self.show_elapsed_ms >= self.show_delay {
self.show_pending = false;
self.visible = true;
}
owes_frame = true;
}
if self.hide_pending {
self.hide_elapsed_ms = self.hide_elapsed_ms.saturating_add(u64::from(delta_ms));
if self.hide_elapsed_ms >= self.hide_delay {
self.hide_pending = false;
self.visible = false;
}
owes_frame = true;
}
let target = if self.visible { 1.0 } else { 0.0 };
self.fade.set_target(target);
if self.fade.tick(delta_ms) {
owes_frame = true;
}
if owes_frame {
self.base.request_redraw();
}
owes_frame
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn is_shown(&self) -> bool {
self.visible
}
pub fn set_shown(&mut self, shown: bool) {
if shown {
self.show();
} else {
self.hide();
}
}
pub fn background_color(&self) -> Color {
self.background_color
}
pub fn set_background_color(&mut self, color: Color) {
self.background_color = color;
self.base.request_redraw();
}
pub fn text_color(&self) -> Color {
self.text_color
}
pub fn set_text_color(&mut self, color: Color) {
self.text_color = color;
self.base.request_redraw();
}
pub fn set_target(&mut self, target: ObjectId) {
self.target_widget = Some(target);
}
pub fn target(&self) -> Option<ObjectId> {
self.target_widget
}
pub fn set_show_delay(&mut self, ms: u64) {
self.show_delay = ms;
}
pub fn set_hide_delay(&mut self, ms: u64) {
self.hide_delay = ms;
}
pub fn preferred_size(&self) -> Size {
if self.text.is_empty() {
return Size::new((self.padding as u32) * 2, (self.padding as u32) * 2 + 16);
}
let char_width = self.font_size * 0.6;
let estimated_width = (self.text.len() as f32 * char_width).ceil() as u32;
let line_height = (self.font_size * 1.4).ceil() as u32;
let width = (estimated_width + (self.padding as u32) * 2).min(self.max_width);
let height = line_height + (self.padding as u32) * 2;
Size::new(width, height)
}
}
impl Widget for Tooltip {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
crate::core::Size::new(100, 30)
}
fn tick(&mut self, delta_ms: u32) -> bool {
Tooltip::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
if self.show_pending || self.hide_pending {
return true;
}
self.fade.is_moving()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Tooltip {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"shown" => Ok(CapabilityValue::Bool(self.is_shown())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(&expect_string(value)?);
Ok(())
}
"shown" => {
self.set_shown(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["text", "shown", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"show" => {
self.show();
Ok(())
}
"hide" => {
self.hide();
Ok(())
}
"set_text" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for Tooltip {
fn handle_event(&mut self, event: &Event) {
match event {
Event::MouseEnter { pos: _ } => {
if !self.hovering {
self.hovering = true;
self.hide_pending = false;
self.hide_elapsed_ms = 0;
if self.show_delay == 0 {
self.show();
} else {
self.show_elapsed_ms = 0;
self.show_pending = true;
self.base.request_redraw();
}
}
}
Event::MouseLeave { pos: _ } => {
if self.hovering {
self.hovering = false;
self.show_pending = false;
self.show_elapsed_ms = 0;
if self.hide_delay == 0 {
self.hide();
} else {
self.hide_elapsed_ms = 0;
self.hide_pending = true;
self.base.request_redraw();
}
}
}
Event::Timer { id } => {
if *id == TIMER_SHOW_ID || *id == TIMER_HIDE_ID {
self.tick(16);
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
impl Draw for Tooltip {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("tooltip");
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(self.text_color);
let caller_color = style.background_color.filter(|_| !style.theme_derived);
let bubble_color = match caller_color {
Some(explicit) => explicit,
None => {
let own = self.background_color;
if own != window_fill {
own
} else {
crate::style::layer_color(crate::style::LayerColor::InverseSurface)
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or_else(|| window_fill.blend(&ink, 0.85))
}
}
};
let bubble_color = window_fill.blend(&bubble_color, self.fade.value());
let text_color = bubble_color.contrast_color();
let font = Font::simple("sans-serif", self.font_size);
let label = if self.text.is_empty() { "Tooltip" } else { self.text.as_str() };
let metrics = context.measure_text(label, &font);
let text_width = metrics.width;
let content_width = text_width.min(self.max_width);
let total_width = (content_width + dimensions::TOOLTIP_PADDING_H * 2).min(rect.width);
let bubble = ControlMetrics::center_in(
rect,
Size::new(total_width.max(1), dimensions::TOOLTIP_HEIGHT),
);
let corner_radius = dimensions::TOOLTIP_PADDING_V;
context.fill_rounded_rect(bubble, corner_radius, bubble_color);
let line = context.text_line(bubble, &font);
let text_x = bubble.x + dimensions::TOOLTIP_PADDING_H as i32;
context.draw_text(
Point::new(text_x, line.y),
label,
&font,
text_color,
HorizontalAlignment::Left,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
#[test]
fn tooltip_default_creation() {
let tooltip = Tooltip::new("Hello", Rect::new(0, 0, 100, 40));
assert_eq!(tooltip.kind(), WidgetKind::Tooltip);
assert_eq!(tooltip.text(), "Hello");
assert!(!tooltip.is_visible());
assert!(tooltip.target().is_none());
assert_eq!(tooltip.geometry(), Rect::new(0, 0, 100, 40));
}
#[test]
fn tooltip_show_hide() {
let mut tooltip = Tooltip::new("Test", Rect::new(0, 0, 100, 40));
assert!(!tooltip.is_visible());
tooltip.show();
assert!(tooltip.is_visible());
tooltip.hide();
assert!(!tooltip.is_visible());
}
#[test]
fn tooltip_text_accessor() {
let mut tooltip = Tooltip::new("Initial", Rect::new(0, 0, 100, 40));
assert_eq!(tooltip.text(), "Initial");
tooltip.set_text("Updated");
assert_eq!(tooltip.text(), "Updated");
tooltip.set_text("");
assert_eq!(tooltip.text(), "");
}
#[test]
fn tooltip_preferred_size_empty_text() {
let tooltip = Tooltip::new("", Rect::new(0, 0, 100, 40));
let size = tooltip.preferred_size();
assert!(size.width >= 12);
assert!(size.height >= 28);
}
#[test]
fn tooltip_preferred_size_with_text() {
let tooltip = Tooltip::new("Hello World", Rect::new(0, 0, 100, 40));
let size = tooltip.preferred_size();
assert!(size.width >= 12);
assert!(size.height >= 28);
}
#[test]
fn tooltip_target_widget() {
let mut tooltip = Tooltip::new("Info", Rect::new(0, 0, 100, 40));
assert!(tooltip.target().is_none());
tooltip.set_target(42);
assert_eq!(tooltip.target(), Some(42));
}
#[test]
fn tooltip_delays() {
let mut tooltip = Tooltip::new("Delayed", Rect::new(0, 0, 100, 40));
assert_eq!(tooltip.show_delay, 500);
assert_eq!(tooltip.hide_delay, 200);
tooltip.set_show_delay(1000);
assert_eq!(tooltip.show_delay, 1000);
tooltip.set_hide_delay(300);
assert_eq!(tooltip.hide_delay, 300);
}
#[test]
fn the_show_delay_is_measured_in_milliseconds() {
let mut tooltip = Tooltip::new("Tooltip", Rect::new(0, 0, 100, 40));
assert!(!tooltip.is_visible());
assert!(!tooltip.show_pending);
assert!(!tooltip.hovering);
tooltip.handle_event(&Event::MouseEnter { pos: Point::new(10, 10) });
assert!(tooltip.hovering, "the pointer is on the target");
assert!(tooltip.show_pending, "and the delay is counting down");
assert!(!tooltip.is_visible(), "but the bubble is not up yet");
assert!(tooltip.tick(DEFAULT_SHOW_DELAY_MS as u32 - 1), "the countdown owes frames");
assert!(!tooltip.is_visible(), "the delay must not be short-circuited");
tooltip.tick(1);
assert!(tooltip.is_visible());
assert!(!tooltip.show_pending);
}
#[test]
fn tooltip_event_mouse_leave_hides_after_its_own_delay() {
let mut tooltip = Tooltip::new("Tooltip", Rect::new(0, 0, 100, 40));
tooltip.handle_event(&Event::MouseEnter { pos: Point::new(10, 10) });
tooltip.tick(DEFAULT_SHOW_DELAY_MS as u32);
assert!(tooltip.is_visible(), "the show delay has elapsed");
tooltip.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert!(!tooltip.hovering);
assert!(tooltip.hide_pending, "the hide delay is counting down");
assert!(tooltip.is_visible(), "still readable until the delay elapses");
tooltip.tick(DEFAULT_HIDE_DELAY_MS as u32 - 1);
assert!(tooltip.is_visible(), "one millisecond short is still shown");
tooltip.tick(1);
assert!(!tooltip.is_visible());
assert!(!tooltip.hide_pending);
}
#[test]
fn the_bubble_fades_rather_than_snapping() {
let mut tooltip = Tooltip::new("Tooltip", Rect::new(0, 0, 100, 40));
assert_eq!(tooltip.fade_progress(), 0.0, "a fresh tooltip starts hidden");
tooltip.show();
assert_eq!(tooltip.fade_progress(), 1.0, "`show` is immediate, so the fade is snapped");
tooltip.handle_event(&Event::MouseEnter { pos: Point::new(10, 10) });
tooltip.set_hide_delay(0);
tooltip.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert!(!tooltip.is_visible(), "a zero hide delay hides at once");
assert_eq!(tooltip.fade_progress(), 0.0, "and snaps the fade with it");
tooltip.set_hide_delay(50);
tooltip.show();
tooltip.handle_event(&Event::MouseEnter { pos: Point::new(10, 10) });
tooltip.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert!(tooltip.is_visible(), "still readable through the delay");
assert_eq!(tooltip.fade_progress(), 1.0, "and fully faded in while it waits");
for _ in 0..4 {
tooltip.tick(16);
}
assert!(!tooltip.is_visible(), "the delay has elapsed");
let after_delay = tooltip.fade_progress();
assert!(after_delay < 1.0, "the fade starts once the delay expires: {after_delay}");
assert!(after_delay > 0.0, "and has only just started: {after_delay}");
assert!(tooltip.tick(16), "a mid-fade bubble owes frames");
let partway = tooltip.fade_progress();
assert!(partway < 1.0, "the fade must have started: {partway}");
assert!(partway > 0.0, "and not jumped to the end: {partway}");
for _ in 0..40 {
tooltip.tick(16);
}
assert_eq!(tooltip.fade_progress(), 0.0, "the fade settles fully hidden");
assert!(!tooltip.tick(16), "and then owes no more frames");
}
#[test]
fn tooltip_immediate_show_with_zero_delay() {
let mut tooltip = Tooltip::new("Fast", Rect::new(0, 0, 100, 40));
tooltip.set_show_delay(0);
assert!(!tooltip.is_visible());
assert!(!tooltip.hovering);
tooltip.handle_event(&Event::MouseEnter { pos: Point::new(5, 5) });
assert!(tooltip.hovering);
assert!(tooltip.is_visible()); }
#[test]
fn tooltip_svg_output_visible() {
let mut tooltip = Tooltip::new("SVG Tooltip", Rect::new(0, 0, 140, 30));
tooltip.show();
let svg = render_to_svg(&mut tooltip);
assert!(svg.starts_with("<svg"), "SVG should start with <svg, got: {svg:.60}");
assert!(svg.ends_with("</svg>"), "SVG should end with </svg>");
}
#[test]
fn tooltip_svg_output_hidden() {
let _theme_guard = crate::style::theme_test_guard();
let mut tooltip = Tooltip::new("Hidden", Rect::new(0, 0, 100, 30));
let svg = render_to_svg(&mut tooltip);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
let fill_count = svg.matches("fill=").count();
assert!(
fill_count > 1,
"a hidden tooltip must still paint its bubble, got only the background fill: {svg}"
);
let mut open = Tooltip::new("Hidden", Rect::new(0, 0, 100, 30));
open.show();
let shown = render_to_svg(&mut open);
assert_ne!(svg, shown, "showing the tooltip must change what is painted");
}
}