1use crate::app::types::CellThemeRecorder;
4use crate::app::BufferMetadata;
5use crate::model::event::{BufferId, LeafId};
6use crate::primitives::display_width::str_width;
7use crate::state::EditorState;
8use crate::view::split::TabTarget;
9use crate::view::ui::layout::point_in_rect;
10use ratatui::layout::Rect;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Paragraph};
14use ratatui::Frame;
15use rust_i18n::t;
16use std::collections::HashMap;
17
18fn is_preview_tab(t: &TabTarget, preview_buffer: Option<BufferId>) -> bool {
22 matches!(t, TabTarget::Buffer(id) if Some(*id) == preview_buffer)
23}
24
25fn preview_suffix(t: &TabTarget, preview_buffer: Option<BufferId>) -> String {
28 if is_preview_tab(t, preview_buffer) {
29 format!(" {}", t!("buffer.preview_indicator"))
30 } else {
31 String::new()
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct TabHitArea {
38 pub target: TabTarget,
40 pub tab_area: Rect,
42 pub close_area: Rect,
44}
45
46impl TabHitArea {
47 pub fn buffer_id(&self) -> Option<BufferId> {
49 self.target.as_buffer()
50 }
51}
52
53#[derive(Debug, Clone, Default)]
58pub struct TabLayout {
59 pub tabs: Vec<TabHitArea>,
61 pub bar_area: Rect,
63 pub left_scroll_area: Option<Rect>,
65 pub right_scroll_area: Option<Rect>,
67 pub new_tab_area: Option<Rect>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum TabHit {
74 TabName(TabTarget),
76 CloseButton(TabTarget),
78 BarBackground,
80 ScrollLeft,
82 ScrollRight,
84 NewTabButton,
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 new_tab_area: None,
97 }
98 }
99
100 pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
102 if let Some(left_area) = self.left_scroll_area {
104 tracing::debug!(
105 "Tab hit_test: checking left_scroll_area {:?} against ({}, {})",
106 left_area,
107 x,
108 y
109 );
110 if point_in_rect(left_area, x, y) {
111 tracing::debug!("Tab hit_test: HIT ScrollLeft");
112 return Some(TabHit::ScrollLeft);
113 }
114 }
115 if let Some(right_area) = self.right_scroll_area {
116 tracing::debug!(
117 "Tab hit_test: checking right_scroll_area {:?} against ({}, {})",
118 right_area,
119 x,
120 y
121 );
122 if point_in_rect(right_area, x, y) {
123 tracing::debug!("Tab hit_test: HIT ScrollRight");
124 return Some(TabHit::ScrollRight);
125 }
126 }
127
128 for tab in &self.tabs {
129 if point_in_rect(tab.close_area, x, y) {
131 return Some(TabHit::CloseButton(tab.target));
132 }
133 if point_in_rect(tab.tab_area, x, y) {
135 return Some(TabHit::TabName(tab.target));
136 }
137 }
138
139 if let Some(new_tab_area) = self.new_tab_area {
141 if point_in_rect(new_tab_area, x, y) {
142 return Some(TabHit::NewTabButton);
143 }
144 }
145
146 if point_in_rect(self.bar_area, x, y) {
148 return Some(TabHit::BarBackground);
149 }
150
151 None
152 }
153}
154
155pub struct TabsRenderer;
157
158const NEW_TAB_BUTTON_TEXT: &str = " + ";
160pub const NEW_TAB_BUTTON_WIDTH: usize = 3;
162
163pub fn tabs_render_width(tabs_total: usize, bar_width: usize) -> usize {
173 let sep_before_plus = if tabs_total > 0 { 1 } else { 0 };
174 let inline_total = tabs_total + sep_before_plus + NEW_TAB_BUTTON_WIDTH;
175 if inline_total > bar_width && bar_width > NEW_TAB_BUTTON_WIDTH {
176 bar_width - NEW_TAB_BUTTON_WIDTH
177 } else {
178 bar_width
179 }
180}
181
182pub fn scroll_to_show_tab(
186 tab_widths: &[usize],
187 active_idx: usize,
188 _current_offset: usize,
189 max_width: usize,
190) -> usize {
191 if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
192 return 0;
193 }
194
195 let total_width: usize = tab_widths.iter().sum();
196 let tab_start: usize = tab_widths[..active_idx].iter().sum();
197 let tab_width = tab_widths[active_idx];
198 let tab_end = tab_start + tab_width;
199
200 let preferred_position = max_width / 4;
202 let target_offset = tab_start.saturating_sub(preferred_position);
203
204 let max_offset_with_indicator = total_width.saturating_sub(max_width.saturating_sub(1));
213 let max_offset_no_indicator = total_width.saturating_sub(max_width);
214 let max_offset = if total_width > max_width {
215 max_offset_with_indicator
216 } else {
217 0
218 };
219 let mut result = target_offset.min(max_offset);
220
221 let available_worst = max_width.saturating_sub(2);
224
225 if tab_end > result + available_worst {
226 result = tab_end.saturating_sub(available_worst);
229 }
230 if tab_start < result {
231 result = tab_start;
234 }
235 let effective_max = if result > 0 {
238 max_offset
239 } else {
240 max_offset_no_indicator
241 };
242 result = result.min(effective_max);
243
244 tracing::debug!(
245 "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
246 active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
247 );
248 result
249}
250
251fn resolve_tab_names(
257 tab_targets: &[TabTarget],
258 buffers: &HashMap<BufferId, EditorState>,
259 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
260 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
261 group_names: &HashMap<LeafId, String>,
262) -> HashMap<TabTarget, String> {
263 let mut names: Vec<(TabTarget, String)> = Vec::new();
264
265 for t in tab_targets.iter() {
266 match t {
267 TabTarget::Buffer(id) => {
268 let is_regular_buffer = buffers.contains_key(id);
269 let is_composite_buffer = composite_buffers.contains_key(id);
270 if !is_regular_buffer && !is_composite_buffer {
271 continue;
272 }
273 if let Some(meta) = buffer_metadata.get(id) {
274 if meta.hidden_from_tabs {
275 continue;
276 }
277 }
278
279 let meta = buffer_metadata.get(id);
280 let is_terminal = meta
281 .and_then(|m| m.virtual_mode())
282 .map(|mode| mode == "terminal")
283 .unwrap_or(false);
284
285 let name = if is_composite_buffer {
286 meta.map(|m| m.display_name.as_str())
287 } else if is_terminal {
288 meta.map(|m| m.display_name.as_str())
289 } else {
290 buffers
291 .get(id)
292 .and_then(|state| state.buffer.file_path())
293 .and_then(|p| p.file_name())
294 .and_then(|n| n.to_str())
295 .or_else(|| meta.map(|m| m.display_name.as_str()))
296 }
297 .unwrap_or("[No Name]");
298
299 names.push((*t, name.to_string()));
300 }
301 TabTarget::Group(leaf_id) => {
302 if let Some(name) = group_names.get(leaf_id) {
303 names.push((*t, name.clone()));
304 }
305 }
306 }
307 }
308
309 let mut name_counts: HashMap<&str, usize> = HashMap::new();
311 for (_, name) in &names {
312 *name_counts.entry(name.as_str()).or_insert(0) += 1;
313 }
314
315 let mut result = HashMap::new();
317 let mut name_indices: HashMap<String, usize> = HashMap::new();
318 for (t, name) in &names {
319 if name_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
320 let idx = name_indices.entry(name.clone()).or_insert(0);
321 *idx += 1;
322 result.insert(*t, format!("{} {}", name, idx));
323 } else {
324 result.insert(*t, name.clone());
325 }
326 }
327
328 result
329}
330
331pub fn calculate_tab_widths(
335 tab_targets: &[TabTarget],
336 buffers: &HashMap<BufferId, EditorState>,
337 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
338 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
339 group_names: &HashMap<LeafId, String>,
340 preview_buffer: Option<BufferId>,
341) -> (Vec<usize>, Vec<TabTarget>) {
342 let mut tab_widths: Vec<usize> = Vec::new();
343 let mut rendered_targets: Vec<TabTarget> = Vec::new();
344 let resolved_names = resolve_tab_names(
345 tab_targets,
346 buffers,
347 buffer_metadata,
348 composite_buffers,
349 group_names,
350 );
351
352 for t in tab_targets.iter() {
353 let Some(name) = resolved_names.get(t) else {
355 continue;
356 };
357
358 let modified = match t {
360 TabTarget::Buffer(id) => {
361 if composite_buffers.contains_key(id) {
362 ""
363 } else if let Some(state) = buffers.get(id) {
364 if state.buffer.is_modified() {
365 "*"
366 } else {
367 ""
368 }
369 } else {
370 ""
371 }
372 }
373 TabTarget::Group(_) => "",
374 };
375
376 let binary_indicator = match t {
377 TabTarget::Buffer(id) => {
378 if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
379 " [BIN]"
380 } else {
381 ""
382 }
383 }
384 TabTarget::Group(_) => "",
385 };
386
387 let preview_indicator = preview_suffix(t, preview_buffer);
388
389 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
391 let close_text = "× ";
392 let tab_width = str_width(&tab_name_text) + str_width(close_text);
393
394 if !rendered_targets.is_empty() {
396 tab_widths.push(1); }
398
399 tab_widths.push(tab_width);
400 rendered_targets.push(*t);
401 }
402
403 (tab_widths, rendered_targets)
404}
405
406impl TabsRenderer {
407 #[allow(clippy::too_many_arguments)]
423 pub fn render_for_split(
424 frame: &mut Frame,
425 area: Rect,
426 tab_targets: &[TabTarget],
427 buffers: &HashMap<BufferId, EditorState>,
428 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
429 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
430 active_target: TabTarget,
431 theme: &crate::view::theme::Theme,
432 is_active_split: bool,
433 tab_scroll_offset: usize,
434 hovered_tab: Option<(TabTarget, bool)>, group_names: &HashMap<LeafId, String>,
436 preview_buffer: Option<BufferId>,
437 mut rec: Option<&mut CellThemeRecorder>,
438 draw: bool,
442 ) -> TabLayout {
443 let mut layout = TabLayout::new(area);
444 if let Some(r) = rec.as_deref_mut() {
447 r.run(
448 area.x,
449 area.y,
450 area.width,
451 None,
452 Some("ui.tab_separator_bg"),
453 "Tab Bar",
454 );
455 }
456 const SCROLL_INDICATOR_LEFT: &str = "<";
457 const SCROLL_INDICATOR_RIGHT: &str = ">";
458 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(
464 tab_targets,
465 buffers,
466 buffer_metadata,
467 composite_buffers,
468 group_names,
469 );
470
471 for t in tab_targets.iter() {
473 let Some(name_owned) = resolved_names.get(t).cloned() else {
475 continue;
476 };
477 let name = name_owned.as_str();
478 rendered_targets.push(*t);
479
480 let modified = match t {
482 TabTarget::Buffer(id) => {
483 if composite_buffers.contains_key(id) {
484 ""
485 } else if let Some(state) = buffers.get(id) {
486 if state.buffer.is_modified() {
487 "*"
488 } else {
489 ""
490 }
491 } else {
492 ""
493 }
494 }
495 TabTarget::Group(_) => "",
496 };
497 let binary_indicator = match t {
498 TabTarget::Buffer(id) => {
499 if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
500 " [BIN]"
501 } else {
502 ""
503 }
504 }
505 TabTarget::Group(_) => "",
506 };
507
508 let is_preview = is_preview_tab(t, preview_buffer);
513 let preview_indicator = preview_suffix(t, preview_buffer);
514
515 let is_active = *t == active_target;
516
517 let (is_hovered_name, is_hovered_close) = match hovered_tab {
519 Some((hover_target, is_close)) if hover_target == *t => (!is_close, is_close),
520 _ => (false, false),
521 };
522
523 let mut base_style = if is_active {
531 if is_active_split {
532 Style::default()
533 .fg(theme.tab_active_fg)
534 .bg(theme.tab_active_bg)
535 .add_modifier(Modifier::BOLD)
536 } else {
537 Style::default()
538 .fg(theme.tab_inactive_fg)
539 .bg(theme.tab_inactive_bg)
540 .add_modifier(Modifier::BOLD)
541 }
542 } else if is_hovered_name {
543 Style::default()
545 .fg(theme.tab_inactive_fg)
546 .bg(theme.tab_hover_bg)
547 } else {
548 Style::default()
549 .fg(theme.tab_inactive_fg)
550 .bg(theme.tab_inactive_bg)
551 };
552 if is_preview {
553 base_style = base_style.add_modifier(Modifier::ITALIC);
554 }
555
556 let close_style = if is_hovered_close {
558 base_style.fg(theme.tab_close_hover_fg)
560 } else {
561 base_style
562 };
563
564 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
566 let tab_name_width = str_width(&tab_name_text);
567
568 let close_text = "× ";
570 let close_width = str_width(close_text);
571
572 let total_width = tab_name_width + close_width;
573
574 let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
575 let close_start_pos = start_pos + tab_name_width;
576 let end_pos = start_pos + total_width;
577 tab_ranges.push((start_pos, end_pos, close_start_pos));
578
579 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
581 all_tab_spans.push((
583 Span::styled(close_text.to_string(), close_style),
584 close_width,
585 ));
586 }
587
588 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
592 let mut separator_offset = 0usize;
593 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
595 if separator_offset > 0 {
597 let (start, end, close_start) = tab_ranges[tab_idx];
598 tab_ranges[tab_idx] = (
599 start + separator_offset,
600 end + separator_offset,
601 close_start + separator_offset,
602 );
603 }
604
605 for span in chunk {
606 final_spans.push(span.clone());
607 }
608 if tab_idx < rendered_targets.len().saturating_sub(1) {
610 final_spans.push((
611 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
612 1,
613 ));
614 separator_offset += 1;
615 }
616 }
617 let tabs_total: usize = final_spans.iter().map(|(_, w)| w).sum();
623 let max_width = tabs_render_width(tabs_total, area.width as usize);
624 let pin_plus = max_width < area.width as usize;
625
626 let mut inline_plus_range: Option<(usize, usize)> = None;
627 if !pin_plus {
628 let plus_start = if !rendered_targets.is_empty() {
629 final_spans.push((
631 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
632 1,
633 ));
634 tabs_total + 1
635 } else {
636 tabs_total
637 };
638 final_spans.push((
639 Span::styled(
640 NEW_TAB_BUTTON_TEXT.to_string(),
641 Style::default()
642 .fg(theme.tab_inactive_fg)
643 .bg(theme.tab_inactive_bg),
644 ),
645 NEW_TAB_BUTTON_WIDTH,
646 ));
647 inline_plus_range = Some((plus_start, plus_start + NEW_TAB_BUTTON_WIDTH));
648 }
649
650 #[allow(clippy::let_and_return)]
651 let all_tab_spans = final_spans;
652
653 let mut current_spans: Vec<Span> = Vec::new();
654
655 let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
656 let _active_tab_idx = rendered_targets.iter().position(|t| *t == active_target);
659
660 let mut tab_widths: Vec<usize> = Vec::new();
661 for (start, end, _close_start) in &tab_ranges {
662 tab_widths.push(end.saturating_sub(*start));
663 }
664
665 let max_offset = total_width.saturating_sub(max_width);
668 let offset = tab_scroll_offset.min(total_width);
669 tracing::trace!(
670 "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
671 tab_scroll_offset, max_offset, offset, total_width, max_width
672 );
673 let show_left = offset > 0;
675 let show_right = total_width.saturating_sub(offset) > max_width;
676 let available = max_width
677 .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
678
679 let mut rendered_width = 0;
680 let mut skip_chars_count = offset;
681
682 if show_left {
683 current_spans.push(Span::styled(
684 SCROLL_INDICATOR_LEFT,
685 Style::default().bg(theme.tab_separator_bg),
686 ));
687 rendered_width += SCROLL_INDICATOR_WIDTH;
688 }
689
690 for (mut span, width) in all_tab_spans.into_iter() {
691 if skip_chars_count >= width {
692 skip_chars_count -= width;
693 continue;
694 }
695
696 let visible_chars_in_span = width - skip_chars_count;
697 if rendered_width + visible_chars_in_span
698 > max_width.saturating_sub(if show_right {
699 SCROLL_INDICATOR_WIDTH
700 } else {
701 0
702 })
703 {
704 let remaining_width =
705 max_width
706 .saturating_sub(rendered_width)
707 .saturating_sub(if show_right {
708 SCROLL_INDICATOR_WIDTH
709 } else {
710 0
711 });
712 let truncated_content = span
713 .content
714 .chars()
715 .skip(skip_chars_count)
716 .take(remaining_width)
717 .collect::<String>();
718 span.content = std::borrow::Cow::Owned(truncated_content);
719 current_spans.push(span);
720 rendered_width += remaining_width;
721 break;
722 } else {
723 let visible_content = span
724 .content
725 .chars()
726 .skip(skip_chars_count)
727 .collect::<String>();
728 span.content = std::borrow::Cow::Owned(visible_content);
729 current_spans.push(span);
730 rendered_width += visible_chars_in_span;
731 skip_chars_count = 0;
732 }
733 }
734
735 let right_indicator_x = if show_right && rendered_width < max_width {
737 Some(area.x + rendered_width as u16)
738 } else {
739 None
740 };
741
742 if show_right && rendered_width < max_width {
743 current_spans.push(Span::styled(
744 SCROLL_INDICATOR_RIGHT,
745 Style::default().bg(theme.tab_separator_bg),
746 ));
747 rendered_width += SCROLL_INDICATOR_WIDTH;
748 }
749
750 if rendered_width < max_width {
751 current_spans.push(Span::styled(
752 " ".repeat(max_width.saturating_sub(rendered_width)),
753 Style::default().bg(theme.tab_separator_bg),
754 ));
755 }
756
757 let line = Line::from(current_spans);
758 let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
759 let paragraph = Paragraph::new(line).block(block);
760 if draw {
761 frame.render_widget(paragraph, area);
762 }
763
764 if pin_plus {
769 let plus_w = NEW_TAB_BUTTON_WIDTH as u16;
770 let plus_x = area.x + area.width.saturating_sub(plus_w);
771 let plus_rect = Rect::new(plus_x, area.y, plus_w, 1);
772 let plus_para = Paragraph::new(Line::from(vec![Span::styled(
773 NEW_TAB_BUTTON_TEXT.to_string(),
774 Style::default()
775 .fg(theme.tab_inactive_fg)
776 .bg(theme.tab_inactive_bg),
777 )]));
778 if draw {
779 frame.render_widget(plus_para, plus_rect);
780 }
781 layout.new_tab_area = Some(plus_rect);
782 }
783
784 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
790
791 if show_left {
793 layout.left_scroll_area =
794 Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
795 }
796 if let Some(right_x) = right_indicator_x {
797 layout.right_scroll_area =
799 Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
800 }
801
802 for (idx, target) in rendered_targets.iter().enumerate() {
803 let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
804
805 let visible_start = offset;
809 let visible_end = offset + available;
810
811 if logical_end <= visible_start || logical_start >= visible_end {
813 continue;
814 }
815
816 let screen_start = if logical_start >= visible_start {
818 area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
819 } else {
820 area.x + left_indicator_offset as u16
821 };
822
823 let screen_end = if logical_end <= visible_end {
824 area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
825 } else {
826 area.x + left_indicator_offset as u16 + available as u16
827 };
828
829 let screen_close_start = if logical_close_start >= visible_start
831 && logical_close_start < visible_end
832 {
833 area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
834 } else if logical_close_start < visible_start {
835 screen_start
837 } else {
838 screen_end
840 };
841
842 let tab_width = screen_end.saturating_sub(screen_start);
844 let close_width = screen_end.saturating_sub(screen_close_start);
845
846 if let Some(r) = rec.as_deref_mut() {
851 let (fg, bg) = if *target == active_target && is_active_split {
852 ("ui.tab_active_fg", "ui.tab_active_bg")
853 } else {
854 ("ui.tab_inactive_fg", "ui.tab_inactive_bg")
855 };
856 r.run(
857 screen_start,
858 area.y,
859 tab_width,
860 Some(fg),
861 Some(bg),
862 "Tab Bar",
863 );
864 }
865
866 layout.tabs.push(TabHitArea {
867 target: *target,
868 tab_area: Rect::new(screen_start, area.y, tab_width, 1),
869 close_area: Rect::new(screen_close_start, area.y, close_width, 1),
870 });
871 }
872
873 if let Some((plus_logical_start, plus_logical_end)) = inline_plus_range {
877 let visible_start = offset;
878 let visible_end = offset + available;
879 if plus_logical_end > visible_start && plus_logical_start < visible_end {
880 let screen_start = if plus_logical_start >= visible_start {
881 area.x
882 + left_indicator_offset as u16
883 + (plus_logical_start - visible_start) as u16
884 } else {
885 area.x + left_indicator_offset as u16
886 };
887 let screen_end = if plus_logical_end <= visible_end {
888 area.x
889 + left_indicator_offset as u16
890 + (plus_logical_end - visible_start) as u16
891 } else {
892 area.x + left_indicator_offset as u16 + available as u16
893 };
894 let width = screen_end.saturating_sub(screen_start);
895 if width > 0 {
896 layout.new_tab_area = Some(Rect::new(screen_start, area.y, width, 1));
897 if let Some(r) = rec.as_deref_mut() {
898 r.run(
899 screen_start,
900 area.y,
901 width,
902 Some("ui.tab_inactive_fg"),
903 Some("ui.tab_inactive_bg"),
904 "Tab Bar",
905 );
906 }
907 }
908 }
909 }
910
911 if let (Some(plus_rect), Some(r)) = (layout.new_tab_area.filter(|_| pin_plus), rec) {
913 r.run(
914 plus_rect.x,
915 area.y,
916 plus_rect.width,
917 Some("ui.tab_inactive_fg"),
918 Some("ui.tab_inactive_bg"),
919 "Tab Bar",
920 );
921 }
922
923 layout
924 }
925
926 #[allow(dead_code)]
929 pub fn render(
930 frame: &mut Frame,
931 area: Rect,
932 buffers: &HashMap<BufferId, EditorState>,
933 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
934 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
935 active_buffer: BufferId,
936 theme: &crate::view::theme::Theme,
937 preview_buffer: Option<BufferId>,
938 ) {
939 let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
941 buffer_ids.sort_by_key(|id| id.0);
942 let tab_targets: Vec<TabTarget> = buffer_ids.into_iter().map(TabTarget::Buffer).collect();
943 let group_names = HashMap::new();
944
945 Self::render_for_split(
946 frame,
947 area,
948 &tab_targets,
949 buffers,
950 buffer_metadata,
951 composite_buffers,
952 TabTarget::Buffer(active_buffer),
953 theme,
954 true, 0, None, &group_names,
958 preview_buffer,
959 None, true, );
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::model::event::BufferId;
969
970 #[test]
971 fn tabs_render_width_inline_when_fits() {
972 assert_eq!(tabs_render_width(10, 40), 40);
974 assert_eq!(tabs_render_width(33, 40), 40);
976 assert_eq!(tabs_render_width(0, 40), 40);
978 }
979
980 #[test]
981 fn tabs_render_width_pins_when_overflow() {
982 assert_eq!(tabs_render_width(37, 40), 37);
984 assert_eq!(tabs_render_width(200, 40), 37);
986 assert_eq!(tabs_render_width(100, 2), 2);
988 }
989
990 #[test]
991 fn scroll_to_show_active_first_tab() {
992 let widths = vec![5, 5, 5];
994 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
995 assert_eq!(offset, 0);
997 }
998
999 #[test]
1000 fn scroll_to_show_tab_already_visible() {
1001 let widths = vec![5, 5, 5];
1003 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
1004 assert_eq!(offset, 0);
1006 }
1007
1008 #[test]
1009 fn scroll_to_show_tab_on_right() {
1010 let widths = vec![10, 10, 10];
1012 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
1013 assert!(offset > 0);
1015 }
1016
1017 fn visible_range(offset: usize, total_width: usize, max_width: usize) -> (usize, usize) {
1020 let show_left = offset > 0;
1021 let show_right = total_width.saturating_sub(offset) > max_width;
1022 let available = max_width
1023 .saturating_sub(if show_left { 1 } else { 0 })
1024 .saturating_sub(if show_right { 1 } else { 0 });
1025 (offset, offset + available)
1026 }
1027
1028 #[test]
1032 fn scroll_to_show_tab_active_always_visible() {
1033 let tab_content_width = 33; let num_tabs = 15;
1038 let max_width = 40;
1039
1040 let mut tab_widths = Vec::new();
1041 for i in 0..num_tabs {
1042 if i > 0 {
1043 tab_widths.push(1); }
1045 tab_widths.push(tab_content_width);
1046 }
1047 let total_width: usize = tab_widths.iter().sum();
1048
1049 for tab_idx in 0..num_tabs {
1050 let active_width_idx = if tab_idx == 0 { 0 } else { tab_idx * 2 };
1051 let tab_start: usize = tab_widths[..active_width_idx].iter().sum();
1052 let tab_end = tab_start + tab_widths[active_width_idx];
1053
1054 let offset = scroll_to_show_tab(&tab_widths, active_width_idx, 0, max_width);
1055 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
1056
1057 assert!(
1058 tab_start >= vis_start && tab_end <= vis_end,
1059 "Tab {} (width_idx={}, {}..{}) not fully visible in range {}..{} (offset={})",
1060 tab_idx,
1061 active_width_idx,
1062 tab_start,
1063 tab_end,
1064 vis_start,
1065 vis_end,
1066 offset
1067 );
1068 }
1069 }
1070
1071 #[test]
1073 fn scroll_to_show_tab_property_varied_sizes() {
1074 let test_cases: Vec<(Vec<usize>, usize)> = vec![
1075 (vec![10, 15, 20, 10, 25], 30),
1076 (vec![5; 20], 20),
1077 (vec![40], 40), (vec![50], 40), (vec![3, 3, 3], 100), ];
1081
1082 for (tab_widths, max_width) in test_cases {
1083 let total_width: usize = tab_widths.iter().sum();
1084 for active_idx in 0..tab_widths.len() {
1085 let tab_start: usize = tab_widths[..active_idx].iter().sum();
1086 let tab_end = tab_start + tab_widths[active_idx];
1087 let tab_w = tab_widths[active_idx];
1088
1089 let offset = scroll_to_show_tab(&tab_widths, active_idx, 0, max_width);
1090 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
1091
1092 if tab_w <= max_width.saturating_sub(2) || (active_idx == 0 && tab_w <= max_width) {
1094 assert!(
1095 tab_start >= vis_start && tab_end <= vis_end,
1096 "Tab {} ({}..{}, w={}) not visible in {}..{} (offset={}, max_width={}, widths={:?})",
1097 active_idx, tab_start, tab_end, tab_w, vis_start, vis_end, offset, max_width, tab_widths
1098 );
1099 }
1100 }
1101 }
1102 }
1103
1104 #[test]
1105 fn test_tab_layout_hit_test() {
1106 let bar_area = Rect::new(0, 0, 80, 1);
1107 let mut layout = TabLayout::new(bar_area);
1108
1109 let buf1 = BufferId(1);
1110 let target1 = TabTarget::Buffer(buf1);
1111
1112 layout.tabs.push(TabHitArea {
1113 target: target1,
1114 tab_area: Rect::new(0, 0, 16, 1),
1115 close_area: Rect::new(12, 0, 4, 1),
1116 });
1117
1118 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(target1)));
1120
1121 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(target1)));
1123
1124 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
1126
1127 assert_eq!(layout.hit_test(50, 5), None);
1129 }
1130}