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;
5use crate::primitives::display_width::str_width;
6use crate::state::EditorState;
7use crate::view::ui::layout::point_in_rect;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Paragraph};
12use ratatui::Frame;
13use std::collections::HashMap;
14
15/// Hit area for a single tab
16#[derive(Debug, Clone)]
17pub struct TabHitArea {
18    /// The buffer ID this tab represents
19    pub buffer_id: BufferId,
20    /// The area covering the tab name (clickable to switch to buffer)
21    pub tab_area: Rect,
22    /// The area covering the close button
23    pub close_area: Rect,
24}
25
26/// Layout information for hit testing tab interactions
27///
28/// Returned by `TabsRenderer::render_for_split()` to enable mouse hit testing
29/// without duplicating position calculations.
30#[derive(Debug, Clone, Default)]
31pub struct TabLayout {
32    /// Hit areas for each visible tab
33    pub tabs: Vec<TabHitArea>,
34    /// The full tab bar area
35    pub bar_area: Rect,
36    /// Hit area for the left scroll button (if shown)
37    pub left_scroll_area: Option<Rect>,
38    /// Hit area for the right scroll button (if shown)
39    pub right_scroll_area: Option<Rect>,
40}
41
42/// Hit test result for tab interactions
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TabHit {
45    /// Hit the tab name area (click to switch buffer)
46    TabName(BufferId),
47    /// Hit the close button area
48    CloseButton(BufferId),
49    /// Hit the tab bar background
50    BarBackground,
51    /// Hit the left scroll button
52    ScrollLeft,
53    /// Hit the right scroll button
54    ScrollRight,
55}
56
57impl TabLayout {
58    /// Create a new empty layout
59    pub fn new(bar_area: Rect) -> Self {
60        Self {
61            tabs: Vec::new(),
62            bar_area,
63            left_scroll_area: None,
64            right_scroll_area: None,
65        }
66    }
67
68    /// Perform a hit test to determine what element is at the given position
69    pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
70        // Check scroll buttons first (they're at the edges)
71        if let Some(left_area) = self.left_scroll_area {
72            tracing::debug!(
73                "Tab hit_test: checking left_scroll_area {:?} against ({}, {})",
74                left_area,
75                x,
76                y
77            );
78            if point_in_rect(left_area, x, y) {
79                tracing::debug!("Tab hit_test: HIT ScrollLeft");
80                return Some(TabHit::ScrollLeft);
81            }
82        }
83        if let Some(right_area) = self.right_scroll_area {
84            tracing::debug!(
85                "Tab hit_test: checking right_scroll_area {:?} against ({}, {})",
86                right_area,
87                x,
88                y
89            );
90            if point_in_rect(right_area, x, y) {
91                tracing::debug!("Tab hit_test: HIT ScrollRight");
92                return Some(TabHit::ScrollRight);
93            }
94        }
95
96        for tab in &self.tabs {
97            // Check close button first (it's inside the tab area)
98            if point_in_rect(tab.close_area, x, y) {
99                return Some(TabHit::CloseButton(tab.buffer_id));
100            }
101            // Check tab area
102            if point_in_rect(tab.tab_area, x, y) {
103                return Some(TabHit::TabName(tab.buffer_id));
104            }
105        }
106
107        // Check bar background
108        if point_in_rect(self.bar_area, x, y) {
109            return Some(TabHit::BarBackground);
110        }
111
112        None
113    }
114}
115
116/// Renders the tab bar showing open buffers
117pub struct TabsRenderer;
118
119/// Compute scroll offset to bring the active tab into view.
120/// Always scrolls to put the active tab at a comfortable position.
121/// `tab_widths` includes separators between tabs.
122pub fn scroll_to_show_tab(
123    tab_widths: &[usize],
124    active_idx: usize,
125    _current_offset: usize,
126    max_width: usize,
127) -> usize {
128    if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
129        return 0;
130    }
131
132    let total_width: usize = tab_widths.iter().sum();
133    let tab_start: usize = tab_widths[..active_idx].iter().sum();
134    let tab_width = tab_widths[active_idx];
135    let tab_end = tab_start + tab_width;
136
137    // Try to put the active tab about 1/4 from the left edge
138    let preferred_position = max_width / 4;
139    let target_offset = tab_start.saturating_sub(preferred_position);
140
141    // Clamp to valid range (0 to max_offset)
142    let max_offset = total_width.saturating_sub(max_width);
143    let mut result = target_offset.min(max_offset);
144
145    // But ensure the tab is fully visible - if clamping pushed the tab off screen,
146    // adjust to show at least the tab
147    if tab_end > result + max_width {
148        // Tab is past right edge, scroll right to show it
149        result = tab_end.saturating_sub(max_width);
150    }
151
152    tracing::debug!(
153        "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
154        active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
155    );
156
157    result
158}
159
160/// Calculate tab widths for scroll offset calculations.
161/// Returns (tab_widths, rendered_buffer_ids) where tab_widths includes separators.
162/// This uses the same logic as render_for_split to ensure consistency.
163pub fn calculate_tab_widths(
164    split_buffers: &[BufferId],
165    buffers: &HashMap<BufferId, EditorState>,
166    buffer_metadata: &HashMap<BufferId, BufferMetadata>,
167    composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
168) -> (Vec<usize>, Vec<BufferId>) {
169    let mut tab_widths: Vec<usize> = Vec::new();
170    let mut rendered_buffer_ids: Vec<BufferId> = Vec::new();
171
172    for id in split_buffers.iter() {
173        // Check if this is a regular buffer or a composite buffer
174        let is_regular_buffer = buffers.contains_key(id);
175        let is_composite_buffer = composite_buffers.contains_key(id);
176
177        if !is_regular_buffer && !is_composite_buffer {
178            continue;
179        }
180
181        // Skip buffers that are marked as hidden from tabs
182        if let Some(meta) = buffer_metadata.get(id) {
183            if meta.hidden_from_tabs {
184                continue;
185            }
186        }
187
188        let meta = buffer_metadata.get(id);
189        let is_terminal = meta
190            .and_then(|m| m.virtual_mode())
191            .map(|mode| mode == "terminal")
192            .unwrap_or(false);
193
194        // Use same name resolution logic as render_for_split
195        let name = if is_composite_buffer {
196            meta.map(|m| m.display_name.as_str())
197        } else if is_terminal {
198            meta.map(|m| m.display_name.as_str())
199        } else {
200            buffers
201                .get(id)
202                .and_then(|state| state.buffer.file_path())
203                .and_then(|p| p.file_name())
204                .and_then(|n| n.to_str())
205                .or_else(|| meta.map(|m| m.display_name.as_str()))
206        }
207        .unwrap_or("[No Name]");
208
209        // Calculate modified indicator
210        let modified = if is_composite_buffer {
211            ""
212        } else if let Some(state) = buffers.get(id) {
213            if state.buffer.is_modified() {
214                "*"
215            } else {
216                ""
217            }
218        } else {
219            ""
220        };
221
222        let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
223            " [BIN]"
224        } else {
225            ""
226        };
227
228        // Same format as render_for_split: " {name}{modified}{binary_indicator} " + "× "
229        let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
230        let close_text = "× ";
231        let tab_width = str_width(&tab_name_text) + str_width(close_text);
232
233        // Add separator if not first tab
234        if !rendered_buffer_ids.is_empty() {
235            tab_widths.push(1); // separator
236        }
237
238        tab_widths.push(tab_width);
239        rendered_buffer_ids.push(*id);
240    }
241
242    (tab_widths, rendered_buffer_ids)
243}
244
245impl TabsRenderer {
246    /// Render the tab bar for a specific split showing only its open buffers
247    ///
248    /// # Arguments
249    /// * `frame` - The ratatui frame to render to
250    /// * `area` - The rectangular area to render the tabs in
251    /// * `split_buffers` - List of buffer IDs open in this split (in order)
252    /// * `buffers` - All open buffers (for accessing state/metadata)
253    /// * `buffer_metadata` - Metadata for buffers (contains display names for virtual buffers)
254    /// * `active_buffer` - The currently active buffer ID for this split
255    /// * `theme` - The active theme for colors
256    /// * `is_active_split` - Whether this split is the active one
257    /// * `hovered_tab` - Optional (buffer_id, is_close_button) if a tab is being hovered
258    ///
259    /// # Returns
260    /// `TabLayout` containing hit areas for mouse interaction.
261    #[allow(clippy::too_many_arguments)]
262    pub fn render_for_split(
263        frame: &mut Frame,
264        area: Rect,
265        split_buffers: &[BufferId],
266        buffers: &HashMap<BufferId, EditorState>,
267        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
268        composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
269        active_buffer: BufferId,
270        theme: &crate::view::theme::Theme,
271        is_active_split: bool,
272        tab_scroll_offset: usize,
273        hovered_tab: Option<(BufferId, bool)>, // (buffer_id, is_close_button)
274    ) -> TabLayout {
275        let mut layout = TabLayout::new(area);
276        const SCROLL_INDICATOR_LEFT: &str = "<";
277        const SCROLL_INDICATOR_RIGHT: &str = ">";
278        const SCROLL_INDICATOR_WIDTH: usize = 1; // Width of "<" or ">"
279
280        let mut all_tab_spans: Vec<(Span, usize)> = Vec::new(); // Store (Span, display_width)
281        let mut tab_ranges: Vec<(usize, usize, usize)> = Vec::new(); // (start, end, close_start) positions for each tab
282        let mut rendered_buffer_ids: Vec<BufferId> = Vec::new(); // Track which buffers actually got rendered
283
284        // First, build all spans and calculate their display widths
285        for id in split_buffers.iter() {
286            // Check if this is a regular buffer or a composite buffer
287            let is_regular_buffer = buffers.contains_key(id);
288            let is_composite_buffer = composite_buffers.contains_key(id);
289
290            if !is_regular_buffer && !is_composite_buffer {
291                continue;
292            }
293
294            // Skip buffers that are marked as hidden from tabs (e.g., composite source buffers)
295            if let Some(meta) = buffer_metadata.get(id) {
296                if meta.hidden_from_tabs {
297                    continue;
298                }
299            }
300            rendered_buffer_ids.push(*id);
301
302            let meta = buffer_metadata.get(id);
303            let is_terminal = meta
304                .and_then(|m| m.virtual_mode())
305                .map(|mode| mode == "terminal")
306                .unwrap_or(false);
307
308            // For composite buffers, use display_name from metadata
309            // For regular buffers, try file_path first, then display_name
310            let name = if is_composite_buffer {
311                meta.map(|m| m.display_name.as_str())
312            } else if is_terminal {
313                meta.map(|m| m.display_name.as_str())
314            } else {
315                buffers
316                    .get(id)
317                    .and_then(|state| state.buffer.file_path())
318                    .and_then(|p| p.file_name())
319                    .and_then(|n| n.to_str())
320                    .or_else(|| meta.map(|m| m.display_name.as_str()))
321            }
322            .unwrap_or("[No Name]");
323
324            // For composite buffers, never show as modified (they're read-only views)
325            let modified = if is_composite_buffer {
326                ""
327            } else if let Some(state) = buffers.get(id) {
328                if state.buffer.is_modified() {
329                    "*"
330                } else {
331                    ""
332                }
333            } else {
334                ""
335            };
336            let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
337                " [BIN]"
338            } else {
339                ""
340            };
341
342            let is_active = *id == active_buffer;
343
344            // Check hover state for this tab
345            let (is_hovered_name, is_hovered_close) = match hovered_tab {
346                Some((hover_buf, is_close)) if hover_buf == *id => (!is_close, is_close),
347                _ => (false, false),
348            };
349
350            // Determine base style
351            let base_style = if is_active {
352                if is_active_split {
353                    Style::default()
354                        .fg(theme.tab_active_fg)
355                        .bg(theme.tab_active_bg)
356                        .add_modifier(Modifier::BOLD)
357                } else {
358                    Style::default()
359                        .fg(theme.tab_active_fg)
360                        .bg(theme.tab_inactive_bg)
361                        .add_modifier(Modifier::BOLD)
362                }
363            } else if is_hovered_name {
364                // Non-active tab with name hovered - use hover background
365                Style::default()
366                    .fg(theme.tab_inactive_fg)
367                    .bg(theme.tab_hover_bg)
368            } else {
369                Style::default()
370                    .fg(theme.tab_inactive_fg)
371                    .bg(theme.tab_inactive_bg)
372            };
373
374            // Style for the close button
375            let close_style = if is_hovered_close {
376                // Close button hovered - use hover color
377                base_style.fg(theme.tab_close_hover_fg)
378            } else {
379                base_style
380            };
381
382            // Build tab content: " {name}{modified}{binary_indicator} "
383            let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
384            let tab_name_width = str_width(&tab_name_text);
385
386            // Close button: "× "
387            let close_text = "× ";
388            let close_width = str_width(close_text);
389
390            let total_width = tab_name_width + close_width;
391
392            let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
393            let close_start_pos = start_pos + tab_name_width;
394            let end_pos = start_pos + total_width;
395            tab_ranges.push((start_pos, end_pos, close_start_pos));
396
397            // Add name span
398            all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
399            // Add close button span (can have different style when hovered)
400            all_tab_spans.push((
401                Span::styled(close_text.to_string(), close_style),
402                close_width,
403            ));
404        }
405
406        // Add separators between tabs (we do this after the loop to handle hidden buffers correctly)
407        // We'll rebuild all_tab_spans with separators inserted, and fix up tab_ranges
408        // to account for the separator widths
409        let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
410        let mut separator_offset = 0usize;
411        let spans_per_tab = 2; // name + close button
412        for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
413            // Adjust tab_ranges for this tab to account for separators before it
414            if separator_offset > 0 {
415                let (start, end, close_start) = tab_ranges[tab_idx];
416                tab_ranges[tab_idx] = (
417                    start + separator_offset,
418                    end + separator_offset,
419                    close_start + separator_offset,
420                );
421            }
422
423            for span in chunk {
424                final_spans.push(span.clone());
425            }
426            // Add separator if not the last tab
427            if tab_idx < rendered_buffer_ids.len().saturating_sub(1) {
428                final_spans.push((
429                    Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
430                    1,
431                ));
432                separator_offset += 1;
433            }
434        }
435        #[allow(clippy::let_and_return)]
436        let all_tab_spans = final_spans;
437
438        let mut current_spans: Vec<Span> = Vec::new();
439        let max_width = area.width as usize;
440
441        let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
442        // Use rendered_buffer_ids (not split_buffers) to find active index,
443        // since some buffers may have been skipped if not in buffers HashMap
444        let _active_tab_idx = rendered_buffer_ids
445            .iter()
446            .position(|id| *id == active_buffer);
447
448        let mut tab_widths: Vec<usize> = Vec::new();
449        for (start, end, _close_start) in &tab_ranges {
450            tab_widths.push(end.saturating_sub(*start));
451        }
452
453        // Use the scroll offset directly - ensure_active_tab_visible handles the calculation
454        // Only clamp to prevent negative or extreme values
455        let max_offset = total_width.saturating_sub(max_width);
456        let offset = tab_scroll_offset.min(total_width);
457        tracing::trace!(
458            "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
459            tab_scroll_offset, max_offset, offset, total_width, max_width
460        );
461
462        // Indicators reserve space based on scroll position
463        let show_left = offset > 0;
464        let show_right = total_width.saturating_sub(offset) > max_width;
465        let available = max_width
466            .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
467
468        let mut rendered_width = 0;
469        let mut skip_chars_count = offset;
470
471        if show_left {
472            current_spans.push(Span::styled(
473                SCROLL_INDICATOR_LEFT,
474                Style::default().bg(theme.tab_separator_bg),
475            ));
476            rendered_width += SCROLL_INDICATOR_WIDTH;
477        }
478
479        for (mut span, width) in all_tab_spans.into_iter() {
480            if skip_chars_count >= width {
481                skip_chars_count -= width;
482                continue;
483            }
484
485            let visible_chars_in_span = width - skip_chars_count;
486            if rendered_width + visible_chars_in_span
487                > max_width.saturating_sub(if show_right {
488                    SCROLL_INDICATOR_WIDTH
489                } else {
490                    0
491                })
492            {
493                let remaining_width =
494                    max_width
495                        .saturating_sub(rendered_width)
496                        .saturating_sub(if show_right {
497                            SCROLL_INDICATOR_WIDTH
498                        } else {
499                            0
500                        });
501                let truncated_content = span
502                    .content
503                    .chars()
504                    .skip(skip_chars_count)
505                    .take(remaining_width)
506                    .collect::<String>();
507                span.content = std::borrow::Cow::Owned(truncated_content);
508                current_spans.push(span);
509                rendered_width += remaining_width;
510                break;
511            } else {
512                let visible_content = span
513                    .content
514                    .chars()
515                    .skip(skip_chars_count)
516                    .collect::<String>();
517                span.content = std::borrow::Cow::Owned(visible_content);
518                current_spans.push(span);
519                rendered_width += visible_chars_in_span;
520                skip_chars_count = 0;
521            }
522        }
523
524        // Track where the right indicator will be rendered (before adding it)
525        let right_indicator_x = if show_right && rendered_width < max_width {
526            Some(area.x + rendered_width as u16)
527        } else {
528            None
529        };
530
531        if show_right && rendered_width < max_width {
532            current_spans.push(Span::styled(
533                SCROLL_INDICATOR_RIGHT,
534                Style::default().bg(theme.tab_separator_bg),
535            ));
536            rendered_width += SCROLL_INDICATOR_WIDTH;
537        }
538
539        if rendered_width < max_width {
540            current_spans.push(Span::styled(
541                " ".repeat(max_width.saturating_sub(rendered_width)),
542                Style::default().bg(theme.tab_separator_bg),
543            ));
544        }
545
546        let line = Line::from(current_spans);
547        let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
548        let paragraph = Paragraph::new(line).block(block);
549        frame.render_widget(paragraph, area);
550
551        // Compute and return hit areas for mouse interaction
552        // We need to map the logical tab positions to screen positions accounting for:
553        // 1. The scroll offset
554        // 2. The left scroll indicator (if shown)
555        // 3. The base area.x position
556        let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
557
558        // Set scroll button areas if shown
559        if show_left {
560            layout.left_scroll_area =
561                Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
562        }
563        if let Some(right_x) = right_indicator_x {
564            // Right scroll button is at the position where it was actually rendered
565            layout.right_scroll_area =
566                Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
567        }
568
569        for (idx, buffer_id) in rendered_buffer_ids.iter().enumerate() {
570            let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
571
572            // Convert logical positions to screen positions
573            // Screen position = area.x + left_indicator_offset + (logical_pos - scroll_offset)
574            // But we need to clamp to visible area
575            let visible_start = offset;
576            let visible_end = offset + available;
577
578            // Skip tabs that are completely scrolled out of view
579            if logical_end <= visible_start || logical_start >= visible_end {
580                continue;
581            }
582
583            // Calculate visible portion of this tab
584            let screen_start = if logical_start >= visible_start {
585                area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
586            } else {
587                area.x + left_indicator_offset as u16
588            };
589
590            let screen_end = if logical_end <= visible_end {
591                area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
592            } else {
593                area.x + left_indicator_offset as u16 + available as u16
594            };
595
596            // Close button position (if visible)
597            let screen_close_start = if logical_close_start >= visible_start
598                && logical_close_start < visible_end
599            {
600                area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
601            } else if logical_close_start < visible_start {
602                // Close button is partially/fully scrolled off left - use screen_start
603                screen_start
604            } else {
605                // Close button is scrolled off right
606                screen_end
607            };
608
609            // Build tab hit area using Rects
610            let tab_width = screen_end.saturating_sub(screen_start);
611            let close_width = screen_end.saturating_sub(screen_close_start);
612
613            layout.tabs.push(TabHitArea {
614                buffer_id: *buffer_id,
615                tab_area: Rect::new(screen_start, area.y, tab_width, 1),
616                close_area: Rect::new(screen_close_start, area.y, close_width, 1),
617            });
618        }
619
620        layout
621    }
622
623    /// Legacy render function for backward compatibility
624    /// Renders all buffers as tabs (used during transition)
625    #[allow(dead_code)]
626    pub fn render(
627        frame: &mut Frame,
628        area: Rect,
629        buffers: &HashMap<BufferId, EditorState>,
630        buffer_metadata: &HashMap<BufferId, BufferMetadata>,
631        composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
632        active_buffer: BufferId,
633        theme: &crate::view::theme::Theme,
634    ) {
635        // Sort buffer IDs to ensure consistent tab order
636        let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
637        buffer_ids.sort_by_key(|id| id.0);
638
639        Self::render_for_split(
640            frame,
641            area,
642            &buffer_ids,
643            buffers,
644            buffer_metadata,
645            composite_buffers,
646            active_buffer,
647            theme,
648            true, // Legacy behavior: always treat as active
649            0,    // Default tab_scroll_offset for legacy render
650            None, // No hover state for legacy render
651        );
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::model::event::BufferId;
659
660    #[test]
661    fn scroll_to_show_active_first_tab() {
662        // Active is first tab, should scroll left to show it
663        let widths = vec![5, 5, 5];
664        let offset = scroll_to_show_tab(&widths, 0, 10, 20);
665        // First tab starts at 0, should scroll to show it
666        assert_eq!(offset, 0);
667    }
668
669    #[test]
670    fn scroll_to_show_tab_already_visible() {
671        // Tab is already visible, offset should stay the same
672        let widths = vec![5, 5, 5];
673        let offset = scroll_to_show_tab(&widths, 1, 0, 20);
674        // Tab 1 starts at 5, ends at 10, visible in 0..20
675        assert_eq!(offset, 0);
676    }
677
678    #[test]
679    fn scroll_to_show_tab_on_right() {
680        // Tab is to the right, need to scroll right
681        let widths = vec![10, 10, 10];
682        let offset = scroll_to_show_tab(&widths, 2, 0, 15);
683        // Tab 2 starts at 20, ends at 30; need to scroll to show it
684        assert!(offset > 0);
685    }
686
687    #[test]
688    fn test_tab_layout_hit_test() {
689        let bar_area = Rect::new(0, 0, 80, 1);
690        let mut layout = TabLayout::new(bar_area);
691
692        let buf1 = BufferId(1);
693
694        layout.tabs.push(TabHitArea {
695            buffer_id: buf1,
696            tab_area: Rect::new(0, 0, 16, 1),
697            close_area: Rect::new(12, 0, 4, 1),
698        });
699
700        // Hit tab name
701        assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(buf1)));
702
703        // Hit close button
704        assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(buf1)));
705
706        // Hit bar background
707        assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
708
709        // Outside everything
710        assert_eq!(layout.hit_test(50, 5), None);
711    }
712}