Skip to main content

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)]
588#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
589mod tests {
590    use super::*;
591
592    // ===== Tab Tests =====
593
594    #[test]
595    fn test_tab_new() {
596        let tab = Tab::new("home", "Home");
597        assert_eq!(tab.id, "home");
598        assert_eq!(tab.label, "Home");
599        assert!(!tab.disabled);
600        assert!(tab.icon.is_none());
601    }
602
603    #[test]
604    fn test_tab_disabled() {
605        let tab = Tab::new("settings", "Settings").disabled();
606        assert!(tab.disabled);
607    }
608
609    #[test]
610    fn test_tab_icon() {
611        let tab = Tab::new("profile", "Profile").icon("user");
612        assert_eq!(tab.icon, Some("user".to_string()));
613    }
614
615    // ===== TabChanged Tests =====
616
617    #[test]
618    fn test_tab_changed() {
619        let msg = TabChanged {
620            tab_id: "settings".to_string(),
621            index: 2,
622        };
623        assert_eq!(msg.tab_id, "settings");
624        assert_eq!(msg.index, 2);
625    }
626
627    // ===== TabOrientation Tests =====
628
629    #[test]
630    fn test_tab_orientation_default() {
631        assert_eq!(TabOrientation::default(), TabOrientation::Top);
632    }
633
634    // ===== Tabs Construction Tests =====
635
636    #[test]
637    fn test_tabs_new() {
638        let tabs = Tabs::new();
639        assert_eq!(tabs.tab_count(), 0);
640        assert!(tabs.is_empty());
641    }
642
643    #[test]
644    fn test_tabs_builder() {
645        let tabs = Tabs::new()
646            .tab(Tab::new("home", "Home"))
647            .tab(Tab::new("about", "About"))
648            .tab(Tab::new("contact", "Contact"))
649            .active(1)
650            .orientation(TabOrientation::Top)
651            .tab_size(50.0)
652            .min_tab_width(100.0)
653            .spacing(4.0)
654            .show_border(true)
655            .accessible_name("Main navigation")
656            .test_id("main-tabs");
657
658        assert_eq!(tabs.tab_count(), 3);
659        assert_eq!(tabs.get_active(), 1);
660        assert_eq!(tabs.get_active_id(), Some("about"));
661        assert_eq!(Widget::accessible_name(&tabs), Some("Main navigation"));
662        assert_eq!(Widget::test_id(&tabs), Some("main-tabs"));
663    }
664
665    #[test]
666    fn test_tabs_multiple() {
667        let tab_list = vec![Tab::new("a", "A"), Tab::new("b", "B"), Tab::new("c", "C")];
668        let tabs = Tabs::new().tabs(tab_list);
669        assert_eq!(tabs.tab_count(), 3);
670    }
671
672    #[test]
673    fn test_tabs_active_id() {
674        let tabs = Tabs::new()
675            .tab(Tab::new("first", "First"))
676            .tab(Tab::new("second", "Second"))
677            .active_id("second");
678
679        assert_eq!(tabs.get_active(), 1);
680    }
681
682    #[test]
683    fn test_tabs_active_id_not_found() {
684        let tabs = Tabs::new()
685            .tab(Tab::new("first", "First"))
686            .active_id("nonexistent");
687
688        assert_eq!(tabs.get_active(), 0);
689    }
690
691    // ===== Active Tab Tests =====
692
693    #[test]
694    fn test_tabs_get_active_tab() {
695        let tabs = Tabs::new()
696            .tab(Tab::new("home", "Home"))
697            .tab(Tab::new("about", "About"))
698            .active(1);
699
700        let active = tabs.get_active_tab().unwrap();
701        assert_eq!(active.id, "about");
702    }
703
704    #[test]
705    fn test_tabs_get_active_tab_empty() {
706        let tabs = Tabs::new();
707        assert!(tabs.get_active_tab().is_none());
708    }
709
710    #[test]
711    fn test_tabs_is_active() {
712        let tabs = Tabs::new()
713            .tab(Tab::new("a", "A"))
714            .tab(Tab::new("b", "B"))
715            .active(1);
716
717        assert!(!tabs.is_active(0));
718        assert!(tabs.is_active(1));
719    }
720
721    // ===== Set Active Tests =====
722
723    #[test]
724    fn test_tabs_set_active() {
725        let mut tabs = Tabs::new()
726            .tab(Tab::new("a", "A"))
727            .tab(Tab::new("b", "B"))
728            .tab(Tab::new("c", "C"));
729
730        tabs.set_active(2);
731        assert_eq!(tabs.get_active(), 2);
732    }
733
734    #[test]
735    fn test_tabs_set_active_out_of_bounds() {
736        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
737
738        tabs.set_active(10);
739        assert_eq!(tabs.get_active(), 0); // Unchanged
740    }
741
742    #[test]
743    fn test_tabs_set_active_disabled() {
744        let mut tabs = Tabs::new()
745            .tab(Tab::new("a", "A"))
746            .tab(Tab::new("b", "B").disabled());
747
748        tabs.set_active(1);
749        assert_eq!(tabs.get_active(), 0); // Unchanged, tab is disabled
750    }
751
752    #[test]
753    fn test_tabs_set_active_id() {
754        let mut tabs = Tabs::new()
755            .tab(Tab::new("home", "Home"))
756            .tab(Tab::new("settings", "Settings"));
757
758        tabs.set_active_id("settings");
759        assert_eq!(tabs.get_active(), 1);
760    }
761
762    // ===== Navigation Tests =====
763
764    #[test]
765    fn test_tabs_next_tab() {
766        let mut tabs = Tabs::new()
767            .tab(Tab::new("a", "A"))
768            .tab(Tab::new("b", "B"))
769            .tab(Tab::new("c", "C"))
770            .active(0);
771
772        tabs.next_tab();
773        assert_eq!(tabs.get_active(), 1);
774
775        tabs.next_tab();
776        assert_eq!(tabs.get_active(), 2);
777
778        tabs.next_tab(); // Wrap around
779        assert_eq!(tabs.get_active(), 0);
780    }
781
782    #[test]
783    fn test_tabs_next_tab_skip_disabled() {
784        let mut tabs = Tabs::new()
785            .tab(Tab::new("a", "A"))
786            .tab(Tab::new("b", "B").disabled())
787            .tab(Tab::new("c", "C"))
788            .active(0);
789
790        tabs.next_tab();
791        assert_eq!(tabs.get_active(), 2); // Skipped disabled tab
792    }
793
794    #[test]
795    fn test_tabs_prev_tab() {
796        let mut tabs = Tabs::new()
797            .tab(Tab::new("a", "A"))
798            .tab(Tab::new("b", "B"))
799            .tab(Tab::new("c", "C"))
800            .active(2);
801
802        tabs.prev_tab();
803        assert_eq!(tabs.get_active(), 1);
804
805        tabs.prev_tab();
806        assert_eq!(tabs.get_active(), 0);
807
808        tabs.prev_tab(); // Wrap around
809        assert_eq!(tabs.get_active(), 2);
810    }
811
812    #[test]
813    fn test_tabs_prev_tab_skip_disabled() {
814        let mut tabs = Tabs::new()
815            .tab(Tab::new("a", "A"))
816            .tab(Tab::new("b", "B").disabled())
817            .tab(Tab::new("c", "C"))
818            .active(2);
819
820        tabs.prev_tab();
821        assert_eq!(tabs.get_active(), 0); // Skipped disabled tab
822    }
823
824    // ===== Dimension Tests =====
825
826    #[test]
827    fn test_tabs_tab_size_min() {
828        let tabs = Tabs::new().tab_size(10.0);
829        assert_eq!(tabs.tab_size, 24.0);
830    }
831
832    #[test]
833    fn test_tabs_min_tab_width_min() {
834        let tabs = Tabs::new().min_tab_width(20.0);
835        assert_eq!(tabs.min_tab_width, 40.0);
836    }
837
838    #[test]
839    fn test_tabs_spacing_min() {
840        let tabs = Tabs::new().spacing(-5.0);
841        assert_eq!(tabs.spacing, 0.0);
842    }
843
844    #[test]
845    fn test_tabs_calculate_tab_width() {
846        let tabs = Tabs::new()
847            .tab(Tab::new("a", "A"))
848            .tab(Tab::new("b", "B"))
849            .min_tab_width(50.0)
850            .spacing(0.0);
851
852        assert_eq!(tabs.calculate_tab_width(200.0), 100.0);
853    }
854
855    #[test]
856    fn test_tabs_calculate_tab_width_with_spacing() {
857        let tabs = Tabs::new()
858            .tab(Tab::new("a", "A"))
859            .tab(Tab::new("b", "B"))
860            .min_tab_width(50.0)
861            .spacing(10.0);
862
863        // (200 - 10) / 2 = 95
864        assert_eq!(tabs.calculate_tab_width(200.0), 95.0);
865    }
866
867    // ===== Widget Trait Tests =====
868
869    #[test]
870    fn test_tabs_type_id() {
871        let tabs = Tabs::new();
872        assert_eq!(Widget::type_id(&tabs), TypeId::of::<Tabs>());
873    }
874
875    #[test]
876    fn test_tabs_measure_horizontal() {
877        let tabs = Tabs::new()
878            .tab(Tab::new("a", "A"))
879            .tab(Tab::new("b", "B"))
880            .orientation(TabOrientation::Top)
881            .min_tab_width(100.0)
882            .tab_size(48.0);
883
884        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
885        assert_eq!(size.width, 200.0);
886        assert_eq!(size.height, 48.0);
887    }
888
889    #[test]
890    fn test_tabs_measure_vertical() {
891        let tabs = Tabs::new()
892            .tab(Tab::new("a", "A"))
893            .tab(Tab::new("b", "B"))
894            .orientation(TabOrientation::Left)
895            .min_tab_width(100.0)
896            .tab_size(48.0);
897
898        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
899        assert_eq!(size.width, 100.0);
900        assert_eq!(size.height, 96.0);
901    }
902
903    #[test]
904    fn test_tabs_layout() {
905        let mut tabs = Tabs::new().tab(Tab::new("a", "A"));
906        let bounds = Rect::new(10.0, 20.0, 300.0, 48.0);
907        let result = tabs.layout(bounds);
908        assert_eq!(result.size, Size::new(300.0, 48.0));
909        assert_eq!(tabs.bounds, bounds);
910    }
911
912    #[test]
913    fn test_tabs_children() {
914        let tabs = Tabs::new();
915        assert!(tabs.children().is_empty());
916    }
917
918    #[test]
919    fn test_tabs_is_interactive() {
920        let tabs = Tabs::new();
921        assert!(!tabs.is_interactive()); // Empty
922
923        let tabs = Tabs::new().tab(Tab::new("a", "A"));
924        assert!(tabs.is_interactive());
925    }
926
927    #[test]
928    fn test_tabs_is_focusable() {
929        let tabs = Tabs::new();
930        assert!(!tabs.is_focusable()); // Empty
931
932        let tabs = Tabs::new().tab(Tab::new("a", "A"));
933        assert!(tabs.is_focusable());
934    }
935
936    #[test]
937    fn test_tabs_accessible_role() {
938        let tabs = Tabs::new();
939        assert_eq!(tabs.accessible_role(), AccessibleRole::Tab);
940    }
941
942    #[test]
943    fn test_tabs_accessible_name() {
944        let tabs = Tabs::new().accessible_name("Section tabs");
945        assert_eq!(Widget::accessible_name(&tabs), Some("Section tabs"));
946    }
947
948    #[test]
949    fn test_tabs_test_id() {
950        let tabs = Tabs::new().test_id("nav-tabs");
951        assert_eq!(Widget::test_id(&tabs), Some("nav-tabs"));
952    }
953
954    // ===== Tab Rect Tests =====
955
956    #[test]
957    fn test_tab_rect_top() {
958        let mut tabs = Tabs::new()
959            .tab(Tab::new("a", "A"))
960            .tab(Tab::new("b", "B"))
961            .orientation(TabOrientation::Top)
962            .tab_size(48.0);
963        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
964
965        let rect0 = tabs.tab_rect(0, 100.0);
966        assert_eq!(rect0.x, 0.0);
967        assert_eq!(rect0.y, 0.0);
968        assert_eq!(rect0.width, 100.0);
969        assert_eq!(rect0.height, 48.0);
970
971        let rect1 = tabs.tab_rect(1, 100.0);
972        assert_eq!(rect1.x, 100.0);
973    }
974
975    #[test]
976    fn test_tab_rect_bottom() {
977        let mut tabs = Tabs::new()
978            .tab(Tab::new("a", "A"))
979            .orientation(TabOrientation::Bottom)
980            .tab_size(48.0);
981        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
982
983        let rect = tabs.tab_rect(0, 100.0);
984        assert_eq!(rect.y, 52.0); // 100 - 48
985    }
986
987    // ===== Event Tests =====
988
989    #[test]
990    fn test_tabs_click_changes_active() {
991        let mut tabs = Tabs::new()
992            .tab(Tab::new("a", "A"))
993            .tab(Tab::new("b", "B"))
994            .tab_size(48.0)
995            .min_tab_width(100.0);
996        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
997
998        // Click on second tab
999        let event = Event::MouseDown {
1000            position: Point::new(150.0, 24.0),
1001            button: MouseButton::Left,
1002        };
1003
1004        let result = tabs.event(&event);
1005        assert!(result.is_some());
1006        assert_eq!(tabs.get_active(), 1);
1007
1008        let msg = result.unwrap().downcast::<TabChanged>().unwrap();
1009        assert_eq!(msg.tab_id, "b");
1010        assert_eq!(msg.index, 1);
1011    }
1012
1013    #[test]
1014    fn test_tabs_click_disabled_no_change() {
1015        let mut tabs = Tabs::new()
1016            .tab(Tab::new("a", "A"))
1017            .tab(Tab::new("b", "B").disabled())
1018            .tab_size(48.0)
1019            .min_tab_width(100.0);
1020        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1021
1022        // Click on disabled tab
1023        let event = Event::MouseDown {
1024            position: Point::new(150.0, 24.0),
1025            button: MouseButton::Left,
1026        };
1027
1028        let result = tabs.event(&event);
1029        assert!(result.is_none());
1030        assert_eq!(tabs.get_active(), 0);
1031    }
1032
1033    #[test]
1034    fn test_tabs_click_same_tab_no_event() {
1035        let mut tabs = Tabs::new()
1036            .tab(Tab::new("a", "A"))
1037            .tab(Tab::new("b", "B"))
1038            .active(0)
1039            .tab_size(48.0)
1040            .min_tab_width(100.0);
1041        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1042
1043        // Click on already active tab
1044        let event = Event::MouseDown {
1045            position: Point::new(50.0, 24.0),
1046            button: MouseButton::Left,
1047        };
1048
1049        let result = tabs.event(&event);
1050        assert!(result.is_none());
1051    }
1052
1053    // ===== Additional Tab Rect Tests =====
1054
1055    #[test]
1056    fn test_tab_rect_left() {
1057        let mut tabs = Tabs::new()
1058            .tab(Tab::new("a", "A"))
1059            .tab(Tab::new("b", "B"))
1060            .orientation(TabOrientation::Left)
1061            .tab_size(48.0)
1062            .min_tab_width(80.0);
1063        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
1064
1065        let rect0 = tabs.tab_rect(0, 80.0);
1066        assert_eq!(rect0.x, 0.0);
1067        assert_eq!(rect0.y, 0.0);
1068        assert_eq!(rect0.width, 80.0);
1069        assert_eq!(rect0.height, 48.0);
1070
1071        let rect1 = tabs.tab_rect(1, 80.0);
1072        assert_eq!(rect1.y, 48.0);
1073    }
1074
1075    #[test]
1076    fn test_tab_rect_right() {
1077        let mut tabs = Tabs::new()
1078            .tab(Tab::new("a", "A"))
1079            .orientation(TabOrientation::Right)
1080            .tab_size(48.0)
1081            .min_tab_width(80.0);
1082        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 200.0);
1083
1084        let rect = tabs.tab_rect(0, 80.0);
1085        assert_eq!(rect.x, 120.0); // 200 - 80
1086    }
1087
1088    #[test]
1089    fn test_tab_rect_with_spacing() {
1090        let mut tabs = Tabs::new()
1091            .tab(Tab::new("a", "A"))
1092            .tab(Tab::new("b", "B"))
1093            .orientation(TabOrientation::Top)
1094            .spacing(10.0)
1095            .tab_size(48.0);
1096        tabs.bounds = Rect::new(0.0, 0.0, 300.0, 48.0);
1097
1098        let rect0 = tabs.tab_rect(0, 100.0);
1099        assert_eq!(rect0.x, 0.0);
1100
1101        let rect1 = tabs.tab_rect(1, 100.0);
1102        assert_eq!(rect1.x, 110.0); // 100 + 10 spacing
1103    }
1104
1105    // ===== Calculate Tab Width Tests =====
1106
1107    #[test]
1108    fn test_calculate_tab_width_empty() {
1109        let tabs = Tabs::new().min_tab_width(80.0);
1110        assert_eq!(tabs.calculate_tab_width(500.0), 80.0);
1111    }
1112
1113    #[test]
1114    fn test_calculate_tab_width_narrow_space() {
1115        let tabs = Tabs::new()
1116            .tab(Tab::new("a", "A"))
1117            .tab(Tab::new("b", "B"))
1118            .min_tab_width(100.0);
1119
1120        // Space so narrow that we need to use min_tab_width
1121        assert_eq!(tabs.calculate_tab_width(50.0), 100.0);
1122    }
1123
1124    // ===== Tab At Point Tests =====
1125
1126    #[test]
1127    fn test_tab_at_point_found() {
1128        let mut tabs = Tabs::new()
1129            .tab(Tab::new("a", "A"))
1130            .tab(Tab::new("b", "B"))
1131            .min_tab_width(100.0)
1132            .tab_size(48.0);
1133        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1134
1135        assert_eq!(tabs.tab_at_point(50.0, 24.0), Some(0));
1136        assert_eq!(tabs.tab_at_point(150.0, 24.0), Some(1));
1137    }
1138
1139    #[test]
1140    fn test_tab_at_point_not_found() {
1141        let mut tabs = Tabs::new()
1142            .tab(Tab::new("a", "A"))
1143            .min_tab_width(100.0)
1144            .tab_size(48.0);
1145        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1146
1147        // Click outside tabs area
1148        assert_eq!(tabs.tab_at_point(50.0, 100.0), None);
1149        assert_eq!(tabs.tab_at_point(-10.0, 24.0), None);
1150    }
1151
1152    // ===== Navigation Edge Cases =====
1153
1154    #[test]
1155    fn test_tabs_next_tab_all_disabled() {
1156        let mut tabs = Tabs::new()
1157            .tab(Tab::new("a", "A").disabled())
1158            .tab(Tab::new("b", "B").disabled())
1159            .tab(Tab::new("c", "C").disabled());
1160        tabs.active = 0; // Start position
1161
1162        tabs.next_tab();
1163        assert_eq!(tabs.get_active(), 0); // Unchanged - all disabled
1164    }
1165
1166    #[test]
1167    fn test_tabs_prev_tab_all_disabled() {
1168        let mut tabs = Tabs::new()
1169            .tab(Tab::new("a", "A").disabled())
1170            .tab(Tab::new("b", "B").disabled());
1171        tabs.active = 0;
1172
1173        tabs.prev_tab();
1174        assert_eq!(tabs.get_active(), 0); // Unchanged - all disabled
1175    }
1176
1177    #[test]
1178    fn test_tabs_prev_tab_empty() {
1179        let mut tabs = Tabs::new();
1180        tabs.prev_tab(); // Should not panic
1181        assert_eq!(tabs.get_active(), 0);
1182    }
1183
1184    #[test]
1185    fn test_tabs_set_active_id_not_found() {
1186        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1187
1188        tabs.set_active_id("nonexistent");
1189        assert_eq!(tabs.get_active(), 0); // Unchanged
1190    }
1191
1192    #[test]
1193    fn test_tabs_set_active_id_disabled() {
1194        let mut tabs = Tabs::new()
1195            .tab(Tab::new("a", "A"))
1196            .tab(Tab::new("b", "B").disabled());
1197
1198        tabs.set_active_id("b");
1199        assert_eq!(tabs.get_active(), 0); // Unchanged - disabled
1200    }
1201
1202    // ===== Color Setters =====
1203
1204    #[test]
1205    fn test_tabs_tab_bg() {
1206        let tabs = Tabs::new().tab_bg(Color::RED);
1207        assert_eq!(tabs.tab_bg, Color::RED);
1208    }
1209
1210    #[test]
1211    fn test_tabs_active_bg() {
1212        let tabs = Tabs::new().active_bg(Color::BLUE);
1213        assert_eq!(tabs.active_bg, Color::BLUE);
1214    }
1215
1216    #[test]
1217    fn test_tabs_inactive_color() {
1218        let tabs = Tabs::new().inactive_color(Color::GREEN);
1219        assert_eq!(tabs.inactive_color, Color::GREEN);
1220    }
1221
1222    #[test]
1223    fn test_tabs_active_color() {
1224        let tabs = Tabs::new().active_color(Color::WHITE);
1225        assert_eq!(tabs.active_color, Color::WHITE);
1226    }
1227
1228    // ===== Get Tabs Tests =====
1229
1230    #[test]
1231    fn test_tabs_get_tabs() {
1232        let tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1233
1234        let items = tabs.get_tabs();
1235        assert_eq!(items.len(), 2);
1236        assert_eq!(items[0].id, "a");
1237        assert_eq!(items[1].id, "b");
1238    }
1239
1240    #[test]
1241    fn test_tabs_get_active_id_none() {
1242        let tabs = Tabs::new();
1243        assert!(tabs.get_active_id().is_none());
1244    }
1245
1246    // ===== Paint Tests =====
1247
1248    #[test]
1249    fn test_tabs_paint() {
1250        use presentar_core::RecordingCanvas;
1251
1252        let mut tabs = Tabs::new()
1253            .tab(Tab::new("a", "A"))
1254            .tab(Tab::new("b", "B"))
1255            .orientation(TabOrientation::Top)
1256            .show_border(true);
1257        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1258
1259        let mut canvas = RecordingCanvas::new();
1260        tabs.paint(&mut canvas);
1261
1262        // Should draw: tab bar bg + tab 1 bg + tab 1 text + tab 2 bg + tab 2 text + active indicator + border
1263        assert!(canvas.command_count() >= 5);
1264    }
1265
1266    #[test]
1267    fn test_tabs_paint_no_border() {
1268        use presentar_core::RecordingCanvas;
1269
1270        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).show_border(false);
1271        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1272
1273        let mut canvas = RecordingCanvas::new();
1274        tabs.paint(&mut canvas);
1275
1276        // Should still draw tabs, just no border
1277        assert!(canvas.command_count() >= 2);
1278    }
1279
1280    #[test]
1281    fn test_tabs_paint_disabled_tab() {
1282        use presentar_core::RecordingCanvas;
1283
1284        let mut tabs = Tabs::new()
1285            .tab(Tab::new("a", "A"))
1286            .tab(Tab::new("b", "B").disabled())
1287            .active(0);
1288        tabs.layout(Rect::new(0.0, 0.0, 200.0, 48.0));
1289
1290        let mut canvas = RecordingCanvas::new();
1291        tabs.paint(&mut canvas);
1292
1293        // Should paint both tabs, disabled with different color
1294        assert!(canvas.command_count() >= 4);
1295    }
1296
1297    #[test]
1298    fn test_tabs_paint_vertical_left() {
1299        use presentar_core::RecordingCanvas;
1300
1301        let mut tabs = Tabs::new()
1302            .tab(Tab::new("a", "A"))
1303            .tab(Tab::new("b", "B"))
1304            .orientation(TabOrientation::Left)
1305            .show_border(true);
1306        tabs.layout(Rect::new(0.0, 0.0, 100.0, 200.0));
1307
1308        let mut canvas = RecordingCanvas::new();
1309        tabs.paint(&mut canvas);
1310
1311        assert!(canvas.command_count() >= 5);
1312    }
1313
1314    #[test]
1315    fn test_tabs_paint_vertical_right() {
1316        use presentar_core::RecordingCanvas;
1317
1318        let mut tabs = Tabs::new()
1319            .tab(Tab::new("a", "A"))
1320            .tab(Tab::new("b", "B"))
1321            .orientation(TabOrientation::Right)
1322            .show_border(true);
1323        tabs.layout(Rect::new(0.0, 0.0, 100.0, 200.0));
1324
1325        let mut canvas = RecordingCanvas::new();
1326        tabs.paint(&mut canvas);
1327
1328        assert!(canvas.command_count() >= 5);
1329    }
1330
1331    #[test]
1332    fn test_tabs_paint_bottom() {
1333        use presentar_core::RecordingCanvas;
1334
1335        let mut tabs = Tabs::new()
1336            .tab(Tab::new("a", "A"))
1337            .tab(Tab::new("b", "B"))
1338            .orientation(TabOrientation::Bottom)
1339            .show_border(true);
1340        tabs.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
1341
1342        let mut canvas = RecordingCanvas::new();
1343        tabs.paint(&mut canvas);
1344
1345        assert!(canvas.command_count() >= 5);
1346    }
1347
1348    // ===== Brick Trait Tests =====
1349
1350    #[test]
1351    fn test_tabs_brick_name() {
1352        let tabs = Tabs::new();
1353        assert_eq!(tabs.brick_name(), "Tabs");
1354    }
1355
1356    #[test]
1357    fn test_tabs_brick_assertions() {
1358        let tabs = Tabs::new();
1359        let assertions = tabs.assertions();
1360        assert_eq!(assertions.len(), 1);
1361        assert!(assertions.contains(&BrickAssertion::MaxLatencyMs(16)));
1362    }
1363
1364    #[test]
1365    fn test_tabs_brick_budget() {
1366        let tabs = Tabs::new();
1367        let budget = tabs.budget();
1368        // BrickBudget::uniform(16) sets internal values
1369        assert!(budget.measure_ms > 0);
1370        assert!(budget.layout_ms > 0);
1371        assert!(budget.paint_ms > 0);
1372    }
1373
1374    #[test]
1375    fn test_tabs_brick_verify() {
1376        let tabs = Tabs::new();
1377        let verification = tabs.verify();
1378        assert!(verification.failed.is_empty());
1379        assert!(!verification.passed.is_empty());
1380    }
1381
1382    #[test]
1383    fn test_tabs_to_html() {
1384        let tabs = Tabs::new();
1385        let html = tabs.to_html();
1386        assert!(html.contains("brick-tabs"));
1387    }
1388
1389    #[test]
1390    fn test_tabs_to_css() {
1391        let tabs = Tabs::new();
1392        let css = tabs.to_css();
1393        assert!(css.contains("brick-tabs"));
1394        assert!(css.contains("display: flex"));
1395    }
1396
1397    // ===== Widget Children Mut Tests =====
1398
1399    #[test]
1400    fn test_tabs_children_mut() {
1401        let mut tabs = Tabs::new();
1402        assert!(tabs.children_mut().is_empty());
1403    }
1404
1405    // ===== Event Edge Cases =====
1406
1407    #[test]
1408    fn test_tabs_click_outside() {
1409        let mut tabs = Tabs::new()
1410            .tab(Tab::new("a", "A"))
1411            .min_tab_width(100.0)
1412            .tab_size(48.0);
1413        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1414
1415        // Click outside tabs
1416        let event = Event::MouseDown {
1417            position: Point::new(500.0, 24.0),
1418            button: MouseButton::Left,
1419        };
1420
1421        let result = tabs.event(&event);
1422        assert!(result.is_none());
1423    }
1424
1425    #[test]
1426    fn test_tabs_right_click_no_event() {
1427        let mut tabs = Tabs::new()
1428            .tab(Tab::new("a", "A"))
1429            .tab(Tab::new("b", "B"))
1430            .min_tab_width(100.0)
1431            .tab_size(48.0);
1432        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1433
1434        // Right click should not change tabs
1435        let event = Event::MouseDown {
1436            position: Point::new(150.0, 24.0),
1437            button: MouseButton::Right,
1438        };
1439
1440        let result = tabs.event(&event);
1441        assert!(result.is_none());
1442    }
1443
1444    #[test]
1445    fn test_tabs_other_event_no_effect() {
1446        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
1447        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1448
1449        let result = tabs.event(&Event::MouseEnter);
1450        assert!(result.is_none());
1451    }
1452}