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