1use presentar_core::{
4 widget::{AccessibleRole, LayoutResult, TextStyle},
5 Canvas, Color, Constraints, Event, Point, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12pub enum TooltipPlacement {
13 #[default]
15 Top,
16 Bottom,
18 Left,
20 Right,
22 TopLeft,
24 TopRight,
26 BottomLeft,
28 BottomRight,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Tooltip {
35 content: String,
37 placement: TooltipPlacement,
39 delay_ms: u32,
41 visible: bool,
43 background: Color,
45 text_color: Color,
47 border_color: Color,
49 border_width: f32,
51 corner_radius: f32,
53 padding: f32,
55 arrow_size: f32,
57 show_arrow: bool,
59 max_width: Option<f32>,
61 text_size: f32,
63 accessible_name_value: Option<String>,
65 test_id_value: Option<String>,
67 #[serde(skip)]
69 anchor_bounds: Rect,
70 #[serde(skip)]
72 bounds: Rect,
73}
74
75impl Default for Tooltip {
76 fn default() -> Self {
77 Self {
78 content: String::new(),
79 placement: TooltipPlacement::Top,
80 delay_ms: 200,
81 visible: false,
82 background: Color::new(0.15, 0.15, 0.15, 0.95),
83 text_color: Color::WHITE,
84 border_color: Color::new(0.3, 0.3, 0.3, 1.0),
85 border_width: 0.0,
86 corner_radius: 4.0,
87 padding: 8.0,
88 arrow_size: 6.0,
89 show_arrow: true,
90 max_width: Some(250.0),
91 text_size: 12.0,
92 accessible_name_value: None,
93 test_id_value: None,
94 anchor_bounds: Rect::default(),
95 bounds: Rect::default(),
96 }
97 }
98}
99
100impl Tooltip {
101 #[must_use]
103 pub fn new(content: impl Into<String>) -> Self {
104 Self {
105 content: content.into(),
106 ..Self::default()
107 }
108 }
109
110 #[must_use]
112 pub fn content(mut self, content: impl Into<String>) -> Self {
113 self.content = content.into();
114 self
115 }
116
117 #[must_use]
119 pub const fn placement(mut self, placement: TooltipPlacement) -> Self {
120 self.placement = placement;
121 self
122 }
123
124 #[must_use]
126 pub const fn delay_ms(mut self, ms: u32) -> Self {
127 self.delay_ms = ms;
128 self
129 }
130
131 #[must_use]
133 pub const fn visible(mut self, visible: bool) -> Self {
134 self.visible = visible;
135 self
136 }
137
138 #[must_use]
140 pub const fn background(mut self, color: Color) -> Self {
141 self.background = color;
142 self
143 }
144
145 #[must_use]
147 pub const fn text_color(mut self, color: Color) -> Self {
148 self.text_color = color;
149 self
150 }
151
152 #[must_use]
154 pub const fn border_color(mut self, color: Color) -> Self {
155 self.border_color = color;
156 self
157 }
158
159 #[must_use]
161 pub fn border_width(mut self, width: f32) -> Self {
162 self.border_width = width.max(0.0);
163 self
164 }
165
166 #[must_use]
168 pub fn corner_radius(mut self, radius: f32) -> Self {
169 self.corner_radius = radius.max(0.0);
170 self
171 }
172
173 #[must_use]
175 pub fn padding(mut self, padding: f32) -> Self {
176 self.padding = padding.max(0.0);
177 self
178 }
179
180 #[must_use]
182 pub fn arrow_size(mut self, size: f32) -> Self {
183 self.arrow_size = size.max(0.0);
184 self
185 }
186
187 #[must_use]
189 pub const fn show_arrow(mut self, show: bool) -> Self {
190 self.show_arrow = show;
191 self
192 }
193
194 #[must_use]
196 pub fn max_width(mut self, width: f32) -> Self {
197 self.max_width = Some(width.max(50.0));
198 self
199 }
200
201 #[must_use]
203 pub const fn no_max_width(mut self) -> Self {
204 self.max_width = None;
205 self
206 }
207
208 #[must_use]
210 pub fn text_size(mut self, size: f32) -> Self {
211 self.text_size = size.max(8.0);
212 self
213 }
214
215 #[must_use]
217 pub const fn anchor(mut self, bounds: Rect) -> Self {
218 self.anchor_bounds = bounds;
219 self
220 }
221
222 #[must_use]
224 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
225 self.accessible_name_value = Some(name.into());
226 self
227 }
228
229 #[must_use]
231 pub fn test_id(mut self, id: impl Into<String>) -> Self {
232 self.test_id_value = Some(id.into());
233 self
234 }
235
236 #[must_use]
238 pub fn get_content(&self) -> &str {
239 &self.content
240 }
241
242 #[must_use]
244 pub const fn get_placement(&self) -> TooltipPlacement {
245 self.placement
246 }
247
248 #[must_use]
250 pub const fn get_delay_ms(&self) -> u32 {
251 self.delay_ms
252 }
253
254 #[must_use]
256 pub const fn is_visible(&self) -> bool {
257 self.visible
258 }
259
260 #[must_use]
262 pub const fn get_anchor(&self) -> Rect {
263 self.anchor_bounds
264 }
265
266 pub fn show(&mut self) {
268 self.visible = true;
269 }
270
271 pub fn hide(&mut self) {
273 self.visible = false;
274 }
275
276 pub fn toggle(&mut self) {
278 self.visible = !self.visible;
279 }
280
281 pub fn set_anchor(&mut self, bounds: Rect) {
283 self.anchor_bounds = bounds;
284 }
285
286 fn estimate_text_width(&self) -> f32 {
288 let char_width = self.text_size * 0.6;
290 self.content.len() as f32 * char_width
291 }
292
293 fn calculate_size(&self) -> Size {
295 let text_width = self.estimate_text_width();
296 let max_text = self.max_width.map(|m| self.padding.mul_add(-2.0, m));
297
298 let content_width = match max_text {
299 Some(max) if text_width > max => max,
300 _ => text_width,
301 };
302
303 let lines = if let Some(max) = max_text {
304 (text_width / max).ceil().max(1.0)
305 } else {
306 1.0
307 };
308
309 let content_height = lines * self.text_size * 1.2;
310
311 Size::new(
312 self.padding.mul_add(2.0, content_width),
313 self.padding.mul_add(2.0, content_height),
314 )
315 }
316
317 fn calculate_position(&self, size: Size) -> Point {
319 let anchor = self.anchor_bounds;
320 let arrow_offset = if self.show_arrow {
321 self.arrow_size
322 } else {
323 0.0
324 };
325
326 match self.placement {
327 TooltipPlacement::Top => Point::new(
328 anchor.x + (anchor.width - size.width) / 2.0,
329 anchor.y - size.height - arrow_offset,
330 ),
331 TooltipPlacement::Bottom => Point::new(
332 anchor.x + (anchor.width - size.width) / 2.0,
333 anchor.y + anchor.height + arrow_offset,
334 ),
335 TooltipPlacement::Left => Point::new(
336 anchor.x - size.width - arrow_offset,
337 anchor.y + (anchor.height - size.height) / 2.0,
338 ),
339 TooltipPlacement::Right => Point::new(
340 anchor.x + anchor.width + arrow_offset,
341 anchor.y + (anchor.height - size.height) / 2.0,
342 ),
343 TooltipPlacement::TopLeft => {
344 Point::new(anchor.x, anchor.y - size.height - arrow_offset)
345 }
346 TooltipPlacement::TopRight => Point::new(
347 anchor.x + anchor.width - size.width,
348 anchor.y - size.height - arrow_offset,
349 ),
350 TooltipPlacement::BottomLeft => {
351 Point::new(anchor.x, anchor.y + anchor.height + arrow_offset)
352 }
353 TooltipPlacement::BottomRight => Point::new(
354 anchor.x + anchor.width - size.width,
355 anchor.y + anchor.height + arrow_offset,
356 ),
357 }
358 }
359}
360
361impl Widget for Tooltip {
362 fn type_id(&self) -> TypeId {
363 TypeId::of::<Self>()
364 }
365
366 fn measure(&self, constraints: Constraints) -> Size {
367 if !self.visible || self.content.is_empty() {
368 return Size::ZERO;
369 }
370
371 let size = self.calculate_size();
372 constraints.constrain(size)
373 }
374
375 fn layout(&mut self, _bounds: Rect) -> LayoutResult {
376 if !self.visible || self.content.is_empty() {
377 self.bounds = Rect::default();
378 return LayoutResult { size: Size::ZERO };
379 }
380
381 let size = self.calculate_size();
382 let position = self.calculate_position(size);
383 self.bounds = Rect::new(position.x, position.y, size.width, size.height);
384
385 LayoutResult { size }
386 }
387
388 fn paint(&self, canvas: &mut dyn Canvas) {
389 if !self.visible || self.content.is_empty() {
390 return;
391 }
392
393 canvas.fill_rect(self.bounds, self.background);
395
396 if self.border_width > 0.0 {
398 canvas.stroke_rect(self.bounds, self.border_color, self.border_width);
399 }
400
401 if self.show_arrow {
403 let arrow_rect = match self.placement {
404 TooltipPlacement::Top | TooltipPlacement::TopLeft | TooltipPlacement::TopRight => {
405 let cx = self.bounds.x + self.bounds.width / 2.0;
406 Rect::new(
407 cx - self.arrow_size,
408 self.bounds.y + self.bounds.height,
409 self.arrow_size * 2.0,
410 self.arrow_size,
411 )
412 }
413 TooltipPlacement::Bottom
414 | TooltipPlacement::BottomLeft
415 | TooltipPlacement::BottomRight => {
416 let cx = self.bounds.x + self.bounds.width / 2.0;
417 Rect::new(
418 cx - self.arrow_size,
419 self.bounds.y - self.arrow_size,
420 self.arrow_size * 2.0,
421 self.arrow_size,
422 )
423 }
424 TooltipPlacement::Left => {
425 let cy = self.bounds.y + self.bounds.height / 2.0;
426 Rect::new(
427 self.bounds.x + self.bounds.width,
428 cy - self.arrow_size,
429 self.arrow_size,
430 self.arrow_size * 2.0,
431 )
432 }
433 TooltipPlacement::Right => {
434 let cy = self.bounds.y + self.bounds.height / 2.0;
435 Rect::new(
436 self.bounds.x - self.arrow_size,
437 cy - self.arrow_size,
438 self.arrow_size,
439 self.arrow_size * 2.0,
440 )
441 }
442 };
443 canvas.fill_rect(arrow_rect, self.background);
444 }
445
446 let text_style = TextStyle {
448 size: self.text_size,
449 color: self.text_color,
450 ..TextStyle::default()
451 };
452
453 canvas.draw_text(
454 &self.content,
455 Point::new(
456 self.bounds.x + self.padding,
457 self.bounds.y + self.padding + self.text_size,
458 ),
459 &text_style,
460 );
461 }
462
463 fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
464 if matches!(event, Event::MouseLeave) {
467 self.hide();
468 }
469 None
470 }
471
472 fn children(&self) -> &[Box<dyn Widget>] {
473 &[]
474 }
475
476 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
477 &mut []
478 }
479
480 fn is_interactive(&self) -> bool {
481 false }
483
484 fn is_focusable(&self) -> bool {
485 false
486 }
487
488 fn accessible_name(&self) -> Option<&str> {
489 self.accessible_name_value
490 .as_deref()
491 .or(Some(&self.content))
492 }
493
494 fn accessible_role(&self) -> AccessibleRole {
495 AccessibleRole::Generic }
497
498 fn test_id(&self) -> Option<&str> {
499 self.test_id_value.as_deref()
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
510 fn test_tooltip_placement_default() {
511 assert_eq!(TooltipPlacement::default(), TooltipPlacement::Top);
512 }
513
514 #[test]
515 fn test_tooltip_placement_variants() {
516 let placements = [
517 TooltipPlacement::Top,
518 TooltipPlacement::Bottom,
519 TooltipPlacement::Left,
520 TooltipPlacement::Right,
521 TooltipPlacement::TopLeft,
522 TooltipPlacement::TopRight,
523 TooltipPlacement::BottomLeft,
524 TooltipPlacement::BottomRight,
525 ];
526 assert_eq!(placements.len(), 8);
527 }
528
529 #[test]
532 fn test_tooltip_new() {
533 let tooltip = Tooltip::new("Help text");
534 assert_eq!(tooltip.get_content(), "Help text");
535 assert!(!tooltip.is_visible());
536 }
537
538 #[test]
539 fn test_tooltip_default() {
540 let tooltip = Tooltip::default();
541 assert!(tooltip.content.is_empty());
542 assert_eq!(tooltip.placement, TooltipPlacement::Top);
543 assert_eq!(tooltip.delay_ms, 200);
544 assert!(!tooltip.visible);
545 }
546
547 #[test]
548 fn test_tooltip_builder() {
549 let tooltip = Tooltip::new("Click to submit")
550 .placement(TooltipPlacement::Bottom)
551 .delay_ms(500)
552 .visible(true)
553 .background(Color::BLACK)
554 .text_color(Color::WHITE)
555 .border_color(Color::RED)
556 .border_width(1.0)
557 .corner_radius(8.0)
558 .padding(12.0)
559 .arrow_size(8.0)
560 .show_arrow(true)
561 .max_width(300.0)
562 .text_size(14.0)
563 .accessible_name("Submit button tooltip")
564 .test_id("submit-tooltip");
565
566 assert_eq!(tooltip.get_content(), "Click to submit");
567 assert_eq!(tooltip.get_placement(), TooltipPlacement::Bottom);
568 assert_eq!(tooltip.get_delay_ms(), 500);
569 assert!(tooltip.is_visible());
570 assert_eq!(
571 Widget::accessible_name(&tooltip),
572 Some("Submit button tooltip")
573 );
574 assert_eq!(Widget::test_id(&tooltip), Some("submit-tooltip"));
575 }
576
577 #[test]
578 fn test_tooltip_content() {
579 let tooltip = Tooltip::new("old").content("new");
580 assert_eq!(tooltip.get_content(), "new");
581 }
582
583 #[test]
586 fn test_tooltip_show() {
587 let mut tooltip = Tooltip::new("Text");
588 assert!(!tooltip.is_visible());
589 tooltip.show();
590 assert!(tooltip.is_visible());
591 }
592
593 #[test]
594 fn test_tooltip_hide() {
595 let mut tooltip = Tooltip::new("Text").visible(true);
596 assert!(tooltip.is_visible());
597 tooltip.hide();
598 assert!(!tooltip.is_visible());
599 }
600
601 #[test]
602 fn test_tooltip_toggle() {
603 let mut tooltip = Tooltip::new("Text");
604 assert!(!tooltip.is_visible());
605 tooltip.toggle();
606 assert!(tooltip.is_visible());
607 tooltip.toggle();
608 assert!(!tooltip.is_visible());
609 }
610
611 #[test]
614 fn test_tooltip_anchor() {
615 let anchor = Rect::new(100.0, 100.0, 80.0, 30.0);
616 let tooltip = Tooltip::new("Help").anchor(anchor);
617 assert_eq!(tooltip.get_anchor(), anchor);
618 }
619
620 #[test]
621 fn test_tooltip_set_anchor() {
622 let mut tooltip = Tooltip::new("Help");
623 let anchor = Rect::new(50.0, 50.0, 100.0, 40.0);
624 tooltip.set_anchor(anchor);
625 assert_eq!(tooltip.get_anchor(), anchor);
626 }
627
628 #[test]
631 fn test_tooltip_border_width_min() {
632 let tooltip = Tooltip::new("Text").border_width(-5.0);
633 assert_eq!(tooltip.border_width, 0.0);
634 }
635
636 #[test]
637 fn test_tooltip_corner_radius_min() {
638 let tooltip = Tooltip::new("Text").corner_radius(-5.0);
639 assert_eq!(tooltip.corner_radius, 0.0);
640 }
641
642 #[test]
643 fn test_tooltip_padding_min() {
644 let tooltip = Tooltip::new("Text").padding(-5.0);
645 assert_eq!(tooltip.padding, 0.0);
646 }
647
648 #[test]
649 fn test_tooltip_arrow_size_min() {
650 let tooltip = Tooltip::new("Text").arrow_size(-5.0);
651 assert_eq!(tooltip.arrow_size, 0.0);
652 }
653
654 #[test]
655 fn test_tooltip_max_width_min() {
656 let tooltip = Tooltip::new("Text").max_width(10.0);
657 assert_eq!(tooltip.max_width, Some(50.0));
658 }
659
660 #[test]
661 fn test_tooltip_no_max_width() {
662 let tooltip = Tooltip::new("Text").max_width(200.0).no_max_width();
663 assert!(tooltip.max_width.is_none());
664 }
665
666 #[test]
667 fn test_tooltip_text_size_min() {
668 let tooltip = Tooltip::new("Text").text_size(2.0);
669 assert_eq!(tooltip.text_size, 8.0);
670 }
671
672 #[test]
675 fn test_tooltip_estimate_text_width() {
676 let tooltip = Tooltip::new("Hello").text_size(12.0);
677 let width = tooltip.estimate_text_width();
678 assert!((width - 36.0).abs() < 0.1);
680 }
681
682 #[test]
683 fn test_tooltip_calculate_size() {
684 let tooltip = Tooltip::new("Test").padding(10.0).text_size(12.0);
685 let size = tooltip.calculate_size();
686 assert!(size.width > 0.0);
687 assert!(size.height > 0.0);
688 }
689
690 #[test]
693 fn test_tooltip_position_top() {
694 let tooltip = Tooltip::new("Text")
695 .placement(TooltipPlacement::Top)
696 .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
697 .show_arrow(true)
698 .arrow_size(6.0);
699
700 let size = Size::new(50.0, 24.0);
701 let pos = tooltip.calculate_position(size);
702
703 assert!(pos.y < 100.0);
705 assert!(pos.x > 100.0); }
707
708 #[test]
709 fn test_tooltip_position_bottom() {
710 let tooltip = Tooltip::new("Text")
711 .placement(TooltipPlacement::Bottom)
712 .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
713 .show_arrow(true)
714 .arrow_size(6.0);
715
716 let size = Size::new(50.0, 24.0);
717 let pos = tooltip.calculate_position(size);
718
719 assert!(pos.y > 130.0); }
722
723 #[test]
724 fn test_tooltip_position_left() {
725 let tooltip = Tooltip::new("Text")
726 .placement(TooltipPlacement::Left)
727 .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
728 .show_arrow(true)
729 .arrow_size(6.0);
730
731 let size = Size::new(50.0, 24.0);
732 let pos = tooltip.calculate_position(size);
733
734 assert!(pos.x < 100.0 - 50.0);
736 }
737
738 #[test]
739 fn test_tooltip_position_right() {
740 let tooltip = Tooltip::new("Text")
741 .placement(TooltipPlacement::Right)
742 .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
743 .show_arrow(true)
744 .arrow_size(6.0);
745
746 let size = Size::new(50.0, 24.0);
747 let pos = tooltip.calculate_position(size);
748
749 assert!(pos.x > 180.0); }
752
753 #[test]
756 fn test_tooltip_type_id() {
757 let tooltip = Tooltip::new("Text");
758 assert_eq!(Widget::type_id(&tooltip), TypeId::of::<Tooltip>());
759 }
760
761 #[test]
762 fn test_tooltip_measure_invisible() {
763 let tooltip = Tooltip::new("Text").visible(false);
764 let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
765 assert_eq!(size, Size::ZERO);
766 }
767
768 #[test]
769 fn test_tooltip_measure_empty() {
770 let tooltip = Tooltip::default().visible(true);
771 let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
772 assert_eq!(size, Size::ZERO);
773 }
774
775 #[test]
776 fn test_tooltip_measure_visible() {
777 let tooltip = Tooltip::new("Some helpful text").visible(true);
778 let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
779 assert!(size.width > 0.0);
780 assert!(size.height > 0.0);
781 }
782
783 #[test]
784 fn test_tooltip_layout_invisible() {
785 let mut tooltip = Tooltip::new("Text").visible(false);
786 let result = tooltip.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
787 assert_eq!(result.size, Size::ZERO);
788 }
789
790 #[test]
791 fn test_tooltip_layout_visible() {
792 let mut tooltip = Tooltip::new("Text")
793 .visible(true)
794 .anchor(Rect::new(100.0, 100.0, 80.0, 30.0));
795 let result = tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
796 assert!(result.size.width > 0.0);
797 assert!(result.size.height > 0.0);
798 }
799
800 #[test]
801 fn test_tooltip_children() {
802 let tooltip = Tooltip::new("Text");
803 assert!(tooltip.children().is_empty());
804 }
805
806 #[test]
807 fn test_tooltip_is_interactive() {
808 let tooltip = Tooltip::new("Text");
809 assert!(!tooltip.is_interactive());
810 }
811
812 #[test]
813 fn test_tooltip_is_focusable() {
814 let tooltip = Tooltip::new("Text");
815 assert!(!tooltip.is_focusable());
816 }
817
818 #[test]
819 fn test_tooltip_accessible_role() {
820 let tooltip = Tooltip::new("Text");
821 assert_eq!(tooltip.accessible_role(), AccessibleRole::Generic);
822 }
823
824 #[test]
825 fn test_tooltip_accessible_name_default() {
826 let tooltip = Tooltip::new("Help text");
827 assert_eq!(Widget::accessible_name(&tooltip), Some("Help text"));
829 }
830
831 #[test]
832 fn test_tooltip_accessible_name_explicit() {
833 let tooltip = Tooltip::new("Help text").accessible_name("Explicit name");
834 assert_eq!(Widget::accessible_name(&tooltip), Some("Explicit name"));
835 }
836
837 #[test]
838 fn test_tooltip_test_id() {
839 let tooltip = Tooltip::new("Text").test_id("help-tooltip");
840 assert_eq!(Widget::test_id(&tooltip), Some("help-tooltip"));
841 }
842
843 #[test]
846 fn test_tooltip_mouse_leave_hides() {
847 let mut tooltip = Tooltip::new("Text").visible(true);
848 assert!(tooltip.is_visible());
849
850 tooltip.event(&Event::MouseLeave);
851 assert!(!tooltip.is_visible());
852 }
853
854 #[test]
855 fn test_tooltip_stays_hidden_on_other_events() {
856 let mut tooltip = Tooltip::new("Text").visible(false);
857 tooltip.event(&Event::MouseMove {
859 position: Point::new(0.0, 0.0),
860 });
861 assert!(!tooltip.is_visible());
862 }
863
864 #[test]
867 fn test_tooltip_colors() {
868 let tooltip = Tooltip::new("Text")
869 .background(Color::BLUE)
870 .text_color(Color::RED)
871 .border_color(Color::GREEN);
872
873 assert_eq!(tooltip.background, Color::BLUE);
874 assert_eq!(tooltip.text_color, Color::RED);
875 assert_eq!(tooltip.border_color, Color::GREEN);
876 }
877}