use crate::event::Rect;
use crate::types::{Color, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tooltip {
pub id: WidgetId,
pub text: String,
#[serde(default)]
pub styled_lines: Option<Vec<StyledText>>,
#[serde(default)]
pub placement: TooltipPlacement,
#[serde(default)]
pub bg: Option<Color>,
#[serde(default)]
pub fg: Option<Color>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TooltipBorder {
Sides,
#[default]
Full,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TooltipPlacement {
Top,
#[default]
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TooltipMeasure {
pub width: f32,
pub height: f32,
}
impl TooltipMeasure {
pub fn new(width: f32, height: f32) -> Self {
Self { width, height }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPlacement {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TooltipHit {
Body(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TooltipLayout {
pub bounds: Rect,
pub resolved_placement: ResolvedPlacement,
}
impl TooltipLayout {
pub fn hit_test(&self, x: f32, y: f32, id: &WidgetId) -> TooltipHit {
if x >= self.bounds.x
&& x < self.bounds.x + self.bounds.width
&& y >= self.bounds.y
&& y < self.bounds.y + self.bounds.height
{
TooltipHit::Body(*id)
} else {
TooltipHit::Empty
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TooltipChrome {
#[serde(default)]
pub border: TooltipBorder,
#[serde(default)]
pub title: Option<String>,
}
impl TooltipChrome {
pub fn new(border: TooltipBorder) -> Self {
Self {
border,
title: None,
}
}
}
impl Tooltip {
pub fn new(id: WidgetId, text: impl Into<String>) -> Self {
Self {
id,
text: text.into(),
styled_lines: None,
placement: TooltipPlacement::default(),
bg: None,
fg: None,
}
}
pub fn layout(
&self,
anchor: Rect,
viewport: Rect,
measure: TooltipMeasure,
margin: f32,
) -> TooltipLayout {
let vw = measure.width;
let vh = measure.height;
let candidate = |p: TooltipPlacement| -> (f32, f32) {
match p {
TooltipPlacement::Top => {
(anchor.x + (anchor.width - vw) * 0.5, anchor.y - margin - vh)
}
TooltipPlacement::Bottom => (
anchor.x + (anchor.width - vw) * 0.5,
anchor.y + anchor.height + margin,
),
TooltipPlacement::Left => (
anchor.x - margin - vw,
anchor.y + (anchor.height - vh) * 0.5,
),
TooltipPlacement::Right => (
anchor.x + anchor.width + margin,
anchor.y + (anchor.height - vh) * 0.5,
),
}
};
let fits = |x: f32, y: f32| -> bool {
x >= viewport.x
&& x + vw <= viewport.x + viewport.width
&& y >= viewport.y
&& y + vh <= viewport.y + viewport.height
};
let opposite = match self.placement {
TooltipPlacement::Top => TooltipPlacement::Bottom,
TooltipPlacement::Bottom => TooltipPlacement::Top,
TooltipPlacement::Left => TooltipPlacement::Right,
TooltipPlacement::Right => TooltipPlacement::Left,
};
let (x, y, resolved) = {
let (px, py) = candidate(self.placement);
if fits(px, py) {
(px, py, self.placement)
} else {
let (ox, oy) = candidate(opposite);
if fits(ox, oy) {
(ox, oy, opposite)
} else {
let max_x = (viewport.x + viewport.width - vw).max(viewport.x);
let max_y = (viewport.y + viewport.height - vh).max(viewport.y);
let cx = px.clamp(viewport.x, max_x);
let cy = py.clamp(viewport.y, max_y);
(cx, cy, self.placement)
}
}
};
let resolved_placement = match resolved {
TooltipPlacement::Top => ResolvedPlacement::Top,
TooltipPlacement::Bottom => ResolvedPlacement::Bottom,
TooltipPlacement::Left => ResolvedPlacement::Left,
TooltipPlacement::Right => ResolvedPlacement::Right,
};
TooltipLayout {
bounds: Rect::new(x, y, vw, vh),
resolved_placement,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{Tooltip, TooltipBorder, TooltipChrome, TooltipLayout};
use crate::paint_surface::PaintSurface;
use crate::style::Style;
use crate::theme::Theme;
use crate::types::Color;
use crate::{Point, Rect};
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint(
tooltip: &Tooltip,
tooltip_layout: &TooltipLayout,
chrome: &TooltipChrome,
line_height: f32,
padding_x: f32,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
) {
let bounds = tooltip_layout.bounds;
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return;
}
let bg = tooltip.bg.unwrap_or(theme.hover_bg);
let fg = tooltip.fg.unwrap_or(theme.hover_fg);
let border = theme.hover_border;
if matches!(chrome.border, TooltipBorder::Full) {
surface.surface_draw_shadow(
bounds,
style.corner_radius,
style.shadow_elevation,
Color::rgba(0, 0, 0, 100),
);
surface.surface_fill_rounded_rect(bounds, style.corner_radius, bg);
} else {
surface.surface_fill_rect(bounds, bg);
}
let mut text_top = bounds.y + 2.0;
match chrome.border {
TooltipBorder::Full => {
surface.surface_stroke_rounded_rect(
bounds,
style.corner_radius,
border,
style.border_width,
);
if let Some(title) = chrome
.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
let (title_w, title_h) = surface.surface_measure_text(title);
let pad = 4.0;
let title_x = bounds.x + ((bounds.width - title_w) / 2.0).max(0.0);
let title_y = bounds.y - title_h / 2.0;
surface.surface_fill_rect(
Rect::new(title_x - pad, title_y, title_w + pad * 2.0, title_h),
bg,
);
surface.surface_draw_text_run(
Rect::new(title_x, title_y, title_w.max(1.0), title_h.max(1.0)),
title,
fg,
);
text_top = text_top.max(title_y + title_h + 2.0);
}
}
TooltipBorder::Sides => {
surface.surface_draw_line(
Point::new(bounds.x, bounds.y),
Point::new(bounds.x, bounds.y + bounds.height),
border,
1.0,
);
surface.surface_draw_line(
Point::new(bounds.x + bounds.width, bounds.y),
Point::new(bounds.x + bounds.width, bounds.y + bounds.height),
border,
1.0,
);
}
TooltipBorder::None => {}
}
let text_padding_x = if matches!(chrome.border, TooltipBorder::None) {
padding_x / 2.0
} else {
padding_x
};
let text_x = bounds.x + text_padding_x;
let text_w = (bounds.x + bounds.width - text_x).max(0.0);
if let Some(ref styled_lines) = tooltip.styled_lines {
for (i, styled) in styled_lines.iter().enumerate() {
let row_y = text_top + i as f32 * line_height;
if row_y + line_height > bounds.y + bounds.height {
break;
}
let mut x_off = text_x;
for span in &styled.spans {
let span_fg = span.fg.unwrap_or(fg);
let (span_w, _) = surface.surface_measure_text_styled(&span.text, span.bold);
let rect = Rect::new(x_off, row_y, span_w.max(1.0), line_height);
surface.surface_draw_text_run_styled(
rect,
&span.text,
span_fg,
span.bold,
span.italic,
span.underline,
1.0,
);
x_off += span_w;
}
}
return;
}
for (i, text_line) in tooltip.text.lines().enumerate() {
let row_y = text_top + i as f32 * line_height;
if row_y + line_height > bounds.y + bounds.height {
break;
}
surface.surface_draw_text_run(
Rect::new(text_x, row_y, text_w, line_height),
text_line,
fg,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tooltip_chrome_defaults_to_full_then_fields_override() {
let chrome = TooltipChrome::default();
assert_eq!(chrome.border, TooltipBorder::Full);
assert_eq!(chrome.title, None);
let mut chrome = TooltipChrome::new(TooltipBorder::None);
chrome.title = Some("Hi".to_string());
assert_eq!(chrome.border, TooltipBorder::None);
assert_eq!(chrome.title.as_deref(), Some("Hi"));
chrome.border = TooltipBorder::Sides;
assert_eq!(chrome.border, TooltipBorder::Sides);
}
#[test]
fn tooltip_layout_stays_exhaustively_constructible() {
let layout = TooltipLayout {
bounds: Rect::new(1.0, 2.0, 30.0, 4.0),
resolved_placement: ResolvedPlacement::Bottom,
};
assert_eq!(layout.bounds.width, 30.0);
}
#[test]
fn tooltip_layout_prefers_bottom_when_room() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "Hello".to_string(),
placement: TooltipPlacement::Bottom,
styled_lines: None,
bg: None,
fg: None,
};
let anchor = Rect::new(100.0, 50.0, 40.0, 20.0);
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(60.0, 16.0), 4.0);
assert_eq!(layout.resolved_placement, ResolvedPlacement::Bottom);
assert_eq!(layout.bounds.y, 74.0);
assert_eq!(layout.bounds.x, 90.0);
}
#[test]
fn tooltip_layout_flips_to_opposite_when_overflow() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "Hello".to_string(),
placement: TooltipPlacement::Bottom,
styled_lines: None,
bg: None,
fg: None,
};
let viewport = Rect::new(0.0, 0.0, 800.0, 100.0);
let anchor = Rect::new(100.0, 80.0, 40.0, 16.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(60.0, 16.0), 4.0);
assert_eq!(layout.resolved_placement, ResolvedPlacement::Top);
assert_eq!(layout.bounds.y, 60.0);
}
#[test]
fn tooltip_layout_clamped_when_neither_fits() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "…".to_string(),
placement: TooltipPlacement::Bottom,
styled_lines: None,
bg: None,
fg: None,
};
let viewport = Rect::new(0.0, 0.0, 100.0, 30.0);
let anchor = Rect::new(10.0, 10.0, 20.0, 10.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(40.0, 20.0), 4.0);
assert_eq!(layout.resolved_placement, ResolvedPlacement::Bottom);
assert!(layout.bounds.y <= 10.0);
}
#[test]
fn tooltip_layout_does_not_panic_when_wider_than_viewport() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "Very wide".to_string(),
placement: TooltipPlacement::Top,
styled_lines: None,
bg: None,
fg: None,
};
let anchor = Rect::new(50.0, 50.0, 10.0, 10.0);
let viewport = Rect::new(0.0, 0.0, 100.0, 100.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(300.0, 16.0), 4.0);
assert_eq!(layout.bounds.x, 0.0);
assert_eq!(layout.bounds.width, 300.0);
}
#[test]
fn tooltip_layout_does_not_panic_when_taller_than_viewport() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "Very tall".to_string(),
placement: TooltipPlacement::Left,
styled_lines: None,
bg: None,
fg: None,
};
let anchor = Rect::new(50.0, 50.0, 10.0, 10.0);
let viewport = Rect::new(0.0, 0.0, 100.0, 100.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(50.0, 500.0), 4.0);
assert_eq!(layout.bounds.y, 0.0);
assert_eq!(layout.bounds.height, 500.0);
}
#[test]
fn tooltip_layout_hit_test() {
let t = Tooltip {
id: WidgetId::new("tip"),
text: "Hover".to_string(),
placement: TooltipPlacement::Right,
styled_lines: None,
bg: None,
fg: None,
};
let anchor = Rect::new(100.0, 100.0, 20.0, 20.0);
let viewport = Rect::new(0.0, 0.0, 400.0, 400.0);
let layout = t.layout(anchor, viewport, TooltipMeasure::new(80.0, 16.0), 4.0);
let center_x = layout.bounds.x + 10.0;
let center_y = layout.bounds.y + 5.0;
match layout.hit_test(center_x, center_y, &t.id) {
TooltipHit::Body(id) => assert_eq!(id.as_str(), "tip"),
_ => panic!(),
}
assert_eq!(layout.hit_test(0.0, 0.0, &t.id), TooltipHit::Empty);
}
}