presentar_widgets/
tabs.rs

1//! Tabs widget for tabbed navigation.
2
3use presentar_core::{
4    widget::{
5        AccessibleRole, Brick, BrickAssertion, BrickBudget, BrickVerification, LayoutResult,
6        TextStyle,
7    },
8    Canvas, Color, Constraints, Event, MouseButton, Point, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12use std::time::Duration;
13
14/// A single tab definition.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Tab {
17    /// Tab ID
18    pub id: String,
19    /// Tab label
20    pub label: String,
21    /// Whether tab is disabled
22    pub disabled: bool,
23    /// Optional icon name
24    pub icon: Option<String>,
25}
26
27impl Tab {
28    /// Create a new tab.
29    #[must_use]
30    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
31        Self {
32            id: id.into(),
33            label: label.into(),
34            disabled: false,
35            icon: None,
36        }
37    }
38
39    /// Set the tab as disabled.
40    #[must_use]
41    pub const fn disabled(mut self) -> Self {
42        self.disabled = true;
43        self
44    }
45
46    /// Set an icon.
47    #[must_use]
48    pub fn icon(mut self, icon: impl Into<String>) -> Self {
49        self.icon = Some(icon.into());
50        self
51    }
52}
53
54/// Message emitted when active tab changes.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TabChanged {
57    /// ID of the newly active tab
58    pub tab_id: String,
59    /// Index of the newly active tab
60    pub index: usize,
61}
62
63/// Tab orientation.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
65pub enum TabOrientation {
66    /// Tabs on top (horizontal)
67    #[default]
68    Top,
69    /// Tabs on bottom (horizontal)
70    Bottom,
71    /// Tabs on left (vertical)
72    Left,
73    /// Tabs on right (vertical)
74    Right,
75}
76
77/// Tabs widget for tabbed navigation.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Tabs {
80    /// Tab definitions
81    items: Vec<Tab>,
82    /// Active tab index
83    active: usize,
84    /// Tab orientation
85    orientation: TabOrientation,
86    /// Tab bar height (for horizontal) or width (for vertical)
87    tab_size: f32,
88    /// Minimum tab width
89    min_tab_width: f32,
90    /// Tab spacing
91    spacing: f32,
92    /// Tab background color
93    tab_bg: Color,
94    /// Active tab background color
95    active_bg: Color,
96    /// Inactive tab text color
97    inactive_color: Color,
98    /// Active tab text color
99    active_color: Color,
100    /// Disabled tab text color
101    disabled_color: Color,
102    /// Border color
103    border_color: Color,
104    /// Show border under tabs
105    show_border: bool,
106    /// Accessible name
107    accessible_name_value: Option<String>,
108    /// Test ID
109    test_id_value: Option<String>,
110    /// Cached bounds
111    #[serde(skip)]
112    bounds: Rect,
113}
114
115impl Default for Tabs {
116    fn default() -> Self {
117        Self {
118            items: Vec::new(),
119            active: 0,
120            orientation: TabOrientation::Top,
121            tab_size: 48.0,
122            min_tab_width: 80.0,
123            spacing: 0.0,
124            tab_bg: Color::new(0.95, 0.95, 0.95, 1.0),
125            active_bg: Color::WHITE,
126            inactive_color: Color::new(0.4, 0.4, 0.4, 1.0),
127            active_color: Color::new(0.2, 0.47, 0.96, 1.0),
128            disabled_color: Color::new(0.7, 0.7, 0.7, 1.0),
129            border_color: Color::new(0.85, 0.85, 0.85, 1.0),
130            show_border: true,
131            accessible_name_value: None,
132            test_id_value: None,
133            bounds: Rect::default(),
134        }
135    }
136}
137
138impl Tabs {
139    /// Create a new tabs widget.
140    #[must_use]
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Add a tab.
146    #[must_use]
147    pub fn tab(mut self, tab: Tab) -> Self {
148        self.items.push(tab);
149        self
150    }
151
152    /// Add multiple tabs.
153    #[must_use]
154    pub fn tabs(mut self, tabs: impl IntoIterator<Item = Tab>) -> Self {
155        self.items.extend(tabs);
156        self
157    }
158
159    /// Set the active tab by index.
160    #[must_use]
161    pub const fn active(mut self, index: usize) -> Self {
162        self.active = index;
163        self
164    }
165
166    /// Set the active tab by ID.
167    #[must_use]
168    pub fn active_id(mut self, id: &str) -> Self {
169        if let Some(index) = self.items.iter().position(|t| t.id == id) {
170            self.active = index;
171        }
172        self
173    }
174
175    /// Set tab orientation.
176    #[must_use]
177    pub const fn orientation(mut self, orientation: TabOrientation) -> Self {
178        self.orientation = orientation;
179        self
180    }
181
182    /// Set tab bar size.
183    #[must_use]
184    pub fn tab_size(mut self, size: f32) -> Self {
185        self.tab_size = size.max(24.0);
186        self
187    }
188
189    /// Set minimum tab width.
190    #[must_use]
191    pub fn min_tab_width(mut self, width: f32) -> Self {
192        self.min_tab_width = width.max(40.0);
193        self
194    }
195
196    /// Set tab spacing.
197    #[must_use]
198    pub fn spacing(mut self, spacing: f32) -> Self {
199        self.spacing = spacing.max(0.0);
200        self
201    }
202
203    /// Set tab background color.
204    #[must_use]
205    pub const fn tab_bg(mut self, color: Color) -> Self {
206        self.tab_bg = color;
207        self
208    }
209
210    /// Set active tab background color.
211    #[must_use]
212    pub const fn active_bg(mut self, color: Color) -> Self {
213        self.active_bg = color;
214        self
215    }
216
217    /// Set inactive tab text color.
218    #[must_use]
219    pub const fn inactive_color(mut self, color: Color) -> Self {
220        self.inactive_color = color;
221        self
222    }
223
224    /// Set active tab text color.
225    #[must_use]
226    pub const fn active_color(mut self, color: Color) -> Self {
227        self.active_color = color;
228        self
229    }
230
231    /// Set whether to show border.
232    #[must_use]
233    pub const fn show_border(mut self, show: bool) -> Self {
234        self.show_border = show;
235        self
236    }
237
238    /// Set the accessible name.
239    #[must_use]
240    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
241        self.accessible_name_value = Some(name.into());
242        self
243    }
244
245    /// Set the test ID.
246    #[must_use]
247    pub fn test_id(mut self, id: impl Into<String>) -> Self {
248        self.test_id_value = Some(id.into());
249        self
250    }
251
252    /// Get tab count.
253    #[must_use]
254    pub fn tab_count(&self) -> usize {
255        self.items.len()
256    }
257
258    /// Get the tabs.
259    #[must_use]
260    pub fn get_tabs(&self) -> &[Tab] {
261        &self.items
262    }
263
264    /// Get active tab index.
265    #[must_use]
266    pub const fn get_active(&self) -> usize {
267        self.active
268    }
269
270    /// Get active tab.
271    #[must_use]
272    pub fn get_active_tab(&self) -> Option<&Tab> {
273        self.items.get(self.active)
274    }
275
276    /// Get active tab ID.
277    #[must_use]
278    pub fn get_active_id(&self) -> Option<&str> {
279        self.items.get(self.active).map(|t| t.id.as_str())
280    }
281
282    /// Check if a tab is active.
283    #[must_use]
284    pub const fn is_active(&self, index: usize) -> bool {
285        self.active == index
286    }
287
288    /// Check if tabs are empty.
289    #[must_use]
290    pub fn is_empty(&self) -> bool {
291        self.items.is_empty()
292    }
293
294    /// Set active tab by index (mutable).
295    pub fn set_active(&mut self, index: usize) {
296        if index < self.items.len() && !self.items[index].disabled {
297            self.active = index;
298        }
299    }
300
301    /// Set active tab by ID (mutable).
302    pub fn set_active_id(&mut self, id: &str) {
303        if let Some(index) = self.items.iter().position(|t| t.id == id) {
304            if !self.items[index].disabled {
305                self.active = index;
306            }
307        }
308    }
309
310    /// Navigate to next tab.
311    pub fn next_tab(&mut self) {
312        let mut next = (self.active + 1) % self.items.len();
313        let start = next;
314        loop {
315            if !self.items[next].disabled {
316                self.active = next;
317                return;
318            }
319            next = (next + 1) % self.items.len();
320            if next == start {
321                return; // All tabs disabled
322            }
323        }
324    }
325
326    /// Navigate to previous tab.
327    pub fn prev_tab(&mut self) {
328        if self.items.is_empty() {
329            return;
330        }
331        let mut prev = if self.active == 0 {
332            self.items.len() - 1
333        } else {
334            self.active - 1
335        };
336        let start = prev;
337        loop {
338            if !self.items[prev].disabled {
339                self.active = prev;
340                return;
341            }
342            prev = if prev == 0 {
343                self.items.len() - 1
344            } else {
345                prev - 1
346            };
347            if prev == start {
348                return; // All tabs disabled
349            }
350        }
351    }
352
353    /// Calculate tab width based on available space.
354    fn calculate_tab_width(&self, available_width: f32) -> f32 {
355        if self.items.is_empty() {
356            return self.min_tab_width;
357        }
358        let total_spacing = self.spacing * (self.items.len() - 1).max(0) as f32;
359        let per_tab = (available_width - total_spacing) / self.items.len() as f32;
360        per_tab.max(self.min_tab_width)
361    }
362
363    /// Get tab rect by index.
364    fn tab_rect(&self, index: usize, tab_width: f32) -> Rect {
365        match self.orientation {
366            TabOrientation::Top | TabOrientation::Bottom => {
367                let x = (index as f32).mul_add(tab_width + self.spacing, self.bounds.x);
368                let y = if self.orientation == TabOrientation::Top {
369                    self.bounds.y
370                } else {
371                    self.bounds.y + self.bounds.height - self.tab_size
372                };
373                Rect::new(x, y, tab_width, self.tab_size)
374            }
375            TabOrientation::Left | TabOrientation::Right => {
376                let y = (index as f32).mul_add(self.tab_size + self.spacing, self.bounds.y);
377                let x = if self.orientation == TabOrientation::Left {
378                    self.bounds.x
379                } else {
380                    self.bounds.x + self.bounds.width - self.min_tab_width
381                };
382                Rect::new(x, y, self.min_tab_width, self.tab_size)
383            }
384        }
385    }
386
387    /// Find tab at point.
388    fn tab_at_point(&self, x: f32, y: f32) -> Option<usize> {
389        let tab_width = self.calculate_tab_width(self.bounds.width);
390        for (i, _) in self.items.iter().enumerate() {
391            let rect = self.tab_rect(i, tab_width);
392            if x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height {
393                return Some(i);
394            }
395        }
396        None
397    }
398}
399
400impl Widget for Tabs {
401    fn type_id(&self) -> TypeId {
402        TypeId::of::<Self>()
403    }
404
405    fn measure(&self, constraints: Constraints) -> Size {
406        let is_horizontal = matches!(
407            self.orientation,
408            TabOrientation::Top | TabOrientation::Bottom
409        );
410
411        let preferred = if is_horizontal {
412            Size::new(self.items.len() as f32 * self.min_tab_width, self.tab_size)
413        } else {
414            Size::new(self.min_tab_width, self.items.len() as f32 * self.tab_size)
415        };
416
417        constraints.constrain(preferred)
418    }
419
420    fn layout(&mut self, bounds: Rect) -> LayoutResult {
421        self.bounds = bounds;
422        LayoutResult {
423            size: bounds.size(),
424        }
425    }
426
427    fn paint(&self, canvas: &mut dyn Canvas) {
428        // Draw tab bar background
429        canvas.fill_rect(self.bounds, self.tab_bg);
430
431        let tab_width = self.calculate_tab_width(self.bounds.width);
432
433        // Draw individual tabs
434        for (i, tab) in self.items.iter().enumerate() {
435            let rect = self.tab_rect(i, tab_width);
436
437            // Draw tab background
438            let bg_color = if i == self.active {
439                self.active_bg
440            } else {
441                self.tab_bg
442            };
443            canvas.fill_rect(rect, bg_color);
444
445            // Draw tab label
446            let text_color = if tab.disabled {
447                self.disabled_color
448            } else if i == self.active {
449                self.active_color
450            } else {
451                self.inactive_color
452            };
453
454            let text_style = TextStyle {
455                size: 14.0,
456                color: text_color,
457                ..TextStyle::default()
458            };
459
460            canvas.draw_text(
461                &tab.label,
462                Point::new(rect.x + 12.0, rect.y + rect.height / 2.0),
463                &text_style,
464            );
465
466            // Draw active indicator
467            if i == self.active && self.show_border {
468                let indicator_rect = match self.orientation {
469                    TabOrientation::Top => {
470                        Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0)
471                    }
472                    TabOrientation::Bottom => Rect::new(rect.x, rect.y, rect.width, 2.0),
473                    TabOrientation::Left => {
474                        Rect::new(rect.x + rect.width - 2.0, rect.y, 2.0, rect.height)
475                    }
476                    TabOrientation::Right => Rect::new(rect.x, rect.y, 2.0, rect.height),
477                };
478                canvas.fill_rect(indicator_rect, self.active_color);
479            }
480        }
481
482        // Draw border
483        if self.show_border {
484            let border_rect = match self.orientation {
485                TabOrientation::Top => Rect::new(
486                    self.bounds.x,
487                    self.bounds.y + self.tab_size - 1.0,
488                    self.bounds.width,
489                    1.0,
490                ),
491                TabOrientation::Bottom => {
492                    Rect::new(self.bounds.x, self.bounds.y, self.bounds.width, 1.0)
493                }
494                TabOrientation::Left => Rect::new(
495                    self.bounds.x + self.min_tab_width - 1.0,
496                    self.bounds.y,
497                    1.0,
498                    self.bounds.height,
499                ),
500                TabOrientation::Right => {
501                    Rect::new(self.bounds.x, self.bounds.y, 1.0, self.bounds.height)
502                }
503            };
504            canvas.fill_rect(border_rect, self.border_color);
505        }
506    }
507
508    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
509        if let Event::MouseDown {
510            position,
511            button: MouseButton::Left,
512        } = event
513        {
514            if let Some(index) = self.tab_at_point(position.x, position.y) {
515                if !self.items[index].disabled && index != self.active {
516                    self.active = index;
517                    return Some(Box::new(TabChanged {
518                        tab_id: self.items[index].id.clone(),
519                        index,
520                    }));
521                }
522            }
523        }
524        None
525    }
526
527    fn children(&self) -> &[Box<dyn Widget>] {
528        &[]
529    }
530
531    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
532        &mut []
533    }
534
535    fn is_interactive(&self) -> bool {
536        !self.items.is_empty()
537    }
538
539    fn is_focusable(&self) -> bool {
540        !self.items.is_empty()
541    }
542
543    fn accessible_name(&self) -> Option<&str> {
544        self.accessible_name_value.as_deref()
545    }
546
547    fn accessible_role(&self) -> AccessibleRole {
548        AccessibleRole::Tab
549    }
550
551    fn test_id(&self) -> Option<&str> {
552        self.test_id_value.as_deref()
553    }
554}
555
556// PROBAR-SPEC-009: Brick Architecture - Tests define interface
557impl Brick for Tabs {
558    fn brick_name(&self) -> &'static str {
559        "Tabs"
560    }
561
562    fn assertions(&self) -> &[BrickAssertion] {
563        &[BrickAssertion::MaxLatencyMs(16)]
564    }
565
566    fn budget(&self) -> BrickBudget {
567        BrickBudget::uniform(16)
568    }
569
570    fn verify(&self) -> BrickVerification {
571        BrickVerification {
572            passed: self.assertions().to_vec(),
573            failed: vec![],
574            verification_time: Duration::from_micros(10),
575        }
576    }
577
578    fn to_html(&self) -> String {
579        r#"<div class="brick-tabs"></div>"#.to_string()
580    }
581
582    fn to_css(&self) -> String {
583        ".brick-tabs { display: flex; flex-direction: column; }".to_string()
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    // ===== Tab Tests =====
592
593    #[test]
594    fn test_tab_new() {
595        let tab = Tab::new("home", "Home");
596        assert_eq!(tab.id, "home");
597        assert_eq!(tab.label, "Home");
598        assert!(!tab.disabled);
599        assert!(tab.icon.is_none());
600    }
601
602    #[test]
603    fn test_tab_disabled() {
604        let tab = Tab::new("settings", "Settings").disabled();
605        assert!(tab.disabled);
606    }
607
608    #[test]
609    fn test_tab_icon() {
610        let tab = Tab::new("profile", "Profile").icon("user");
611        assert_eq!(tab.icon, Some("user".to_string()));
612    }
613
614    // ===== TabChanged Tests =====
615
616    #[test]
617    fn test_tab_changed() {
618        let msg = TabChanged {
619            tab_id: "settings".to_string(),
620            index: 2,
621        };
622        assert_eq!(msg.tab_id, "settings");
623        assert_eq!(msg.index, 2);
624    }
625
626    // ===== TabOrientation Tests =====
627
628    #[test]
629    fn test_tab_orientation_default() {
630        assert_eq!(TabOrientation::default(), TabOrientation::Top);
631    }
632
633    // ===== Tabs Construction Tests =====
634
635    #[test]
636    fn test_tabs_new() {
637        let tabs = Tabs::new();
638        assert_eq!(tabs.tab_count(), 0);
639        assert!(tabs.is_empty());
640    }
641
642    #[test]
643    fn test_tabs_builder() {
644        let tabs = Tabs::new()
645            .tab(Tab::new("home", "Home"))
646            .tab(Tab::new("about", "About"))
647            .tab(Tab::new("contact", "Contact"))
648            .active(1)
649            .orientation(TabOrientation::Top)
650            .tab_size(50.0)
651            .min_tab_width(100.0)
652            .spacing(4.0)
653            .show_border(true)
654            .accessible_name("Main navigation")
655            .test_id("main-tabs");
656
657        assert_eq!(tabs.tab_count(), 3);
658        assert_eq!(tabs.get_active(), 1);
659        assert_eq!(tabs.get_active_id(), Some("about"));
660        assert_eq!(Widget::accessible_name(&tabs), Some("Main navigation"));
661        assert_eq!(Widget::test_id(&tabs), Some("main-tabs"));
662    }
663
664    #[test]
665    fn test_tabs_multiple() {
666        let tab_list = vec![Tab::new("a", "A"), Tab::new("b", "B"), Tab::new("c", "C")];
667        let tabs = Tabs::new().tabs(tab_list);
668        assert_eq!(tabs.tab_count(), 3);
669    }
670
671    #[test]
672    fn test_tabs_active_id() {
673        let tabs = Tabs::new()
674            .tab(Tab::new("first", "First"))
675            .tab(Tab::new("second", "Second"))
676            .active_id("second");
677
678        assert_eq!(tabs.get_active(), 1);
679    }
680
681    #[test]
682    fn test_tabs_active_id_not_found() {
683        let tabs = Tabs::new()
684            .tab(Tab::new("first", "First"))
685            .active_id("nonexistent");
686
687        assert_eq!(tabs.get_active(), 0);
688    }
689
690    // ===== Active Tab Tests =====
691
692    #[test]
693    fn test_tabs_get_active_tab() {
694        let tabs = Tabs::new()
695            .tab(Tab::new("home", "Home"))
696            .tab(Tab::new("about", "About"))
697            .active(1);
698
699        let active = tabs.get_active_tab().unwrap();
700        assert_eq!(active.id, "about");
701    }
702
703    #[test]
704    fn test_tabs_get_active_tab_empty() {
705        let tabs = Tabs::new();
706        assert!(tabs.get_active_tab().is_none());
707    }
708
709    #[test]
710    fn test_tabs_is_active() {
711        let tabs = Tabs::new()
712            .tab(Tab::new("a", "A"))
713            .tab(Tab::new("b", "B"))
714            .active(1);
715
716        assert!(!tabs.is_active(0));
717        assert!(tabs.is_active(1));
718    }
719
720    // ===== Set Active Tests =====
721
722    #[test]
723    fn test_tabs_set_active() {
724        let mut tabs = Tabs::new()
725            .tab(Tab::new("a", "A"))
726            .tab(Tab::new("b", "B"))
727            .tab(Tab::new("c", "C"));
728
729        tabs.set_active(2);
730        assert_eq!(tabs.get_active(), 2);
731    }
732
733    #[test]
734    fn test_tabs_set_active_out_of_bounds() {
735        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
736
737        tabs.set_active(10);
738        assert_eq!(tabs.get_active(), 0); // Unchanged
739    }
740
741    #[test]
742    fn test_tabs_set_active_disabled() {
743        let mut tabs = Tabs::new()
744            .tab(Tab::new("a", "A"))
745            .tab(Tab::new("b", "B").disabled());
746
747        tabs.set_active(1);
748        assert_eq!(tabs.get_active(), 0); // Unchanged, tab is disabled
749    }
750
751    #[test]
752    fn test_tabs_set_active_id() {
753        let mut tabs = Tabs::new()
754            .tab(Tab::new("home", "Home"))
755            .tab(Tab::new("settings", "Settings"));
756
757        tabs.set_active_id("settings");
758        assert_eq!(tabs.get_active(), 1);
759    }
760
761    // ===== Navigation Tests =====
762
763    #[test]
764    fn test_tabs_next_tab() {
765        let mut tabs = Tabs::new()
766            .tab(Tab::new("a", "A"))
767            .tab(Tab::new("b", "B"))
768            .tab(Tab::new("c", "C"))
769            .active(0);
770
771        tabs.next_tab();
772        assert_eq!(tabs.get_active(), 1);
773
774        tabs.next_tab();
775        assert_eq!(tabs.get_active(), 2);
776
777        tabs.next_tab(); // Wrap around
778        assert_eq!(tabs.get_active(), 0);
779    }
780
781    #[test]
782    fn test_tabs_next_tab_skip_disabled() {
783        let mut tabs = Tabs::new()
784            .tab(Tab::new("a", "A"))
785            .tab(Tab::new("b", "B").disabled())
786            .tab(Tab::new("c", "C"))
787            .active(0);
788
789        tabs.next_tab();
790        assert_eq!(tabs.get_active(), 2); // Skipped disabled tab
791    }
792
793    #[test]
794    fn test_tabs_prev_tab() {
795        let mut tabs = Tabs::new()
796            .tab(Tab::new("a", "A"))
797            .tab(Tab::new("b", "B"))
798            .tab(Tab::new("c", "C"))
799            .active(2);
800
801        tabs.prev_tab();
802        assert_eq!(tabs.get_active(), 1);
803
804        tabs.prev_tab();
805        assert_eq!(tabs.get_active(), 0);
806
807        tabs.prev_tab(); // Wrap around
808        assert_eq!(tabs.get_active(), 2);
809    }
810
811    #[test]
812    fn test_tabs_prev_tab_skip_disabled() {
813        let mut tabs = Tabs::new()
814            .tab(Tab::new("a", "A"))
815            .tab(Tab::new("b", "B").disabled())
816            .tab(Tab::new("c", "C"))
817            .active(2);
818
819        tabs.prev_tab();
820        assert_eq!(tabs.get_active(), 0); // Skipped disabled tab
821    }
822
823    // ===== Dimension Tests =====
824
825    #[test]
826    fn test_tabs_tab_size_min() {
827        let tabs = Tabs::new().tab_size(10.0);
828        assert_eq!(tabs.tab_size, 24.0);
829    }
830
831    #[test]
832    fn test_tabs_min_tab_width_min() {
833        let tabs = Tabs::new().min_tab_width(20.0);
834        assert_eq!(tabs.min_tab_width, 40.0);
835    }
836
837    #[test]
838    fn test_tabs_spacing_min() {
839        let tabs = Tabs::new().spacing(-5.0);
840        assert_eq!(tabs.spacing, 0.0);
841    }
842
843    #[test]
844    fn test_tabs_calculate_tab_width() {
845        let tabs = Tabs::new()
846            .tab(Tab::new("a", "A"))
847            .tab(Tab::new("b", "B"))
848            .min_tab_width(50.0)
849            .spacing(0.0);
850
851        assert_eq!(tabs.calculate_tab_width(200.0), 100.0);
852    }
853
854    #[test]
855    fn test_tabs_calculate_tab_width_with_spacing() {
856        let tabs = Tabs::new()
857            .tab(Tab::new("a", "A"))
858            .tab(Tab::new("b", "B"))
859            .min_tab_width(50.0)
860            .spacing(10.0);
861
862        // (200 - 10) / 2 = 95
863        assert_eq!(tabs.calculate_tab_width(200.0), 95.0);
864    }
865
866    // ===== Widget Trait Tests =====
867
868    #[test]
869    fn test_tabs_type_id() {
870        let tabs = Tabs::new();
871        assert_eq!(Widget::type_id(&tabs), TypeId::of::<Tabs>());
872    }
873
874    #[test]
875    fn test_tabs_measure_horizontal() {
876        let tabs = Tabs::new()
877            .tab(Tab::new("a", "A"))
878            .tab(Tab::new("b", "B"))
879            .orientation(TabOrientation::Top)
880            .min_tab_width(100.0)
881            .tab_size(48.0);
882
883        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
884        assert_eq!(size.width, 200.0);
885        assert_eq!(size.height, 48.0);
886    }
887
888    #[test]
889    fn test_tabs_measure_vertical() {
890        let tabs = Tabs::new()
891            .tab(Tab::new("a", "A"))
892            .tab(Tab::new("b", "B"))
893            .orientation(TabOrientation::Left)
894            .min_tab_width(100.0)
895            .tab_size(48.0);
896
897        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
898        assert_eq!(size.width, 100.0);
899        assert_eq!(size.height, 96.0);
900    }
901
902    #[test]
903    fn test_tabs_layout() {
904        let mut tabs = Tabs::new().tab(Tab::new("a", "A"));
905        let bounds = Rect::new(10.0, 20.0, 300.0, 48.0);
906        let result = tabs.layout(bounds);
907        assert_eq!(result.size, Size::new(300.0, 48.0));
908        assert_eq!(tabs.bounds, bounds);
909    }
910
911    #[test]
912    fn test_tabs_children() {
913        let tabs = Tabs::new();
914        assert!(tabs.children().is_empty());
915    }
916
917    #[test]
918    fn test_tabs_is_interactive() {
919        let tabs = Tabs::new();
920        assert!(!tabs.is_interactive()); // Empty
921
922        let tabs = Tabs::new().tab(Tab::new("a", "A"));
923        assert!(tabs.is_interactive());
924    }
925
926    #[test]
927    fn test_tabs_is_focusable() {
928        let tabs = Tabs::new();
929        assert!(!tabs.is_focusable()); // Empty
930
931        let tabs = Tabs::new().tab(Tab::new("a", "A"));
932        assert!(tabs.is_focusable());
933    }
934
935    #[test]
936    fn test_tabs_accessible_role() {
937        let tabs = Tabs::new();
938        assert_eq!(tabs.accessible_role(), AccessibleRole::Tab);
939    }
940
941    #[test]
942    fn test_tabs_accessible_name() {
943        let tabs = Tabs::new().accessible_name("Section tabs");
944        assert_eq!(Widget::accessible_name(&tabs), Some("Section tabs"));
945    }
946
947    #[test]
948    fn test_tabs_test_id() {
949        let tabs = Tabs::new().test_id("nav-tabs");
950        assert_eq!(Widget::test_id(&tabs), Some("nav-tabs"));
951    }
952
953    // ===== Tab Rect Tests =====
954
955    #[test]
956    fn test_tab_rect_top() {
957        let mut tabs = Tabs::new()
958            .tab(Tab::new("a", "A"))
959            .tab(Tab::new("b", "B"))
960            .orientation(TabOrientation::Top)
961            .tab_size(48.0);
962        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
963
964        let rect0 = tabs.tab_rect(0, 100.0);
965        assert_eq!(rect0.x, 0.0);
966        assert_eq!(rect0.y, 0.0);
967        assert_eq!(rect0.width, 100.0);
968        assert_eq!(rect0.height, 48.0);
969
970        let rect1 = tabs.tab_rect(1, 100.0);
971        assert_eq!(rect1.x, 100.0);
972    }
973
974    #[test]
975    fn test_tab_rect_bottom() {
976        let mut tabs = Tabs::new()
977            .tab(Tab::new("a", "A"))
978            .orientation(TabOrientation::Bottom)
979            .tab_size(48.0);
980        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
981
982        let rect = tabs.tab_rect(0, 100.0);
983        assert_eq!(rect.y, 52.0); // 100 - 48
984    }
985
986    // ===== Event Tests =====
987
988    #[test]
989    fn test_tabs_click_changes_active() {
990        let mut tabs = Tabs::new()
991            .tab(Tab::new("a", "A"))
992            .tab(Tab::new("b", "B"))
993            .tab_size(48.0)
994            .min_tab_width(100.0);
995        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
996
997        // Click on second tab
998        let event = Event::MouseDown {
999            position: Point::new(150.0, 24.0),
1000            button: MouseButton::Left,
1001        };
1002
1003        let result = tabs.event(&event);
1004        assert!(result.is_some());
1005        assert_eq!(tabs.get_active(), 1);
1006
1007        let msg = result.unwrap().downcast::<TabChanged>().unwrap();
1008        assert_eq!(msg.tab_id, "b");
1009        assert_eq!(msg.index, 1);
1010    }
1011
1012    #[test]
1013    fn test_tabs_click_disabled_no_change() {
1014        let mut tabs = Tabs::new()
1015            .tab(Tab::new("a", "A"))
1016            .tab(Tab::new("b", "B").disabled())
1017            .tab_size(48.0)
1018            .min_tab_width(100.0);
1019        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1020
1021        // Click on disabled tab
1022        let event = Event::MouseDown {
1023            position: Point::new(150.0, 24.0),
1024            button: MouseButton::Left,
1025        };
1026
1027        let result = tabs.event(&event);
1028        assert!(result.is_none());
1029        assert_eq!(tabs.get_active(), 0);
1030    }
1031
1032    #[test]
1033    fn test_tabs_click_same_tab_no_event() {
1034        let mut tabs = Tabs::new()
1035            .tab(Tab::new("a", "A"))
1036            .tab(Tab::new("b", "B"))
1037            .active(0)
1038            .tab_size(48.0)
1039            .min_tab_width(100.0);
1040        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1041
1042        // Click on already active tab
1043        let event = Event::MouseDown {
1044            position: Point::new(50.0, 24.0),
1045            button: MouseButton::Left,
1046        };
1047
1048        let result = tabs.event(&event);
1049        assert!(result.is_none());
1050    }
1051
1052    // ===== Additional Tab Rect Tests =====
1053
1054    #[test]
1055    fn test_tab_rect_left() {
1056        let mut tabs = Tabs::new()
1057            .tab(Tab::new("a", "A"))
1058            .tab(Tab::new("b", "B"))
1059            .orientation(TabOrientation::Left)
1060            .tab_size(48.0)
1061            .min_tab_width(80.0);
1062        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
1063
1064        let rect0 = tabs.tab_rect(0, 80.0);
1065        assert_eq!(rect0.x, 0.0);
1066        assert_eq!(rect0.y, 0.0);
1067        assert_eq!(rect0.width, 80.0);
1068        assert_eq!(rect0.height, 48.0);
1069
1070        let rect1 = tabs.tab_rect(1, 80.0);
1071        assert_eq!(rect1.y, 48.0);
1072    }
1073
1074    #[test]
1075    fn test_tab_rect_right() {
1076        let mut tabs = Tabs::new()
1077            .tab(Tab::new("a", "A"))
1078            .orientation(TabOrientation::Right)
1079            .tab_size(48.0)
1080            .min_tab_width(80.0);
1081        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
1082
1083        let rect = tabs.tab_rect(0, 80.0);
1084        assert_eq!(rect.x, 120.0); // 200 - 80
1085    }
1086
1087    #[test]
1088    fn test_tab_rect_with_spacing() {
1089        let mut tabs = Tabs::new()
1090            .tab(Tab::new("a", "A"))
1091            .tab(Tab::new("b", "B"))
1092            .orientation(TabOrientation::Top)
1093            .spacing(10.0)
1094            .tab_size(48.0);
1095        tabs.bounds = Rect::new(0.0, 0.0, 300.0, 48.0);
1096
1097        let rect0 = tabs.tab_rect(0, 100.0);
1098        assert_eq!(rect0.x, 0.0);
1099
1100        let rect1 = tabs.tab_rect(1, 100.0);
1101        assert_eq!(rect1.x, 110.0); // 100 + 10 spacing
1102    }
1103
1104    // ===== Calculate Tab Width Tests =====
1105
1106    #[test]
1107    fn test_calculate_tab_width_empty() {
1108        let tabs = Tabs::new().min_tab_width(80.0);
1109        assert_eq!(tabs.calculate_tab_width(500.0), 80.0);
1110    }
1111
1112    #[test]
1113    fn test_calculate_tab_width_narrow_space() {
1114        let tabs = Tabs::new()
1115            .tab(Tab::new("a", "A"))
1116            .tab(Tab::new("b", "B"))
1117            .min_tab_width(100.0);
1118
1119        // Space so narrow that we need to use min_tab_width
1120        assert_eq!(tabs.calculate_tab_width(50.0), 100.0);
1121    }
1122
1123    // ===== Tab At Point Tests =====
1124
1125    #[test]
1126    fn test_tab_at_point_found() {
1127        let mut tabs = Tabs::new()
1128            .tab(Tab::new("a", "A"))
1129            .tab(Tab::new("b", "B"))
1130            .min_tab_width(100.0)
1131            .tab_size(48.0);
1132        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1133
1134        assert_eq!(tabs.tab_at_point(50.0, 24.0), Some(0));
1135        assert_eq!(tabs.tab_at_point(150.0, 24.0), Some(1));
1136    }
1137
1138    #[test]
1139    fn test_tab_at_point_not_found() {
1140        let mut tabs = Tabs::new()
1141            .tab(Tab::new("a", "A"))
1142            .min_tab_width(100.0)
1143            .tab_size(48.0);
1144        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1145
1146        // Click outside tabs area
1147        assert_eq!(tabs.tab_at_point(50.0, 100.0), None);
1148        assert_eq!(tabs.tab_at_point(-10.0, 24.0), None);
1149    }
1150
1151    // ===== Navigation Edge Cases =====
1152
1153    #[test]
1154    fn test_tabs_next_tab_all_disabled() {
1155        let mut tabs = Tabs::new()
1156            .tab(Tab::new("a", "A").disabled())
1157            .tab(Tab::new("b", "B").disabled())
1158            .tab(Tab::new("c", "C").disabled());
1159        tabs.active = 0; // Start position
1160
1161        tabs.next_tab();
1162        assert_eq!(tabs.get_active(), 0); // Unchanged - all disabled
1163    }
1164
1165    #[test]
1166    fn test_tabs_prev_tab_all_disabled() {
1167        let mut tabs = Tabs::new()
1168            .tab(Tab::new("a", "A").disabled())
1169            .tab(Tab::new("b", "B").disabled());
1170        tabs.active = 0;
1171
1172        tabs.prev_tab();
1173        assert_eq!(tabs.get_active(), 0); // Unchanged - all disabled
1174    }
1175
1176    #[test]
1177    fn test_tabs_prev_tab_empty() {
1178        let mut tabs = Tabs::new();
1179        tabs.prev_tab(); // Should not panic
1180        assert_eq!(tabs.get_active(), 0);
1181    }
1182
1183    #[test]
1184    fn test_tabs_set_active_id_not_found() {
1185        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1186
1187        tabs.set_active_id("nonexistent");
1188        assert_eq!(tabs.get_active(), 0); // Unchanged
1189    }
1190
1191    #[test]
1192    fn test_tabs_set_active_id_disabled() {
1193        let mut tabs = Tabs::new()
1194            .tab(Tab::new("a", "A"))
1195            .tab(Tab::new("b", "B").disabled());
1196
1197        tabs.set_active_id("b");
1198        assert_eq!(tabs.get_active(), 0); // Unchanged - disabled
1199    }
1200
1201    // ===== Color Setters =====
1202
1203    #[test]
1204    fn test_tabs_tab_bg() {
1205        let tabs = Tabs::new().tab_bg(Color::RED);
1206        assert_eq!(tabs.tab_bg, Color::RED);
1207    }
1208
1209    #[test]
1210    fn test_tabs_active_bg() {
1211        let tabs = Tabs::new().active_bg(Color::BLUE);
1212        assert_eq!(tabs.active_bg, Color::BLUE);
1213    }
1214
1215    #[test]
1216    fn test_tabs_inactive_color() {
1217        let tabs = Tabs::new().inactive_color(Color::GREEN);
1218        assert_eq!(tabs.inactive_color, Color::GREEN);
1219    }
1220
1221    #[test]
1222    fn test_tabs_active_color() {
1223        let tabs = Tabs::new().active_color(Color::WHITE);
1224        assert_eq!(tabs.active_color, Color::WHITE);
1225    }
1226
1227    // ===== Get Tabs Tests =====
1228
1229    #[test]
1230    fn test_tabs_get_tabs() {
1231        let tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1232
1233        let items = tabs.get_tabs();
1234        assert_eq!(items.len(), 2);
1235        assert_eq!(items[0].id, "a");
1236        assert_eq!(items[1].id, "b");
1237    }
1238
1239    #[test]
1240    fn test_tabs_get_active_id_none() {
1241        let tabs = Tabs::new();
1242        assert!(tabs.get_active_id().is_none());
1243    }
1244
1245    // ===== Paint Tests =====
1246
1247    #[test]
1248    fn test_tabs_paint() {
1249        use presentar_core::RecordingCanvas;
1250
1251        let mut tabs = Tabs::new()
1252            .tab(Tab::new("a", "A"))
1253            .tab(Tab::new("b", "B"))
1254            .orientation(TabOrientation::Top)
1255            .show_border(true);
1256        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1257
1258        let mut canvas = RecordingCanvas::new();
1259        tabs.paint(&mut canvas);
1260
1261        // Should draw: tab bar bg + tab 1 bg + tab 1 text + tab 2 bg + tab 2 text + active indicator + border
1262        assert!(canvas.command_count() >= 5);
1263    }
1264
1265    #[test]
1266    fn test_tabs_paint_no_border() {
1267        use presentar_core::RecordingCanvas;
1268
1269        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).show_border(false);
1270        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1271
1272        let mut canvas = RecordingCanvas::new();
1273        tabs.paint(&mut canvas);
1274
1275        // Should still draw tabs, just no border
1276        assert!(canvas.command_count() >= 2);
1277    }
1278
1279    #[test]
1280    fn test_tabs_paint_disabled_tab() {
1281        use presentar_core::RecordingCanvas;
1282
1283        let mut tabs = Tabs::new()
1284            .tab(Tab::new("a", "A"))
1285            .tab(Tab::new("b", "B").disabled())
1286            .active(0);
1287        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1288
1289        let mut canvas = RecordingCanvas::new();
1290        tabs.paint(&mut canvas);
1291
1292        // Should paint both tabs, disabled with different color
1293        assert!(canvas.command_count() >= 4);
1294    }
1295
1296    #[test]
1297    fn test_tabs_paint_vertical_left() {
1298        use presentar_core::RecordingCanvas;
1299
1300        let mut tabs = Tabs::new()
1301            .tab(Tab::new("a", "A"))
1302            .tab(Tab::new("b", "B"))
1303            .orientation(TabOrientation::Left)
1304            .show_border(true);
1305        tabs.layout(Rect::new(0.0, 0.0, 100.0, 200.0));
1306
1307        let mut canvas = RecordingCanvas::new();
1308        tabs.paint(&mut canvas);
1309
1310        assert!(canvas.command_count() >= 5);
1311    }
1312
1313    #[test]
1314    fn test_tabs_paint_vertical_right() {
1315        use presentar_core::RecordingCanvas;
1316
1317        let mut tabs = Tabs::new()
1318            .tab(Tab::new("a", "A"))
1319            .tab(Tab::new("b", "B"))
1320            .orientation(TabOrientation::Right)
1321            .show_border(true);
1322        tabs.layout(Rect::new(0.0, 0.0, 100.0, 200.0));
1323
1324        let mut canvas = RecordingCanvas::new();
1325        tabs.paint(&mut canvas);
1326
1327        assert!(canvas.command_count() >= 5);
1328    }
1329
1330    #[test]
1331    fn test_tabs_paint_bottom() {
1332        use presentar_core::RecordingCanvas;
1333
1334        let mut tabs = Tabs::new()
1335            .tab(Tab::new("a", "A"))
1336            .tab(Tab::new("b", "B"))
1337            .orientation(TabOrientation::Bottom)
1338            .show_border(true);
1339        tabs.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
1340
1341        let mut canvas = RecordingCanvas::new();
1342        tabs.paint(&mut canvas);
1343
1344        assert!(canvas.command_count() >= 5);
1345    }
1346
1347    // ===== Brick Trait Tests =====
1348
1349    #[test]
1350    fn test_tabs_brick_name() {
1351        let tabs = Tabs::new();
1352        assert_eq!(tabs.brick_name(), "Tabs");
1353    }
1354
1355    #[test]
1356    fn test_tabs_brick_assertions() {
1357        let tabs = Tabs::new();
1358        let assertions = tabs.assertions();
1359        assert_eq!(assertions.len(), 1);
1360        assert!(assertions.contains(&BrickAssertion::MaxLatencyMs(16)));
1361    }
1362
1363    #[test]
1364    fn test_tabs_brick_budget() {
1365        let tabs = Tabs::new();
1366        let budget = tabs.budget();
1367        // BrickBudget::uniform(16) sets internal values
1368        assert!(budget.measure_ms > 0);
1369        assert!(budget.layout_ms > 0);
1370        assert!(budget.paint_ms > 0);
1371    }
1372
1373    #[test]
1374    fn test_tabs_brick_verify() {
1375        let tabs = Tabs::new();
1376        let verification = tabs.verify();
1377        assert!(verification.failed.is_empty());
1378        assert!(!verification.passed.is_empty());
1379    }
1380
1381    #[test]
1382    fn test_tabs_to_html() {
1383        let tabs = Tabs::new();
1384        let html = tabs.to_html();
1385        assert!(html.contains("brick-tabs"));
1386    }
1387
1388    #[test]
1389    fn test_tabs_to_css() {
1390        let tabs = Tabs::new();
1391        let css = tabs.to_css();
1392        assert!(css.contains("brick-tabs"));
1393        assert!(css.contains("display: flex"));
1394    }
1395
1396    // ===== Widget Children Mut Tests =====
1397
1398    #[test]
1399    fn test_tabs_children_mut() {
1400        let mut tabs = Tabs::new();
1401        assert!(tabs.children_mut().is_empty());
1402    }
1403
1404    // ===== Event Edge Cases =====
1405
1406    #[test]
1407    fn test_tabs_click_outside() {
1408        let mut tabs = Tabs::new()
1409            .tab(Tab::new("a", "A"))
1410            .min_tab_width(100.0)
1411            .tab_size(48.0);
1412        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1413
1414        // Click outside tabs
1415        let event = Event::MouseDown {
1416            position: Point::new(500.0, 24.0),
1417            button: MouseButton::Left,
1418        };
1419
1420        let result = tabs.event(&event);
1421        assert!(result.is_none());
1422    }
1423
1424    #[test]
1425    fn test_tabs_right_click_no_event() {
1426        let mut tabs = Tabs::new()
1427            .tab(Tab::new("a", "A"))
1428            .tab(Tab::new("b", "B"))
1429            .min_tab_width(100.0)
1430            .tab_size(48.0);
1431        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1432
1433        // Right click should not change tabs
1434        let event = Event::MouseDown {
1435            position: Point::new(150.0, 24.0),
1436            button: MouseButton::Right,
1437        };
1438
1439        let result = tabs.event(&event);
1440        assert!(result.is_none());
1441    }
1442
1443    #[test]
1444    fn test_tabs_other_event_no_effect() {
1445        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1446        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1447
1448        let result = tabs.event(&Event::MouseEnter);
1449        assert!(result.is_none());
1450    }
1451}