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