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)]
586mod tests {
587    use super::*;
588
589    // ===== TooltipPlacement Tests =====
590
591    #[test]
592    fn test_tooltip_placement_default() {
593        assert_eq!(TooltipPlacement::default(), TooltipPlacement::Top);
594    }
595
596    #[test]
597    fn test_tooltip_placement_variants() {
598        let placements = [
599            TooltipPlacement::Top,
600            TooltipPlacement::Bottom,
601            TooltipPlacement::Left,
602            TooltipPlacement::Right,
603            TooltipPlacement::TopLeft,
604            TooltipPlacement::TopRight,
605            TooltipPlacement::BottomLeft,
606            TooltipPlacement::BottomRight,
607        ];
608        assert_eq!(placements.len(), 8);
609    }
610
611    // ===== Tooltip Construction Tests =====
612
613    #[test]
614    fn test_tooltip_new() {
615        let tooltip = Tooltip::new("Help text");
616        assert_eq!(tooltip.get_content(), "Help text");
617        assert!(!tooltip.is_visible());
618    }
619
620    #[test]
621    fn test_tooltip_default() {
622        let tooltip = Tooltip::default();
623        assert!(tooltip.content.is_empty());
624        assert_eq!(tooltip.placement, TooltipPlacement::Top);
625        assert_eq!(tooltip.delay_ms, 200);
626        assert!(!tooltip.visible);
627    }
628
629    #[test]
630    fn test_tooltip_builder() {
631        let tooltip = Tooltip::new("Click to submit")
632            .placement(TooltipPlacement::Bottom)
633            .delay_ms(500)
634            .visible(true)
635            .background(Color::BLACK)
636            .text_color(Color::WHITE)
637            .border_color(Color::RED)
638            .border_width(1.0)
639            .corner_radius(8.0)
640            .padding(12.0)
641            .arrow_size(8.0)
642            .show_arrow(true)
643            .max_width(300.0)
644            .text_size(14.0)
645            .accessible_name("Submit button tooltip")
646            .test_id("submit-tooltip");
647
648        assert_eq!(tooltip.get_content(), "Click to submit");
649        assert_eq!(tooltip.get_placement(), TooltipPlacement::Bottom);
650        assert_eq!(tooltip.get_delay_ms(), 500);
651        assert!(tooltip.is_visible());
652        assert_eq!(
653            Widget::accessible_name(&tooltip),
654            Some("Submit button tooltip")
655        );
656        assert_eq!(Widget::test_id(&tooltip), Some("submit-tooltip"));
657    }
658
659    #[test]
660    fn test_tooltip_content() {
661        let tooltip = Tooltip::new("old").content("new");
662        assert_eq!(tooltip.get_content(), "new");
663    }
664
665    // ===== Visibility Tests =====
666
667    #[test]
668    fn test_tooltip_show() {
669        let mut tooltip = Tooltip::new("Text");
670        assert!(!tooltip.is_visible());
671        tooltip.show();
672        assert!(tooltip.is_visible());
673    }
674
675    #[test]
676    fn test_tooltip_hide() {
677        let mut tooltip = Tooltip::new("Text").visible(true);
678        assert!(tooltip.is_visible());
679        tooltip.hide();
680        assert!(!tooltip.is_visible());
681    }
682
683    #[test]
684    fn test_tooltip_toggle() {
685        let mut tooltip = Tooltip::new("Text");
686        assert!(!tooltip.is_visible());
687        tooltip.toggle();
688        assert!(tooltip.is_visible());
689        tooltip.toggle();
690        assert!(!tooltip.is_visible());
691    }
692
693    // ===== Anchor Tests =====
694
695    #[test]
696    fn test_tooltip_anchor() {
697        let anchor = Rect::new(100.0, 100.0, 80.0, 30.0);
698        let tooltip = Tooltip::new("Help").anchor(anchor);
699        assert_eq!(tooltip.get_anchor(), anchor);
700    }
701
702    #[test]
703    fn test_tooltip_set_anchor() {
704        let mut tooltip = Tooltip::new("Help");
705        let anchor = Rect::new(50.0, 50.0, 100.0, 40.0);
706        tooltip.set_anchor(anchor);
707        assert_eq!(tooltip.get_anchor(), anchor);
708    }
709
710    // ===== Dimension Constraints Tests =====
711
712    #[test]
713    fn test_tooltip_border_width_min() {
714        let tooltip = Tooltip::new("Text").border_width(-5.0);
715        assert_eq!(tooltip.border_width, 0.0);
716    }
717
718    #[test]
719    fn test_tooltip_corner_radius_min() {
720        let tooltip = Tooltip::new("Text").corner_radius(-5.0);
721        assert_eq!(tooltip.corner_radius, 0.0);
722    }
723
724    #[test]
725    fn test_tooltip_padding_min() {
726        let tooltip = Tooltip::new("Text").padding(-5.0);
727        assert_eq!(tooltip.padding, 0.0);
728    }
729
730    #[test]
731    fn test_tooltip_arrow_size_min() {
732        let tooltip = Tooltip::new("Text").arrow_size(-5.0);
733        assert_eq!(tooltip.arrow_size, 0.0);
734    }
735
736    #[test]
737    fn test_tooltip_max_width_min() {
738        let tooltip = Tooltip::new("Text").max_width(10.0);
739        assert_eq!(tooltip.max_width, Some(50.0));
740    }
741
742    #[test]
743    fn test_tooltip_no_max_width() {
744        let tooltip = Tooltip::new("Text").max_width(200.0).no_max_width();
745        assert!(tooltip.max_width.is_none());
746    }
747
748    #[test]
749    fn test_tooltip_text_size_min() {
750        let tooltip = Tooltip::new("Text").text_size(2.0);
751        assert_eq!(tooltip.text_size, 8.0);
752    }
753
754    // ===== Size Calculation Tests =====
755
756    #[test]
757    fn test_tooltip_estimate_text_width() {
758        let tooltip = Tooltip::new("Hello").text_size(12.0);
759        let width = tooltip.estimate_text_width();
760        // 5 chars * 12 * 0.6 = 36
761        assert!((width - 36.0).abs() < 0.1);
762    }
763
764    #[test]
765    fn test_tooltip_calculate_size() {
766        let tooltip = Tooltip::new("Test").padding(10.0).text_size(12.0);
767        let size = tooltip.calculate_size();
768        assert!(size.width > 0.0);
769        assert!(size.height > 0.0);
770    }
771
772    // ===== Position Calculation Tests =====
773
774    #[test]
775    fn test_tooltip_position_top() {
776        let tooltip = Tooltip::new("Text")
777            .placement(TooltipPlacement::Top)
778            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
779            .show_arrow(true)
780            .arrow_size(6.0);
781
782        let size = Size::new(50.0, 24.0);
783        let pos = tooltip.calculate_position(size);
784
785        // Should be above anchor, centered
786        assert!(pos.y < 100.0);
787        assert!(pos.x > 100.0); // Offset for centering
788    }
789
790    #[test]
791    fn test_tooltip_position_bottom() {
792        let tooltip = Tooltip::new("Text")
793            .placement(TooltipPlacement::Bottom)
794            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
795            .show_arrow(true)
796            .arrow_size(6.0);
797
798        let size = Size::new(50.0, 24.0);
799        let pos = tooltip.calculate_position(size);
800
801        // Should be below anchor
802        assert!(pos.y > 130.0); // 100 + 30 height + arrow
803    }
804
805    #[test]
806    fn test_tooltip_position_left() {
807        let tooltip = Tooltip::new("Text")
808            .placement(TooltipPlacement::Left)
809            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
810            .show_arrow(true)
811            .arrow_size(6.0);
812
813        let size = Size::new(50.0, 24.0);
814        let pos = tooltip.calculate_position(size);
815
816        // Should be to the left
817        assert!(pos.x < 100.0 - 50.0);
818    }
819
820    #[test]
821    fn test_tooltip_position_right() {
822        let tooltip = Tooltip::new("Text")
823            .placement(TooltipPlacement::Right)
824            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
825            .show_arrow(true)
826            .arrow_size(6.0);
827
828        let size = Size::new(50.0, 24.0);
829        let pos = tooltip.calculate_position(size);
830
831        // Should be to the right
832        assert!(pos.x > 180.0); // 100 + 80 width + arrow
833    }
834
835    // ===== Widget Trait Tests =====
836
837    #[test]
838    fn test_tooltip_type_id() {
839        let tooltip = Tooltip::new("Text");
840        assert_eq!(Widget::type_id(&tooltip), TypeId::of::<Tooltip>());
841    }
842
843    #[test]
844    fn test_tooltip_measure_invisible() {
845        let tooltip = Tooltip::new("Text").visible(false);
846        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
847        assert_eq!(size, Size::ZERO);
848    }
849
850    #[test]
851    fn test_tooltip_measure_empty() {
852        let tooltip = Tooltip::default().visible(true);
853        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
854        assert_eq!(size, Size::ZERO);
855    }
856
857    #[test]
858    fn test_tooltip_measure_visible() {
859        let tooltip = Tooltip::new("Some helpful text").visible(true);
860        let size = tooltip.measure(Constraints::loose(Size::new(500.0, 500.0)));
861        assert!(size.width > 0.0);
862        assert!(size.height > 0.0);
863    }
864
865    #[test]
866    fn test_tooltip_layout_invisible() {
867        let mut tooltip = Tooltip::new("Text").visible(false);
868        let result = tooltip.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
869        assert_eq!(result.size, Size::ZERO);
870    }
871
872    #[test]
873    fn test_tooltip_layout_visible() {
874        let mut tooltip = Tooltip::new("Text")
875            .visible(true)
876            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0));
877        let result = tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
878        assert!(result.size.width > 0.0);
879        assert!(result.size.height > 0.0);
880    }
881
882    #[test]
883    fn test_tooltip_children() {
884        let tooltip = Tooltip::new("Text");
885        assert!(tooltip.children().is_empty());
886    }
887
888    #[test]
889    fn test_tooltip_is_interactive() {
890        let tooltip = Tooltip::new("Text");
891        assert!(!tooltip.is_interactive());
892    }
893
894    #[test]
895    fn test_tooltip_is_focusable() {
896        let tooltip = Tooltip::new("Text");
897        assert!(!tooltip.is_focusable());
898    }
899
900    #[test]
901    fn test_tooltip_accessible_role() {
902        let tooltip = Tooltip::new("Text");
903        assert_eq!(tooltip.accessible_role(), AccessibleRole::Generic);
904    }
905
906    #[test]
907    fn test_tooltip_accessible_name_default() {
908        let tooltip = Tooltip::new("Help text");
909        // Falls back to content if no explicit name
910        assert_eq!(Widget::accessible_name(&tooltip), Some("Help text"));
911    }
912
913    #[test]
914    fn test_tooltip_accessible_name_explicit() {
915        let tooltip = Tooltip::new("Help text").accessible_name("Explicit name");
916        assert_eq!(Widget::accessible_name(&tooltip), Some("Explicit name"));
917    }
918
919    #[test]
920    fn test_tooltip_test_id() {
921        let tooltip = Tooltip::new("Text").test_id("help-tooltip");
922        assert_eq!(Widget::test_id(&tooltip), Some("help-tooltip"));
923    }
924
925    // ===== Event Tests =====
926
927    #[test]
928    fn test_tooltip_mouse_leave_hides() {
929        let mut tooltip = Tooltip::new("Text").visible(true);
930        assert!(tooltip.is_visible());
931
932        tooltip.event(&Event::MouseLeave);
933        assert!(!tooltip.is_visible());
934    }
935
936    #[test]
937    fn test_tooltip_stays_hidden_on_other_events() {
938        let mut tooltip = Tooltip::new("Text").visible(false);
939        // Other events don't affect visibility - delay handled externally
940        tooltip.event(&Event::MouseMove {
941            position: Point::new(0.0, 0.0),
942        });
943        assert!(!tooltip.is_visible());
944    }
945
946    // ===== Color Tests =====
947
948    #[test]
949    fn test_tooltip_colors() {
950        let tooltip = Tooltip::new("Text")
951            .background(Color::BLUE)
952            .text_color(Color::RED)
953            .border_color(Color::GREEN);
954
955        assert_eq!(tooltip.background, Color::BLUE);
956        assert_eq!(tooltip.text_color, Color::RED);
957        assert_eq!(tooltip.border_color, Color::GREEN);
958    }
959
960    // ===== Additional Position Tests =====
961
962    #[test]
963    fn test_tooltip_position_top_left() {
964        let tooltip = Tooltip::new("Text")
965            .placement(TooltipPlacement::TopLeft)
966            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
967            .show_arrow(true)
968            .arrow_size(6.0);
969
970        let size = Size::new(50.0, 24.0);
971        let pos = tooltip.calculate_position(size);
972
973        // Should be aligned to left edge of anchor
974        assert_eq!(pos.x, 100.0);
975        assert!(pos.y < 100.0); // Above anchor
976    }
977
978    #[test]
979    fn test_tooltip_position_top_right() {
980        let tooltip = Tooltip::new("Text")
981            .placement(TooltipPlacement::TopRight)
982            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
983            .show_arrow(true)
984            .arrow_size(6.0);
985
986        let size = Size::new(50.0, 24.0);
987        let pos = tooltip.calculate_position(size);
988
989        // Should be aligned to right edge of anchor (100 + 80 - 50 = 130)
990        assert_eq!(pos.x, 130.0);
991        assert!(pos.y < 100.0);
992    }
993
994    #[test]
995    fn test_tooltip_position_bottom_left() {
996        let tooltip = Tooltip::new("Text")
997            .placement(TooltipPlacement::BottomLeft)
998            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
999            .show_arrow(true)
1000            .arrow_size(6.0);
1001
1002        let size = Size::new(50.0, 24.0);
1003        let pos = tooltip.calculate_position(size);
1004
1005        // Should be aligned to left edge of anchor
1006        assert_eq!(pos.x, 100.0);
1007        assert!(pos.y > 130.0); // Below anchor (100 + 30 + arrow)
1008    }
1009
1010    #[test]
1011    fn test_tooltip_position_bottom_right() {
1012        let tooltip = Tooltip::new("Text")
1013            .placement(TooltipPlacement::BottomRight)
1014            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1015            .show_arrow(true)
1016            .arrow_size(6.0);
1017
1018        let size = Size::new(50.0, 24.0);
1019        let pos = tooltip.calculate_position(size);
1020
1021        // Should be aligned to right edge of anchor
1022        assert_eq!(pos.x, 130.0);
1023        assert!(pos.y > 130.0);
1024    }
1025
1026    #[test]
1027    fn test_tooltip_position_no_arrow() {
1028        let tooltip = Tooltip::new("Text")
1029            .placement(TooltipPlacement::Top)
1030            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1031            .show_arrow(false);
1032
1033        let size = Size::new(50.0, 24.0);
1034        let pos = tooltip.calculate_position(size);
1035
1036        // Should be directly above without arrow offset
1037        assert_eq!(pos.y, 100.0 - 24.0); // anchor.y - size.height
1038    }
1039
1040    // ===== Calculate Size Tests =====
1041
1042    #[test]
1043    fn test_calculate_size_no_max_width() {
1044        let tooltip = Tooltip::new("Short text")
1045            .padding(8.0)
1046            .text_size(12.0)
1047            .no_max_width();
1048
1049        let size = tooltip.calculate_size();
1050        assert!(size.width > 0.0);
1051        assert!(size.height > 0.0);
1052    }
1053
1054    #[test]
1055    fn test_calculate_size_wraps_long_text() {
1056        let tooltip = Tooltip::new("This is a very long tooltip text that should wrap")
1057            .padding(8.0)
1058            .text_size(12.0)
1059            .max_width(100.0);
1060
1061        let size = tooltip.calculate_size();
1062        // Width should be capped at max_width
1063        assert!(size.width <= 100.0);
1064        // Height should increase due to wrapping
1065        assert!(size.height > 12.0 * 1.2 + 16.0); // More than single line
1066    }
1067
1068    // ===== Paint Tests =====
1069
1070    #[test]
1071    fn test_tooltip_paint_invisible() {
1072        use presentar_core::RecordingCanvas;
1073
1074        let tooltip = Tooltip::new("Text").visible(false);
1075        let mut canvas = RecordingCanvas::new();
1076        tooltip.paint(&mut canvas);
1077
1078        // Should not draw anything when invisible
1079        assert_eq!(canvas.command_count(), 0);
1080    }
1081
1082    #[test]
1083    fn test_tooltip_paint_empty_content() {
1084        use presentar_core::RecordingCanvas;
1085
1086        let tooltip = Tooltip::default().visible(true);
1087        let mut canvas = RecordingCanvas::new();
1088        tooltip.paint(&mut canvas);
1089
1090        // Should not draw anything when content is empty
1091        assert_eq!(canvas.command_count(), 0);
1092    }
1093
1094    #[test]
1095    fn test_tooltip_paint_visible() {
1096        use presentar_core::RecordingCanvas;
1097
1098        let mut tooltip = Tooltip::new("Help text")
1099            .visible(true)
1100            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1101            .placement(TooltipPlacement::Top);
1102
1103        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1104
1105        let mut canvas = RecordingCanvas::new();
1106        tooltip.paint(&mut canvas);
1107
1108        // Should draw: background rect + arrow rect + text
1109        assert!(canvas.command_count() >= 2);
1110    }
1111
1112    #[test]
1113    fn test_tooltip_paint_with_border() {
1114        use presentar_core::RecordingCanvas;
1115
1116        let mut tooltip = Tooltip::new("Help text")
1117            .visible(true)
1118            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1119            .border_width(2.0);
1120
1121        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1122
1123        let mut canvas = RecordingCanvas::new();
1124        tooltip.paint(&mut canvas);
1125
1126        // Should draw border when border_width > 0
1127        assert!(canvas.command_count() >= 3); // bg + border + text + possibly arrow
1128    }
1129
1130    #[test]
1131    fn test_tooltip_paint_without_arrow() {
1132        use presentar_core::RecordingCanvas;
1133
1134        let mut tooltip = Tooltip::new("Help text")
1135            .visible(true)
1136            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1137            .show_arrow(false);
1138
1139        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1140
1141        let mut canvas = RecordingCanvas::new();
1142        tooltip.paint(&mut canvas);
1143
1144        // Should draw bg + text but no arrow
1145        assert!(canvas.command_count() >= 2);
1146    }
1147
1148    #[test]
1149    fn test_tooltip_paint_bottom_arrow() {
1150        use presentar_core::RecordingCanvas;
1151
1152        let mut tooltip = Tooltip::new("Help text")
1153            .visible(true)
1154            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1155            .placement(TooltipPlacement::Bottom);
1156
1157        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1158
1159        let mut canvas = RecordingCanvas::new();
1160        tooltip.paint(&mut canvas);
1161
1162        assert!(canvas.command_count() >= 3);
1163    }
1164
1165    #[test]
1166    fn test_tooltip_paint_left_arrow() {
1167        use presentar_core::RecordingCanvas;
1168
1169        let mut tooltip = Tooltip::new("Help text")
1170            .visible(true)
1171            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1172            .placement(TooltipPlacement::Left);
1173
1174        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1175
1176        let mut canvas = RecordingCanvas::new();
1177        tooltip.paint(&mut canvas);
1178
1179        assert!(canvas.command_count() >= 3);
1180    }
1181
1182    #[test]
1183    fn test_tooltip_paint_right_arrow() {
1184        use presentar_core::RecordingCanvas;
1185
1186        let mut tooltip = Tooltip::new("Help text")
1187            .visible(true)
1188            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1189            .placement(TooltipPlacement::Right);
1190
1191        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1192
1193        let mut canvas = RecordingCanvas::new();
1194        tooltip.paint(&mut canvas);
1195
1196        assert!(canvas.command_count() >= 3);
1197    }
1198
1199    // ===== Brick Trait Tests =====
1200
1201    #[test]
1202    fn test_tooltip_brick_name() {
1203        let tooltip = Tooltip::new("Text");
1204        assert_eq!(tooltip.brick_name(), "Tooltip");
1205    }
1206
1207    #[test]
1208    fn test_tooltip_brick_assertions() {
1209        let tooltip = Tooltip::new("Text");
1210        let assertions = tooltip.assertions();
1211        assert_eq!(assertions.len(), 2);
1212        assert!(assertions.contains(&BrickAssertion::MaxLatencyMs(16)));
1213        assert!(assertions.contains(&BrickAssertion::ContrastRatio(4.5)));
1214    }
1215
1216    #[test]
1217    fn test_tooltip_brick_budget() {
1218        let tooltip = Tooltip::new("Text");
1219        let budget = tooltip.budget();
1220        // BrickBudget::uniform(16) sets internal values
1221        assert!(budget.measure_ms > 0);
1222        assert!(budget.layout_ms > 0);
1223        assert!(budget.paint_ms > 0);
1224    }
1225
1226    #[test]
1227    fn test_tooltip_brick_verify_good_contrast() {
1228        // Dark background with white text should pass
1229        let tooltip = Tooltip::new("Text")
1230            .background(Color::BLACK)
1231            .text_color(Color::WHITE);
1232
1233        let verification = tooltip.verify();
1234        assert!(verification
1235            .passed
1236            .contains(&BrickAssertion::ContrastRatio(4.5)));
1237        assert!(verification.failed.is_empty());
1238    }
1239
1240    #[test]
1241    fn test_tooltip_brick_verify_bad_contrast() {
1242        // Light background with white text should fail
1243        let tooltip = Tooltip::new("Text")
1244            .background(Color::WHITE)
1245            .text_color(Color::rgb(0.9, 0.9, 0.9)); // Very light gray
1246
1247        let verification = tooltip.verify();
1248        assert!(!verification.failed.is_empty());
1249        assert!(verification
1250            .failed
1251            .iter()
1252            .any(|(a, _)| *a == BrickAssertion::ContrastRatio(4.5)));
1253    }
1254
1255    #[test]
1256    fn test_tooltip_to_html() {
1257        let tooltip = Tooltip::new("Help text")
1258            .test_id("help-tooltip")
1259            .accessible_name("Help information");
1260
1261        let html = tooltip.to_html();
1262        assert!(html.contains("role=\"tooltip\""));
1263        assert!(html.contains("data-testid=\"help-tooltip\""));
1264        assert!(html.contains("aria-label=\"Help information\""));
1265        assert!(html.contains("Help text"));
1266    }
1267
1268    #[test]
1269    fn test_tooltip_to_html_default_values() {
1270        let tooltip = Tooltip::new("Text");
1271        let html = tooltip.to_html();
1272
1273        // Should use default test_id and content as aria-label
1274        assert!(html.contains("data-testid=\"tooltip\""));
1275        assert!(html.contains("aria-label=\"Text\""));
1276    }
1277
1278    #[test]
1279    fn test_tooltip_to_css() {
1280        let tooltip = Tooltip::new("Text")
1281            .padding(12.0)
1282            .text_size(14.0)
1283            .corner_radius(6.0)
1284            .max_width(300.0);
1285
1286        let css = tooltip.to_css();
1287        assert!(css.contains("padding: 12px"));
1288        assert!(css.contains("font-size: 14px"));
1289        assert!(css.contains("border-radius: 6px"));
1290        assert!(css.contains("max-width: 300px"));
1291    }
1292
1293    // ===== Widget Children Tests =====
1294
1295    #[test]
1296    fn test_tooltip_children_mut() {
1297        let mut tooltip = Tooltip::new("Text");
1298        assert!(tooltip.children_mut().is_empty());
1299    }
1300
1301    // ===== Event Tests =====
1302
1303    #[test]
1304    fn test_tooltip_event_other_events() {
1305        let mut tooltip = Tooltip::new("Text").visible(true);
1306
1307        // Other events should not affect visibility or return a message
1308        let result = tooltip.event(&Event::MouseEnter);
1309        assert!(result.is_none());
1310        assert!(tooltip.is_visible());
1311    }
1312
1313    // ===== Additional Coverage Tests =====
1314
1315    #[test]
1316    fn test_tooltip_paint_topleft_arrow() {
1317        use presentar_core::RecordingCanvas;
1318
1319        let mut tooltip = Tooltip::new("Help text")
1320            .visible(true)
1321            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1322            .placement(TooltipPlacement::TopLeft);
1323
1324        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1325
1326        let mut canvas = RecordingCanvas::new();
1327        tooltip.paint(&mut canvas);
1328
1329        assert!(canvas.command_count() >= 3);
1330    }
1331
1332    #[test]
1333    fn test_tooltip_paint_topright_arrow() {
1334        use presentar_core::RecordingCanvas;
1335
1336        let mut tooltip = Tooltip::new("Help text")
1337            .visible(true)
1338            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1339            .placement(TooltipPlacement::TopRight);
1340
1341        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1342
1343        let mut canvas = RecordingCanvas::new();
1344        tooltip.paint(&mut canvas);
1345
1346        assert!(canvas.command_count() >= 3);
1347    }
1348
1349    #[test]
1350    fn test_tooltip_paint_bottomleft_arrow() {
1351        use presentar_core::RecordingCanvas;
1352
1353        let mut tooltip = Tooltip::new("Help text")
1354            .visible(true)
1355            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1356            .placement(TooltipPlacement::BottomLeft);
1357
1358        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1359
1360        let mut canvas = RecordingCanvas::new();
1361        tooltip.paint(&mut canvas);
1362
1363        assert!(canvas.command_count() >= 3);
1364    }
1365
1366    #[test]
1367    fn test_tooltip_paint_bottomright_arrow() {
1368        use presentar_core::RecordingCanvas;
1369
1370        let mut tooltip = Tooltip::new("Help text")
1371            .visible(true)
1372            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1373            .placement(TooltipPlacement::BottomRight);
1374
1375        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1376
1377        let mut canvas = RecordingCanvas::new();
1378        tooltip.paint(&mut canvas);
1379
1380        assert!(canvas.command_count() >= 3);
1381    }
1382
1383    #[test]
1384    fn test_tooltip_layout_empty() {
1385        let mut tooltip = Tooltip::default().visible(true);
1386        let result = tooltip.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
1387        assert_eq!(result.size, Size::ZERO);
1388    }
1389
1390    #[test]
1391    fn test_tooltip_placement_clone() {
1392        let placement = TooltipPlacement::Bottom;
1393        let cloned = placement;
1394        assert_eq!(cloned, TooltipPlacement::Bottom);
1395    }
1396
1397    #[test]
1398    fn test_tooltip_placement_debug() {
1399        let placement = TooltipPlacement::Right;
1400        let debug = format!("{:?}", placement);
1401        assert!(debug.contains("Right"));
1402    }
1403
1404    #[test]
1405    fn test_tooltip_clone() {
1406        let tooltip = Tooltip::new("Text")
1407            .placement(TooltipPlacement::Left)
1408            .delay_ms(500);
1409        let cloned = tooltip.clone();
1410        assert_eq!(cloned.get_content(), "Text");
1411        assert_eq!(cloned.get_placement(), TooltipPlacement::Left);
1412        assert_eq!(cloned.get_delay_ms(), 500);
1413    }
1414
1415    #[test]
1416    fn test_tooltip_serde() {
1417        let tooltip = Tooltip::new("Help")
1418            .placement(TooltipPlacement::Bottom)
1419            .delay_ms(300);
1420
1421        let json = serde_json::to_string(&tooltip).unwrap();
1422        let deserialized: Tooltip = serde_json::from_str(&json).unwrap();
1423
1424        assert_eq!(deserialized.get_content(), "Help");
1425        assert_eq!(deserialized.get_placement(), TooltipPlacement::Bottom);
1426        assert_eq!(deserialized.get_delay_ms(), 300);
1427    }
1428
1429    #[test]
1430    fn test_tooltip_placement_serde() {
1431        let placement = TooltipPlacement::TopRight;
1432        let json = serde_json::to_string(&placement).unwrap();
1433        let deserialized: TooltipPlacement = serde_json::from_str(&json).unwrap();
1434        assert_eq!(deserialized, TooltipPlacement::TopRight);
1435    }
1436
1437    #[test]
1438    fn test_tooltip_bounds_after_layout() {
1439        let mut tooltip = Tooltip::new("Test tooltip text")
1440            .visible(true)
1441            .anchor(Rect::new(100.0, 100.0, 80.0, 30.0))
1442            .placement(TooltipPlacement::Top);
1443
1444        tooltip.layout(Rect::new(0.0, 0.0, 500.0, 500.0));
1445
1446        // bounds should be set after layout
1447        assert!(tooltip.bounds.width > 0.0);
1448        assert!(tooltip.bounds.height > 0.0);
1449    }
1450}