Skip to main content

ftui_widgets/
tabs.rs

1#![forbid(unsafe_code)]
2
3//! Tabs widget.
4//!
5//! Provides a horizontal tab bar with keyboard navigation, overflow handling,
6//! closable tabs, and tab reordering helpers.
7
8use crate::mouse::MouseResult;
9use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
10use ftui_core::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
11use ftui_core::geometry::Rect;
12use ftui_render::frame::{Frame, HitId, HitRegion};
13use ftui_style::Style;
14use ftui_text::display_width;
15#[cfg(feature = "tracing")]
16use web_time::Instant;
17
18/// A single tab entry.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Tab<'a> {
21    title: String,
22    style: Style,
23    closable: bool,
24    _marker: std::marker::PhantomData<&'a ()>,
25}
26
27impl<'a> Tab<'a> {
28    /// Create a new tab with a title.
29    #[must_use]
30    pub fn new(title: impl Into<String>) -> Self {
31        Self {
32            title: title.into(),
33            style: Style::default(),
34            closable: false,
35            _marker: std::marker::PhantomData,
36        }
37    }
38
39    /// Set style for this tab.
40    #[must_use]
41    pub fn style(mut self, style: Style) -> Self {
42        self.style = style;
43        self
44    }
45
46    /// Set whether this tab can be closed.
47    #[must_use]
48    pub fn closable(mut self, closable: bool) -> Self {
49        self.closable = closable;
50        self
51    }
52
53    /// Get tab title.
54    #[must_use]
55    pub fn title(&self) -> &str {
56        &self.title
57    }
58
59    /// Whether this tab can be closed.
60    #[must_use]
61    pub const fn is_closable(&self) -> bool {
62        self.closable
63    }
64}
65
66/// State for a [`Tabs`] widget.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct TabsState {
69    /// Active tab index.
70    pub active: usize,
71    /// Left-most tab index when overflow scrolling is active.
72    pub offset: usize,
73}
74
75impl TabsState {
76    /// Select a specific tab index.
77    pub fn select(&mut self, index: usize, tab_count: usize) -> bool {
78        if tab_count == 0 {
79            self.active = 0;
80            self.offset = 0;
81            return false;
82        }
83        let next = index.min(tab_count.saturating_sub(1));
84        if self.active == next {
85            return false;
86        }
87        #[cfg(feature = "tracing")]
88        let old = self.active;
89        self.active = next;
90        if self.active < self.offset {
91            self.offset = self.active;
92        }
93        #[cfg(feature = "tracing")]
94        Self::log_switch("select", old, self.active);
95        true
96    }
97
98    /// Move active tab right by one.
99    pub fn next(&mut self, tab_count: usize) -> bool {
100        if tab_count == 0 {
101            return false;
102        }
103        self.select(
104            self.active
105                .saturating_add(1)
106                .min(tab_count.saturating_sub(1)),
107            tab_count,
108        )
109    }
110
111    /// Move active tab left by one.
112    pub fn previous(&mut self, tab_count: usize) -> bool {
113        if tab_count == 0 {
114            return false;
115        }
116        self.select(self.active.saturating_sub(1), tab_count)
117    }
118
119    /// Handle keyboard tab switching.
120    ///
121    /// Supported:
122    /// - `Left` / `Right`
123    /// - number keys `1..9`
124    pub fn handle_key(&mut self, key: &KeyEvent, tab_count: usize) -> bool {
125        match key.code {
126            KeyCode::Left => self.previous(tab_count),
127            KeyCode::Right => self.next(tab_count),
128            KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {
129                let idx = ch as usize - '1' as usize;
130                if idx >= tab_count {
131                    false
132                } else {
133                    self.select(idx, tab_count)
134                }
135            }
136            _ => false,
137        }
138    }
139
140    /// Handle mouse selection for tabs.
141    ///
142    /// Hit data convention: each tab row registers `data = tab_index as u64`.
143    pub fn handle_mouse(
144        &mut self,
145        event: &MouseEvent,
146        hit: Option<(HitId, HitRegion, u64)>,
147        expected_id: HitId,
148        tab_count: usize,
149    ) -> MouseResult {
150        match event.kind {
151            MouseEventKind::Down(MouseButton::Left) => {
152                if let Some((id, HitRegion::Content, data)) = hit
153                    && id == expected_id
154                {
155                    let idx = data as usize;
156                    if idx < tab_count {
157                        if self.active == idx {
158                            return MouseResult::Activated(idx);
159                        }
160                        self.select(idx, tab_count);
161                        return MouseResult::Selected(idx);
162                    }
163                }
164                MouseResult::Ignored
165            }
166            _ => MouseResult::Ignored,
167        }
168    }
169
170    #[cfg(feature = "tracing")]
171    fn log_switch(reason: &str, from: usize, to: usize) {
172        tracing::debug!(message = "tabs.switch", reason, from, to);
173    }
174}
175
176/// Tabs widget.
177#[derive(Debug, Clone, Default)]
178pub struct Tabs<'a> {
179    tabs: Vec<Tab<'a>>,
180    style: Style,
181    active_style: Style,
182    separator: &'a str,
183    close_marker: &'a str,
184    overflow_left_marker: &'a str,
185    overflow_right_marker: &'a str,
186    hit_id: Option<HitId>,
187}
188
189impl<'a> Tabs<'a> {
190    /// Create tabs from an iterator.
191    #[must_use]
192    pub fn new(tabs: impl IntoIterator<Item = Tab<'a>>) -> Self {
193        Self {
194            tabs: tabs.into_iter().collect(),
195            style: Style::default(),
196            active_style: Style::default(),
197            separator: " ",
198            close_marker: " x",
199            overflow_left_marker: "<",
200            overflow_right_marker: ">",
201            hit_id: None,
202        }
203    }
204
205    /// Set base style.
206    #[must_use]
207    pub fn style(mut self, style: Style) -> Self {
208        self.style = style;
209        self
210    }
211
212    /// Set active tab style.
213    #[must_use]
214    pub fn active_style(mut self, style: Style) -> Self {
215        self.active_style = style;
216        self
217    }
218
219    /// Set separator between tabs.
220    #[must_use]
221    pub fn separator(mut self, separator: &'a str) -> Self {
222        self.separator = separator;
223        self
224    }
225
226    /// Set hit id for mouse interactions.
227    #[must_use]
228    pub fn hit_id(mut self, id: HitId) -> Self {
229        self.hit_id = Some(id);
230        self
231    }
232
233    /// Immutable tab slice.
234    #[must_use]
235    pub fn tabs(&self) -> &[Tab<'a>] {
236        &self.tabs
237    }
238
239    fn tab_label(&self, tab: &Tab<'_>, active: bool) -> String {
240        let mut out = String::new();
241        if active {
242            out.push('[');
243        } else {
244            out.push(' ');
245        }
246        out.push_str(tab.title());
247        if tab.is_closable() {
248            out.push_str(self.close_marker);
249        }
250        if active {
251            out.push(']');
252        } else {
253            out.push(' ');
254        }
255        out
256    }
257
258    fn visible_end(&self, state: &TabsState, width: usize) -> usize {
259        if self.tabs.is_empty() || width == 0 {
260            return state.offset;
261        }
262        let sep_width = display_width(self.separator);
263        let mut used = 0usize;
264        let mut end = state.offset;
265
266        for idx in state.offset..self.tabs.len() {
267            let w = display_width(
268                self.tab_label(&self.tabs[idx], idx == state.active)
269                    .as_str(),
270            );
271            let extra = if idx == state.offset { 0 } else { sep_width };
272            if end == state.offset {
273                // Always allow at least one tab; draw helper clips if too long.
274                used = w;
275                end = idx + 1;
276                if used > width {
277                    break;
278                }
279                continue;
280            }
281            if used.saturating_add(extra).saturating_add(w) > width {
282                break;
283            }
284            used = used.saturating_add(extra).saturating_add(w);
285            end = idx + 1;
286        }
287
288        end.max((state.offset + 1).min(self.tabs.len()))
289    }
290
291    fn compute_visible_range(
292        &self,
293        state: &mut TabsState,
294        area_width: usize,
295    ) -> (usize, usize, bool, bool) {
296        if self.tabs.is_empty() || area_width == 0 {
297            state.active = 0;
298            state.offset = 0;
299            return (0, 0, false, false);
300        }
301        state.active = state.active.min(self.tabs.len().saturating_sub(1));
302        state.offset = state.offset.min(self.tabs.len().saturating_sub(1));
303        if state.active < state.offset {
304            state.offset = state.active;
305        }
306
307        let left_marker_w = display_width(self.overflow_left_marker);
308        let right_marker_w = display_width(self.overflow_right_marker);
309
310        let mut available_width = area_width;
311        let mut start = state.offset;
312        let mut end = self.visible_end(state, available_width);
313
314        // If active is out of view (e.g. initial render with small width), jump to it
315        if state.active >= end {
316            start = state.active;
317            state.offset = start;
318            end = self.visible_end(state, available_width);
319        }
320
321        // Iteratively refine width based on overflow markers
322        for _ in 0..3 {
323            let overflow_left = start > 0;
324            let overflow_right = end < self.tabs.len();
325
326            let mut next_width = area_width;
327            if overflow_left {
328                next_width = next_width.saturating_sub(left_marker_w);
329            }
330            if overflow_right {
331                next_width = next_width.saturating_sub(right_marker_w);
332            }
333
334            if next_width == available_width {
335                break;
336            }
337            available_width = next_width;
338
339            // Re-calculate with new width
340            end = self.visible_end(state, available_width);
341
342            // Ensure active is still visible
343            if state.active >= end {
344                start = state.active;
345                state.offset = start;
346                end = self.visible_end(state, available_width);
347            }
348        }
349
350        let overflow_left = start > 0;
351        let overflow_right = end < self.tabs.len();
352        (start, end, overflow_left, overflow_right)
353    }
354
355    /// Close the active tab if it is closable.
356    pub fn close_active(&mut self, state: &mut TabsState) -> Option<Tab<'a>> {
357        if self.tabs.is_empty() {
358            state.active = 0;
359            state.offset = 0;
360            return None;
361        }
362        state.active = state.active.min(self.tabs.len().saturating_sub(1));
363        if !self.tabs[state.active].is_closable() {
364            return None;
365        }
366        let removed = self.tabs.remove(state.active);
367        if self.tabs.is_empty() {
368            state.active = 0;
369            state.offset = 0;
370        } else if state.active >= self.tabs.len() {
371            state.active = self.tabs.len().saturating_sub(1);
372            state.offset = state.offset.min(state.active);
373        }
374        Some(removed)
375    }
376
377    /// Move active tab one position to the left.
378    pub fn move_active_left(&mut self, state: &mut TabsState) -> bool {
379        if self.tabs.len() < 2 || state.active == 0 || state.active >= self.tabs.len() {
380            return false;
381        }
382        self.tabs.swap(state.active, state.active - 1);
383        state.active -= 1;
384        state.offset = state.offset.min(state.active);
385        true
386    }
387
388    /// Move active tab one position to the right.
389    pub fn move_active_right(&mut self, state: &mut TabsState) -> bool {
390        if self.tabs.len() < 2 || state.active + 1 >= self.tabs.len() {
391            return false;
392        }
393        self.tabs.swap(state.active, state.active + 1);
394        state.active += 1;
395        true
396    }
397}
398
399impl StatefulWidget for Tabs<'_> {
400    type State = TabsState;
401
402    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
403        #[cfg(feature = "tracing")]
404        let render_start = Instant::now();
405
406        if area.is_empty() || area.height == 0 {
407            return;
408        }
409
410        let deg = frame.buffer.degradation;
411        let base_style = if deg.apply_styling() {
412            self.style
413        } else {
414            Style::default()
415        };
416
417        clear_text_row(frame, area, base_style);
418
419        if !deg.render_content() || self.tabs.is_empty() {
420            return;
421        }
422
423        let (start, end, overflow_left, overflow_right) =
424            self.compute_visible_range(state, area.width as usize);
425
426        #[cfg(feature = "tracing")]
427        let tab_count = self.tabs.len();
428        #[cfg(feature = "tracing")]
429        let active_tab = state.active.min(self.tabs.len().saturating_sub(1));
430        #[cfg(feature = "tracing")]
431        let render_span = tracing::debug_span!(
432            "tabs.render",
433            tab_count,
434            active_tab,
435            overflow = overflow_left || overflow_right,
436            render_duration_us = tracing::field::Empty
437        );
438        #[cfg(feature = "tracing")]
439        let _render_guard = render_span.enter();
440
441        let mut left = area.x;
442        let mut right = area.right();
443        if overflow_left {
444            draw_text_span(
445                frame,
446                area.x,
447                area.y,
448                self.overflow_left_marker,
449                base_style,
450                area.right(),
451            );
452            left = left.saturating_add(display_width(self.overflow_left_marker) as u16);
453        }
454        if overflow_right {
455            right = right.saturating_sub(display_width(self.overflow_right_marker) as u16);
456            draw_text_span(
457                frame,
458                right,
459                area.y,
460                self.overflow_right_marker,
461                base_style,
462                area.right(),
463            );
464        }
465
466        let mut x = left;
467        for idx in start..end {
468            if x >= right {
469                break;
470            }
471            if idx > start && !self.separator.is_empty() {
472                x = draw_text_span(frame, x, area.y, self.separator, base_style, right);
473                if x >= right {
474                    break;
475                }
476            }
477            let tab = &self.tabs[idx];
478            let label = self.tab_label(tab, idx == state.active);
479            let mut tab_style = base_style;
480            if deg.apply_styling() {
481                tab_style = self.style.merge(&tab.style);
482                if idx == state.active {
483                    tab_style = self.active_style.merge(&tab_style);
484                }
485            }
486            let before = x;
487            x = draw_text_span(frame, x, area.y, &label, tab_style, right);
488            if let Some(id) = self.hit_id {
489                let width = x.saturating_sub(before).max(1);
490                frame.register_hit(
491                    Rect::new(before, area.y, width, 1),
492                    id,
493                    HitRegion::Content,
494                    idx as u64,
495                );
496            }
497        }
498
499        #[cfg(feature = "tracing")]
500        {
501            let elapsed_us = render_start.elapsed().as_micros() as u64;
502            render_span.record("render_duration_us", elapsed_us);
503        }
504    }
505}
506
507impl Widget for Tabs<'_> {
508    fn render(&self, area: Rect, frame: &mut Frame) {
509        let mut state = TabsState::default();
510        StatefulWidget::render(self, area, frame, &mut state);
511    }
512
513    fn is_essential(&self) -> bool {
514        true
515    }
516}
517
518impl ftui_a11y::Accessible for Tabs<'_> {
519    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
520        use ftui_a11y::node::{A11yNodeInfo, A11yRole};
521
522        let base_id = crate::a11y_node_id(area);
523        let tab_count = self.tabs.len();
524        let child_ids: Vec<u64> = (0..tab_count).map(|i| base_id + 1 + i as u64).collect();
525
526        let group_node = A11yNodeInfo::new(base_id, A11yRole::Group, area)
527            .with_name(format!("{tab_count} tabs"))
528            .with_children(child_ids);
529
530        let mut nodes = vec![group_node];
531        for (i, tab) in self.tabs.iter().enumerate() {
532            let tab_id = base_id + 1 + i as u64;
533            nodes.push(
534                A11yNodeInfo::new(tab_id, A11yRole::Tab, area)
535                    .with_name(&tab.title)
536                    .with_parent(base_id),
537            );
538        }
539        nodes
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use ftui_core::event::{KeyCode, KeyEvent};
547    use ftui_render::budget::DegradationLevel;
548    use ftui_render::grapheme_pool::GraphemePool;
549    #[cfg(feature = "tracing")]
550    use std::sync::{Arc, Mutex};
551    #[cfg(feature = "tracing")]
552    use tracing::Subscriber;
553    #[cfg(feature = "tracing")]
554    use tracing_subscriber::Layer;
555    #[cfg(feature = "tracing")]
556    use tracing_subscriber::layer::{Context, SubscriberExt};
557
558    fn row_text(frame: &Frame, y: u16) -> String {
559        let mut out = String::new();
560        for x in 0..frame.buffer.width() {
561            let ch = frame
562                .buffer
563                .get(x, y)
564                .and_then(|cell| cell.content.as_char())
565                .unwrap_or(' ');
566            out.push(ch);
567        }
568        out
569    }
570
571    #[test]
572    fn tabs_render_basic() {
573        let tabs = Tabs::new(vec![Tab::new("One"), Tab::new("Two"), Tab::new("Three")]);
574        let mut state = TabsState::default();
575        state.select(1, 3);
576        let mut pool = GraphemePool::new();
577        let mut frame = Frame::new(30, 1, &mut pool);
578        StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
579        let row = row_text(&frame, 0);
580        assert!(row.contains("[Two]"));
581    }
582
583    #[test]
584    fn tabs_keyboard_switching_arrows_and_numbers() {
585        let mut state = TabsState::default();
586        assert!(state.handle_key(&KeyEvent::new(KeyCode::Right), 4));
587        assert_eq!(state.active, 1);
588        assert!(state.handle_key(&KeyEvent::new(KeyCode::Left), 4));
589        assert_eq!(state.active, 0);
590        assert!(state.handle_key(&KeyEvent::new(KeyCode::Char('3')), 4));
591        assert_eq!(state.active, 2);
592        assert!(!state.handle_key(&KeyEvent::new(KeyCode::Char('9')), 4));
593        assert_eq!(state.active, 2);
594    }
595
596    #[test]
597    fn tabs_overflow_markers_render_when_needed() {
598        let tabs = Tabs::new((0..8).map(|i| Tab::new(format!("Tab{i}"))));
599        let mut state = TabsState::default();
600        state.select(0, 8);
601        let mut pool = GraphemePool::new();
602        let mut frame = Frame::new(12, 1, &mut pool);
603        StatefulWidget::render(&tabs, Rect::new(0, 0, 12, 1), &mut frame, &mut state);
604        assert_eq!(
605            frame.buffer.get(11, 0).and_then(|c| c.content.as_char()),
606            Some('>')
607        );
608
609        state.select(7, 8);
610        StatefulWidget::render(&tabs, Rect::new(0, 0, 12, 1), &mut frame, &mut state);
611        assert_eq!(
612            frame.buffer.get(0, 0).and_then(|c| c.content.as_char()),
613            Some('<')
614        );
615    }
616
617    #[test]
618    fn tabs_close_active_respects_closable() {
619        let mut tabs = Tabs::new(vec![
620            Tab::new("Pinned").closable(false),
621            Tab::new("Temp").closable(true),
622        ]);
623        let mut state = TabsState::default();
624        state.select(0, 2);
625        assert!(tabs.close_active(&mut state).is_none());
626        state.select(1, 2);
627        assert!(tabs.close_active(&mut state).is_some());
628        assert_eq!(tabs.tabs().len(), 1);
629        assert_eq!(tabs.tabs()[0].title(), "Pinned");
630    }
631
632    #[test]
633    fn tabs_reorder_active_left_and_right() {
634        let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
635        let mut state = TabsState::default();
636        state.select(1, 3);
637        assert!(tabs.move_active_left(&mut state));
638        assert_eq!(state.active, 0);
639        assert_eq!(tabs.tabs()[0].title(), "B");
640        assert!(tabs.move_active_right(&mut state));
641        assert_eq!(state.active, 1);
642        assert_eq!(tabs.tabs()[1].title(), "B");
643    }
644
645    #[test]
646    fn tabs_hit_regions_encode_tab_index() {
647        let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]).hit_id(HitId::new(5));
648        let mut state = TabsState::default();
649        let mut pool = GraphemePool::new();
650        let mut frame = Frame::with_hit_grid(20, 1, &mut pool);
651        StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
652        let hit_a = frame.hit_test(1, 0);
653        let hit_b = frame.hit_test(6, 0);
654        assert_eq!(hit_a.map(|(_, _, data)| data), Some(0));
655        assert_eq!(hit_b.map(|(_, _, data)| data), Some(1));
656    }
657
658    #[cfg(feature = "tracing")]
659    #[derive(Default)]
660    struct TabsTraceState {
661        saw_render_span: bool,
662        saw_switch_event: bool,
663        saw_duration_record: bool,
664    }
665
666    #[cfg(feature = "tracing")]
667    struct TabsTraceCapture {
668        state: Arc<Mutex<TabsTraceState>>,
669    }
670
671    #[cfg(feature = "tracing")]
672    impl<S> Layer<S> for TabsTraceCapture
673    where
674        S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
675    {
676        fn on_new_span(
677            &self,
678            attrs: &tracing::span::Attributes<'_>,
679            _id: &tracing::Id,
680            _ctx: Context<'_, S>,
681        ) {
682            if attrs.metadata().name() == "tabs.render" {
683                self.state.lock().expect("tabs trace lock").saw_render_span = true;
684            }
685        }
686
687        fn on_record(
688            &self,
689            id: &tracing::Id,
690            values: &tracing::span::Record<'_>,
691            ctx: Context<'_, S>,
692        ) {
693            let Some(span) = ctx.span(id) else {
694                return;
695            };
696            if span.metadata().name() != "tabs.render" {
697                return;
698            }
699            struct V {
700                saw: bool,
701            }
702            impl tracing::field::Visit for V {
703                fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
704                    if field.name() == "render_duration_us" {
705                        self.saw = true;
706                    }
707                }
708
709                fn record_debug(
710                    &mut self,
711                    _field: &tracing::field::Field,
712                    _value: &dyn std::fmt::Debug,
713                ) {
714                }
715            }
716            let mut v = V { saw: false };
717            values.record(&mut v);
718            if v.saw {
719                self.state
720                    .lock()
721                    .expect("tabs trace lock")
722                    .saw_duration_record = true;
723            }
724        }
725
726        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
727            struct Msg {
728                message: Option<String>,
729            }
730            impl tracing::field::Visit for Msg {
731                fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
732                    if field.name() == "message" {
733                        self.message = Some(value.to_string());
734                    }
735                }
736
737                fn record_debug(
738                    &mut self,
739                    field: &tracing::field::Field,
740                    value: &dyn std::fmt::Debug,
741                ) {
742                    if field.name() == "message" {
743                        self.message = Some(format!("{value:?}").trim_matches('"').to_string());
744                    }
745                }
746            }
747            let mut msg = Msg { message: None };
748            event.record(&mut msg);
749            if msg.message.as_deref() == Some("tabs.switch") {
750                self.state.lock().expect("tabs trace lock").saw_switch_event = true;
751            }
752        }
753    }
754
755    #[cfg(feature = "tracing")]
756    #[test]
757    fn tabs_tracing_span_and_switch_event_emitted() {
758        let state = Arc::new(Mutex::new(TabsTraceState::default()));
759        let _trace_test_guard = crate::tracing_test_support::acquire();
760        let subscriber = tracing_subscriber::registry().with(TabsTraceCapture {
761            state: Arc::clone(&state),
762        });
763        let _guard = tracing::subscriber::set_default(subscriber);
764
765        let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
766        let mut tabs_state = TabsState::default();
767        let mut pool = GraphemePool::new();
768        let mut frame = Frame::new(20, 1, &mut pool);
769        tracing::callsite::rebuild_interest_cache();
770        StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut tabs_state);
771        tracing::callsite::rebuild_interest_cache();
772        assert!(tabs_state.handle_key(&KeyEvent::new(KeyCode::Right), 3));
773        tracing::callsite::rebuild_interest_cache();
774
775        let snapshot = state.lock().expect("tabs trace lock");
776        assert!(snapshot.saw_render_span, "expected tabs.render span");
777        assert!(
778            snapshot.saw_duration_record,
779            "expected render_duration_us record"
780        );
781        assert!(
782            snapshot.saw_switch_event,
783            "expected tabs.switch debug event"
784        );
785    }
786
787    // --- bd-1lg.28: Selection & switching tests ---
788
789    #[test]
790    fn tabs_select_zero_count_resets() {
791        let mut state = TabsState {
792            active: 3,
793            offset: 2,
794        };
795        assert!(!state.select(0, 0));
796        assert_eq!(state.active, 0);
797        assert_eq!(state.offset, 0);
798    }
799
800    #[test]
801    fn tabs_select_same_tab_returns_false() {
802        let mut state = TabsState::default();
803        state.select(2, 5);
804        assert!(!state.select(2, 5));
805    }
806
807    #[test]
808    fn tabs_select_out_of_range_clamps() {
809        let mut state = TabsState::default();
810        assert!(state.select(100, 5));
811        assert_eq!(state.active, 4); // clamped to last
812    }
813
814    #[test]
815    fn tabs_select_updates_offset_when_active_before_offset() {
816        let mut state = TabsState {
817            active: 3,
818            offset: 3,
819        };
820        assert!(state.select(1, 5));
821        assert_eq!(state.active, 1);
822        assert_eq!(state.offset, 1); // offset scrolled back to active
823    }
824
825    #[test]
826    fn tabs_next_at_last_tab_returns_false() {
827        let mut state = TabsState::default();
828        state.select(4, 5);
829        assert!(!state.next(5));
830        assert_eq!(state.active, 4);
831    }
832
833    #[test]
834    fn tabs_next_empty_returns_false() {
835        let mut state = TabsState::default();
836        assert!(!state.next(0));
837    }
838
839    #[test]
840    fn tabs_previous_at_first_tab_returns_false() {
841        let mut state = TabsState::default();
842        assert!(!state.previous(5));
843        assert_eq!(state.active, 0);
844    }
845
846    #[test]
847    fn tabs_previous_empty_returns_false() {
848        let mut state = TabsState::default();
849        assert!(!state.previous(0));
850    }
851
852    #[test]
853    fn tabs_handle_key_unhandled_returns_false() {
854        let mut state = TabsState::default();
855        assert!(!state.handle_key(&KeyEvent::new(KeyCode::Enter), 3));
856        assert!(!state.handle_key(&KeyEvent::new(KeyCode::Escape), 3));
857        assert!(!state.handle_key(&KeyEvent::new(KeyCode::Up), 3));
858    }
859
860    #[test]
861    fn tabs_handle_key_number_at_exact_tab_count_returns_false() {
862        let mut state = TabsState::default();
863        // 3 tabs: valid are '1','2','3'; '4' is out of range
864        assert!(!state.handle_key(&KeyEvent::new(KeyCode::Char('4')), 3));
865    }
866
867    #[test]
868    fn tabs_handle_key_number_one_selects_first() {
869        let mut state = TabsState::default();
870        state.select(2, 5);
871        assert!(state.handle_key(&KeyEvent::new(KeyCode::Char('1')), 5));
872        assert_eq!(state.active, 0);
873    }
874
875    // --- Mouse handling tests ---
876
877    use crate::mouse::MouseResult;
878    use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
879
880    #[test]
881    fn tabs_mouse_click_selects() {
882        let mut state = TabsState::default();
883        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
884        let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
885        let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
886        assert_eq!(result, MouseResult::Selected(2));
887        assert_eq!(state.active, 2);
888    }
889
890    #[test]
891    fn tabs_mouse_click_same_tab_activates() {
892        let mut state = TabsState::default();
893        state.select(2, 5);
894        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
895        let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
896        let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
897        assert_eq!(result, MouseResult::Activated(2));
898    }
899
900    #[test]
901    fn tabs_mouse_click_wrong_id_ignored() {
902        let mut state = TabsState::default();
903        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
904        let hit = Some((HitId::new(99), HitRegion::Content, 2u64));
905        let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
906        assert_eq!(result, MouseResult::Ignored);
907    }
908
909    #[test]
910    fn tabs_mouse_right_click_ignored() {
911        let mut state = TabsState::default();
912        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 0);
913        let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
914        let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
915        assert_eq!(result, MouseResult::Ignored);
916    }
917
918    #[test]
919    fn tabs_mouse_click_out_of_range() {
920        let mut state = TabsState::default();
921        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
922        let hit = Some((HitId::new(1), HitRegion::Content, 20u64));
923        let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
924        assert_eq!(result, MouseResult::Ignored);
925    }
926
927    #[test]
928    fn tabs_mouse_no_hit_ignored() {
929        let mut state = TabsState::default();
930        let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
931        let result = state.handle_mouse(&event, None, HitId::new(1), 5);
932        assert_eq!(result, MouseResult::Ignored);
933    }
934
935    // --- Close active tests ---
936
937    #[test]
938    fn tabs_close_active_empty_returns_none() {
939        let mut tabs = Tabs::new(Vec::<Tab>::new());
940        let mut state = TabsState::default();
941        assert!(tabs.close_active(&mut state).is_none());
942    }
943
944    #[test]
945    fn tabs_close_active_last_remaining_resets_state() {
946        let mut tabs = Tabs::new(vec![Tab::new("Only").closable(true)]);
947        let mut state = TabsState::default();
948        let removed = tabs.close_active(&mut state);
949        assert!(removed.is_some());
950        assert_eq!(removed.unwrap().title(), "Only");
951        assert!(tabs.tabs().is_empty());
952        assert_eq!(state.active, 0);
953        assert_eq!(state.offset, 0);
954    }
955
956    #[test]
957    fn tabs_close_active_middle_shifts_active() {
958        let mut tabs = Tabs::new(vec![
959            Tab::new("A"),
960            Tab::new("B").closable(true),
961            Tab::new("C"),
962        ]);
963        let mut state = TabsState::default();
964        state.select(1, 3); // select B
965        let removed = tabs.close_active(&mut state);
966        assert_eq!(removed.unwrap().title(), "B");
967        assert_eq!(tabs.tabs().len(), 2);
968        // Active should stay at 1 (now "C"), or adjust if at end
969        assert!(state.active < tabs.tabs().len());
970    }
971
972    #[test]
973    fn tabs_close_active_at_end_moves_active_back() {
974        let mut tabs = Tabs::new(vec![
975            Tab::new("A"),
976            Tab::new("B"),
977            Tab::new("C").closable(true),
978        ]);
979        let mut state = TabsState::default();
980        state.select(2, 3); // select C (last)
981        tabs.close_active(&mut state);
982        assert_eq!(tabs.tabs().len(), 2);
983        assert_eq!(state.active, 1); // moved back to B
984    }
985
986    // --- Reorder tests ---
987
988    #[test]
989    fn tabs_move_active_left_at_boundary_returns_false() {
990        let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
991        let mut state = TabsState::default(); // active = 0
992        assert!(!tabs.move_active_left(&mut state));
993    }
994
995    #[test]
996    fn tabs_move_active_right_at_boundary_returns_false() {
997        let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
998        let mut state = TabsState::default();
999        state.select(1, 2); // active = last
1000        assert!(!tabs.move_active_right(&mut state));
1001    }
1002
1003    #[test]
1004    fn tabs_move_active_single_tab_returns_false() {
1005        let mut tabs = Tabs::new(vec![Tab::new("Only")]);
1006        let mut state = TabsState::default();
1007        assert!(!tabs.move_active_left(&mut state));
1008        assert!(!tabs.move_active_right(&mut state));
1009    }
1010
1011    // --- Render tests ---
1012
1013    #[test]
1014    fn tabs_render_empty() {
1015        let tabs = Tabs::new(Vec::<Tab>::new());
1016        let mut state = TabsState::default();
1017        let mut pool = GraphemePool::new();
1018        let mut frame = Frame::new(20, 1, &mut pool);
1019        StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1020        // Should not panic; row should be blank
1021        let row = row_text(&frame, 0);
1022        assert_eq!(row.trim(), "");
1023    }
1024
1025    #[test]
1026    fn tabs_render_single_tab() {
1027        let tabs = Tabs::new(vec![Tab::new("Solo")]);
1028        let mut state = TabsState::default();
1029        let mut pool = GraphemePool::new();
1030        let mut frame = Frame::new(20, 1, &mut pool);
1031        StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1032        let row = row_text(&frame, 0);
1033        assert!(row.contains("[Solo]"));
1034    }
1035
1036    #[test]
1037    fn tabs_render_empty_clears_stale_row() {
1038        let populated = Tabs::new(vec![Tab::new("LongTab"), Tab::new("Other")]);
1039        let empty = Tabs::new(Vec::<Tab>::new());
1040        let mut state = TabsState::default();
1041        let mut pool = GraphemePool::new();
1042        let mut frame = Frame::new(20, 1, &mut pool);
1043
1044        StatefulWidget::render(&populated, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1045        assert_ne!(row_text(&frame, 0), " ".repeat(20));
1046
1047        StatefulWidget::render(&empty, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1048        assert_eq!(row_text(&frame, 0), " ".repeat(20));
1049    }
1050
1051    #[test]
1052    fn tabs_render_shorter_titles_clear_stale_suffix() {
1053        let long = Tabs::new(vec![Tab::new("LongTitle"), Tab::new("Second")]);
1054        let short = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1055        let mut state = TabsState::default();
1056        let mut pool = GraphemePool::new();
1057        let mut frame = Frame::new(20, 1, &mut pool);
1058
1059        StatefulWidget::render(&long, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1060        StatefulWidget::render(&short, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1061
1062        assert_eq!(row_text(&frame, 0), "[A]  B              ");
1063    }
1064
1065    #[test]
1066    fn tabs_no_styling_drops_configured_styles() {
1067        let tabs = Tabs::new(vec![Tab::new("One").style(Style::new().italic())])
1068            .style(Style::new().bold())
1069            .active_style(Style::new().underline());
1070        let plain_tabs = Tabs::new(vec![Tab::new("One")]);
1071        let mut state = TabsState::default();
1072        let mut plain_state = TabsState::default();
1073        let mut pool = GraphemePool::new();
1074        let mut plain_pool = GraphemePool::new();
1075        let mut frame = Frame::new(10, 1, &mut pool);
1076        let mut plain_frame = Frame::new(10, 1, &mut plain_pool);
1077        frame.buffer.degradation = DegradationLevel::NoStyling;
1078        plain_frame.buffer.degradation = DegradationLevel::NoStyling;
1079
1080        StatefulWidget::render(&tabs, Rect::new(0, 0, 10, 1), &mut frame, &mut state);
1081        StatefulWidget::render(
1082            &plain_tabs,
1083            Rect::new(0, 0, 10, 1),
1084            &mut plain_frame,
1085            &mut plain_state,
1086        );
1087
1088        for x in 0..10 {
1089            let cell = frame.buffer.get(x, 0).expect("styled tab cell");
1090            let plain = plain_frame.buffer.get(x, 0).expect("plain tab cell");
1091            assert_eq!(cell, plain);
1092        }
1093    }
1094
1095    #[test]
1096    fn tabs_render_zero_area() {
1097        let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1098        let mut state = TabsState::default();
1099        let mut pool = GraphemePool::new();
1100        let mut frame = Frame::new(20, 1, &mut pool);
1101        // Zero width area
1102        StatefulWidget::render(&tabs, Rect::new(0, 0, 0, 1), &mut frame, &mut state);
1103        // Should not panic
1104    }
1105
1106    #[test]
1107    fn tabs_render_closable_shows_marker() {
1108        let tabs = Tabs::new(vec![Tab::new("File").closable(true)]);
1109        let mut state = TabsState::default();
1110        let mut pool = GraphemePool::new();
1111        let mut frame = Frame::new(20, 1, &mut pool);
1112        StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1113        let row = row_text(&frame, 0);
1114        assert!(row.contains("x"), "closable tab should show close marker");
1115    }
1116
1117    #[test]
1118    fn tabs_render_active_tab_bracketed() {
1119        let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
1120        let mut state = TabsState::default();
1121        state.select(1, 3);
1122        let mut pool = GraphemePool::new();
1123        let mut frame = Frame::new(30, 1, &mut pool);
1124        StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
1125        let row = row_text(&frame, 0);
1126        // Active tab B should be bracketed, A and C should not
1127        assert!(row.contains("[B]"), "active tab should be bracketed");
1128        assert!(row.contains(" A "), "inactive tab A should be space-padded");
1129        assert!(row.contains(" C "), "inactive tab C should be space-padded");
1130    }
1131
1132    #[test]
1133    fn tabs_no_overflow_when_all_fit() {
1134        let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1135        let mut state = TabsState::default();
1136        let mut pool = GraphemePool::new();
1137        let mut frame = Frame::new(30, 1, &mut pool);
1138        StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
1139        let row = row_text(&frame, 0);
1140        // Should not contain overflow markers
1141        assert!(!row.starts_with('<'), "no left overflow marker expected");
1142        assert!(!row.ends_with('>'), "no right overflow marker expected");
1143    }
1144
1145    // --- Tab struct tests ---
1146
1147    #[test]
1148    fn tab_new_defaults() {
1149        let tab = Tab::new("test");
1150        assert_eq!(tab.title(), "test");
1151        assert!(!tab.is_closable());
1152    }
1153
1154    #[test]
1155    fn tab_closable_builder() {
1156        let tab = Tab::new("temp").closable(true);
1157        assert!(tab.is_closable());
1158    }
1159
1160    // --- Widget trait stateless render ---
1161
1162    #[test]
1163    fn tabs_widget_stateless_render() {
1164        let tabs = Tabs::new(vec![Tab::new("X"), Tab::new("Y")]);
1165        let mut pool = GraphemePool::new();
1166        let mut frame = Frame::new(20, 1, &mut pool);
1167        Widget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame);
1168        let row = row_text(&frame, 0);
1169        // Default state: active=0, so X should be bracketed
1170        assert!(row.contains("[X]"));
1171    }
1172
1173    // --- Overflow visible range tests ---
1174
1175    #[test]
1176    fn tabs_overflow_both_sides() {
1177        let tabs = Tabs::new((0..10).map(|i| Tab::new(format!("Tab{i}"))));
1178        let mut state = TabsState::default();
1179        state.select(5, 10); // middle tab
1180        let mut pool = GraphemePool::new();
1181        let mut frame = Frame::new(15, 1, &mut pool);
1182        StatefulWidget::render(&tabs, Rect::new(0, 0, 15, 1), &mut frame, &mut state);
1183        let row = row_text(&frame, 0);
1184        // Should show both overflow markers
1185        assert!(row.starts_with('<'), "expected left overflow marker");
1186        assert!(
1187            row.trim_end().ends_with('>'),
1188            "expected right overflow marker"
1189        );
1190    }
1191}