1use frus_core::{
9 BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextAlign,
10 TextStyle,
11};
12use frus_layout::{Dimension, Style};
13
14use crate::interaction::Status;
15use crate::portal::Placement;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19const PAD_X: f32 = 8.0;
24const PAD_Y: f32 = 4.0;
25
26const MAX_WIDTH: f32 = 320.0;
30
31const RADIUS: f32 = 4.0;
35
36pub struct Tooltip<Msg = crate::callback::Callback> {
56 children: Vec<Box<dyn Widget<Msg>>>,
58 message: String,
59 enabled: bool,
60 background: Option<Color>,
61 text_style: Option<TextStyle>,
62 text_color: Option<Color>,
63 text_align: Option<TextAlign>,
64 padding: Option<Insets>,
65 shape: Option<ShapeBorder>,
66 max_width: Option<f32>,
67}
68
69impl<Msg: Clone + 'static> Tooltip<Msg> {
70 pub fn new(message: impl Into<String>) -> Self {
76 Self {
77 children: Vec::new(),
78 message: message.into(),
79 enabled: true,
80 background: None,
81 text_style: None,
82 text_color: None,
83 text_align: None,
84 padding: None,
85 shape: None,
86 max_width: None,
87 }
88 }
89
90 #[must_use]
92 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
93 if self.children.is_empty() {
94 self.children.push(Box::new(child));
95 } else {
96 self.children[0] = Box::new(child);
97 }
98 self.rebuild();
99 self
100 }
101
102 #[must_use]
104 pub fn enabled(mut self, enabled: bool) -> Self {
105 self.enabled = enabled;
106 self.rebuild();
107 self
108 }
109
110 #[must_use]
112 pub fn background(mut self, color: Color) -> Self {
113 self.background = Some(color);
114 self.rebuild();
115 self
116 }
117
118 #[must_use]
120 pub fn text_style(mut self, style: TextStyle) -> Self {
121 self.text_style = Some(style);
122 self.rebuild();
123 self
124 }
125
126 #[must_use]
128 pub fn text_color(mut self, color: Color) -> Self {
129 self.text_color = Some(color);
130 self.rebuild();
131 self
132 }
133
134 #[must_use]
136 pub fn text_align(mut self, align: TextAlign) -> Self {
137 self.text_align = Some(align);
138 self.rebuild();
139 self
140 }
141
142 #[must_use]
144 pub fn padding(mut self, padding: Insets) -> Self {
145 self.padding = Some(padding);
146 self.rebuild();
147 self
148 }
149
150 #[must_use]
152 pub fn shape(mut self, shape: ShapeBorder) -> Self {
153 self.shape = Some(shape);
154 self.rebuild();
155 self
156 }
157
158 #[must_use]
160 pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
161 self.shape(ShapeBorder::rounded(radius.into()))
162 }
163
164 #[must_use]
166 pub fn max_width(mut self, width: f32) -> Self {
167 self.max_width = Some(width);
168 self.rebuild();
169 self
170 }
171
172 fn rebuild(&mut self) {
178 self.children.truncate(1);
179 if self.children.is_empty() || self.message.is_empty() || !self.enabled {
180 return;
181 }
182 self.children.push(Box::new(Bubble {
183 message: self.message.clone(),
184 background: self.background,
185 text_style: self.text_style,
186 text_color: self.text_color,
187 text_align: self.text_align,
188 padding: self.padding,
189 shape: self.shape,
190 max_width: self.max_width,
191 }));
192 }
193}
194
195impl<Msg: Clone> Widget<Msg> for Tooltip<Msg> {
196 fn style(&self) -> Style {
197 Style::default()
198 }
199
200 fn style_themed(&self, theme: &Theme) -> Style {
205 match self.children.first() {
209 Some(child) => Style {
210 padding: frus_core::Insets::ZERO,
211 ..child.style_themed(theme)
212 },
213 None => Style::default(),
214 }
215 }
216
217 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
218 &self.children
219 }
220
221 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
222
223 fn on_click(&self) -> Option<Msg> {
224 None
225 }
226
227 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
228 self.children
229 .get(1)
230 .map(|bubble| (bubble.as_ref(), Placement::Tooltip))
231 }
232
233 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
246 if self.message.is_empty() || !self.enabled {
247 return None;
248 }
249 Some(frus_core::SemanticsProperties::new(frus_core::Role::None).label(self.message.clone()))
250 }
251}
252
253struct Bubble {
259 message: String,
260 background: Option<Color>,
261 text_style: Option<TextStyle>,
262 text_color: Option<Color>,
263 text_align: Option<TextAlign>,
264 padding: Option<Insets>,
265 shape: Option<ShapeBorder>,
266 max_width: Option<f32>,
267}
268
269impl Bubble {
270 fn label_style(&self, theme: Option<&Theme>) -> ResolvedTextStyle {
274 self.text_style
275 .or(theme.and_then(|t| t.widgets.tooltip.text_style))
276 .unwrap_or_else(|| crate::theme::type_scale(theme).body_small)
277 .resolved()
278 }
279
280 fn align(&self, theme: Option<&Theme>) -> TextAlign {
284 self.text_align
285 .or(theme.and_then(|t| t.widgets.tooltip.text_align))
286 .unwrap_or(TextAlign::Start)
287 }
288
289 fn padding(&self, theme: Option<&Theme>) -> Insets {
290 self.padding
291 .or(theme.and_then(|t| t.widgets.tooltip.padding))
292 .unwrap_or(Insets::new(PAD_Y, PAD_X, PAD_Y, PAD_X))
293 }
294
295 fn max_width(&self, theme: Option<&Theme>) -> f32 {
296 self.max_width
297 .or(theme.and_then(|t| t.widgets.tooltip.max_width))
298 .unwrap_or(MAX_WIDTH)
299 }
300
301 fn shape_of(&self, theme: &Theme) -> ShapeBorder {
302 crate::resolve_shape(
303 self.shape,
304 theme.widgets.tooltip.shape,
305 theme.widgets.tooltip.radius.map(BorderRadius::uniform),
306 ShapeBorder::rounded(RADIUS),
307 )
308 }
309
310 fn sizing(&self, theme: Option<&Theme>) -> Style {
311 let padding = self.padding(theme);
312 let style = self.label_style(theme);
313 let room = (self.max_width(theme) - padding.left - padding.right).max(1.0);
314 let measured = frus_text::measure_wrapped_resolved(&self.message, &style, Some(room));
315 Style {
316 width: Dimension::Length((measured.width + padding.left + padding.right).ceil()),
317 height: Dimension::Length((measured.height + padding.top + padding.bottom).ceil()),
318 ..Default::default()
319 }
320 }
321}
322
323impl<Msg> Widget<Msg> for Bubble {
324 fn style(&self) -> Style {
325 self.sizing(None)
326 }
327
328 fn style_themed(&self, theme: &Theme) -> Style {
329 self.sizing(Some(theme))
330 }
331
332 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
333 &[]
334 }
335
336 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
337 let o = status.opacity;
338 let t = &theme.widgets.tooltip;
339 let fill = self
344 .background
345 .or(t.background)
346 .unwrap_or(theme.scheme.inverse_surface);
347 scene.draw_shape(bounds, self.shape_of(theme), fill.fade(o));
348 let padding = self.padding(Some(theme));
349 let style = self.label_style(Some(theme));
350 let ink = self
351 .text_color
352 .or(t.text_color)
353 .unwrap_or(theme.scheme.on_inverse_surface);
354 scene.text_block(
355 Point::new(bounds.x + padding.left, bounds.y + padding.top),
356 self.message.clone(),
357 &style,
358 ink.fade(o),
359 frus_core::TextBlock {
360 width: Some((bounds.width - padding.left - padding.right).max(0.0)),
361 soft_wrap: true,
362 align: self.align(Some(theme)),
363 },
364 );
365 }
366
367 fn on_click(&self) -> Option<Msg> {
368 None
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::{build_ui, Container, Runtime, Size, Text};
376 use frus_core::Primitive;
377
378 fn child() -> Container<()> {
379 Container::<()>::new().width(40.0).height(20.0)
380 }
381
382 #[test]
387 fn a_tooltip_floats_a_bubble_over_its_child() {
388 let tip = Tooltip::<()>::new("Delete this row").child(child());
389 assert!(Widget::<()>::overlay(&tip).is_some());
390 assert_eq!(
391 Widget::<()>::overlay(&tip).map(|(_, placement)| placement),
392 Some(Placement::Tooltip)
393 );
394 assert_eq!(Widget::<()>::children(&tip).len(), 2);
395 }
396
397 #[test]
401 fn an_empty_message_is_no_tooltip() {
402 let empty = Tooltip::<()>::new("").child(child());
403 assert!(Widget::<()>::overlay(&empty).is_none());
404 assert_eq!(Widget::<()>::children(&empty).len(), 1);
405
406 let off = Tooltip::<()>::new("Something")
407 .child(child())
408 .enabled(false);
409 assert!(Widget::<()>::overlay(&off).is_none());
410 }
411
412 #[test]
416 fn a_tooltip_forwards_its_child_s_layout() {
417 let theme = Theme::default();
418 let bare = child().width(123.0);
419 let wrapped = Tooltip::<()>::new("hint").child(child().width(123.0));
420 assert_eq!(
421 Widget::<()>::style_themed(&wrapped, &theme).width,
422 Widget::<()>::style_themed(&bare, &theme).width
423 );
424 }
425
426 #[test]
430 fn a_tooltip_is_spoken() {
431 let tip = Tooltip::<()>::new("Delete this row").child(child());
432 let semantics = Widget::<()>::semantics(&tip).expect("a tooltip speaks");
433 assert_eq!(semantics.label.as_deref(), Some("Delete this row"));
434 assert!(semantics.is_meaningful(), "so it reaches the tree at all");
435 assert!(Widget::<()>::semantics(&Tooltip::<()>::new("").child(child())).is_none());
436 }
437
438 fn bubble_of(tip: &Tooltip<()>, theme: &Theme) -> (frus_core::Color, BorderRadius) {
439 let (bubble, _) = Widget::<()>::overlay(tip).expect("a bubble");
440 let mut scene = Scene::new();
441 bubble.paint(
442 Rect::new(0.0, 0.0, 100.0, 24.0),
443 Status::default(),
444 theme,
445 &mut scene,
446 );
447 scene
448 .primitives()
449 .iter()
450 .find_map(|p| match p {
451 Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
452 _ => None,
453 })
454 .expect("a surface")
455 }
456
457 #[test]
462 fn a_bubble_is_the_inverted_surface_and_answers_to_a_theme() {
463 let theme = Theme::default();
464 let plain = Tooltip::<()>::new("hint").child(child());
465 assert_eq!(
466 bubble_of(&plain, &theme),
467 (theme.scheme.inverse_surface, BorderRadius::uniform(RADIUS))
468 );
469
470 let mut themed = Theme::default();
471 themed.widgets.tooltip.background = Some(Color::rgb(0.2, 0.4, 0.6));
472 themed.widgets.tooltip.radius = Some(11.0);
473 assert_eq!(
474 bubble_of(&plain, &themed),
475 (Color::rgb(0.2, 0.4, 0.6), BorderRadius::uniform(11.0))
476 );
477
478 let told = Tooltip::<()>::new("hint")
479 .child(child())
480 .background(Color::rgb(0.9, 0.1, 0.1))
481 .radius(3.0);
482 assert_eq!(
483 bubble_of(&told, &themed),
484 (Color::rgb(0.9, 0.1, 0.1), BorderRadius::uniform(3.0))
485 );
486 }
487
488 #[test]
492 fn a_long_hint_wraps_rather_than_crossing_the_window() {
493 let theme = Theme::default();
494 let long = "This row cannot be deleted while it is the last one left in the table, \
495 because a table with no rows has nothing to describe its columns.";
496 let box_of = |width: Option<f32>| {
497 let mut tip = Tooltip::<()>::new(long).child(child());
498 if let Some(width) = width {
499 tip = tip.max_width(width);
500 }
501 let (bubble, _) = Widget::<()>::overlay(&tip).expect("a bubble");
502 let style = bubble.style_themed(&theme);
503 match (style.width, style.height) {
504 (Dimension::Length(w), Dimension::Length(h)) => (w, h),
505 other => panic!("a measured box, not {other:?}"),
506 }
507 };
508
509 let (wide_w, wide_h) = box_of(None);
512 assert!(
513 wide_w <= MAX_WIDTH && wide_w > MAX_WIDTH - 20.0,
514 "it filled the allowance and did not cross it: {wide_w}"
515 );
516 assert!(wide_h > 30.0, "it wrapped onto several lines: {wide_h}");
517
518 let (narrow_w, narrow_h) = box_of(Some(120.0));
521 assert!(narrow_w <= 120.0, "{narrow_w}");
522 assert!(narrow_h > wide_h, "{narrow_h} vs {wide_h}");
523 }
524
525 #[test]
529 fn a_bubble_waits_for_the_pointer() {
530 let tip = Tooltip::<()>::new("hint").child(child());
531 let ui = build_ui(
532 &tip,
533 Size::new(300.0, 200.0),
534 &Runtime::default(),
535 &Theme::default(),
536 );
537 let painted = ui.scene().primitives().len();
538 assert_eq!(painted, 0, "nothing is hovered, so nothing floats");
539
540 let with_text = Tooltip::<()>::new("hint").child(Text::new("x"));
543 let ui = build_ui(
544 &with_text,
545 Size::new(300.0, 200.0),
546 &Runtime::default(),
547 &Theme::default(),
548 );
549 assert!(!ui.scene().primitives().is_empty(), "the child is drawn");
550 }
551}