Skip to main content

fresh/view/ui/
tabs.rs

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