Skip to main content

fresh/view/ui/
tabs.rs

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