1use 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
17fn 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
29fn 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#[derive(Debug, Clone)]
41pub struct TabHitArea {
42 pub target: TabTarget,
44 pub tab_area: Rect,
46 pub close_area: Rect,
48}
49
50impl TabHitArea {
51 pub fn buffer_id(&self) -> Option<BufferId> {
53 self.target.as_buffer()
54 }
55}
56
57#[derive(Debug, Clone, Default)]
62pub struct TabLayout {
63 pub tabs: Vec<TabHitArea>,
65 pub bar_area: Rect,
67 pub left_scroll_area: Option<Rect>,
69 pub right_scroll_area: Option<Rect>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TabHit {
76 TabName(TabTarget),
78 CloseButton(TabTarget),
80 BarBackground,
82 ScrollLeft,
84 ScrollRight,
86}
87
88impl TabLayout {
89 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 pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
101 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 if point_in_rect(tab.close_area, x, y) {
130 return Some(TabHit::CloseButton(tab.target));
131 }
132 if point_in_rect(tab.tab_area, x, y) {
134 return Some(TabHit::TabName(tab.target));
135 }
136 }
137
138 if point_in_rect(self.bar_area, x, y) {
140 return Some(TabHit::BarBackground);
141 }
142
143 None
144 }
145}
146
147pub struct TabsRenderer;
149
150pub 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 let preferred_position = max_width / 4;
170 let target_offset = tab_start.saturating_sub(preferred_position);
171
172 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 let available_worst = max_width.saturating_sub(2);
192
193 if tab_end > result + available_worst {
194 result = tab_end.saturating_sub(available_worst);
197 }
198 if tab_start < result {
199 result = tab_start;
202 }
203 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
219fn 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 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 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
299pub 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 let Some(name) = resolved_names.get(t) else {
322 continue;
323 };
324
325 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 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 if !rendered_targets.is_empty() {
363 tab_widths.push(1); }
365
366 tab_widths.push(tab_width);
367 rendered_targets.push(*t);
368 }
369
370 (tab_widths, rendered_targets)
371}
372
373impl TabsRenderer {
374 #[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)>, 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; let mut all_tab_spans: Vec<(Span, usize)> = Vec::new(); let mut tab_ranges: Vec<(usize, usize, usize)> = Vec::new(); let mut rendered_targets: Vec<TabTarget> = Vec::new(); let resolved_names = resolve_tab_names(
413 tab_targets,
414 buffers,
415 buffer_metadata,
416 composite_buffers,
417 group_names,
418 );
419
420 for t in tab_targets.iter() {
422 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 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 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 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 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 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 let close_style = if is_hovered_close {
507 base_style.fg(theme.tab_close_hover_fg)
509 } else {
510 base_style
511 };
512
513 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
515 let tab_name_width = str_width(&tab_name_text);
516
517 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 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
530 all_tab_spans.push((
532 Span::styled(close_text.to_string(), close_style),
533 close_width,
534 ));
535 }
536
537 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
541 let mut separator_offset = 0usize;
542 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
544 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 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 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 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 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 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 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
685
686 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 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 let visible_start = offset;
704 let visible_end = offset + available;
705
706 if logical_end <= visible_start || logical_start >= visible_end {
708 continue;
709 }
710
711 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 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 screen_start
732 } else {
733 screen_end
735 };
736
737 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 #[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 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, 0, None, &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 let widths = vec![5, 5, 5];
795 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
796 assert_eq!(offset, 0);
798 }
799
800 #[test]
801 fn scroll_to_show_tab_already_visible() {
802 let widths = vec![5, 5, 5];
804 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
805 assert_eq!(offset, 0);
807 }
808
809 #[test]
810 fn scroll_to_show_tab_on_right() {
811 let widths = vec![10, 10, 10];
813 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
814 assert!(offset > 0);
816 }
817
818 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 #[test]
833 fn scroll_to_show_tab_active_always_visible() {
834 let tab_content_width = 33; 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); }
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 #[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), (vec![50], 40), (vec![3, 3, 3], 100), ];
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 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 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(target1)));
921
922 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(target1)));
924
925 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
927
928 assert_eq!(layout.hit_test(50, 5), None);
930 }
931}