Skip to main content

boxmux_lib/components/
tab_bar.rs

1use crate::draw_utils::{
2    draw_horizontal_line, fill_horizontal_background, print_with_color_and_background_at,
3};
4use crate::model::common::ScreenBuffer;
5
6/// Tab bar component for displaying stream tabs with scrolling support
7pub struct TabBar;
8
9/// Navigation action for tab scrolling
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub enum TabNavigationAction {
12    ScrollLeft,
13    ScrollRight,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum TabHoverTarget {
18    Tab(usize),
19    CloseButton(usize),
20    NavigationLeft,
21    NavigationRight,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum TabHitTarget {
26    Tab(usize),
27    CloseButton(usize),
28    Navigation(TabNavigationAction),
29}
30
31#[derive(Debug, Clone)]
32struct TabLayout {
33    left_arrow_x: usize,
34    right_arrow_x: usize,
35    tab_area_start: usize,
36    tab_area_end: usize,
37    trailing_border_x: usize,
38    needs_scrolling: bool,
39    max_scroll_offset: usize,
40    centering_offset: usize,
41    total_tabs_width: usize,
42    tabs: Vec<TabRect>,
43}
44
45#[derive(Debug, Clone)]
46struct TabRect {
47    index: usize,
48    x: usize,
49    width: usize,
50    close_glyph_x: Option<usize>,
51}
52
53impl TabBar {
54    fn layout(
55        x1: usize,
56        x2: usize,
57        tab_labels: &[String],
58        tab_close_buttons: &[bool],
59        tab_scroll_offset: usize,
60        fg_color: &Option<String>,
61        bg_color: &Option<String>,
62    ) -> TabLayout {
63        let total_width = x2.saturating_sub(x1).saturating_add(1);
64        let left_arrow_space = 2;
65        let right_arrow_space = 2;
66        let has_border_color = crate::color_utils::should_draw_color(fg_color)
67            || crate::color_utils::should_draw_color(bg_color);
68        let border_space = if has_border_color { 4 } else { 0 };
69        let reserved_space = left_arrow_space + right_arrow_space + border_space;
70        let tab_area_width = total_width.saturating_sub(reserved_space);
71        let left_arrow_x = x1 + if has_border_color { 2 } else { 0 };
72        let tab_area_start = left_arrow_x + left_arrow_space;
73        let tab_area_end = tab_area_start + tab_area_width;
74        let right_arrow_x = tab_area_end;
75        let trailing_border_x = right_arrow_x + right_arrow_space;
76
77        let max_tab_width = 16;
78        let min_tab_width = 6;
79        let ideal_tab_width = if !tab_labels.is_empty() {
80            tab_area_width / tab_labels.len()
81        } else {
82            max_tab_width
83        };
84        let tab_width = ideal_tab_width.clamp(min_tab_width, max_tab_width);
85        let natural_tabs_width = tab_labels.len() * tab_width;
86        let needs_scrolling = (natural_tabs_width as f32) > (tab_area_width as f32 * 0.85);
87
88        let separator_width = 1;
89        let (visible_tabs, adjusted_tab_width) = if needs_scrolling {
90            let min_tab_with_separator = min_tab_width + separator_width;
91            let max_visible_tabs = tab_area_width / min_tab_with_separator.max(1);
92            let remaining_tabs = tab_labels.len().saturating_sub(tab_scroll_offset);
93            let visible_tabs = max_visible_tabs.min(remaining_tabs).max(1);
94            let adjusted_tab_width = if visible_tabs > 0 {
95                let total_separator_space = (visible_tabs.saturating_sub(1)) * separator_width;
96                (tab_area_width.saturating_sub(total_separator_space) / visible_tabs)
97                    .max(min_tab_width)
98            } else {
99                min_tab_width
100            };
101            (visible_tabs, adjusted_tab_width)
102        } else {
103            let visible_tabs = tab_labels.len();
104            let adjusted_tab_width = if visible_tabs > 0 {
105                let total_separator_space = (visible_tabs.saturating_sub(1)) * separator_width;
106                tab_area_width.saturating_sub(total_separator_space) / visible_tabs
107            } else {
108                tab_width
109            };
110            (visible_tabs, adjusted_tab_width)
111        };
112
113        let total_tabs_width = if visible_tabs > 0 {
114            visible_tabs * adjusted_tab_width + (visible_tabs.saturating_sub(1)) * separator_width
115        } else {
116            0
117        };
118        let centering_offset = if needs_scrolling {
119            0
120        } else {
121            tab_area_width.saturating_sub(total_tabs_width) / 2
122        };
123        let start_offset = if needs_scrolling {
124            tab_scroll_offset
125        } else {
126            0
127        };
128
129        let mut tabs = Vec::with_capacity(visible_tabs);
130        let mut tab_x = tab_area_start + centering_offset;
131        for i in 0..visible_tabs {
132            let tab_index = start_offset + i;
133            if tab_index >= tab_labels.len() {
134                break;
135            }
136
137            let close_glyph_x = if tab_close_buttons.get(tab_index).copied().unwrap_or(false) {
138                Self::close_glyph_x(tab_x, adjusted_tab_width)
139            } else {
140                None
141            };
142
143            tabs.push(TabRect {
144                index: tab_index,
145                x: tab_x,
146                width: adjusted_tab_width,
147                close_glyph_x,
148            });
149
150            tab_x += adjusted_tab_width;
151            if i < visible_tabs.saturating_sub(1) {
152                tab_x += separator_width;
153            }
154        }
155
156        let max_scroll_offset = if !tab_labels.is_empty() {
157            tab_labels
158                .len()
159                .saturating_sub(tab_area_width / tab_width.max(1))
160        } else {
161            0
162        };
163
164        TabLayout {
165            left_arrow_x,
166            right_arrow_x,
167            tab_area_start,
168            tab_area_end,
169            trailing_border_x,
170            needs_scrolling,
171            max_scroll_offset,
172            centering_offset,
173            total_tabs_width,
174            tabs,
175        }
176    }
177
178    fn close_glyph_x(tab_x: usize, tab_width: usize) -> Option<usize> {
179        if tab_width < 2 {
180            return None;
181        }
182
183        let min_close_area = 2;
184        let preferred_close_area = if tab_width >= 8 { 3 } else { min_close_area };
185        let close_area_width = preferred_close_area.min(tab_width.saturating_sub(2));
186
187        Some(if close_area_width >= 3 {
188            tab_x + tab_width - close_area_width + 1
189        } else {
190            tab_x + tab_width - 2
191        })
192    }
193
194    /// Draw tab bar with all functionality preserved from original implementation
195    pub fn draw(
196        y: usize,
197        x1: usize,
198        x2: usize,
199        fg_color: &Option<String>,
200        bg_color: &Option<String>,
201        title_fg_color: &Option<String>,
202        title_bg_color: &Option<String>,
203        tab_labels: &[String],
204        tab_close_buttons: &[bool],
205        active_tab_index: usize,
206        box_selected: bool,
207        tab_scroll_offset: usize,
208        hovered_target: Option<&TabHoverTarget>,
209        buffer: &mut ScreenBuffer,
210    ) {
211        let layout = Self::layout(
212            x1,
213            x2,
214            tab_labels,
215            tab_close_buttons,
216            tab_scroll_offset,
217            fg_color,
218            bg_color,
219        );
220        let left_arrow_space = 2;
221        let right_arrow_space = 2;
222
223        // Draw leading border if border colors are not transparent
224        if (crate::color_utils::should_draw_color(fg_color)
225            || crate::color_utils::should_draw_color(bg_color))
226            && x1 < x2
227        {
228            draw_horizontal_line(y, x1, x1 + 1, fg_color, bg_color, buffer);
229        }
230
231        // Draw left scroll arrow or border fill in reserved space
232        if layout.needs_scrolling && tab_scroll_offset > 0 {
233            // Show left arrow when can scroll left
234            let (arrow_fg, arrow_bg) = Self::hover_colors(
235                title_fg_color,
236                bg_color,
237                matches!(hovered_target, Some(TabHoverTarget::NavigationLeft)),
238            );
239            print_with_color_and_background_at(
240                y,
241                layout.left_arrow_x,
242                &arrow_fg,
243                &arrow_bg,
244                "◀",
245                buffer,
246            );
247            print_with_color_and_background_at(
248                y,
249                layout.left_arrow_x + 1,
250                &arrow_fg,
251                &arrow_bg,
252                " ",
253                buffer,
254            );
255        } else {
256            // Fill reserved space with border when arrow not needed
257            draw_horizontal_line(
258                y,
259                layout.left_arrow_x,
260                layout.left_arrow_x + left_arrow_space - 1,
261                fg_color,
262                bg_color,
263                buffer,
264            );
265        }
266
267        // Draw tabs with proper spacing and centering
268        if !layout.tabs.is_empty() {
269            for (visible_index, tab) in layout.tabs.iter().enumerate() {
270                Self::draw_single_tab(
271                    y,
272                    tab.x,
273                    tab.width,
274                    &tab_labels[tab.index],
275                    tab.index == active_tab_index,
276                    box_selected,
277                    tab.close_glyph_x.is_some(),
278                    matches!(hovered_target, Some(TabHoverTarget::Tab(index)) if *index == tab.index),
279                    matches!(hovered_target, Some(TabHoverTarget::CloseButton(index)) if *index == tab.index),
280                    title_fg_color,
281                    title_bg_color,
282                    buffer,
283                );
284
285                // Draw separator after tab (except for last tab)
286                if visible_index < layout.tabs.len().saturating_sub(1) {
287                    print_with_color_and_background_at(
288                        y,
289                        tab.x + tab.width,
290                        fg_color,
291                        bg_color,
292                        "│",
293                        buffer,
294                    );
295                }
296            }
297        }
298
299        // Fill remaining tab area with border (before and after tab group)
300        if layout.tab_area_start < layout.tab_area_start + layout.centering_offset {
301            draw_horizontal_line(
302                y,
303                layout.tab_area_start,
304                layout.tab_area_start + layout.centering_offset - 1,
305                fg_color,
306                bg_color,
307                buffer,
308            );
309        }
310
311        let tabs_end = layout.tab_area_start + layout.centering_offset + layout.total_tabs_width;
312        if tabs_end < layout.tab_area_end {
313            draw_horizontal_line(
314                y,
315                tabs_end,
316                layout.tab_area_end - 1,
317                fg_color,
318                bg_color,
319                buffer,
320            );
321        }
322
323        if layout.needs_scrolling && tab_scroll_offset < layout.max_scroll_offset {
324            // Show right arrow when can scroll right
325            let (arrow_fg, arrow_bg) = Self::hover_colors(
326                title_fg_color,
327                bg_color,
328                matches!(hovered_target, Some(TabHoverTarget::NavigationRight)),
329            );
330            print_with_color_and_background_at(
331                y,
332                layout.right_arrow_x,
333                &arrow_fg,
334                &arrow_bg,
335                " ",
336                buffer,
337            );
338            print_with_color_and_background_at(
339                y,
340                layout.right_arrow_x + 1,
341                &arrow_fg,
342                &arrow_bg,
343                "▶",
344                buffer,
345            );
346        } else {
347            // Fill reserved space with border when arrow not needed
348            draw_horizontal_line(
349                y,
350                layout.right_arrow_x,
351                layout.right_arrow_x + right_arrow_space - 1,
352                fg_color,
353                bg_color,
354                buffer,
355            );
356        }
357
358        // Draw trailing border if border colors are not transparent
359        if (crate::color_utils::should_draw_color(fg_color)
360            || crate::color_utils::should_draw_color(bg_color))
361            && layout.trailing_border_x < x2
362        {
363            draw_horizontal_line(y, layout.trailing_border_x, x2, fg_color, bg_color, buffer);
364        }
365    }
366
367    /// Helper function to draw a single tab with consistent styling
368    fn draw_single_tab(
369        y: usize,
370        x: usize,
371        width: usize,
372        label: &str,
373        is_active: bool,
374        box_selected: bool,
375        has_close_button: bool,
376        is_hovered: bool,
377        is_close_hovered: bool,
378        title_fg_color: &Option<String>,
379        title_bg_color: &Option<String>,
380        buffer: &mut ScreenBuffer,
381    ) {
382        // Active tab uses theme-aware selected-title colors instead of inverting
383        // title_fg/title_bg. Inverting made the active tab's background = title_fg,
384        // which is white in dark mode (a glaring light title bar). The selected-title
385        // defaults keep light's liked dark bar and give dark a dark accent bar. The
386        // FOCUSED box's active tab uses a distinct color so the focused box stands out.
387        let active_fg = Some(crate::color_utils::default_selected_title_fg_color().to_string());
388        let active_bg = Some(if box_selected {
389            crate::color_utils::default_focused_title_bg_color().to_string()
390        } else {
391            crate::color_utils::default_selected_title_bg_color().to_string()
392        });
393        let (base_tab_fg, base_tab_bg): (&Option<String>, &Option<String>) = if is_active {
394            (&active_fg, &active_bg)
395        } else {
396            (title_fg_color, title_bg_color)
397        };
398        let (tab_fg, tab_bg) = Self::hover_colors(base_tab_fg, base_tab_bg, is_hovered);
399
400        // F0219: Reserve space for close button if needed
401        let close_button_space = if has_close_button { 2 } else { 0 }; // "×" + space
402        let available_label_width = width.saturating_sub(close_button_space + 2); // 2 for padding
403
404        // Truncate label to fit available space (character-aware)
405        let mut display_label = label.to_string();
406        let max_label_chars = available_label_width;
407
408        if display_label.chars().count() > max_label_chars {
409            let truncate_chars = max_label_chars.saturating_sub(1);
410            display_label = display_label
411                .chars()
412                .take(truncate_chars)
413                .collect::<String>();
414            display_label.push('…');
415        }
416
417        // Draw tab background first
418        fill_horizontal_background(y, x, x + width - 1, &tab_fg, &tab_bg, buffer);
419
420        if has_close_button {
421            // F0219: Position close button with aesthetic spacing when possible
422            // Reserve 2 chars minimum for "×" (with space if room), more if width allows
423            let min_close_area = 2; // Minimum: "×" + space
424            let preferred_close_area = if width >= 8 { 3 } else { min_close_area }; // Preferred: " ×" + space
425
426            let close_area_width = preferred_close_area.min(width.saturating_sub(2)); // Don't take all width
427            let close_button_x = x + width - close_area_width;
428            let label_space = width.saturating_sub(close_area_width + 1); // 1 for left padding
429
430            // Truncate label if needed to leave room for close button area
431            let mut final_label = display_label.clone();
432            if final_label.chars().count() > label_space {
433                let truncate_chars = label_space.saturating_sub(1); // Leave room for "…"
434                if truncate_chars > 0 {
435                    final_label = final_label.chars().take(truncate_chars).collect::<String>();
436                    final_label.push('…');
437                } else {
438                    final_label = "…".to_string();
439                }
440            }
441
442            // Draw label with left padding
443            let label_content = format!(" {}", final_label);
444            print_with_color_and_background_at(y, x, &tab_fg, &tab_bg, &label_content, buffer);
445
446            // Draw close button with aesthetic spacing
447            let (close_fg, close_bg) = Self::hover_colors(&tab_fg, &tab_bg, is_close_hovered);
448            if close_area_width >= 3 {
449                // Enough room for " ×" pattern
450                print_with_color_and_background_at(
451                    y,
452                    close_button_x,
453                    &close_fg,
454                    &close_bg,
455                    if is_close_hovered { " X" } else { " ×" },
456                    buffer,
457                );
458            } else {
459                // Minimal space - just "×" at edge-1 position
460                print_with_color_and_background_at(
461                    y,
462                    x + width - 2,
463                    &close_fg,
464                    &close_bg,
465                    if is_close_hovered { "X" } else { "×" },
466                    buffer,
467                );
468            }
469        } else {
470            // Regular tab without close button - center the label
471            let tab_content = format!(" {} ", display_label);
472            let tab_char_count = tab_content.chars().count();
473            let display_chars = tab_char_count.min(width);
474
475            let display_text = if display_chars < tab_char_count {
476                tab_content.chars().take(display_chars).collect::<String>()
477            } else {
478                tab_content
479            };
480
481            // Draw tab text
482            print_with_color_and_background_at(y, x, &tab_fg, &tab_bg, &display_text, buffer);
483        }
484    }
485
486    /// Calculate which tab was clicked based on mouse position
487    pub fn calculate_tab_click_index(
488        click_x: usize,
489        x1: usize,
490        x2: usize,
491        tab_labels: &[String],
492        tab_scroll_offset: usize,
493        fg_color: &Option<String>,
494        bg_color: &Option<String>,
495    ) -> Option<usize> {
496        match Self::calculate_tab_hit_target(
497            click_x,
498            x1,
499            x2,
500            tab_labels,
501            &[],
502            tab_scroll_offset,
503            fg_color,
504            bg_color,
505        ) {
506            Some(TabHitTarget::Tab(index)) => Some(index),
507            _ => None,
508        }
509    }
510
511    /// Calculate if click was on navigation arrows
512    pub fn calculate_tab_navigation_click(
513        click_x: usize,
514        x1: usize,
515        x2: usize,
516        tab_labels: &[String],
517        tab_scroll_offset: usize,
518        fg_color: &Option<String>,
519        bg_color: &Option<String>,
520    ) -> Option<TabNavigationAction> {
521        match Self::calculate_tab_hit_target(
522            click_x,
523            x1,
524            x2,
525            tab_labels,
526            &[],
527            tab_scroll_offset,
528            fg_color,
529            bg_color,
530        ) {
531            Some(TabHitTarget::Navigation(action)) => Some(action),
532            _ => None,
533        }
534    }
535
536    /// Calculate if click was on a close button within a tab
537    pub fn calculate_tab_close_click(
538        click_x: usize,
539        x1: usize,
540        x2: usize,
541        tab_labels: &[String],
542        tab_close_buttons: &[bool],
543        tab_scroll_offset: usize,
544        fg_color: &Option<String>,
545        bg_color: &Option<String>,
546    ) -> Option<usize> {
547        match Self::calculate_tab_hit_target(
548            click_x,
549            x1,
550            x2,
551            tab_labels,
552            tab_close_buttons,
553            tab_scroll_offset,
554            fg_color,
555            bg_color,
556        ) {
557            Some(TabHitTarget::CloseButton(index)) => Some(index),
558            _ => None,
559        }
560    }
561
562    pub fn calculate_tab_hit_target(
563        x: usize,
564        x1: usize,
565        x2: usize,
566        tab_labels: &[String],
567        tab_close_buttons: &[bool],
568        tab_scroll_offset: usize,
569        fg_color: &Option<String>,
570        bg_color: &Option<String>,
571    ) -> Option<TabHitTarget> {
572        if tab_labels.is_empty() {
573            return None;
574        }
575
576        let layout = Self::layout(
577            x1,
578            x2,
579            tab_labels,
580            tab_close_buttons,
581            tab_scroll_offset,
582            fg_color,
583            bg_color,
584        );
585
586        if layout.needs_scrolling {
587            if tab_scroll_offset > 0 && x == layout.left_arrow_x {
588                return Some(TabHitTarget::Navigation(TabNavigationAction::ScrollLeft));
589            }
590            if tab_scroll_offset < layout.max_scroll_offset && x == layout.right_arrow_x + 1 {
591                return Some(TabHitTarget::Navigation(TabNavigationAction::ScrollRight));
592            }
593        }
594
595        if x < layout.tab_area_start || x >= layout.tab_area_end {
596            return None;
597        }
598
599        for tab in &layout.tabs {
600            if tab.close_glyph_x == Some(x) {
601                return Some(TabHitTarget::CloseButton(tab.index));
602            }
603            if x >= tab.x && x < tab.x + tab.width {
604                return Some(TabHitTarget::Tab(tab.index));
605            }
606        }
607
608        None
609    }
610
611    pub fn calculate_tab_hover_target(
612        hover_x: usize,
613        x1: usize,
614        x2: usize,
615        tab_labels: &[String],
616        tab_close_buttons: &[bool],
617        tab_scroll_offset: usize,
618        fg_color: &Option<String>,
619        bg_color: &Option<String>,
620    ) -> Option<TabHoverTarget> {
621        Self::calculate_tab_hit_target(
622            hover_x,
623            x1,
624            x2,
625            tab_labels,
626            tab_close_buttons,
627            tab_scroll_offset,
628            fg_color,
629            bg_color,
630        )
631        .map(|target| match target {
632            TabHitTarget::Tab(index) => TabHoverTarget::Tab(index),
633            TabHitTarget::CloseButton(index) => TabHoverTarget::CloseButton(index),
634            TabHitTarget::Navigation(TabNavigationAction::ScrollLeft) => {
635                TabHoverTarget::NavigationLeft
636            }
637            TabHitTarget::Navigation(TabNavigationAction::ScrollRight) => {
638                TabHoverTarget::NavigationRight
639            }
640        })
641    }
642
643    fn hover_colors(
644        fg_color: &Option<String>,
645        bg_color: &Option<String>,
646        is_hovered: bool,
647    ) -> (Option<String>, Option<String>) {
648        if is_hovered {
649            (
650                Some(crate::color_utils::default_hover_fg_color().to_string()),
651                Some(crate::color_utils::default_hover_bg_color().to_string()),
652            )
653        } else {
654            (fg_color.clone(), bg_color.clone())
655        }
656    }
657}