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() {
206 Some(child) => child.style_themed(theme),
207 None => Style::default(),
208 }
209 }
210
211 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
212 &self.children
213 }
214
215 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
216
217 fn on_click(&self) -> Option<Msg> {
218 None
219 }
220
221 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
222 self.children
223 .get(1)
224 .map(|bubble| (bubble.as_ref(), Placement::Tooltip))
225 }
226
227 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
240 if self.message.is_empty() || !self.enabled {
241 return None;
242 }
243 Some(frus_core::SemanticsProperties::new(frus_core::Role::None).label(self.message.clone()))
244 }
245}
246
247struct Bubble {
253 message: String,
254 background: Option<Color>,
255 text_style: Option<TextStyle>,
256 text_color: Option<Color>,
257 text_align: Option<TextAlign>,
258 padding: Option<Insets>,
259 shape: Option<ShapeBorder>,
260 max_width: Option<f32>,
261}
262
263impl Bubble {
264 fn label_style(&self, theme: Option<&Theme>) -> ResolvedTextStyle {
268 self.text_style
269 .or(theme.and_then(|t| t.widgets.tooltip.text_style))
270 .unwrap_or_else(|| crate::theme::type_scale(theme).body_small)
271 .resolved()
272 }
273
274 fn align(&self, theme: Option<&Theme>) -> TextAlign {
278 self.text_align
279 .or(theme.and_then(|t| t.widgets.tooltip.text_align))
280 .unwrap_or(TextAlign::Start)
281 }
282
283 fn padding(&self, theme: Option<&Theme>) -> Insets {
284 self.padding
285 .or(theme.and_then(|t| t.widgets.tooltip.padding))
286 .unwrap_or(Insets::new(PAD_Y, PAD_X, PAD_Y, PAD_X))
287 }
288
289 fn max_width(&self, theme: Option<&Theme>) -> f32 {
290 self.max_width
291 .or(theme.and_then(|t| t.widgets.tooltip.max_width))
292 .unwrap_or(MAX_WIDTH)
293 }
294
295 fn shape_of(&self, theme: &Theme) -> ShapeBorder {
296 crate::resolve_shape(
297 self.shape,
298 theme.widgets.tooltip.shape,
299 theme.widgets.tooltip.radius.map(BorderRadius::uniform),
300 ShapeBorder::rounded(RADIUS),
301 )
302 }
303
304 fn sizing(&self, theme: Option<&Theme>) -> Style {
305 let padding = self.padding(theme);
306 let style = self.label_style(theme);
307 let room = (self.max_width(theme) - padding.left - padding.right).max(1.0);
308 let measured = frus_text::measure_wrapped_resolved(&self.message, &style, Some(room));
309 Style {
310 width: Dimension::Length((measured.width + padding.left + padding.right).ceil()),
311 height: Dimension::Length((measured.height + padding.top + padding.bottom).ceil()),
312 ..Default::default()
313 }
314 }
315}
316
317impl<Msg> Widget<Msg> for Bubble {
318 fn style(&self) -> Style {
319 self.sizing(None)
320 }
321
322 fn style_themed(&self, theme: &Theme) -> Style {
323 self.sizing(Some(theme))
324 }
325
326 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
327 &[]
328 }
329
330 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
331 let o = status.opacity;
332 let t = &theme.widgets.tooltip;
333 let fill = self
338 .background
339 .or(t.background)
340 .unwrap_or(theme.scheme.inverse_surface);
341 scene.draw_shape(bounds, self.shape_of(theme), fill.fade(o));
342 let padding = self.padding(Some(theme));
343 let style = self.label_style(Some(theme));
344 let ink = self
345 .text_color
346 .or(t.text_color)
347 .unwrap_or(theme.scheme.on_inverse_surface);
348 scene.text_block(
349 Point::new(bounds.x + padding.left, bounds.y + padding.top),
350 self.message.clone(),
351 &style,
352 ink.fade(o),
353 frus_core::TextBlock {
354 width: Some((bounds.width - padding.left - padding.right).max(0.0)),
355 soft_wrap: true,
356 align: self.align(Some(theme)),
357 },
358 );
359 }
360
361 fn on_click(&self) -> Option<Msg> {
362 None
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::{build_ui, Container, Runtime, Size, Text};
370 use frus_core::Primitive;
371
372 fn child() -> Container<()> {
373 Container::<()>::new().width(40.0).height(20.0)
374 }
375
376 #[test]
381 fn a_tooltip_floats_a_bubble_over_its_child() {
382 let tip = Tooltip::<()>::new("Delete this row").child(child());
383 assert!(Widget::<()>::overlay(&tip).is_some());
384 assert_eq!(
385 Widget::<()>::overlay(&tip).map(|(_, placement)| placement),
386 Some(Placement::Tooltip)
387 );
388 assert_eq!(Widget::<()>::children(&tip).len(), 2);
389 }
390
391 #[test]
395 fn an_empty_message_is_no_tooltip() {
396 let empty = Tooltip::<()>::new("").child(child());
397 assert!(Widget::<()>::overlay(&empty).is_none());
398 assert_eq!(Widget::<()>::children(&empty).len(), 1);
399
400 let off = Tooltip::<()>::new("Something")
401 .child(child())
402 .enabled(false);
403 assert!(Widget::<()>::overlay(&off).is_none());
404 }
405
406 #[test]
410 fn a_tooltip_forwards_its_child_s_layout() {
411 let theme = Theme::default();
412 let bare = child().width(123.0);
413 let wrapped = Tooltip::<()>::new("hint").child(child().width(123.0));
414 assert_eq!(
415 Widget::<()>::style_themed(&wrapped, &theme).width,
416 Widget::<()>::style_themed(&bare, &theme).width
417 );
418 }
419
420 #[test]
424 fn a_tooltip_is_spoken() {
425 let tip = Tooltip::<()>::new("Delete this row").child(child());
426 let semantics = Widget::<()>::semantics(&tip).expect("a tooltip speaks");
427 assert_eq!(semantics.label.as_deref(), Some("Delete this row"));
428 assert!(semantics.is_meaningful(), "so it reaches the tree at all");
429 assert!(Widget::<()>::semantics(&Tooltip::<()>::new("").child(child())).is_none());
430 }
431
432 fn bubble_of(tip: &Tooltip<()>, theme: &Theme) -> (frus_core::Color, BorderRadius) {
433 let (bubble, _) = Widget::<()>::overlay(tip).expect("a bubble");
434 let mut scene = Scene::new();
435 bubble.paint(
436 Rect::new(0.0, 0.0, 100.0, 24.0),
437 Status::default(),
438 theme,
439 &mut scene,
440 );
441 scene
442 .primitives()
443 .iter()
444 .find_map(|p| match p {
445 Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
446 _ => None,
447 })
448 .expect("a surface")
449 }
450
451 #[test]
456 fn a_bubble_is_the_inverted_surface_and_answers_to_a_theme() {
457 let theme = Theme::default();
458 let plain = Tooltip::<()>::new("hint").child(child());
459 assert_eq!(
460 bubble_of(&plain, &theme),
461 (theme.scheme.inverse_surface, BorderRadius::uniform(RADIUS))
462 );
463
464 let mut themed = Theme::default();
465 themed.widgets.tooltip.background = Some(Color::rgb(0.2, 0.4, 0.6));
466 themed.widgets.tooltip.radius = Some(11.0);
467 assert_eq!(
468 bubble_of(&plain, &themed),
469 (Color::rgb(0.2, 0.4, 0.6), BorderRadius::uniform(11.0))
470 );
471
472 let told = Tooltip::<()>::new("hint")
473 .child(child())
474 .background(Color::rgb(0.9, 0.1, 0.1))
475 .radius(3.0);
476 assert_eq!(
477 bubble_of(&told, &themed),
478 (Color::rgb(0.9, 0.1, 0.1), BorderRadius::uniform(3.0))
479 );
480 }
481
482 #[test]
486 fn a_long_hint_wraps_rather_than_crossing_the_window() {
487 let theme = Theme::default();
488 let long = "This row cannot be deleted while it is the last one left in the table, \
489 because a table with no rows has nothing to describe its columns.";
490 let box_of = |width: Option<f32>| {
491 let mut tip = Tooltip::<()>::new(long).child(child());
492 if let Some(width) = width {
493 tip = tip.max_width(width);
494 }
495 let (bubble, _) = Widget::<()>::overlay(&tip).expect("a bubble");
496 let style = bubble.style_themed(&theme);
497 match (style.width, style.height) {
498 (Dimension::Length(w), Dimension::Length(h)) => (w, h),
499 other => panic!("a measured box, not {other:?}"),
500 }
501 };
502
503 let (wide_w, wide_h) = box_of(None);
506 assert!(
507 wide_w <= MAX_WIDTH && wide_w > MAX_WIDTH - 20.0,
508 "it filled the allowance and did not cross it: {wide_w}"
509 );
510 assert!(wide_h > 30.0, "it wrapped onto several lines: {wide_h}");
511
512 let (narrow_w, narrow_h) = box_of(Some(120.0));
515 assert!(narrow_w <= 120.0, "{narrow_w}");
516 assert!(narrow_h > wide_h, "{narrow_h} vs {wide_h}");
517 }
518
519 #[test]
523 fn a_bubble_waits_for_the_pointer() {
524 let tip = Tooltip::<()>::new("hint").child(child());
525 let ui = build_ui(
526 &tip,
527 Size::new(300.0, 200.0),
528 &Runtime::default(),
529 &Theme::default(),
530 );
531 let painted = ui.scene().primitives().len();
532 assert_eq!(painted, 0, "nothing is hovered, so nothing floats");
533
534 let with_text = Tooltip::<()>::new("hint").child(Text::new("x"));
537 let ui = build_ui(
538 &with_text,
539 Size::new(300.0, 200.0),
540 &Runtime::default(),
541 &Theme::default(),
542 );
543 assert!(!ui.scene().primitives().is_empty(), "the child is drawn");
544 }
545}