Skip to main content

fresh/view/ui/
tabs.rs

1//! Tab bar rendering for multiple buffers
2
3use crate::app::BufferMetadata;
4use crate::model::event::BufferId;
5use crate::primitives::display_width::str_width;
6use crate::state::EditorState;
7use crate::view::ui::layout::point_in_rect;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Paragraph};
12use ratatui::Frame;
13use std::collections::HashMap;
14
15/// Hit area for a single tab
16#[derive(Debug, Clone)]
17pub struct TabHitArea {
18    /// The buffer ID this tab represents
19    pub buffer_id: BufferId,
20    /// The area covering the tab name (clickable to switch to buffer)
21    pub tab_area: Rect,
22    /// The area covering the close button
23    pub close_area: Rect,
24}
25
26/// Layout information for hit testing tab interactions
27///
28/// Returned by `TabsRenderer::render_for_split()` to enable mouse hit testing
29/// without duplicating position calculations.
30#[derive(Debug, Clone, Default)]
31pub struct TabLayout {
32    /// Hit areas for each visible tab
33    pub tabs: Vec<TabHitArea>,
34    /// The full tab bar area
35    pub bar_area: Rect,
36    /// Hit area for the left scroll button (if shown)
37    pub left_scroll_area: Option<Rect>,
38    /// Hit area for the right scroll button (if shown)
39    pub right_scroll_area: Option<Rect>,
40}
41
42/// Hit test result for tab interactions
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TabHit {
45    /// Hit the tab name area (click to switch buffer)
46    TabName(BufferId),
47    /// Hit the close button area
48    CloseButton(BufferId),
49    /// Hit the tab bar background
50    BarBackground,
51    /// Hit the left scroll button
52    ScrollLeft,
53    /// Hit the right scroll button
54    ScrollRight,
55}
56
57impl TabLayout {
58    /// Create a new empty layout
59    pub fn new(bar_area: Rect) -> Self {
60        Self {
61            tabs: Vec::new(),
62            bar_area,
63            left_scroll_area: None,
64            right_scroll_area: None,
65        }
66    }
67
68    /// Perform a hit test to determine what element is at the given position
69    pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
70        // Check scroll buttons first (they're at the edges)
71        if let Some(left_area) = self.left_scroll_area {
72            tracing::debug!(
73                "Tab hit_test: checking left_scroll_area {:?} against ({}, {})",
74                left_area,
75                x,
76                y
77            );
78            if point_in_rect(left_area, x, y) {
79                tracing::debug!("Tab hit_test: HIT ScrollLeft");
80                return Some(TabHit::ScrollLeft);
81            }
82        }
83        if let Some(right_area) = self.right_scroll_area {
84            tracing::debug!(
85                "Tab hit_test: checking right_scroll_area {:?} against ({}, {})",
86                right_area,
87                x,
88                y
89            );
90            if point_in_rect(right_area, x, y) {
91                tracing::debug!("Tab hit_test: HIT ScrollRight");
92                return Some(TabHit::ScrollRight);
93            }
94        }
95
96        for tab in &self.tabs {
97            // Check close button first (it's inside the tab area)
98            if point_in_rect(tab.close_area, x, y) {
99                return Some(TabHit::CloseButton(tab.buffer_id));
100            }
101            // Check tab area
102            if point_in_rect(tab.tab_area, x, y) {
103                return Some(TabHit::TabName(tab.buffer_id));
104            }
105        }
106
107        // Check bar background
108        if point_in_rect(self.bar_area, x, y) {
109            return Some(TabHit::BarBackground);
110        }
111
112        None
113    }
114}
115
116/// Renders the tab bar showing open buffers
117pub struct TabsRenderer;
118
119/// Compute scroll offset to bring the active tab into view.
120/// Always scrolls to put the active tab at a comfortable position.
121/// `tab_widths` includes separators between tabs.
122pub fn scroll_to_show_tab(
123    tab_widths: &[usize],
124    active_idx: usize,
125    _current_offset: usize,
126    max_width: usize,
127) -> usize {
128    if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
129        return 0;
130    }
131
132    let total_width: usize = tab_widths.iter().sum();
133    let tab_start: usize = tab_widths[..active_idx].iter().sum();
134    let tab_width = tab_widths[active_idx];
135    let tab_end = tab_start + tab_width;
136
137    // Try to put the active tab about 1/4 from the left edge
138    let preferred_position = max_width / 4;
139    let target_offset = tab_start.saturating_sub(preferred_position);
140
141    // Ensure the active tab is fully visible, accounting for scroll indicators.
142    // When offset > 0, a "<" indicator uses 1 column on the left.
143    // When content extends past the right edge, a ">" uses 1 column on the right.
144    // The visible content window is [offset .. offset+available) where
145    // available = max_width - indicator_columns.
146    //
147    // max_offset must also account for the left indicator: when scrolled to the
148    // end, the "<" takes 1 column, so we can see only max_width-1 content chars.
149    let max_offset_with_indicator = total_width.saturating_sub(max_width.saturating_sub(1));
150    let max_offset_no_indicator = total_width.saturating_sub(max_width);
151    let max_offset = if total_width > max_width {
152        max_offset_with_indicator
153    } else {
154        0
155    };
156    let mut result = target_offset.min(max_offset);
157
158    // Use worst-case (both indicators) for the right-edge check to avoid
159    // circular dependency between offset and indicator presence.
160    let available_worst = max_width.saturating_sub(2);
161
162    if tab_end > result + available_worst {
163        // Tab extends past the visible window — scroll right so tab_end
164        // aligns with the right edge of the visible content area.
165        result = tab_end.saturating_sub(available_worst);
166    }
167    if tab_start < result {
168        // Tab starts before the visible window, scroll left to reveal it.
169        // If this brings us to 0, no left indicator needed.
170        result = tab_start;
171    }
172    // Final clamp — use the no-indicator max if result is 0, otherwise the
173    // indicator-aware max.
174    let effective_max = if result > 0 {
175        max_offset
176    } else {
177        max_offset_no_indicator
178    };
179    result = result.min(effective_max);
180
181    tracing::debug!(
182        "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
183        active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
184    );
185    result
186}
187
188/// Resolve display names for tab buffers, disambiguating duplicates by appending a number.
189/// For example, if there are three unnamed buffers, they become "[No Name]", "[No Name] 2", "[No Name] 3".
190/// Similarly, duplicate filenames get numbered: "main.rs", "main.rs 2".
191fn resolve_tab_names(
192    split_buffers: &[BufferId],
193    buffers: &HashMap<BufferId, EditorState>,
194    buffer_metadata: &HashMap<BufferId, BufferMetadata>,
195    composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
196) -> HashMap<BufferId, String> {
197    let mut names: Vec<(BufferId, String)> = Vec::new();
198
199    for id in split_buffers.iter() {
200        let is_regular_buffer = buffers.contains_key(id);
201        let is_composite_buffer = composite_buffers.contains_key(id);
202        if !is_regular_buffer && !is_composite_buffer {
203            continue;
204        }
205        if let Some(meta) = buffer_metadata.get(id) {
206            if meta.hidden_from_tabs {
207                continue;
208            }
209        }
210
211        let meta = buffer_metadata.get(id);
212        let is_terminal = meta
213            .and_then(|m| m.virtual_mode())
214            .map(|mode| mode == "terminal")
215            .unwrap_or(false);
216
217        let name = if is_composite_buffer {
218            meta.map(|m| m.display_name.as_str())
219        } else if is_terminal {
220            meta.map(|m| m.display_name.as_str())
221        } else {
222            buffers
223                .get(id)
224                .and_then(|state| state.buffer.file_path())
225                .and_then(|p| p.file_name())
226                .and_then(|n| n.to_str())
227                .or_else(|| meta.map(|m| m.display_name.as_str()))
228        }
229        .unwrap_or("[No Name]");
230
231        names.push((*id, name.to_string()));
232    }
233
234    // Count occurrences of each name
235    let mut name_counts: HashMap<&str, usize> = HashMap::new();
236    for (_, name) in &names {
237        *name_counts.entry(name.as_str()).or_insert(0) += 1;
238    }
239
240    // Assign disambiguated names — all duplicates get a number, including the first
241    let mut result = HashMap::new();
242    let mut name_indices: HashMap<String, usize> = HashMap::new();
243    for (id, name) in &names {
244        if name_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
245            let idx = name_indices.entry(name.clone()).or_insert(0);
246            *idx += 1;
247            result.insert(*id, format!("{} {}", name, idx));
248        } else {
249            result.insert(*id, name.clone());
250        }
251    }
252
253    result
254}
255
256/// Calculate tab widths for scroll offset calculations.
257/// Returns (tab_widths, rendered_buffer_ids) where tab_widths includes separators.
258/// This uses the same logic as render_for_split to ensure consistency.
259pub fn calculate_tab_widths(
260    split_buffers: &[BufferId],
261    buffers: &HashMap<BufferId, EditorState>,
262    buffer_metadata: &HashMap<BufferId, BufferMetadata>,
263    composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
264) -> (Vec<usize>, Vec<BufferId>) {
265    let mut tab_widths: Vec<usize> = Vec::new();
266    let mut rendered_buffer_ids: Vec<BufferId> = Vec::new();
267    let resolved_names =
268        resolve_tab_names(split_buffers, buffers, buffer_metadata, composite_buffers);
269
270    for id in split_buffers.iter() {
271        // Check if this is a regular buffer or a composite buffer
272        let is_regular_buffer = buffers.contains_key(id);
273        let is_composite_buffer = composite_buffers.contains_key(id);
274
275        if !is_regular_buffer && !is_composite_buffer {
276            continue;
277        }
278
279        // Skip buffers that are marked as hidden from tabs
280        if let Some(meta) = buffer_metadata.get(id) {
281            if meta.hidden_from_tabs {
282                continue;
283            }
284        }
285
286        let name = resolved_names
287            .get(id)
288            .map(|s| s.as_str())
289            .unwrap_or("[No Name]");
290
291        // Calculate modified indicator
292        let modified = if is_composite_buffer {
293            ""
294        } else if let Some(state) = buffers.get(id) {
295            if state.buffer.is_modified() {
296                "*"
297            } else {
298                ""
299            }
300        } else {
301            ""
302        };
303
304        let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
305            " [BIN]"
306        } else {
307            ""
308        };
309
310        // Same format as render_for_split: " {name}{modified}{binary_indicator} " + "× "
311        let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
312        let close_text = "× ";
313        let tab_width = str_width(&tab_name_text) + str_width(close_text);
314
315        // Add separator if not first tab
316        if !rendered_buffer_ids.is_empty() {
317            tab_widths.push(1); // separator
318        }
319
320        tab_widths.push(tab_width);
321        rendered_buffer_ids.push(*id);
322    }
323
324    (tab_widths, rendered_buffer_ids)
325}
326
327impl TabsRenderer {
328    /// Render the tab bar for a specific split showing only its open buffers
329    ///
330    /// # Arguments
331    /// * `frame` - The ratatui frame to render to
332    /// * `area` - The rectangular area to render the tabs in
333    /// * `split_buffers` - List of buffer IDs open in this split (in order)
334    /// * `buffers` - All open buffers (for accessing state/metadata)
335    /// * `buffer_metadata` - Metadata for buffers (contains display names for virtual buffers)
336    /// * `active_buffer` - The currently active buffer ID for this split
337    /// * `theme` - The active theme for colors
338    /// * `is_active_split` - Whether this split is the active one
339    /// * `hovered_tab` - Optional (buffer_id, is_close_button) if a tab is being hovered
340    ///
341    /// # Returns
342    /// `TabLayout` containing hit areas for mouse interaction.
343    #[allow(clippy::too_many_arguments)]
344    pub fn render_for_split(
345        frame: &mut Frame,
346        area: Rect,
347        split_buffers: &[BufferId],
348        buffers: &HashMap<BufferId, EditorState>,
349        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
350        composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
351        active_buffer: BufferId,
352        theme: &crate::view::theme::Theme,
353        is_active_split: bool,
354        tab_scroll_offset: usize,
355        hovered_tab: Option<(BufferId, bool)>, // (buffer_id, is_close_button)
356    ) -> TabLayout {
357        let mut layout = TabLayout::new(area);
358        const SCROLL_INDICATOR_LEFT: &str = "<";
359        const SCROLL_INDICATOR_RIGHT: &str = ">";
360        const SCROLL_INDICATOR_WIDTH: usize = 1; // Width of "<" or ">"
361
362        let mut all_tab_spans: Vec<(Span, usize)> = Vec::new(); // Store (Span, display_width)
363        let mut tab_ranges: Vec<(usize, usize, usize)> = Vec::new(); // (start, end, close_start) positions for each tab
364        let mut rendered_buffer_ids: Vec<BufferId> = Vec::new(); // Track which buffers actually got rendered
365        let resolved_names =
366            resolve_tab_names(split_buffers, buffers, buffer_metadata, composite_buffers);
367
368        // First, build all spans and calculate their display widths
369        for id in split_buffers.iter() {
370            // Check if this is a regular buffer or a composite buffer
371            let is_regular_buffer = buffers.contains_key(id);
372            let is_composite_buffer = composite_buffers.contains_key(id);
373
374            if !is_regular_buffer && !is_composite_buffer {
375                continue;
376            }
377
378            // Skip buffers that are marked as hidden from tabs (e.g., composite source buffers)
379            if let Some(meta) = buffer_metadata.get(id) {
380                if meta.hidden_from_tabs {
381                    continue;
382                }
383            }
384            rendered_buffer_ids.push(*id);
385
386            let name = resolved_names
387                .get(id)
388                .map(|s| s.as_str())
389                .unwrap_or("[No Name]");
390
391            // For composite buffers, never show as modified (they're read-only views)
392            let modified = if is_composite_buffer {
393                ""
394            } else if let Some(state) = buffers.get(id) {
395                if state.buffer.is_modified() {
396                    "*"
397                } else {
398                    ""
399                }
400            } else {
401                ""
402            };
403            let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
404                " [BIN]"
405            } else {
406                ""
407            };
408
409            let is_active = *id == active_buffer;
410
411            // Check hover state for this tab
412            let (is_hovered_name, is_hovered_close) = match hovered_tab {
413                Some((hover_buf, is_close)) if hover_buf == *id => (!is_close, is_close),
414                _ => (false, false),
415            };
416
417            // Determine base style
418            let base_style = if is_active {
419                if is_active_split {
420                    Style::default()
421                        .fg(theme.tab_active_fg)
422                        .bg(theme.tab_active_bg)
423                        .add_modifier(Modifier::BOLD)
424                } else {
425                    Style::default()
426                        .fg(theme.tab_active_fg)
427                        .bg(theme.tab_inactive_bg)
428                        .add_modifier(Modifier::BOLD)
429                }
430            } else if is_hovered_name {
431                // Non-active tab with name hovered - use hover background
432                Style::default()
433                    .fg(theme.tab_inactive_fg)
434                    .bg(theme.tab_hover_bg)
435            } else {
436                Style::default()
437                    .fg(theme.tab_inactive_fg)
438                    .bg(theme.tab_inactive_bg)
439            };
440
441            // Style for the close button
442            let close_style = if is_hovered_close {
443                // Close button hovered - use hover color
444                base_style.fg(theme.tab_close_hover_fg)
445            } else {
446                base_style
447            };
448
449            // Build tab content: " {name}{modified}{binary_indicator} "
450            let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
451            let tab_name_width = str_width(&tab_name_text);
452
453            // Close button: "× "
454            let close_text = "× ";
455            let close_width = str_width(close_text);
456
457            let total_width = tab_name_width + close_width;
458
459            let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
460            let close_start_pos = start_pos + tab_name_width;
461            let end_pos = start_pos + total_width;
462            tab_ranges.push((start_pos, end_pos, close_start_pos));
463
464            // Add name span
465            all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
466            // Add close button span (can have different style when hovered)
467            all_tab_spans.push((
468                Span::styled(close_text.to_string(), close_style),
469                close_width,
470            ));
471        }
472
473        // Add separators between tabs (we do this after the loop to handle hidden buffers correctly)
474        // We'll rebuild all_tab_spans with separators inserted, and fix up tab_ranges
475        // to account for the separator widths
476        let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
477        let mut separator_offset = 0usize;
478        let spans_per_tab = 2; // name + close button
479        for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
480            // Adjust tab_ranges for this tab to account for separators before it
481            if separator_offset > 0 {
482                let (start, end, close_start) = tab_ranges[tab_idx];
483                tab_ranges[tab_idx] = (
484                    start + separator_offset,
485                    end + separator_offset,
486                    close_start + separator_offset,
487                );
488            }
489
490            for span in chunk {
491                final_spans.push(span.clone());
492            }
493            // Add separator if not the last tab
494            if tab_idx < rendered_buffer_ids.len().saturating_sub(1) {
495                final_spans.push((
496                    Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
497                    1,
498                ));
499                separator_offset += 1;
500            }
501        }
502        #[allow(clippy::let_and_return)]
503        let all_tab_spans = final_spans;
504
505        let mut current_spans: Vec<Span> = Vec::new();
506        let max_width = area.width as usize;
507
508        let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
509        // Use rendered_buffer_ids (not split_buffers) to find active index,
510        // since some buffers may have been skipped if not in buffers HashMap
511        let _active_tab_idx = rendered_buffer_ids
512            .iter()
513            .position(|id| *id == active_buffer);
514
515        let mut tab_widths: Vec<usize> = Vec::new();
516        for (start, end, _close_start) in &tab_ranges {
517            tab_widths.push(end.saturating_sub(*start));
518        }
519
520        // Use the scroll offset directly - ensure_active_tab_visible handles the calculation
521        // Only clamp to prevent negative or extreme values
522        let max_offset = total_width.saturating_sub(max_width);
523        let offset = tab_scroll_offset.min(total_width);
524        tracing::trace!(
525            "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
526            tab_scroll_offset, max_offset, offset, total_width, max_width
527        );
528        // Indicators reserve space based on scroll position
529        let show_left = offset > 0;
530        let show_right = total_width.saturating_sub(offset) > max_width;
531        let available = max_width
532            .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
533
534        let mut rendered_width = 0;
535        let mut skip_chars_count = offset;
536
537        if show_left {
538            current_spans.push(Span::styled(
539                SCROLL_INDICATOR_LEFT,
540                Style::default().bg(theme.tab_separator_bg),
541            ));
542            rendered_width += SCROLL_INDICATOR_WIDTH;
543        }
544
545        for (mut span, width) in all_tab_spans.into_iter() {
546            if skip_chars_count >= width {
547                skip_chars_count -= width;
548                continue;
549            }
550
551            let visible_chars_in_span = width - skip_chars_count;
552            if rendered_width + visible_chars_in_span
553                > max_width.saturating_sub(if show_right {
554                    SCROLL_INDICATOR_WIDTH
555                } else {
556                    0
557                })
558            {
559                let remaining_width =
560                    max_width
561                        .saturating_sub(rendered_width)
562                        .saturating_sub(if show_right {
563                            SCROLL_INDICATOR_WIDTH
564                        } else {
565                            0
566                        });
567                let truncated_content = span
568                    .content
569                    .chars()
570                    .skip(skip_chars_count)
571                    .take(remaining_width)
572                    .collect::<String>();
573                span.content = std::borrow::Cow::Owned(truncated_content);
574                current_spans.push(span);
575                rendered_width += remaining_width;
576                break;
577            } else {
578                let visible_content = span
579                    .content
580                    .chars()
581                    .skip(skip_chars_count)
582                    .collect::<String>();
583                span.content = std::borrow::Cow::Owned(visible_content);
584                current_spans.push(span);
585                rendered_width += visible_chars_in_span;
586                skip_chars_count = 0;
587            }
588        }
589
590        // Track where the right indicator will be rendered (before adding it)
591        let right_indicator_x = if show_right && rendered_width < max_width {
592            Some(area.x + rendered_width as u16)
593        } else {
594            None
595        };
596
597        if show_right && rendered_width < max_width {
598            current_spans.push(Span::styled(
599                SCROLL_INDICATOR_RIGHT,
600                Style::default().bg(theme.tab_separator_bg),
601            ));
602            rendered_width += SCROLL_INDICATOR_WIDTH;
603        }
604
605        if rendered_width < max_width {
606            current_spans.push(Span::styled(
607                " ".repeat(max_width.saturating_sub(rendered_width)),
608                Style::default().bg(theme.tab_separator_bg),
609            ));
610        }
611
612        let line = Line::from(current_spans);
613        let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
614        let paragraph = Paragraph::new(line).block(block);
615        frame.render_widget(paragraph, area);
616
617        // Compute and return hit areas for mouse interaction
618        // We need to map the logical tab positions to screen positions accounting for:
619        // 1. The scroll offset
620        // 2. The left scroll indicator (if shown)
621        // 3. The base area.x position
622        let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
623
624        // Set scroll button areas if shown
625        if show_left {
626            layout.left_scroll_area =
627                Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
628        }
629        if let Some(right_x) = right_indicator_x {
630            // Right scroll button is at the position where it was actually rendered
631            layout.right_scroll_area =
632                Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
633        }
634
635        for (idx, buffer_id) in rendered_buffer_ids.iter().enumerate() {
636            let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
637
638            // Convert logical positions to screen positions
639            // Screen position = area.x + left_indicator_offset + (logical_pos - scroll_offset)
640            // But we need to clamp to visible area
641            let visible_start = offset;
642            let visible_end = offset + available;
643
644            // Skip tabs that are completely scrolled out of view
645            if logical_end <= visible_start || logical_start >= visible_end {
646                continue;
647            }
648
649            // Calculate visible portion of this tab
650            let screen_start = if logical_start >= visible_start {
651                area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
652            } else {
653                area.x + left_indicator_offset as u16
654            };
655
656            let screen_end = if logical_end <= visible_end {
657                area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
658            } else {
659                area.x + left_indicator_offset as u16 + available as u16
660            };
661
662            // Close button position (if visible)
663            let screen_close_start = if logical_close_start >= visible_start
664                && logical_close_start < visible_end
665            {
666                area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
667            } else if logical_close_start < visible_start {
668                // Close button is partially/fully scrolled off left - use screen_start
669                screen_start
670            } else {
671                // Close button is scrolled off right
672                screen_end
673            };
674
675            // Build tab hit area using Rects
676            let tab_width = screen_end.saturating_sub(screen_start);
677            let close_width = screen_end.saturating_sub(screen_close_start);
678
679            layout.tabs.push(TabHitArea {
680                buffer_id: *buffer_id,
681                tab_area: Rect::new(screen_start, area.y, tab_width, 1),
682                close_area: Rect::new(screen_close_start, area.y, close_width, 1),
683            });
684        }
685
686        layout
687    }
688
689    /// Legacy render function for backward compatibility
690    /// Renders all buffers as tabs (used during transition)
691    #[allow(dead_code)]
692    pub fn render(
693        frame: &mut Frame,
694        area: Rect,
695        buffers: &HashMap<BufferId, EditorState>,
696        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
697        composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
698        active_buffer: BufferId,
699        theme: &crate::view::theme::Theme,
700    ) {
701        // Sort buffer IDs to ensure consistent tab order
702        let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
703        buffer_ids.sort_by_key(|id| id.0);
704
705        Self::render_for_split(
706            frame,
707            area,
708            &buffer_ids,
709            buffers,
710            buffer_metadata,
711            composite_buffers,
712            active_buffer,
713            theme,
714            true, // Legacy behavior: always treat as active
715            0,    // Default tab_scroll_offset for legacy render
716            None, // No hover state for legacy render
717        );
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::model::event::BufferId;
725
726    #[test]
727    fn scroll_to_show_active_first_tab() {
728        // Active is first tab, should scroll left to show it
729        let widths = vec![5, 5, 5];
730        let offset = scroll_to_show_tab(&widths, 0, 10, 20);
731        // First tab starts at 0, should scroll to show it
732        assert_eq!(offset, 0);
733    }
734
735    #[test]
736    fn scroll_to_show_tab_already_visible() {
737        // Tab is already visible, offset should stay the same
738        let widths = vec![5, 5, 5];
739        let offset = scroll_to_show_tab(&widths, 1, 0, 20);
740        // Tab 1 starts at 5, ends at 10, visible in 0..20
741        assert_eq!(offset, 0);
742    }
743
744    #[test]
745    fn scroll_to_show_tab_on_right() {
746        // Tab is to the right, need to scroll right
747        let widths = vec![10, 10, 10];
748        let offset = scroll_to_show_tab(&widths, 2, 0, 15);
749        // Tab 2 starts at 20, ends at 30; need to scroll to show it
750        assert!(offset > 0);
751    }
752
753    /// Helper: given a scroll offset, compute the visible content range
754    /// accounting for scroll indicators (1 char each).
755    fn visible_range(offset: usize, total_width: usize, max_width: usize) -> (usize, usize) {
756        let show_left = offset > 0;
757        let show_right = total_width.saturating_sub(offset) > max_width;
758        let available = max_width
759            .saturating_sub(if show_left { 1 } else { 0 })
760            .saturating_sub(if show_right { 1 } else { 0 });
761        (offset, offset + available)
762    }
763
764    /// Property: scroll_to_show_tab must produce an offset where the active tab
765    /// is fully contained within the visible content range (after accounting for
766    /// scroll indicator columns).
767    #[test]
768    fn scroll_to_show_tab_active_always_visible() {
769        // Simulate the e2e scenario: 15 tabs with long names in a 40-char-wide bar.
770        // tab_widths includes separators: [tab0, 1, tab1, 1, tab2, ...]
771        // Active index for tab N is N*2 (matching ensure_active_tab_visible logic).
772        let tab_content_width = 33; // " long_file_name_number_XX.txt × "
773        let num_tabs = 15;
774        let max_width = 40;
775
776        let mut tab_widths = Vec::new();
777        for i in 0..num_tabs {
778            if i > 0 {
779                tab_widths.push(1); // separator
780            }
781            tab_widths.push(tab_content_width);
782        }
783        let total_width: usize = tab_widths.iter().sum();
784
785        for tab_idx in 0..num_tabs {
786            let active_width_idx = if tab_idx == 0 { 0 } else { tab_idx * 2 };
787            let tab_start: usize = tab_widths[..active_width_idx].iter().sum();
788            let tab_end = tab_start + tab_widths[active_width_idx];
789
790            let offset = scroll_to_show_tab(&tab_widths, active_width_idx, 0, max_width);
791            let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
792
793            assert!(
794                tab_start >= vis_start && tab_end <= vis_end,
795                "Tab {} (width_idx={}, {}..{}) not fully visible in range {}..{} (offset={})",
796                tab_idx,
797                active_width_idx,
798                tab_start,
799                tab_end,
800                vis_start,
801                vis_end,
802                offset
803            );
804        }
805    }
806
807    /// Property: same as above but with varying tab widths and screen sizes
808    #[test]
809    fn scroll_to_show_tab_property_varied_sizes() {
810        let test_cases: Vec<(Vec<usize>, usize)> = vec![
811            (vec![10, 15, 20, 10, 25], 30),
812            (vec![5; 20], 20),
813            (vec![40], 40),       // single tab exactly fills
814            (vec![50], 40),       // single tab wider than screen
815            (vec![3, 3, 3], 100), // all fit easily
816        ];
817
818        for (tab_widths, max_width) in test_cases {
819            let total_width: usize = tab_widths.iter().sum();
820            for active_idx in 0..tab_widths.len() {
821                let tab_start: usize = tab_widths[..active_idx].iter().sum();
822                let tab_end = tab_start + tab_widths[active_idx];
823                let tab_w = tab_widths[active_idx];
824
825                let offset = scroll_to_show_tab(&tab_widths, active_idx, 0, max_width);
826                let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
827
828                // Only check if the tab can physically fit in the viewport
829                if tab_w <= max_width.saturating_sub(2) || (active_idx == 0 && tab_w <= max_width) {
830                    assert!(
831                        tab_start >= vis_start && tab_end <= vis_end,
832                        "Tab {} ({}..{}, w={}) not visible in {}..{} (offset={}, max_width={}, widths={:?})",
833                        active_idx, tab_start, tab_end, tab_w, vis_start, vis_end, offset, max_width, tab_widths
834                    );
835                }
836            }
837        }
838    }
839
840    #[test]
841    fn test_tab_layout_hit_test() {
842        let bar_area = Rect::new(0, 0, 80, 1);
843        let mut layout = TabLayout::new(bar_area);
844
845        let buf1 = BufferId(1);
846
847        layout.tabs.push(TabHitArea {
848            buffer_id: buf1,
849            tab_area: Rect::new(0, 0, 16, 1),
850            close_area: Rect::new(12, 0, 4, 1),
851        });
852
853        // Hit tab name
854        assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(buf1)));
855
856        // Hit close button
857        assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(buf1)));
858
859        // Hit bar background
860        assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
861
862        // Outside everything
863        assert_eq!(layout.hit_test(50, 5), None);
864    }
865}