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