Skip to main content

presentar_widgets/
tooltip.rs

1//! Tooltip widget for contextual hover information.
2
3use presentar_core::{
4    widget::{AccessibleRole, LayoutResult, TextStyle},
5    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
6    Point, Rect, Size, TypeId, Widget,
7};
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::time::Duration;
11
12/// Tooltip placement relative to the anchor element.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14pub enum TooltipPlacement {
15    /// Above the anchor
16    #[default]
17    Top,
18    /// Below the anchor
19    Bottom,
20    /// Left of the anchor
21    Left,
22    /// Right of the anchor
23    Right,
24    /// Top left corner
25    TopLeft,
26    /// Top right corner
27    TopRight,
28    /// Bottom left corner
29    BottomLeft,
30    /// Bottom right corner
31    BottomRight,
32}
33
34/// Tooltip widget for showing contextual information on hover.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Tooltip {
37    /// Tooltip text content
38    content: String,
39    /// Placement preference
40    placement: TooltipPlacement,
41    /// Show delay in milliseconds
42    delay_ms: u32,
43    /// Whether tooltip is currently visible
44    visible: bool,
45    /// Background color
46    background: Color,
47    /// Text color
48    text_color: Color,
49    /// Border color
50    border_color: Color,
51    /// Border width
52    border_width: f32,
53    /// Corner radius
54    corner_radius: f32,
55    /// Padding
56    padding: f32,
57    /// Arrow size
58    arrow_size: f32,
59    /// Show arrow
60    show_arrow: bool,
61    /// Maximum width
62    max_width: Option<f32>,
63    /// Text size
64    text_size: f32,
65    /// Accessible name
66    accessible_name_value: Option<String>,
67    /// Test ID
68    test_id_value: Option<String>,
69    /// Anchor bounds (for positioning)
70    #[serde(skip)]
71    anchor_bounds: Rect,
72    /// Cached bounds
73    #[serde(skip)]
74    bounds: Rect,
75}
76
77impl Default for Tooltip {
78    fn default() -> Self {
79        Self {
80            content: String::new(),
81            placement: TooltipPlacement::Top,
82            delay_ms: 200,
83            visible: false,
84            background: Color::new(0.15, 0.15, 0.15, 0.95),
85            text_color: Color::WHITE,
86            border_color: Color::new(0.3, 0.3, 0.3, 1.0),
87            border_width: 0.0,
88            corner_radius: 4.0,
89            padding: 8.0,
90            arrow_size: 6.0,
91            show_arrow: true,
92            max_width: Some(250.0),
93            text_size: 12.0,
94            accessible_name_value: None,
95            test_id_value: None,
96            anchor_bounds: Rect::default(),
97            bounds: Rect::default(),
98        }
99    }
100}
101
102impl Tooltip {
103    /// Create a new tooltip.
104    #[must_use]
105    pub fn new(content: impl Into<String>) -> Self {
106        Self {
107            content: content.into(),
108            ..Self::default()
109        }
110    }
111
112    /// Set the content.
113    #[must_use]
114    pub fn content(mut self, content: impl Into<String>) -> Self {
115        self.content = content.into();
116        self
117    }
118
119    /// Set the placement.
120    #[must_use]
121    pub const fn placement(mut self, placement: TooltipPlacement) -> Self {
122        self.placement = placement;
123        self
124    }
125
126    /// Set the show delay in milliseconds.
127    #[must_use]
128    pub const fn delay_ms(mut self, ms: u32) -> Self {
129        self.delay_ms = ms;
130        self
131    }
132
133    /// Set visibility.
134    #[must_use]
135    pub const fn visible(mut self, visible: bool) -> Self {
136        self.visible = visible;
137        self
138    }
139
140    /// Set background color.
141    #[must_use]
142    pub const fn background(mut self, color: Color) -> Self {
143        self.background = color;
144        self
145    }
146
147    /// Set text color.
148    #[must_use]
149    pub const fn text_color(mut self, color: Color) -> Self {
150        self.text_color = color;
151        self
152    }
153
154    /// Set border color.
155    #[must_use]
156    pub const fn border_color(mut self, color: Color) -> Self {
157        self.border_color = color;
158        self
159    }
160
161    /// Set border width.
162    #[must_use]
163    pub fn border_width(mut self, width: f32) -> Self {
164        self.border_width = width.max(0.0);
165        self
166    }
167
168    /// Set corner radius.
169    #[must_use]
170    pub fn corner_radius(mut self, radius: f32) -> Self {
171        self.corner_radius = radius.max(0.0);
172        self
173    }
174
175    /// Set padding.
176    #[must_use]
177    pub fn padding(mut self, padding: f32) -> Self {
178        self.padding = padding.max(0.0);
179        self
180    }
181
182    /// Set arrow size.
183    #[must_use]
184    pub fn arrow_size(mut self, size: f32) -> Self {
185        self.arrow_size = size.max(0.0);
186        self
187    }
188
189    /// Set whether to show arrow.
190    #[must_use]
191    pub const fn show_arrow(mut self, show: bool) -> Self {
192        self.show_arrow = show;
193        self
194    }
195
196    /// Set maximum width.
197    #[must_use]
198    pub fn max_width(mut self, width: f32) -> Self {
199        self.max_width = Some(width.max(50.0));
200        self
201    }
202
203    /// Remove maximum width constraint.
204    #[must_use]
205    pub const fn no_max_width(mut self) -> Self {
206        self.max_width = None;
207        self
208    }
209
210    /// Set text size.
211    #[must_use]
212    pub fn text_size(mut self, size: f32) -> Self {
213        self.text_size = size.max(8.0);
214        self
215    }
216
217    /// Set anchor bounds for positioning.
218    #[must_use]
219    pub const fn anchor(mut self, bounds: Rect) -> Self {
220        self.anchor_bounds = bounds;
221        self
222    }
223
224    /// Set accessible name.
225    #[must_use]
226    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
227        self.accessible_name_value = Some(name.into());
228        self
229    }
230
231    /// Set test ID.
232    #[must_use]
233    pub fn test_id(mut self, id: impl Into<String>) -> Self {
234        self.test_id_value = Some(id.into());
235        self
236    }
237
238    /// Get the content.
239    #[must_use]
240    pub fn get_content(&self) -> &str {
241        &self.content
242    }
243
244    /// Get the placement.
245    #[must_use]
246    pub const fn get_placement(&self) -> TooltipPlacement {
247        self.placement
248    }
249
250    /// Get the delay in milliseconds.
251    #[must_use]
252    pub const fn get_delay_ms(&self) -> u32 {
253        self.delay_ms
254    }
255
256    /// Check if visible.
257    #[must_use]
258    pub const fn is_visible(&self) -> bool {
259        self.visible
260    }
261
262    /// Get the anchor bounds.
263    #[must_use]
264    pub const fn get_anchor(&self) -> Rect {
265        self.anchor_bounds
266    }
267
268    /// Show the tooltip.
269    pub fn show(&mut self) {
270        self.visible = true;
271    }
272
273    /// Hide the tooltip.
274    pub fn hide(&mut self) {
275        self.visible = false;
276    }
277
278    /// Toggle visibility.
279    pub fn toggle(&mut self) {
280        self.visible = !self.visible;
281    }
282
283    /// Set anchor bounds (mutable).
284    pub fn set_anchor(&mut self, bounds: Rect) {
285        self.anchor_bounds = bounds;
286    }
287
288    /// Estimate text width.
289    fn estimate_text_width(&self) -> f32 {
290        // Approximate: chars * text_size * 0.6
291        let char_width = self.text_size * 0.6;
292        self.content.len() as f32 * char_width
293    }
294
295    /// Calculate tooltip size.
296    fn calculate_size(&self) -> Size {
297        let text_width = self.estimate_text_width();
298        let max_text = self.max_width.map(|m| self.padding.mul_add(-2.0, m));
299
300        let content_width = match max_text {
301            Some(max) if text_width > max => max,
302            _ => text_width,
303        };
304
305        let lines = if let Some(max) = max_text {
306            (text_width / max).ceil().max(1.0)
307        } else {
308            1.0
309        };
310
311        let content_height = lines * self.text_size * 1.2;
312
313        Size::new(
314            self.padding.mul_add(2.0, content_width),
315            self.padding.mul_add(2.0, content_height),
316        )
317    }
318
319    /// Calculate tooltip position based on placement and anchor.
320    fn calculate_position(&self, size: Size) -> Point {
321        let anchor = self.anchor_bounds;
322        let arrow_offset = if self.show_arrow {
323            self.arrow_size
324        } else {
325            0.0
326        };
327
328        match self.placement {
329            TooltipPlacement::Top => Point::new(
330                anchor.x + (anchor.width - size.width) / 2.0,
331                anchor.y - size.height - arrow_offset,
332            ),
333            TooltipPlacement::Bottom => Point::new(
334                anchor.x + (anchor.width - size.width) / 2.0,
335                anchor.y + anchor.height + arrow_offset,
336            ),
337            TooltipPlacement::Left => Point::new(
338                anchor.x - size.width - arrow_offset,
339                anchor.y + (anchor.height - size.height) / 2.0,
340            ),
341            TooltipPlacement::Right => Point::new(
342                anchor.x + anchor.width + arrow_offset,
343                anchor.y + (anchor.height - size.height) / 2.0,
344            ),
345            TooltipPlacement::TopLeft => {
346                Point::new(anchor.x, anchor.y - size.height - arrow_offset)
347            }
348            TooltipPlacement::TopRight => Point::new(
349                anchor.x + anchor.width - size.width,
350                anchor.y - size.height - arrow_offset,
351            ),
352            TooltipPlacement::BottomLeft => {
353                Point::new(anchor.x, anchor.y + anchor.height + arrow_offset)
354            }
355            TooltipPlacement::BottomRight => Point::new(
356                anchor.x + anchor.width - size.width,
357                anchor.y + anchor.height + arrow_offset,
358            ),
359        }
360    }
361}
362
363impl Widget for Tooltip {
364    fn type_id(&self) -> TypeId {
365        TypeId::of::<Self>()
366    }
367
368    fn measure(&self, constraints: Constraints) -> Size {
369        if !self.visible || self.content.is_empty() {
370            return Size::ZERO;
371        }
372
373        let size = self.calculate_size();
374        constraints.constrain(size)
375    }
376
377    fn layout(&mut self, _bounds: Rect) -> LayoutResult {
378        if !self.visible || self.content.is_empty() {
379            self.bounds = Rect::default();
380            return LayoutResult { size: Size::ZERO };
381        }
382
383        let size = self.calculate_size();
384        let position = self.calculate_position(size);
385        self.bounds = Rect::new(position.x, position.y, size.width, size.height);
386
387        LayoutResult { size }
388    }
389
390    fn paint(&self, canvas: &mut dyn Canvas) {
391        if !self.visible || self.content.is_empty() {
392            return;
393        }
394
395        // Draw background
396        canvas.fill_rect(self.bounds, self.background);
397
398        // Draw border if needed
399        if self.border_width > 0.0 {
400            canvas.stroke_rect(self.bounds, self.border_color, self.border_width);
401        }
402
403        // Draw arrow
404        if self.show_arrow {
405            let arrow_rect = match self.placement {
406                TooltipPlacement::Top | TooltipPlacement::TopLeft | TooltipPlacement::TopRight => {
407                    let cx = self.bounds.x + self.bounds.width / 2.0;
408                    Rect::new(
409                        cx - self.arrow_size,
410                        self.bounds.y + self.bounds.height,
411                        self.arrow_size * 2.0,
412                        self.arrow_size,
413                    )
414                }
415                TooltipPlacement::Bottom
416                | TooltipPlacement::BottomLeft
417                | TooltipPlacement::BottomRight => {
418                    let cx = self.bounds.x + self.bounds.width / 2.0;
419                    Rect::new(
420                        cx - self.arrow_size,
421                        self.bounds.y - self.arrow_size,
422                        self.arrow_size * 2.0,
423                        self.arrow_size,
424                    )
425                }
426                TooltipPlacement::Left => {
427                    let cy = self.bounds.y + self.bounds.height / 2.0;
428                    Rect::new(
429                        self.bounds.x + self.bounds.width,
430                        cy - self.arrow_size,
431                        self.arrow_size,
432                        self.arrow_size * 2.0,
433                    )
434                }
435                TooltipPlacement::Right => {
436                    let cy = self.bounds.y + self.bounds.height / 2.0;
437                    Rect::new(
438                        self.bounds.x - self.arrow_size,
439                        cy - self.arrow_size,
440                        self.arrow_size,
441                        self.arrow_size * 2.0,
442                    )
443                }
444            };
445            canvas.fill_rect(arrow_rect, self.background);
446        }
447
448        // Draw text
449        let text_style = TextStyle {
450            size: self.text_size,
451            color: self.text_color,
452            ..TextStyle::default()
453        };
454
455        canvas.draw_text(
456            &self.content,
457            Point::new(
458                self.bounds.x + self.padding,
459                self.bounds.y + self.padding + self.text_size,
460            ),
461            &text_style,
462        );
463    }
464
465    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
466        // Tooltip doesn't handle events directly
467        // Visibility is controlled by the parent/anchor widget
468        if matches!(event, Event::MouseLeave) {
469            self.hide();
470        }
471        None
472    }
473
474    fn children(&self) -> &[Box<dyn Widget>] {
475        &[]
476    }
477
478    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
479        &mut []
480    }
481
482    fn is_interactive(&self) -> bool {
483        false // Tooltip itself is not interactive
484    }
485
486    fn is_focusable(&self) -> bool {
487        false
488    }
489
490    fn accessible_name(&self) -> Option<&str> {
491        self.accessible_name_value
492            .as_deref()
493            .or(Some(&self.content))
494    }
495
496    fn accessible_role(&self) -> AccessibleRole {
497        AccessibleRole::Generic // Tooltip role
498    }
499
500    fn test_id(&self) -> Option<&str> {
501        self.test_id_value.as_deref()
502    }
503}
504
505// PROBAR-SPEC-009: Brick Architecture - Tests define interface
506impl Brick for Tooltip {
507    fn brick_name(&self) -> &'static str {
508        "Tooltip"
509    }
510
511    fn assertions(&self) -> &[BrickAssertion] {
512        &[
513            BrickAssertion::MaxLatencyMs(16),
514            BrickAssertion::ContrastRatio(4.5), // WCAG AA for tooltip text
515        ]
516    }
517
518    fn budget(&self) -> BrickBudget {
519        BrickBudget::uniform(16)
520    }
521
522    fn verify(&self) -> BrickVerification {
523        let mut passed = Vec::new();
524        let mut failed = Vec::new();
525
526        // Verify text contrast against background
527        let contrast = self.background.contrast_ratio(&self.text_color);
528        if contrast >= 4.5 {
529            passed.push(BrickAssertion::ContrastRatio(4.5));
530        } else {
531            failed.push((
532                BrickAssertion::ContrastRatio(4.5),
533                format!("Contrast ratio {contrast:.2}:1 < 4.5:1"),
534            ));
535        }
536
537        // Latency assertion always passes at verification time
538        passed.push(BrickAssertion::MaxLatencyMs(16));
539
540        BrickVerification {
541            passed,
542            failed,
543            verification_time: Duration::from_micros(10),
544        }
545    }
546
547    fn to_html(&self) -> String {
548        let test_id = self.test_id_value.as_deref().unwrap_or("tooltip");
549        let aria_label = self
550            .accessible_name_value
551            .as_deref()
552            .unwrap_or(&self.content);
553        format!(
554            r#"<div class="brick-tooltip" role="tooltip" data-testid="{}" aria-label="{}">{}</div>"#,
555            test_id, aria_label, self.content
556        )
557    }
558
559    fn to_css(&self) -> String {
560        format!(
561            r#".brick-tooltip {{
562    background: {};
563    color: {};
564    padding: {}px;
565    font-size: {}px;
566    border-radius: {}px;
567    max-width: {}px;
568    position: absolute;
569    z-index: 1000;
570    pointer-events: none;
571}}
572.brick-tooltip[data-visible="false"] {{
573    display: none;
574}}"#,
575            self.background.to_hex(),
576            self.text_color.to_hex(),
577            self.padding,
578            self.text_size,
579            self.corner_radius,
580            self.max_width.unwrap_or(250.0),
581        )
582    }
583}
584
585#[cfg(test)]
586#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
587mod tests {
588    use super::*;
589
590    // ===== TooltipPlacement Tests =====
591
592    #[test]
593    fn test_tooltip_placement_default() {
594        assert_eq!(TooltipPlacement::default(), TooltipPlacement::Top);
595    }
596
597    #[test]
598    fn test_tooltip_placement_variants() {
599        let placements = [
600            TooltipPlacement::Top,
601            TooltipPlacement::Bottom,
602            TooltipPlacement::Left,
603            TooltipPlacement::Right,
604            TooltipPlacement::TopLeft,
605            TooltipPlacement::TopRight,
606            TooltipPlacement::BottomLeft,
607            TooltipPlacement::BottomRight,
608        ];
609        assert_eq!(placements.len(), 8);
610    }
611
612    // ===== Tooltip Construction Tests =====
613
614    #[test]
615    fn test_tooltip_new() {
616        let tooltip = Tooltip::new("Help text");
617        assert_eq!(tooltip.get_content(), "Help text");
618        assert!(!tooltip.is_visible());
619    }
620
621    #[test]
622    fn test_tooltip_default() {
623        let tooltip = Tooltip::default();
624        assert!(tooltip.content.is_empty());
625        assert_eq!(tooltip.placement, TooltipPlacement::Top);
626        assert_eq!(tooltip.delay_ms, 200);
627        assert!(!tooltip.visible);
628    }
629
630    #[test]
631    fn test_tooltip_builder() {
632        let tooltip = Tooltip::new("Click to submit")
633            .placement(TooltipPlacement::Bottom)
634            .delay_ms(500)
635            .visible(true)
636            .background(Color::BLACK)
637            .text_color(Color::WHITE)
638            .border_color(Color::RED)
639            .border_width(1.0)
640            .corner_radius(8.0)
641            .padding(12.0)
642            .arrow_size(8.0)
643            .show_arrow(true)
644            .max_width(300.0)
645            .text_size(14.0)
646            .accessible_name("Submit button tooltip")
647            .test_id("submit-tooltip");
648
649        assert_eq!(tooltip.get_content(), "Click to submit");
650        assert_eq!(tooltip.get_placement(), TooltipPlacement::Bottom);
651        assert_eq!(tooltip.get_delay_ms(), 500);
652        assert!(tooltip.is_visible());
653        assert_eq!(
654            Widget::accessible_name(&tooltip),
655            Some("Submit button tooltip")
656        );
657        assert_eq!(Widget::test_id(&tooltip), Some("submit-tooltip"));
658    }
659
660    #[test]
661    fn test_tooltip_content() {
662        let tooltip = Tooltip::new("old").content("new");
663        assert_eq!(tooltip.get_content(), "new");
664    }
665
666    // ===== Visibility Tests =====
667
668    #[test]
669    fn test_tooltip_show() {
670        let mut tooltip = Tooltip::new("Text");
671        assert!(!tooltip.is_visible());
672        tooltip.show();
673        assert!(tooltip.is_visible());
674    }
675
676    #[test]
677    fn test_tooltip_hide() {
678        let mut tooltip = Tooltip::new("Text").visible(true);
679        assert!(tooltip.is_visible());
680        tooltip.hide();
681        assert!(!tooltip.is_visible());
682    }
683
684    #[test]
685    fn test_tooltip_toggle() {
686        let mut tooltip = Tooltip::new("Text");
687        assert!(!tooltip.is_visible());
688        tooltip.toggle();
689        assert!(tooltip.is_visible());
690        tooltip.toggle();
691        assert!(!tooltip.is_visible());
692    }
693
694    // ===== Anchor Tests =====
695
696    #[test]
697    fn test_tooltip_anchor() {
698        let anchor = Rect::new(100.0, 100.0, 80.0, 30.0);
699        let tooltip = Tooltip::new("Help").anchor(anchor);
700        assert_eq!(tooltip.get_anchor(), anchor);
701    }
702
703    #[test]
704    fn test_tooltip_set_anchor() {
705        let mut tooltip = Tooltip::new("Help");
706        let anchor = Rect::new(50.0, 50.0, 100.0, 40.0);
707        tooltip.set_anchor(anchor);
708        assert_eq!(tooltip.get_anchor(), anchor);
709    }
710
711    // ===== Dimension Constraints Tests =====
712
713    #[test]
714    fn test_tooltip_border_width_min() {
715        let tooltip = Tooltip::new("Text").border_width(-5.0);
716        assert_eq!(tooltip.border_width, 0.0);
717    }
718
719    #[test]
720    fn test_tooltip_corner_radius_min() {
721        let tooltip = Tooltip::new("Text").corner_radius(-5.0);
722        assert_eq!(tooltip.corner_radius, 0.0);
723    }
724
725    #[test]
726    fn test_tooltip_padding_min() {
727        let tooltip = Tooltip::new("Text").padding(-5.0);
728        assert_eq!(tooltip.padding, 0.0);
729    }
730
731    #[test]
732    fn test_tooltip_arrow_size_min() {
733        let tooltip = Tooltip::new("Text").arrow_size(-5.0);
734        assert_eq!(tooltip.arrow_size, 0.0);
735    }
736
737    #[test]
738    fn test_tooltip_max_width_min() {
739        let tooltip = Tooltip::new("Text").max_width(10.0);
740        assert_eq!(tooltip.max_width, Some(50.0));
741    }
742
743    #[test]
744    fn test_tooltip_no_max_width() {
745        let tooltip = Tooltip::new("Text").max_width(200.0).no_max_width();
746        assert!(tooltip.max_width.is_none());
747    }
748
749    #[test]
750    fn test_tooltip_text_size_min() {
751        let tooltip = Tooltip::new("Text").text_size(2.0);
752        assert_eq!(tooltip.text_size, 8.0);
753    }
754
755    // ===== Size Calculation Tests =====
756
757    #[test]
758    fn test_tooltip_estimate_text_width() {
759        let tooltip = Tooltip::new("Hello").text_size(12.0);
760        let width = tooltip.estimate_text_width();
761        // 5 chars * 12 * 0.6 = 36
762        assert!((width - 36.0).abs() < 0.1);
763    }
764
765    #[test]
766    fn test_tooltip_calculate_size() {
767        let tooltip = Tooltip::new("Test").padding(10.0).text_size(12.0);
768        let size = tooltip.calculate_size();
769        assert!(size.width > 0.0);
770        assert!(size.height > 0.0);
771    }
772
773    // ===== Position Calculation Tests =====
774
775    #[test]
776    fn test_tooltip_position_top() {
777        let tooltip = Tooltip::new("Text")
778            .placement(TooltipPlacement::Top)
779            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
780            .show_arrow(true)
781            .arrow_size(6.0);
782
783        let size = Size::new(50.0, 24.0);
784        let pos = tooltip.calculate_position(size);
785
786        // Should be above anchor, centered
787        assert!(pos.y < 100.0);
788        assert!(pos.x > 100.0); // Offset for centering
789    }
790
791    #[test]
792    fn test_tooltip_position_bottom() {
793        let tooltip = Tooltip::new("Text")
794            .placement(TooltipPlacement::Bottom)
795            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
796            .show_arrow(true)
797            .arrow_size(6.0);
798
799        let size = Size::new(50.0, 24.0);
800        let pos = tooltip.calculate_position(size);
801
802        // Should be below anchor
803        assert!(pos.y > 130.0); // 100 + 30 height + arrow
804    }
805
806    #[test]
807    fn test_tooltip_position_left() {
808        let tooltip = Tooltip::new("Text")
809            .placement(TooltipPlacement::Left)
810            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
811            .show_arrow(true)
812            .arrow_size(6.0);
813
814        let size = Size::new(50.0, 24.0);
815        let pos = tooltip.calculate_position(size);
816
817        // Should be to the left
818        assert!(pos.x < 100.0 - 50.0);
819    }
820
821    #[test]
822    fn test_tooltip_position_right() {
823        let tooltip = Tooltip::new("Text")
824            .placement(TooltipPlacement::Right)
825            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
826            .show_arrow(true)
827            .arrow_size(6.0);
828
829        let size = Size::new(50.0, 24.0);
830        let pos = tooltip.calculate_position(size);
831
832        // Should be to the right
833        assert!(pos.x > 180.0); // 100 + 80 width + arrow
834    }
835
836    // ===== Widget Trait Tests =====
837
838    #[test]
839    fn test_tooltip_type_id() {
840        let tooltip = Tooltip::new("Text");
841        assert_eq!(Widget::type_id(&tooltip), TypeId::of::<Tooltip>());
842    }
843
844    #[test]
845    fn test_tooltip_measure_invisible() {
846        let tooltip = Tooltip::new("Text").visible(false);
847        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
848        assert_eq!(size, Size::ZERO);
849    }
850
851    #[test]
852    fn test_tooltip_measure_empty() {
853        let tooltip = Tooltip::default().visible(true);
854        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
855        assert_eq!(size, Size::ZERO);
856    }
857
858    #[test]
859    fn test_tooltip_measure_visible() {
860        let tooltip = Tooltip::new("Some helpful text").visible(true);
861        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
862        assert!(size.width > 0.0);
863        assert!(size.height > 0.0);
864    }
865
866    #[test]
867    fn test_tooltip_layout_invisible() {
868        let mut tooltip = Tooltip::new("Text").visible(false);
869        let result = tooltip.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
870        assert_eq!(result.size, Size::ZERO);
871    }
872
873    #[test]
874    fn test_tooltip_layout_visible() {
875        let mut tooltip = Tooltip::new("Text")
876            .visible(true)
877            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0));
878        let result = tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
879        assert!(result.size.width > 0.0);
880        assert!(result.size.height > 0.0);
881    }
882
883    #[test]
884    fn test_tooltip_children() {
885        let tooltip = Tooltip::new("Text");
886        assert!(tooltip.children().is_empty());
887    }
888
889    #[test]
890    fn test_tooltip_is_interactive() {
891        let tooltip = Tooltip::new("Text");
892        assert!(!tooltip.is_interactive());
893    }
894
895    #[test]
896    fn test_tooltip_is_focusable() {
897        let tooltip = Tooltip::new("Text");
898        assert!(!tooltip.is_focusable());
899    }
900
901    #[test]
902    fn test_tooltip_accessible_role() {
903        let tooltip = Tooltip::new("Text");
904        assert_eq!(tooltip.accessible_role(), AccessibleRole::Generic);
905    }
906
907    #[test]
908    fn test_tooltip_accessible_name_default() {
909        let tooltip = Tooltip::new("Help text");
910        // Falls back to content if no explicit name
911        assert_eq!(Widget::accessible_name(&tooltip), Some("Help text"));
912    }
913
914    #[test]
915    fn test_tooltip_accessible_name_explicit() {
916        let tooltip = Tooltip::new("Help text").accessible_name("Explicit name");
917        assert_eq!(Widget::accessible_name(&tooltip), Some("Explicit name"));
918    }
919
920    #[test]
921    fn test_tooltip_test_id() {
922        let tooltip = Tooltip::new("Text").test_id("help-tooltip");
923        assert_eq!(Widget::test_id(&tooltip), Some("help-tooltip"));
924    }
925
926    // ===== Event Tests =====
927
928    #[test]
929    fn test_tooltip_mouse_leave_hides() {
930        let mut tooltip = Tooltip::new("Text").visible(true);
931        assert!(tooltip.is_visible());
932
933        tooltip.event(&Event::MouseLeave);
934        assert!(!tooltip.is_visible());
935    }
936
937    #[test]
938    fn test_tooltip_stays_hidden_on_other_events() {
939        let mut tooltip = Tooltip::new("Text").visible(false);
940        // Other events don't affect visibility - delay handled externally
941        tooltip.event(&Event::MouseMove {
942            position: Point::new(0.0, 0.0),
943        });
944        assert!(!tooltip.is_visible());
945    }
946
947    // ===== Color Tests =====
948
949    #[test]
950    fn test_tooltip_colors() {
951        let tooltip = Tooltip::new("Text")
952            .background(Color::BLUE)
953            .text_color(Color::RED)
954            .border_color(Color::GREEN);
955
956        assert_eq!(tooltip.background, Color::BLUE);
957        assert_eq!(tooltip.text_color, Color::RED);
958        assert_eq!(tooltip.border_color, Color::GREEN);
959    }
960
961    // ===== Additional Position Tests =====
962
963    #[test]
964    fn test_tooltip_position_top_left() {
965        let tooltip = Tooltip::new("Text")
966            .placement(TooltipPlacement::TopLeft)
967            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
968            .show_arrow(true)
969            .arrow_size(6.0);
970
971        let size = Size::new(50.0, 24.0);
972        let pos = tooltip.calculate_position(size);
973
974        // Should be aligned to left edge of anchor
975        assert_eq!(pos.x, 100.0);
976        assert!(pos.y < 100.0); // Above anchor
977    }
978
979    #[test]
980    fn test_tooltip_position_top_right() {
981        let tooltip = Tooltip::new("Text")
982            .placement(TooltipPlacement::TopRight)
983            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
984            .show_arrow(true)
985            .arrow_size(6.0);
986
987        let size = Size::new(50.0, 24.0);
988        let pos = tooltip.calculate_position(size);
989
990        // Should be aligned to right edge of anchor (100 + 80 - 50 = 130)
991        assert_eq!(pos.x, 130.0);
992        assert!(pos.y < 100.0);
993    }
994
995    #[test]
996    fn test_tooltip_position_bottom_left() {
997        let tooltip = Tooltip::new("Text")
998            .placement(TooltipPlacement::BottomLeft)
999            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1000            .show_arrow(true)
1001            .arrow_size(6.0);
1002
1003        let size = Size::new(50.0, 24.0);
1004        let pos = tooltip.calculate_position(size);
1005
1006        // Should be aligned to left edge of anchor
1007        assert_eq!(pos.x, 100.0);
1008        assert!(pos.y > 130.0); // Below anchor (100 + 30 + arrow)
1009    }
1010
1011    #[test]
1012    fn test_tooltip_position_bottom_right() {
1013        let tooltip = Tooltip::new("Text")
1014            .placement(TooltipPlacement::BottomRight)
1015            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1016            .show_arrow(true)
1017            .arrow_size(6.0);
1018
1019        let size = Size::new(50.0, 24.0);
1020        let pos = tooltip.calculate_position(size);
1021
1022        // Should be aligned to right edge of anchor
1023        assert_eq!(pos.x, 130.0);
1024        assert!(pos.y > 130.0);
1025    }
1026
1027    #[test]
1028    fn test_tooltip_position_no_arrow() {
1029        let tooltip = Tooltip::new("Text")
1030            .placement(TooltipPlacement::Top)
1031            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1032            .show_arrow(false);
1033
1034        let size = Size::new(50.0, 24.0);
1035        let pos = tooltip.calculate_position(size);
1036
1037        // Should be directly above without arrow offset
1038        assert_eq!(pos.y, 100.0 - 24.0); // anchor.y - size.height
1039    }
1040
1041    // ===== Calculate Size Tests =====
1042
1043    #[test]
1044    fn test_calculate_size_no_max_width() {
1045        let tooltip = Tooltip::new("Short text")
1046            .padding(8.0)
1047            .text_size(12.0)
1048            .no_max_width();
1049
1050        let size = tooltip.calculate_size();
1051        assert!(size.width > 0.0);
1052        assert!(size.height > 0.0);
1053    }
1054
1055    #[test]
1056    fn test_calculate_size_wraps_long_text() {
1057        let tooltip = Tooltip::new("This is a very long tooltip text that should wrap")
1058            .padding(8.0)
1059            .text_size(12.0)
1060            .max_width(100.0);
1061
1062        let size = tooltip.calculate_size();
1063        // Width should be capped at max_width
1064        assert!(size.width <= 100.0);
1065        // Height should increase due to wrapping
1066        assert!(size.height > 12.0f32.mul_add(1.2, 16.0)); // More than single line
1067    }
1068
1069    // ===== Paint Tests =====
1070
1071    #[test]
1072    fn test_tooltip_paint_invisible() {
1073        use presentar_core::RecordingCanvas;
1074
1075        let tooltip = Tooltip::new("Text").visible(false);
1076        let mut canvas = RecordingCanvas::new();
1077        tooltip.paint(&mut canvas);
1078
1079        // Should not draw anything when invisible
1080        assert_eq!(canvas.command_count(), 0);
1081    }
1082
1083    #[test]
1084    fn test_tooltip_paint_empty_content() {
1085        use presentar_core::RecordingCanvas;
1086
1087        let tooltip = Tooltip::default().visible(true);
1088        let mut canvas = RecordingCanvas::new();
1089        tooltip.paint(&mut canvas);
1090
1091        // Should not draw anything when content is empty
1092        assert_eq!(canvas.command_count(), 0);
1093    }
1094
1095    #[test]
1096    fn test_tooltip_paint_visible() {
1097        use presentar_core::RecordingCanvas;
1098
1099        let mut tooltip = Tooltip::new("Help text")
1100            .visible(true)
1101            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1102            .placement(TooltipPlacement::Top);
1103
1104        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1105
1106        let mut canvas = RecordingCanvas::new();
1107        tooltip.paint(&mut canvas);
1108
1109        // Should draw: background rect + arrow rect + text
1110        assert!(canvas.command_count() >= 2);
1111    }
1112
1113    #[test]
1114    fn test_tooltip_paint_with_border() {
1115        use presentar_core::RecordingCanvas;
1116
1117        let mut tooltip = Tooltip::new("Help text")
1118            .visible(true)
1119            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1120            .border_width(2.0);
1121
1122        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1123
1124        let mut canvas = RecordingCanvas::new();
1125        tooltip.paint(&mut canvas);
1126
1127        // Should draw border when border_width > 0
1128        assert!(canvas.command_count() >= 3); // bg + border + text + possibly arrow
1129    }
1130
1131    #[test]
1132    fn test_tooltip_paint_without_arrow() {
1133        use presentar_core::RecordingCanvas;
1134
1135        let mut tooltip = Tooltip::new("Help text")
1136            .visible(true)
1137            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1138            .show_arrow(false);
1139
1140        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1141
1142        let mut canvas = RecordingCanvas::new();
1143        tooltip.paint(&mut canvas);
1144
1145        // Should draw bg + text but no arrow
1146        assert!(canvas.command_count() >= 2);
1147    }
1148
1149    #[test]
1150    fn test_tooltip_paint_bottom_arrow() {
1151        use presentar_core::RecordingCanvas;
1152
1153        let mut tooltip = Tooltip::new("Help text")
1154            .visible(true)
1155            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1156            .placement(TooltipPlacement::Bottom);
1157
1158        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1159
1160        let mut canvas = RecordingCanvas::new();
1161        tooltip.paint(&mut canvas);
1162
1163        assert!(canvas.command_count() >= 3);
1164    }
1165
1166    #[test]
1167    fn test_tooltip_paint_left_arrow() {
1168        use presentar_core::RecordingCanvas;
1169
1170        let mut tooltip = Tooltip::new("Help text")
1171            .visible(true)
1172            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1173            .placement(TooltipPlacement::Left);
1174
1175        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1176
1177        let mut canvas = RecordingCanvas::new();
1178        tooltip.paint(&mut canvas);
1179
1180        assert!(canvas.command_count() >= 3);
1181    }
1182
1183    #[test]
1184    fn test_tooltip_paint_right_arrow() {
1185        use presentar_core::RecordingCanvas;
1186
1187        let mut tooltip = Tooltip::new("Help text")
1188            .visible(true)
1189            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1190            .placement(TooltipPlacement::Right);
1191
1192        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1193
1194        let mut canvas = RecordingCanvas::new();
1195        tooltip.paint(&mut canvas);
1196
1197        assert!(canvas.command_count() >= 3);
1198    }
1199
1200    // ===== Brick Trait Tests =====
1201
1202    #[test]
1203    fn test_tooltip_brick_name() {
1204        let tooltip = Tooltip::new("Text");
1205        assert_eq!(tooltip.brick_name(), "Tooltip");
1206    }
1207
1208    #[test]
1209    fn test_tooltip_brick_assertions() {
1210        let tooltip = Tooltip::new("Text");
1211        let assertions = tooltip.assertions();
1212        assert_eq!(assertions.len(), 2);
1213        assert!(assertions.contains(&BrickAssertion::MaxLatencyMs(16)));
1214        assert!(assertions.contains(&BrickAssertion::ContrastRatio(4.5)));
1215    }
1216
1217    #[test]
1218    fn test_tooltip_brick_budget() {
1219        let tooltip = Tooltip::new("Text");
1220        let budget = tooltip.budget();
1221        // BrickBudget::uniform(16) sets internal values
1222        assert!(budget.measure_ms > 0);
1223        assert!(budget.layout_ms > 0);
1224        assert!(budget.paint_ms > 0);
1225    }
1226
1227    #[test]
1228    fn test_tooltip_brick_verify_good_contrast() {
1229        // Dark background with white text should pass
1230        let tooltip = Tooltip::new("Text")
1231            .background(Color::BLACK)
1232            .text_color(Color::WHITE);
1233
1234        let verification = tooltip.verify();
1235        assert!(verification
1236            .passed
1237            .contains(&BrickAssertion::ContrastRatio(4.5)));
1238        assert!(verification.failed.is_empty());
1239    }
1240
1241    #[test]
1242    fn test_tooltip_brick_verify_bad_contrast() {
1243        // Light background with white text should fail
1244        let tooltip = Tooltip::new("Text")
1245            .background(Color::WHITE)
1246            .text_color(Color::rgb(0.9, 0.9, 0.9)); // Very light gray
1247
1248        let verification = tooltip.verify();
1249        assert!(!verification.failed.is_empty());
1250        assert!(verification
1251            .failed
1252            .iter()
1253            .any(|(a, _)| *a == BrickAssertion::ContrastRatio(4.5)));
1254    }
1255
1256    #[test]
1257    fn test_tooltip_to_html() {
1258        let tooltip = Tooltip::new("Help text")
1259            .test_id("help-tooltip")
1260            .accessible_name("Help information");
1261
1262        let html = tooltip.to_html();
1263        assert!(html.contains("role=\"tooltip\""));
1264        assert!(html.contains("data-testid=\"help-tooltip\""));
1265        assert!(html.contains("aria-label=\"Help information\""));
1266        assert!(html.contains("Help text"));
1267    }
1268
1269    #[test]
1270    fn test_tooltip_to_html_default_values() {
1271        let tooltip = Tooltip::new("Text");
1272        let html = tooltip.to_html();
1273
1274        // Should use default test_id and content as aria-label
1275        assert!(html.contains("data-testid=\"tooltip\""));
1276        assert!(html.contains("aria-label=\"Text\""));
1277    }
1278
1279    #[test]
1280    fn test_tooltip_to_css() {
1281        let tooltip = Tooltip::new("Text")
1282            .padding(12.0)
1283            .text_size(14.0)
1284            .corner_radius(6.0)
1285            .max_width(300.0);
1286
1287        let css = tooltip.to_css();
1288        assert!(css.contains("padding: 12px"));
1289        assert!(css.contains("font-size: 14px"));
1290        assert!(css.contains("border-radius: 6px"));
1291        assert!(css.contains("max-width: 300px"));
1292    }
1293
1294    // ===== Widget Children Tests =====
1295
1296    #[test]
1297    fn test_tooltip_children_mut() {
1298        let mut tooltip = Tooltip::new("Text");
1299        assert!(tooltip.children_mut().is_empty());
1300    }
1301
1302    // ===== Event Tests =====
1303
1304    #[test]
1305    fn test_tooltip_event_other_events() {
1306        let mut tooltip = Tooltip::new("Text").visible(true);
1307
1308        // Other events should not affect visibility or return a message
1309        let result = tooltip.event(&Event::MouseEnter);
1310        assert!(result.is_none());
1311        assert!(tooltip.is_visible());
1312    }
1313
1314    // ===== Additional Coverage Tests =====
1315
1316    #[test]
1317    fn test_tooltip_paint_topleft_arrow() {
1318        use presentar_core::RecordingCanvas;
1319
1320        let mut tooltip = Tooltip::new("Help text")
1321            .visible(true)
1322            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1323            .placement(TooltipPlacement::TopLeft);
1324
1325        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1326
1327        let mut canvas = RecordingCanvas::new();
1328        tooltip.paint(&mut canvas);
1329
1330        assert!(canvas.command_count() >= 3);
1331    }
1332
1333    #[test]
1334    fn test_tooltip_paint_topright_arrow() {
1335        use presentar_core::RecordingCanvas;
1336
1337        let mut tooltip = Tooltip::new("Help text")
1338            .visible(true)
1339            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1340            .placement(TooltipPlacement::TopRight);
1341
1342        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1343
1344        let mut canvas = RecordingCanvas::new();
1345        tooltip.paint(&mut canvas);
1346
1347        assert!(canvas.command_count() >= 3);
1348    }
1349
1350    #[test]
1351    fn test_tooltip_paint_bottomleft_arrow() {
1352        use presentar_core::RecordingCanvas;
1353
1354        let mut tooltip = Tooltip::new("Help text")
1355            .visible(true)
1356            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1357            .placement(TooltipPlacement::BottomLeft);
1358
1359        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1360
1361        let mut canvas = RecordingCanvas::new();
1362        tooltip.paint(&mut canvas);
1363
1364        assert!(canvas.command_count() >= 3);
1365    }
1366
1367    #[test]
1368    fn test_tooltip_paint_bottomright_arrow() {
1369        use presentar_core::RecordingCanvas;
1370
1371        let mut tooltip = Tooltip::new("Help text")
1372            .visible(true)
1373            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1374            .placement(TooltipPlacement::BottomRight);
1375
1376        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1377
1378        let mut canvas = RecordingCanvas::new();
1379        tooltip.paint(&mut canvas);
1380
1381        assert!(canvas.command_count() >= 3);
1382    }
1383
1384    #[test]
1385    fn test_tooltip_layout_empty() {
1386        let mut tooltip = Tooltip::default().visible(true);
1387        let result = tooltip.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
1388        assert_eq!(result.size, Size::ZERO);
1389    }
1390
1391    #[test]
1392    fn test_tooltip_placement_clone() {
1393        let placement = TooltipPlacement::Bottom;
1394        let cloned = placement;
1395        assert_eq!(cloned, TooltipPlacement::Bottom);
1396    }
1397
1398    #[test]
1399    fn test_tooltip_placement_debug() {
1400        let placement = TooltipPlacement::Right;
1401        let debug = format!("{placement:?}");
1402        assert!(debug.contains("Right"));
1403    }
1404
1405    #[test]
1406    fn test_tooltip_clone() {
1407        let tooltip = Tooltip::new("Text")
1408            .placement(TooltipPlacement::Left)
1409            .delay_ms(500);
1410        let cloned = tooltip;
1411        assert_eq!(cloned.get_content(), "Text");
1412        assert_eq!(cloned.get_placement(), TooltipPlacement::Left);
1413        assert_eq!(cloned.get_delay_ms(), 500);
1414    }
1415
1416    #[test]
1417    fn test_tooltip_serde() {
1418        let tooltip = Tooltip::new("Help")
1419            .placement(TooltipPlacement::Bottom)
1420            .delay_ms(300);
1421
1422        let json = serde_json::to_string(&tooltip).unwrap();
1423        let deserialized: Tooltip = serde_json::from_str(&json).unwrap();
1424
1425        assert_eq!(deserialized.get_content(), "Help");
1426        assert_eq!(deserialized.get_placement(), TooltipPlacement::Bottom);
1427        assert_eq!(deserialized.get_delay_ms(), 300);
1428    }
1429
1430    #[test]
1431    fn test_tooltip_placement_serde() {
1432        let placement = TooltipPlacement::TopRight;
1433        let json = serde_json::to_string(&placement).unwrap();
1434        let deserialized: TooltipPlacement = serde_json::from_str(&json).unwrap();
1435        assert_eq!(deserialized, TooltipPlacement::TopRight);
1436    }
1437
1438    #[test]
1439    fn test_tooltip_bounds_after_layout() {
1440        let mut tooltip = Tooltip::new("Test tooltip text")
1441            .visible(true)
1442            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1443            .placement(TooltipPlacement::Top);
1444
1445        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1446
1447        // bounds should be set after layout
1448        assert!(tooltip.bounds.width > 0.0);
1449        assert!(tooltip.bounds.height > 0.0);
1450    }
1451}