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 pub new_tab_area: Option<Rect>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TabHit {
78 TabName(TabTarget),
80 CloseButton(TabTarget),
82 BarBackground,
84 ScrollLeft,
86 ScrollRight,
88 NewTabButton,
90}
91
92impl TabLayout {
93 pub fn new(bar_area: Rect) -> Self {
95 Self {
96 tabs: Vec::new(),
97 bar_area,
98 left_scroll_area: None,
99 right_scroll_area: None,
100 new_tab_area: None,
101 }
102 }
103
104 pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
106 if let Some(left_area) = self.left_scroll_area {
108 tracing::debug!(
109 "Tab hit_test: checking left_scroll_area {:?} against ({}, {})",
110 left_area,
111 x,
112 y
113 );
114 if point_in_rect(left_area, x, y) {
115 tracing::debug!("Tab hit_test: HIT ScrollLeft");
116 return Some(TabHit::ScrollLeft);
117 }
118 }
119 if let Some(right_area) = self.right_scroll_area {
120 tracing::debug!(
121 "Tab hit_test: checking right_scroll_area {:?} against ({}, {})",
122 right_area,
123 x,
124 y
125 );
126 if point_in_rect(right_area, x, y) {
127 tracing::debug!("Tab hit_test: HIT ScrollRight");
128 return Some(TabHit::ScrollRight);
129 }
130 }
131
132 for tab in &self.tabs {
133 if point_in_rect(tab.close_area, x, y) {
135 return Some(TabHit::CloseButton(tab.target));
136 }
137 if point_in_rect(tab.tab_area, x, y) {
139 return Some(TabHit::TabName(tab.target));
140 }
141 }
142
143 if let Some(new_tab_area) = self.new_tab_area {
145 if point_in_rect(new_tab_area, x, y) {
146 return Some(TabHit::NewTabButton);
147 }
148 }
149
150 if point_in_rect(self.bar_area, x, y) {
152 return Some(TabHit::BarBackground);
153 }
154
155 None
156 }
157}
158
159pub struct TabsRenderer;
161
162const NEW_TAB_BUTTON_TEXT: &str = " + ";
164pub const NEW_TAB_BUTTON_WIDTH: usize = 3;
166
167pub fn tabs_render_width(tabs_total: usize, bar_width: usize) -> usize {
177 let sep_before_plus = if tabs_total > 0 { 1 } else { 0 };
178 let inline_total = tabs_total + sep_before_plus + NEW_TAB_BUTTON_WIDTH;
179 if inline_total > bar_width && bar_width > NEW_TAB_BUTTON_WIDTH {
180 bar_width - NEW_TAB_BUTTON_WIDTH
181 } else {
182 bar_width
183 }
184}
185
186pub fn scroll_to_show_tab(
190 tab_widths: &[usize],
191 active_idx: usize,
192 _current_offset: usize,
193 max_width: usize,
194) -> usize {
195 if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
196 return 0;
197 }
198
199 let total_width: usize = tab_widths.iter().sum();
200 let tab_start: usize = tab_widths[..active_idx].iter().sum();
201 let tab_width = tab_widths[active_idx];
202 let tab_end = tab_start + tab_width;
203
204 let preferred_position = max_width / 4;
206 let target_offset = tab_start.saturating_sub(preferred_position);
207
208 let max_offset_with_indicator = total_width.saturating_sub(max_width.saturating_sub(1));
217 let max_offset_no_indicator = total_width.saturating_sub(max_width);
218 let max_offset = if total_width > max_width {
219 max_offset_with_indicator
220 } else {
221 0
222 };
223 let mut result = target_offset.min(max_offset);
224
225 let available_worst = max_width.saturating_sub(2);
228
229 if tab_end > result + available_worst {
230 result = tab_end.saturating_sub(available_worst);
233 }
234 if tab_start < result {
235 result = tab_start;
238 }
239 let effective_max = if result > 0 {
242 max_offset
243 } else {
244 max_offset_no_indicator
245 };
246 result = result.min(effective_max);
247
248 tracing::debug!(
249 "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
250 active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
251 );
252 result
253}
254
255fn resolve_tab_names(
261 tab_targets: &[TabTarget],
262 buffers: &HashMap<BufferId, EditorState>,
263 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
264 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
265 group_names: &HashMap<LeafId, String>,
266) -> HashMap<TabTarget, String> {
267 let mut names: Vec<(TabTarget, String)> = Vec::new();
268
269 for t in tab_targets.iter() {
270 match t {
271 TabTarget::Buffer(id) => {
272 let is_regular_buffer = buffers.contains_key(id);
273 let is_composite_buffer = composite_buffers.contains_key(id);
274 if !is_regular_buffer && !is_composite_buffer {
275 continue;
276 }
277 if let Some(meta) = buffer_metadata.get(id) {
278 if meta.hidden_from_tabs {
279 continue;
280 }
281 }
282
283 let meta = buffer_metadata.get(id);
284 let is_terminal = meta
285 .and_then(|m| m.virtual_mode())
286 .map(|mode| mode == "terminal")
287 .unwrap_or(false);
288
289 let name = if is_composite_buffer {
290 meta.map(|m| m.display_name.as_str())
291 } else if is_terminal {
292 meta.map(|m| m.display_name.as_str())
293 } else {
294 buffers
295 .get(id)
296 .and_then(|state| state.buffer.file_path())
297 .and_then(|p| p.file_name())
298 .and_then(|n| n.to_str())
299 .or_else(|| meta.map(|m| m.display_name.as_str()))
300 }
301 .unwrap_or("[No Name]");
302
303 names.push((*t, name.to_string()));
304 }
305 TabTarget::Group(leaf_id) => {
306 if let Some(name) = group_names.get(leaf_id) {
307 names.push((*t, name.clone()));
308 }
309 }
310 }
311 }
312
313 let mut name_counts: HashMap<&str, usize> = HashMap::new();
315 for (_, name) in &names {
316 *name_counts.entry(name.as_str()).or_insert(0) += 1;
317 }
318
319 let mut result = HashMap::new();
321 let mut name_indices: HashMap<String, usize> = HashMap::new();
322 for (t, name) in &names {
323 if name_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
324 let idx = name_indices.entry(name.clone()).or_insert(0);
325 *idx += 1;
326 result.insert(*t, format!("{} {}", name, idx));
327 } else {
328 result.insert(*t, name.clone());
329 }
330 }
331
332 result
333}
334
335pub fn calculate_tab_widths(
339 tab_targets: &[TabTarget],
340 buffers: &HashMap<BufferId, EditorState>,
341 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
342 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
343 group_names: &HashMap<LeafId, String>,
344) -> (Vec<usize>, Vec<TabTarget>) {
345 let mut tab_widths: Vec<usize> = Vec::new();
346 let mut rendered_targets: Vec<TabTarget> = Vec::new();
347 let resolved_names = resolve_tab_names(
348 tab_targets,
349 buffers,
350 buffer_metadata,
351 composite_buffers,
352 group_names,
353 );
354
355 for t in tab_targets.iter() {
356 let Some(name) = resolved_names.get(t) else {
358 continue;
359 };
360
361 let modified = match t {
363 TabTarget::Buffer(id) => {
364 if composite_buffers.contains_key(id) {
365 ""
366 } else if let Some(state) = buffers.get(id) {
367 if state.buffer.is_modified() {
368 "*"
369 } else {
370 ""
371 }
372 } else {
373 ""
374 }
375 }
376 TabTarget::Group(_) => "",
377 };
378
379 let binary_indicator = match t {
380 TabTarget::Buffer(id) => {
381 if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
382 " [BIN]"
383 } else {
384 ""
385 }
386 }
387 TabTarget::Group(_) => "",
388 };
389
390 let preview_indicator = preview_suffix(t, buffer_metadata);
391
392 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
394 let close_text = "× ";
395 let tab_width = str_width(&tab_name_text) + str_width(close_text);
396
397 if !rendered_targets.is_empty() {
399 tab_widths.push(1); }
401
402 tab_widths.push(tab_width);
403 rendered_targets.push(*t);
404 }
405
406 (tab_widths, rendered_targets)
407}
408
409impl TabsRenderer {
410 #[allow(clippy::too_many_arguments)]
426 pub fn render_for_split(
427 frame: &mut Frame,
428 area: Rect,
429 tab_targets: &[TabTarget],
430 buffers: &HashMap<BufferId, EditorState>,
431 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
432 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
433 active_target: TabTarget,
434 theme: &crate::view::theme::Theme,
435 is_active_split: bool,
436 tab_scroll_offset: usize,
437 hovered_tab: Option<(TabTarget, bool)>, group_names: &HashMap<LeafId, String>,
439 ) -> TabLayout {
440 let mut layout = TabLayout::new(area);
441 const SCROLL_INDICATOR_LEFT: &str = "<";
442 const SCROLL_INDICATOR_RIGHT: &str = ">";
443 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(
449 tab_targets,
450 buffers,
451 buffer_metadata,
452 composite_buffers,
453 group_names,
454 );
455
456 for t in tab_targets.iter() {
458 let Some(name_owned) = resolved_names.get(t).cloned() else {
460 continue;
461 };
462 let name = name_owned.as_str();
463 rendered_targets.push(*t);
464
465 let modified = match t {
467 TabTarget::Buffer(id) => {
468 if composite_buffers.contains_key(id) {
469 ""
470 } else if let Some(state) = buffers.get(id) {
471 if state.buffer.is_modified() {
472 "*"
473 } else {
474 ""
475 }
476 } else {
477 ""
478 }
479 }
480 TabTarget::Group(_) => "",
481 };
482 let binary_indicator = match t {
483 TabTarget::Buffer(id) => {
484 if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
485 " [BIN]"
486 } else {
487 ""
488 }
489 }
490 TabTarget::Group(_) => "",
491 };
492
493 let is_preview = is_preview_tab(t, buffer_metadata);
498 let preview_indicator = preview_suffix(t, buffer_metadata);
499
500 let is_active = *t == active_target;
501
502 let (is_hovered_name, is_hovered_close) = match hovered_tab {
504 Some((hover_target, is_close)) if hover_target == *t => (!is_close, is_close),
505 _ => (false, false),
506 };
507
508 let mut base_style = if is_active {
516 if is_active_split {
517 Style::default()
518 .fg(theme.tab_active_fg)
519 .bg(theme.tab_active_bg)
520 .add_modifier(Modifier::BOLD)
521 } else {
522 Style::default()
523 .fg(theme.tab_inactive_fg)
524 .bg(theme.tab_inactive_bg)
525 .add_modifier(Modifier::BOLD)
526 }
527 } else if is_hovered_name {
528 Style::default()
530 .fg(theme.tab_inactive_fg)
531 .bg(theme.tab_hover_bg)
532 } else {
533 Style::default()
534 .fg(theme.tab_inactive_fg)
535 .bg(theme.tab_inactive_bg)
536 };
537 if is_preview {
538 base_style = base_style.add_modifier(Modifier::ITALIC);
539 }
540
541 let close_style = if is_hovered_close {
543 base_style.fg(theme.tab_close_hover_fg)
545 } else {
546 base_style
547 };
548
549 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
551 let tab_name_width = str_width(&tab_name_text);
552
553 let close_text = "× ";
555 let close_width = str_width(close_text);
556
557 let total_width = tab_name_width + close_width;
558
559 let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
560 let close_start_pos = start_pos + tab_name_width;
561 let end_pos = start_pos + total_width;
562 tab_ranges.push((start_pos, end_pos, close_start_pos));
563
564 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
566 all_tab_spans.push((
568 Span::styled(close_text.to_string(), close_style),
569 close_width,
570 ));
571 }
572
573 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
577 let mut separator_offset = 0usize;
578 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
580 if separator_offset > 0 {
582 let (start, end, close_start) = tab_ranges[tab_idx];
583 tab_ranges[tab_idx] = (
584 start + separator_offset,
585 end + separator_offset,
586 close_start + separator_offset,
587 );
588 }
589
590 for span in chunk {
591 final_spans.push(span.clone());
592 }
593 if tab_idx < rendered_targets.len().saturating_sub(1) {
595 final_spans.push((
596 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
597 1,
598 ));
599 separator_offset += 1;
600 }
601 }
602 let tabs_total: usize = final_spans.iter().map(|(_, w)| w).sum();
608 let max_width = tabs_render_width(tabs_total, area.width as usize);
609 let pin_plus = max_width < area.width as usize;
610
611 let mut inline_plus_range: Option<(usize, usize)> = None;
612 if !pin_plus {
613 let plus_start = if !rendered_targets.is_empty() {
614 final_spans.push((
616 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
617 1,
618 ));
619 tabs_total + 1
620 } else {
621 tabs_total
622 };
623 final_spans.push((
624 Span::styled(
625 NEW_TAB_BUTTON_TEXT.to_string(),
626 Style::default()
627 .fg(theme.tab_inactive_fg)
628 .bg(theme.tab_inactive_bg),
629 ),
630 NEW_TAB_BUTTON_WIDTH,
631 ));
632 inline_plus_range = Some((plus_start, plus_start + NEW_TAB_BUTTON_WIDTH));
633 }
634
635 #[allow(clippy::let_and_return)]
636 let all_tab_spans = final_spans;
637
638 let mut current_spans: Vec<Span> = Vec::new();
639
640 let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
641 let _active_tab_idx = rendered_targets.iter().position(|t| *t == active_target);
644
645 let mut tab_widths: Vec<usize> = Vec::new();
646 for (start, end, _close_start) in &tab_ranges {
647 tab_widths.push(end.saturating_sub(*start));
648 }
649
650 let max_offset = total_width.saturating_sub(max_width);
653 let offset = tab_scroll_offset.min(total_width);
654 tracing::trace!(
655 "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
656 tab_scroll_offset, max_offset, offset, total_width, max_width
657 );
658 let show_left = offset > 0;
660 let show_right = total_width.saturating_sub(offset) > max_width;
661 let available = max_width
662 .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
663
664 let mut rendered_width = 0;
665 let mut skip_chars_count = offset;
666
667 if show_left {
668 current_spans.push(Span::styled(
669 SCROLL_INDICATOR_LEFT,
670 Style::default().bg(theme.tab_separator_bg),
671 ));
672 rendered_width += SCROLL_INDICATOR_WIDTH;
673 }
674
675 for (mut span, width) in all_tab_spans.into_iter() {
676 if skip_chars_count >= width {
677 skip_chars_count -= width;
678 continue;
679 }
680
681 let visible_chars_in_span = width - skip_chars_count;
682 if rendered_width + visible_chars_in_span
683 > max_width.saturating_sub(if show_right {
684 SCROLL_INDICATOR_WIDTH
685 } else {
686 0
687 })
688 {
689 let remaining_width =
690 max_width
691 .saturating_sub(rendered_width)
692 .saturating_sub(if show_right {
693 SCROLL_INDICATOR_WIDTH
694 } else {
695 0
696 });
697 let truncated_content = span
698 .content
699 .chars()
700 .skip(skip_chars_count)
701 .take(remaining_width)
702 .collect::<String>();
703 span.content = std::borrow::Cow::Owned(truncated_content);
704 current_spans.push(span);
705 rendered_width += remaining_width;
706 break;
707 } else {
708 let visible_content = span
709 .content
710 .chars()
711 .skip(skip_chars_count)
712 .collect::<String>();
713 span.content = std::borrow::Cow::Owned(visible_content);
714 current_spans.push(span);
715 rendered_width += visible_chars_in_span;
716 skip_chars_count = 0;
717 }
718 }
719
720 let right_indicator_x = if show_right && rendered_width < max_width {
722 Some(area.x + rendered_width as u16)
723 } else {
724 None
725 };
726
727 if show_right && rendered_width < max_width {
728 current_spans.push(Span::styled(
729 SCROLL_INDICATOR_RIGHT,
730 Style::default().bg(theme.tab_separator_bg),
731 ));
732 rendered_width += SCROLL_INDICATOR_WIDTH;
733 }
734
735 if rendered_width < max_width {
736 current_spans.push(Span::styled(
737 " ".repeat(max_width.saturating_sub(rendered_width)),
738 Style::default().bg(theme.tab_separator_bg),
739 ));
740 }
741
742 let line = Line::from(current_spans);
743 let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
744 let paragraph = Paragraph::new(line).block(block);
745 frame.render_widget(paragraph, area);
746
747 if pin_plus {
752 let plus_w = NEW_TAB_BUTTON_WIDTH as u16;
753 let plus_x = area.x + area.width.saturating_sub(plus_w);
754 let plus_rect = Rect::new(plus_x, area.y, plus_w, 1);
755 let plus_para = Paragraph::new(Line::from(vec![Span::styled(
756 NEW_TAB_BUTTON_TEXT.to_string(),
757 Style::default()
758 .fg(theme.tab_inactive_fg)
759 .bg(theme.tab_inactive_bg),
760 )]));
761 frame.render_widget(plus_para, plus_rect);
762 layout.new_tab_area = Some(plus_rect);
763 }
764
765 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
771
772 if show_left {
774 layout.left_scroll_area =
775 Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
776 }
777 if let Some(right_x) = right_indicator_x {
778 layout.right_scroll_area =
780 Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
781 }
782
783 for (idx, target) in rendered_targets.iter().enumerate() {
784 let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
785
786 let visible_start = offset;
790 let visible_end = offset + available;
791
792 if logical_end <= visible_start || logical_start >= visible_end {
794 continue;
795 }
796
797 let screen_start = if logical_start >= visible_start {
799 area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
800 } else {
801 area.x + left_indicator_offset as u16
802 };
803
804 let screen_end = if logical_end <= visible_end {
805 area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
806 } else {
807 area.x + left_indicator_offset as u16 + available as u16
808 };
809
810 let screen_close_start = if logical_close_start >= visible_start
812 && logical_close_start < visible_end
813 {
814 area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
815 } else if logical_close_start < visible_start {
816 screen_start
818 } else {
819 screen_end
821 };
822
823 let tab_width = screen_end.saturating_sub(screen_start);
825 let close_width = screen_end.saturating_sub(screen_close_start);
826
827 layout.tabs.push(TabHitArea {
828 target: *target,
829 tab_area: Rect::new(screen_start, area.y, tab_width, 1),
830 close_area: Rect::new(screen_close_start, area.y, close_width, 1),
831 });
832 }
833
834 if let Some((plus_logical_start, plus_logical_end)) = inline_plus_range {
838 let visible_start = offset;
839 let visible_end = offset + available;
840 if plus_logical_end > visible_start && plus_logical_start < visible_end {
841 let screen_start = if plus_logical_start >= visible_start {
842 area.x
843 + left_indicator_offset as u16
844 + (plus_logical_start - visible_start) as u16
845 } else {
846 area.x + left_indicator_offset as u16
847 };
848 let screen_end = if plus_logical_end <= visible_end {
849 area.x
850 + left_indicator_offset as u16
851 + (plus_logical_end - visible_start) as u16
852 } else {
853 area.x + left_indicator_offset as u16 + available as u16
854 };
855 let width = screen_end.saturating_sub(screen_start);
856 if width > 0 {
857 layout.new_tab_area = Some(Rect::new(screen_start, area.y, width, 1));
858 }
859 }
860 }
861
862 layout
863 }
864
865 #[allow(dead_code)]
868 pub fn render(
869 frame: &mut Frame,
870 area: Rect,
871 buffers: &HashMap<BufferId, EditorState>,
872 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
873 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
874 active_buffer: BufferId,
875 theme: &crate::view::theme::Theme,
876 ) {
877 let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
879 buffer_ids.sort_by_key(|id| id.0);
880 let tab_targets: Vec<TabTarget> = buffer_ids.into_iter().map(TabTarget::Buffer).collect();
881 let group_names = HashMap::new();
882
883 Self::render_for_split(
884 frame,
885 area,
886 &tab_targets,
887 buffers,
888 buffer_metadata,
889 composite_buffers,
890 TabTarget::Buffer(active_buffer),
891 theme,
892 true, 0, None, &group_names,
896 );
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use crate::model::event::BufferId;
904
905 #[test]
906 fn tabs_render_width_inline_when_fits() {
907 assert_eq!(tabs_render_width(10, 40), 40);
909 assert_eq!(tabs_render_width(33, 40), 40);
911 assert_eq!(tabs_render_width(0, 40), 40);
913 }
914
915 #[test]
916 fn tabs_render_width_pins_when_overflow() {
917 assert_eq!(tabs_render_width(37, 40), 37);
919 assert_eq!(tabs_render_width(200, 40), 37);
921 assert_eq!(tabs_render_width(100, 2), 2);
923 }
924
925 #[test]
926 fn scroll_to_show_active_first_tab() {
927 let widths = vec![5, 5, 5];
929 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
930 assert_eq!(offset, 0);
932 }
933
934 #[test]
935 fn scroll_to_show_tab_already_visible() {
936 let widths = vec![5, 5, 5];
938 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
939 assert_eq!(offset, 0);
941 }
942
943 #[test]
944 fn scroll_to_show_tab_on_right() {
945 let widths = vec![10, 10, 10];
947 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
948 assert!(offset > 0);
950 }
951
952 fn visible_range(offset: usize, total_width: usize, max_width: usize) -> (usize, usize) {
955 let show_left = offset > 0;
956 let show_right = total_width.saturating_sub(offset) > max_width;
957 let available = max_width
958 .saturating_sub(if show_left { 1 } else { 0 })
959 .saturating_sub(if show_right { 1 } else { 0 });
960 (offset, offset + available)
961 }
962
963 #[test]
967 fn scroll_to_show_tab_active_always_visible() {
968 let tab_content_width = 33; let num_tabs = 15;
973 let max_width = 40;
974
975 let mut tab_widths = Vec::new();
976 for i in 0..num_tabs {
977 if i > 0 {
978 tab_widths.push(1); }
980 tab_widths.push(tab_content_width);
981 }
982 let total_width: usize = tab_widths.iter().sum();
983
984 for tab_idx in 0..num_tabs {
985 let active_width_idx = if tab_idx == 0 { 0 } else { tab_idx * 2 };
986 let tab_start: usize = tab_widths[..active_width_idx].iter().sum();
987 let tab_end = tab_start + tab_widths[active_width_idx];
988
989 let offset = scroll_to_show_tab(&tab_widths, active_width_idx, 0, max_width);
990 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
991
992 assert!(
993 tab_start >= vis_start && tab_end <= vis_end,
994 "Tab {} (width_idx={}, {}..{}) not fully visible in range {}..{} (offset={})",
995 tab_idx,
996 active_width_idx,
997 tab_start,
998 tab_end,
999 vis_start,
1000 vis_end,
1001 offset
1002 );
1003 }
1004 }
1005
1006 #[test]
1008 fn scroll_to_show_tab_property_varied_sizes() {
1009 let test_cases: Vec<(Vec<usize>, usize)> = vec![
1010 (vec![10, 15, 20, 10, 25], 30),
1011 (vec![5; 20], 20),
1012 (vec![40], 40), (vec![50], 40), (vec![3, 3, 3], 100), ];
1016
1017 for (tab_widths, max_width) in test_cases {
1018 let total_width: usize = tab_widths.iter().sum();
1019 for active_idx in 0..tab_widths.len() {
1020 let tab_start: usize = tab_widths[..active_idx].iter().sum();
1021 let tab_end = tab_start + tab_widths[active_idx];
1022 let tab_w = tab_widths[active_idx];
1023
1024 let offset = scroll_to_show_tab(&tab_widths, active_idx, 0, max_width);
1025 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
1026
1027 if tab_w <= max_width.saturating_sub(2) || (active_idx == 0 && tab_w <= max_width) {
1029 assert!(
1030 tab_start >= vis_start && tab_end <= vis_end,
1031 "Tab {} ({}..{}, w={}) not visible in {}..{} (offset={}, max_width={}, widths={:?})",
1032 active_idx, tab_start, tab_end, tab_w, vis_start, vis_end, offset, max_width, tab_widths
1033 );
1034 }
1035 }
1036 }
1037 }
1038
1039 #[test]
1040 fn test_tab_layout_hit_test() {
1041 let bar_area = Rect::new(0, 0, 80, 1);
1042 let mut layout = TabLayout::new(bar_area);
1043
1044 let buf1 = BufferId(1);
1045 let target1 = TabTarget::Buffer(buf1);
1046
1047 layout.tabs.push(TabHitArea {
1048 target: target1,
1049 tab_area: Rect::new(0, 0, 16, 1),
1050 close_area: Rect::new(12, 0, 4, 1),
1051 });
1052
1053 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(target1)));
1055
1056 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(target1)));
1058
1059 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
1061
1062 assert_eq!(layout.hit_test(50, 5), None);
1064 }
1065}