presentar_widgets/
tabs.rs

1//! Tabs widget for tabbed navigation.
2
3use presentar_core::{
4    widget::{AccessibleRole, LayoutResult, TextStyle},
5    Canvas, Color, Constraints, Event, MouseButton, Point, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9
10/// A single tab definition.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Tab {
13    /// Tab ID
14    pub id: String,
15    /// Tab label
16    pub label: String,
17    /// Whether tab is disabled
18    pub disabled: bool,
19    /// Optional icon name
20    pub icon: Option<String>,
21}
22
23impl Tab {
24    /// Create a new tab.
25    #[must_use]
26    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
27        Self {
28            id: id.into(),
29            label: label.into(),
30            disabled: false,
31            icon: None,
32        }
33    }
34
35    /// Set the tab as disabled.
36    #[must_use]
37    pub const fn disabled(mut self) -> Self {
38        self.disabled = true;
39        self
40    }
41
42    /// Set an icon.
43    #[must_use]
44    pub fn icon(mut self, icon: impl Into<String>) -> Self {
45        self.icon = Some(icon.into());
46        self
47    }
48}
49
50/// Message emitted when active tab changes.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct TabChanged {
53    /// ID of the newly active tab
54    pub tab_id: String,
55    /// Index of the newly active tab
56    pub index: usize,
57}
58
59/// Tab orientation.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61pub enum TabOrientation {
62    /// Tabs on top (horizontal)
63    #[default]
64    Top,
65    /// Tabs on bottom (horizontal)
66    Bottom,
67    /// Tabs on left (vertical)
68    Left,
69    /// Tabs on right (vertical)
70    Right,
71}
72
73/// Tabs widget for tabbed navigation.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Tabs {
76    /// Tab definitions
77    items: Vec<Tab>,
78    /// Active tab index
79    active: usize,
80    /// Tab orientation
81    orientation: TabOrientation,
82    /// Tab bar height (for horizontal) or width (for vertical)
83    tab_size: f32,
84    /// Minimum tab width
85    min_tab_width: f32,
86    /// Tab spacing
87    spacing: f32,
88    /// Tab background color
89    tab_bg: Color,
90    /// Active tab background color
91    active_bg: Color,
92    /// Inactive tab text color
93    inactive_color: Color,
94    /// Active tab text color
95    active_color: Color,
96    /// Disabled tab text color
97    disabled_color: Color,
98    /// Border color
99    border_color: Color,
100    /// Show border under tabs
101    show_border: bool,
102    /// Accessible name
103    accessible_name_value: Option<String>,
104    /// Test ID
105    test_id_value: Option<String>,
106    /// Cached bounds
107    #[serde(skip)]
108    bounds: Rect,
109}
110
111impl Default for Tabs {
112    fn default() -> Self {
113        Self {
114            items: Vec::new(),
115            active: 0,
116            orientation: TabOrientation::Top,
117            tab_size: 48.0,
118            min_tab_width: 80.0,
119            spacing: 0.0,
120            tab_bg: Color::new(0.95, 0.95, 0.95, 1.0),
121            active_bg: Color::WHITE,
122            inactive_color: Color::new(0.4, 0.4, 0.4, 1.0),
123            active_color: Color::new(0.2, 0.47, 0.96, 1.0),
124            disabled_color: Color::new(0.7, 0.7, 0.7, 1.0),
125            border_color: Color::new(0.85, 0.85, 0.85, 1.0),
126            show_border: true,
127            accessible_name_value: None,
128            test_id_value: None,
129            bounds: Rect::default(),
130        }
131    }
132}
133
134impl Tabs {
135    /// Create a new tabs widget.
136    #[must_use]
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Add a tab.
142    #[must_use]
143    pub fn tab(mut self, tab: Tab) -> Self {
144        self.items.push(tab);
145        self
146    }
147
148    /// Add multiple tabs.
149    #[must_use]
150    pub fn tabs(mut self, tabs: impl IntoIterator<Item = Tab>) -> Self {
151        self.items.extend(tabs);
152        self
153    }
154
155    /// Set the active tab by index.
156    #[must_use]
157    pub const fn active(mut self, index: usize) -> Self {
158        self.active = index;
159        self
160    }
161
162    /// Set the active tab by ID.
163    #[must_use]
164    pub fn active_id(mut self, id: &str) -> Self {
165        if let Some(index) = self.items.iter().position(|t| t.id == id) {
166            self.active = index;
167        }
168        self
169    }
170
171    /// Set tab orientation.
172    #[must_use]
173    pub const fn orientation(mut self, orientation: TabOrientation) -> Self {
174        self.orientation = orientation;
175        self
176    }
177
178    /// Set tab bar size.
179    #[must_use]
180    pub fn tab_size(mut self, size: f32) -> Self {
181        self.tab_size = size.max(24.0);
182        self
183    }
184
185    /// Set minimum tab width.
186    #[must_use]
187    pub fn min_tab_width(mut self, width: f32) -> Self {
188        self.min_tab_width = width.max(40.0);
189        self
190    }
191
192    /// Set tab spacing.
193    #[must_use]
194    pub fn spacing(mut self, spacing: f32) -> Self {
195        self.spacing = spacing.max(0.0);
196        self
197    }
198
199    /// Set tab background color.
200    #[must_use]
201    pub const fn tab_bg(mut self, color: Color) -> Self {
202        self.tab_bg = color;
203        self
204    }
205
206    /// Set active tab background color.
207    #[must_use]
208    pub const fn active_bg(mut self, color: Color) -> Self {
209        self.active_bg = color;
210        self
211    }
212
213    /// Set inactive tab text color.
214    #[must_use]
215    pub const fn inactive_color(mut self, color: Color) -> Self {
216        self.inactive_color = color;
217        self
218    }
219
220    /// Set active tab text color.
221    #[must_use]
222    pub const fn active_color(mut self, color: Color) -> Self {
223        self.active_color = color;
224        self
225    }
226
227    /// Set whether to show border.
228    #[must_use]
229    pub const fn show_border(mut self, show: bool) -> Self {
230        self.show_border = show;
231        self
232    }
233
234    /// Set the accessible name.
235    #[must_use]
236    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
237        self.accessible_name_value = Some(name.into());
238        self
239    }
240
241    /// Set the test ID.
242    #[must_use]
243    pub fn test_id(mut self, id: impl Into<String>) -> Self {
244        self.test_id_value = Some(id.into());
245        self
246    }
247
248    /// Get tab count.
249    #[must_use]
250    pub fn tab_count(&self) -> usize {
251        self.items.len()
252    }
253
254    /// Get the tabs.
255    #[must_use]
256    pub fn get_tabs(&self) -> &[Tab] {
257        &self.items
258    }
259
260    /// Get active tab index.
261    #[must_use]
262    pub const fn get_active(&self) -> usize {
263        self.active
264    }
265
266    /// Get active tab.
267    #[must_use]
268    pub fn get_active_tab(&self) -> Option<&Tab> {
269        self.items.get(self.active)
270    }
271
272    /// Get active tab ID.
273    #[must_use]
274    pub fn get_active_id(&self) -> Option<&str> {
275        self.items.get(self.active).map(|t| t.id.as_str())
276    }
277
278    /// Check if a tab is active.
279    #[must_use]
280    pub const fn is_active(&self, index: usize) -> bool {
281        self.active == index
282    }
283
284    /// Check if tabs are empty.
285    #[must_use]
286    pub fn is_empty(&self) -> bool {
287        self.items.is_empty()
288    }
289
290    /// Set active tab by index (mutable).
291    pub fn set_active(&mut self, index: usize) {
292        if index < self.items.len() && !self.items[index].disabled {
293            self.active = index;
294        }
295    }
296
297    /// Set active tab by ID (mutable).
298    pub fn set_active_id(&mut self, id: &str) {
299        if let Some(index) = self.items.iter().position(|t| t.id == id) {
300            if !self.items[index].disabled {
301                self.active = index;
302            }
303        }
304    }
305
306    /// Navigate to next tab.
307    pub fn next_tab(&mut self) {
308        let mut next = (self.active + 1) % self.items.len();
309        let start = next;
310        loop {
311            if !self.items[next].disabled {
312                self.active = next;
313                return;
314            }
315            next = (next + 1) % self.items.len();
316            if next == start {
317                return; // All tabs disabled
318            }
319        }
320    }
321
322    /// Navigate to previous tab.
323    pub fn prev_tab(&mut self) {
324        if self.items.is_empty() {
325            return;
326        }
327        let mut prev = if self.active == 0 {
328            self.items.len() - 1
329        } else {
330            self.active - 1
331        };
332        let start = prev;
333        loop {
334            if !self.items[prev].disabled {
335                self.active = prev;
336                return;
337            }
338            prev = if prev == 0 {
339                self.items.len() - 1
340            } else {
341                prev - 1
342            };
343            if prev == start {
344                return; // All tabs disabled
345            }
346        }
347    }
348
349    /// Calculate tab width based on available space.
350    fn calculate_tab_width(&self, available_width: f32) -> f32 {
351        if self.items.is_empty() {
352            return self.min_tab_width;
353        }
354        let total_spacing = self.spacing * (self.items.len() - 1).max(0) as f32;
355        let per_tab = (available_width - total_spacing) / self.items.len() as f32;
356        per_tab.max(self.min_tab_width)
357    }
358
359    /// Get tab rect by index.
360    fn tab_rect(&self, index: usize, tab_width: f32) -> Rect {
361        match self.orientation {
362            TabOrientation::Top | TabOrientation::Bottom => {
363                let x = (index as f32).mul_add(tab_width + self.spacing, self.bounds.x);
364                let y = if self.orientation == TabOrientation::Top {
365                    self.bounds.y
366                } else {
367                    self.bounds.y + self.bounds.height - self.tab_size
368                };
369                Rect::new(x, y, tab_width, self.tab_size)
370            }
371            TabOrientation::Left | TabOrientation::Right => {
372                let y = (index as f32).mul_add(self.tab_size + self.spacing, self.bounds.y);
373                let x = if self.orientation == TabOrientation::Left {
374                    self.bounds.x
375                } else {
376                    self.bounds.x + self.bounds.width - self.min_tab_width
377                };
378                Rect::new(x, y, self.min_tab_width, self.tab_size)
379            }
380        }
381    }
382
383    /// Find tab at point.
384    fn tab_at_point(&self, x: f32, y: f32) -> Option<usize> {
385        let tab_width = self.calculate_tab_width(self.bounds.width);
386        for (i, _) in self.items.iter().enumerate() {
387            let rect = self.tab_rect(i, tab_width);
388            if x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height {
389                return Some(i);
390            }
391        }
392        None
393    }
394}
395
396impl Widget for Tabs {
397    fn type_id(&self) -> TypeId {
398        TypeId::of::<Self>()
399    }
400
401    fn measure(&self, constraints: Constraints) -> Size {
402        let is_horizontal = matches!(
403            self.orientation,
404            TabOrientation::Top | TabOrientation::Bottom
405        );
406
407        let preferred = if is_horizontal {
408            Size::new(self.items.len() as f32 * self.min_tab_width, self.tab_size)
409        } else {
410            Size::new(self.min_tab_width, self.items.len() as f32 * self.tab_size)
411        };
412
413        constraints.constrain(preferred)
414    }
415
416    fn layout(&mut self, bounds: Rect) -> LayoutResult {
417        self.bounds = bounds;
418        LayoutResult {
419            size: bounds.size(),
420        }
421    }
422
423    fn paint(&self, canvas: &mut dyn Canvas) {
424        // Draw tab bar background
425        canvas.fill_rect(self.bounds, self.tab_bg);
426
427        let tab_width = self.calculate_tab_width(self.bounds.width);
428
429        // Draw individual tabs
430        for (i, tab) in self.items.iter().enumerate() {
431            let rect = self.tab_rect(i, tab_width);
432
433            // Draw tab background
434            let bg_color = if i == self.active {
435                self.active_bg
436            } else {
437                self.tab_bg
438            };
439            canvas.fill_rect(rect, bg_color);
440
441            // Draw tab label
442            let text_color = if tab.disabled {
443                self.disabled_color
444            } else if i == self.active {
445                self.active_color
446            } else {
447                self.inactive_color
448            };
449
450            let text_style = TextStyle {
451                size: 14.0,
452                color: text_color,
453                ..TextStyle::default()
454            };
455
456            canvas.draw_text(
457                &tab.label,
458                Point::new(rect.x + 12.0, rect.y + rect.height / 2.0),
459                &text_style,
460            );
461
462            // Draw active indicator
463            if i == self.active && self.show_border {
464                let indicator_rect = match self.orientation {
465                    TabOrientation::Top => {
466                        Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0)
467                    }
468                    TabOrientation::Bottom => Rect::new(rect.x, rect.y, rect.width, 2.0),
469                    TabOrientation::Left => {
470                        Rect::new(rect.x + rect.width - 2.0, rect.y, 2.0, rect.height)
471                    }
472                    TabOrientation::Right => Rect::new(rect.x, rect.y, 2.0, rect.height),
473                };
474                canvas.fill_rect(indicator_rect, self.active_color);
475            }
476        }
477
478        // Draw border
479        if self.show_border {
480            let border_rect = match self.orientation {
481                TabOrientation::Top => Rect::new(
482                    self.bounds.x,
483                    self.bounds.y + self.tab_size - 1.0,
484                    self.bounds.width,
485                    1.0,
486                ),
487                TabOrientation::Bottom => {
488                    Rect::new(self.bounds.x, self.bounds.y, self.bounds.width, 1.0)
489                }
490                TabOrientation::Left => Rect::new(
491                    self.bounds.x + self.min_tab_width - 1.0,
492                    self.bounds.y,
493                    1.0,
494                    self.bounds.height,
495                ),
496                TabOrientation::Right => {
497                    Rect::new(self.bounds.x, self.bounds.y, 1.0, self.bounds.height)
498                }
499            };
500            canvas.fill_rect(border_rect, self.border_color);
501        }
502    }
503
504    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
505        if let Event::MouseDown {
506            position,
507            button: MouseButton::Left,
508        } = event
509        {
510            if let Some(index) = self.tab_at_point(position.x, position.y) {
511                if !self.items[index].disabled && index != self.active {
512                    self.active = index;
513                    return Some(Box::new(TabChanged {
514                        tab_id: self.items[index].id.clone(),
515                        index,
516                    }));
517                }
518            }
519        }
520        None
521    }
522
523    fn children(&self) -> &[Box<dyn Widget>] {
524        &[]
525    }
526
527    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
528        &mut []
529    }
530
531    fn is_interactive(&self) -> bool {
532        !self.items.is_empty()
533    }
534
535    fn is_focusable(&self) -> bool {
536        !self.items.is_empty()
537    }
538
539    fn accessible_name(&self) -> Option<&str> {
540        self.accessible_name_value.as_deref()
541    }
542
543    fn accessible_role(&self) -> AccessibleRole {
544        AccessibleRole::Tab
545    }
546
547    fn test_id(&self) -> Option<&str> {
548        self.test_id_value.as_deref()
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    // ===== Tab Tests =====
557
558    #[test]
559    fn test_tab_new() {
560        let tab = Tab::new("home", "Home");
561        assert_eq!(tab.id, "home");
562        assert_eq!(tab.label, "Home");
563        assert!(!tab.disabled);
564        assert!(tab.icon.is_none());
565    }
566
567    #[test]
568    fn test_tab_disabled() {
569        let tab = Tab::new("settings", "Settings").disabled();
570        assert!(tab.disabled);
571    }
572
573    #[test]
574    fn test_tab_icon() {
575        let tab = Tab::new("profile", "Profile").icon("user");
576        assert_eq!(tab.icon, Some("user".to_string()));
577    }
578
579    // ===== TabChanged Tests =====
580
581    #[test]
582    fn test_tab_changed() {
583        let msg = TabChanged {
584            tab_id: "settings".to_string(),
585            index: 2,
586        };
587        assert_eq!(msg.tab_id, "settings");
588        assert_eq!(msg.index, 2);
589    }
590
591    // ===== TabOrientation Tests =====
592
593    #[test]
594    fn test_tab_orientation_default() {
595        assert_eq!(TabOrientation::default(), TabOrientation::Top);
596    }
597
598    // ===== Tabs Construction Tests =====
599
600    #[test]
601    fn test_tabs_new() {
602        let tabs = Tabs::new();
603        assert_eq!(tabs.tab_count(), 0);
604        assert!(tabs.is_empty());
605    }
606
607    #[test]
608    fn test_tabs_builder() {
609        let tabs = Tabs::new()
610            .tab(Tab::new("home", "Home"))
611            .tab(Tab::new("about", "About"))
612            .tab(Tab::new("contact", "Contact"))
613            .active(1)
614            .orientation(TabOrientation::Top)
615            .tab_size(50.0)
616            .min_tab_width(100.0)
617            .spacing(4.0)
618            .show_border(true)
619            .accessible_name("Main navigation")
620            .test_id("main-tabs");
621
622        assert_eq!(tabs.tab_count(), 3);
623        assert_eq!(tabs.get_active(), 1);
624        assert_eq!(tabs.get_active_id(), Some("about"));
625        assert_eq!(Widget::accessible_name(&tabs), Some("Main navigation"));
626        assert_eq!(Widget::test_id(&tabs), Some("main-tabs"));
627    }
628
629    #[test]
630    fn test_tabs_multiple() {
631        let tab_list = vec![Tab::new("a", "A"), Tab::new("b", "B"), Tab::new("c", "C")];
632        let tabs = Tabs::new().tabs(tab_list);
633        assert_eq!(tabs.tab_count(), 3);
634    }
635
636    #[test]
637    fn test_tabs_active_id() {
638        let tabs = Tabs::new()
639            .tab(Tab::new("first", "First"))
640            .tab(Tab::new("second", "Second"))
641            .active_id("second");
642
643        assert_eq!(tabs.get_active(), 1);
644    }
645
646    #[test]
647    fn test_tabs_active_id_not_found() {
648        let tabs = Tabs::new()
649            .tab(Tab::new("first", "First"))
650            .active_id("nonexistent");
651
652        assert_eq!(tabs.get_active(), 0);
653    }
654
655    // ===== Active Tab Tests =====
656
657    #[test]
658    fn test_tabs_get_active_tab() {
659        let tabs = Tabs::new()
660            .tab(Tab::new("home", "Home"))
661            .tab(Tab::new("about", "About"))
662            .active(1);
663
664        let active = tabs.get_active_tab().unwrap();
665        assert_eq!(active.id, "about");
666    }
667
668    #[test]
669    fn test_tabs_get_active_tab_empty() {
670        let tabs = Tabs::new();
671        assert!(tabs.get_active_tab().is_none());
672    }
673
674    #[test]
675    fn test_tabs_is_active() {
676        let tabs = Tabs::new()
677            .tab(Tab::new("a", "A"))
678            .tab(Tab::new("b", "B"))
679            .active(1);
680
681        assert!(!tabs.is_active(0));
682        assert!(tabs.is_active(1));
683    }
684
685    // ===== Set Active Tests =====
686
687    #[test]
688    fn test_tabs_set_active() {
689        let mut tabs = Tabs::new()
690            .tab(Tab::new("a", "A"))
691            .tab(Tab::new("b", "B"))
692            .tab(Tab::new("c", "C"));
693
694        tabs.set_active(2);
695        assert_eq!(tabs.get_active(), 2);
696    }
697
698    #[test]
699    fn test_tabs_set_active_out_of_bounds() {
700        let mut tabs = Tabs::new().tab(Tab::new("a", "A")).tab(Tab::new("b", "B"));
701
702        tabs.set_active(10);
703        assert_eq!(tabs.get_active(), 0); // Unchanged
704    }
705
706    #[test]
707    fn test_tabs_set_active_disabled() {
708        let mut tabs = Tabs::new()
709            .tab(Tab::new("a", "A"))
710            .tab(Tab::new("b", "B").disabled());
711
712        tabs.set_active(1);
713        assert_eq!(tabs.get_active(), 0); // Unchanged, tab is disabled
714    }
715
716    #[test]
717    fn test_tabs_set_active_id() {
718        let mut tabs = Tabs::new()
719            .tab(Tab::new("home", "Home"))
720            .tab(Tab::new("settings", "Settings"));
721
722        tabs.set_active_id("settings");
723        assert_eq!(tabs.get_active(), 1);
724    }
725
726    // ===== Navigation Tests =====
727
728    #[test]
729    fn test_tabs_next_tab() {
730        let mut tabs = Tabs::new()
731            .tab(Tab::new("a", "A"))
732            .tab(Tab::new("b", "B"))
733            .tab(Tab::new("c", "C"))
734            .active(0);
735
736        tabs.next_tab();
737        assert_eq!(tabs.get_active(), 1);
738
739        tabs.next_tab();
740        assert_eq!(tabs.get_active(), 2);
741
742        tabs.next_tab(); // Wrap around
743        assert_eq!(tabs.get_active(), 0);
744    }
745
746    #[test]
747    fn test_tabs_next_tab_skip_disabled() {
748        let mut tabs = Tabs::new()
749            .tab(Tab::new("a", "A"))
750            .tab(Tab::new("b", "B").disabled())
751            .tab(Tab::new("c", "C"))
752            .active(0);
753
754        tabs.next_tab();
755        assert_eq!(tabs.get_active(), 2); // Skipped disabled tab
756    }
757
758    #[test]
759    fn test_tabs_prev_tab() {
760        let mut tabs = Tabs::new()
761            .tab(Tab::new("a", "A"))
762            .tab(Tab::new("b", "B"))
763            .tab(Tab::new("c", "C"))
764            .active(2);
765
766        tabs.prev_tab();
767        assert_eq!(tabs.get_active(), 1);
768
769        tabs.prev_tab();
770        assert_eq!(tabs.get_active(), 0);
771
772        tabs.prev_tab(); // Wrap around
773        assert_eq!(tabs.get_active(), 2);
774    }
775
776    #[test]
777    fn test_tabs_prev_tab_skip_disabled() {
778        let mut tabs = Tabs::new()
779            .tab(Tab::new("a", "A"))
780            .tab(Tab::new("b", "B").disabled())
781            .tab(Tab::new("c", "C"))
782            .active(2);
783
784        tabs.prev_tab();
785        assert_eq!(tabs.get_active(), 0); // Skipped disabled tab
786    }
787
788    // ===== Dimension Tests =====
789
790    #[test]
791    fn test_tabs_tab_size_min() {
792        let tabs = Tabs::new().tab_size(10.0);
793        assert_eq!(tabs.tab_size, 24.0);
794    }
795
796    #[test]
797    fn test_tabs_min_tab_width_min() {
798        let tabs = Tabs::new().min_tab_width(20.0);
799        assert_eq!(tabs.min_tab_width, 40.0);
800    }
801
802    #[test]
803    fn test_tabs_spacing_min() {
804        let tabs = Tabs::new().spacing(-5.0);
805        assert_eq!(tabs.spacing, 0.0);
806    }
807
808    #[test]
809    fn test_tabs_calculate_tab_width() {
810        let tabs = Tabs::new()
811            .tab(Tab::new("a", "A"))
812            .tab(Tab::new("b", "B"))
813            .min_tab_width(50.0)
814            .spacing(0.0);
815
816        assert_eq!(tabs.calculate_tab_width(200.0), 100.0);
817    }
818
819    #[test]
820    fn test_tabs_calculate_tab_width_with_spacing() {
821        let tabs = Tabs::new()
822            .tab(Tab::new("a", "A"))
823            .tab(Tab::new("b", "B"))
824            .min_tab_width(50.0)
825            .spacing(10.0);
826
827        // (200 - 10) / 2 = 95
828        assert_eq!(tabs.calculate_tab_width(200.0), 95.0);
829    }
830
831    // ===== Widget Trait Tests =====
832
833    #[test]
834    fn test_tabs_type_id() {
835        let tabs = Tabs::new();
836        assert_eq!(Widget::type_id(&tabs), TypeId::of::<Tabs>());
837    }
838
839    #[test]
840    fn test_tabs_measure_horizontal() {
841        let tabs = Tabs::new()
842            .tab(Tab::new("a", "A"))
843            .tab(Tab::new("b", "B"))
844            .orientation(TabOrientation::Top)
845            .min_tab_width(100.0)
846            .tab_size(48.0);
847
848        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
849        assert_eq!(size.width, 200.0);
850        assert_eq!(size.height, 48.0);
851    }
852
853    #[test]
854    fn test_tabs_measure_vertical() {
855        let tabs = Tabs::new()
856            .tab(Tab::new("a", "A"))
857            .tab(Tab::new("b", "B"))
858            .orientation(TabOrientation::Left)
859            .min_tab_width(100.0)
860            .tab_size(48.0);
861
862        let size = tabs.measure(Constraints::loose(Size::new(500.0, 500.0)));
863        assert_eq!(size.width, 100.0);
864        assert_eq!(size.height, 96.0);
865    }
866
867    #[test]
868    fn test_tabs_layout() {
869        let mut tabs = Tabs::new().tab(Tab::new("a", "A"));
870        let bounds = Rect::new(10.0, 20.0, 300.0, 48.0);
871        let result = tabs.layout(bounds);
872        assert_eq!(result.size, Size::new(300.0, 48.0));
873        assert_eq!(tabs.bounds, bounds);
874    }
875
876    #[test]
877    fn test_tabs_children() {
878        let tabs = Tabs::new();
879        assert!(tabs.children().is_empty());
880    }
881
882    #[test]
883    fn test_tabs_is_interactive() {
884        let tabs = Tabs::new();
885        assert!(!tabs.is_interactive()); // Empty
886
887        let tabs = Tabs::new().tab(Tab::new("a", "A"));
888        assert!(tabs.is_interactive());
889    }
890
891    #[test]
892    fn test_tabs_is_focusable() {
893        let tabs = Tabs::new();
894        assert!(!tabs.is_focusable()); // Empty
895
896        let tabs = Tabs::new().tab(Tab::new("a", "A"));
897        assert!(tabs.is_focusable());
898    }
899
900    #[test]
901    fn test_tabs_accessible_role() {
902        let tabs = Tabs::new();
903        assert_eq!(tabs.accessible_role(), AccessibleRole::Tab);
904    }
905
906    #[test]
907    fn test_tabs_accessible_name() {
908        let tabs = Tabs::new().accessible_name("Section tabs");
909        assert_eq!(Widget::accessible_name(&tabs), Some("Section tabs"));
910    }
911
912    #[test]
913    fn test_tabs_test_id() {
914        let tabs = Tabs::new().test_id("nav-tabs");
915        assert_eq!(Widget::test_id(&tabs), Some("nav-tabs"));
916    }
917
918    // ===== Tab Rect Tests =====
919
920    #[test]
921    fn test_tab_rect_top() {
922        let mut tabs = Tabs::new()
923            .tab(Tab::new("a", "A"))
924            .tab(Tab::new("b", "B"))
925            .orientation(TabOrientation::Top)
926            .tab_size(48.0);
927        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
928
929        let rect0 = tabs.tab_rect(0, 100.0);
930        assert_eq!(rect0.x, 0.0);
931        assert_eq!(rect0.y, 0.0);
932        assert_eq!(rect0.width, 100.0);
933        assert_eq!(rect0.height, 48.0);
934
935        let rect1 = tabs.tab_rect(1, 100.0);
936        assert_eq!(rect1.x, 100.0);
937    }
938
939    #[test]
940    fn test_tab_rect_bottom() {
941        let mut tabs = Tabs::new()
942            .tab(Tab::new("a", "A"))
943            .orientation(TabOrientation::Bottom)
944            .tab_size(48.0);
945        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
946
947        let rect = tabs.tab_rect(0, 100.0);
948        assert_eq!(rect.y, 52.0); // 100 - 48
949    }
950
951    // ===== Event Tests =====
952
953    #[test]
954    fn test_tabs_click_changes_active() {
955        let mut tabs = Tabs::new()
956            .tab(Tab::new("a", "A"))
957            .tab(Tab::new("b", "B"))
958            .tab_size(48.0)
959            .min_tab_width(100.0);
960        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
961
962        // Click on second tab
963        let event = Event::MouseDown {
964            position: Point::new(150.0, 24.0),
965            button: MouseButton::Left,
966        };
967
968        let result = tabs.event(&event);
969        assert!(result.is_some());
970        assert_eq!(tabs.get_active(), 1);
971
972        let msg = result.unwrap().downcast::<TabChanged>().unwrap();
973        assert_eq!(msg.tab_id, "b");
974        assert_eq!(msg.index, 1);
975    }
976
977    #[test]
978    fn test_tabs_click_disabled_no_change() {
979        let mut tabs = Tabs::new()
980            .tab(Tab::new("a", "A"))
981            .tab(Tab::new("b", "B").disabled())
982            .tab_size(48.0)
983            .min_tab_width(100.0);
984        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
985
986        // Click on disabled tab
987        let event = Event::MouseDown {
988            position: Point::new(150.0, 24.0),
989            button: MouseButton::Left,
990        };
991
992        let result = tabs.event(&event);
993        assert!(result.is_none());
994        assert_eq!(tabs.get_active(), 0);
995    }
996
997    #[test]
998    fn test_tabs_click_same_tab_no_event() {
999        let mut tabs = Tabs::new()
1000            .tab(Tab::new("a", "A"))
1001            .tab(Tab::new("b", "B"))
1002            .active(0)
1003            .tab_size(48.0)
1004            .min_tab_width(100.0);
1005        tabs.bounds = Rect::new(0.0, 0.0, 200.0, 48.0);
1006
1007        // Click on already active tab
1008        let event = Event::MouseDown {
1009            position: Point::new(50.0, 24.0),
1010            button: MouseButton::Left,
1011        };
1012
1013        let result = tabs.event(&event);
1014        assert!(result.is_none());
1015    }
1016}