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::Widget;
14use ratatui::widgets::{Block, Paragraph};
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
163const SCROLL_INDICATOR_LEFT: &str = "<";
165const SCROLL_INDICATOR_RIGHT: &str = ">";
167const SCROLL_INDICATOR_WIDTH: usize = 1;
169
170pub fn tabs_render_width(tabs_total: usize, bar_width: usize) -> usize {
180 let sep_before_plus = if tabs_total > 0 { 1 } else { 0 };
181 let inline_total = tabs_total + sep_before_plus + NEW_TAB_BUTTON_WIDTH;
182 if inline_total > bar_width && bar_width > NEW_TAB_BUTTON_WIDTH {
183 bar_width - NEW_TAB_BUTTON_WIDTH
184 } else {
185 bar_width
186 }
187}
188
189pub fn scroll_to_show_tab(
193 tab_widths: &[usize],
194 active_idx: usize,
195 _current_offset: usize,
196 max_width: usize,
197) -> usize {
198 if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
199 return 0;
200 }
201
202 let total_width: usize = tab_widths.iter().sum();
203 let tab_start: usize = tab_widths[..active_idx].iter().sum();
204 let tab_width = tab_widths[active_idx];
205 let tab_end = tab_start + tab_width;
206
207 let preferred_position = max_width / 4;
209 let target_offset = tab_start.saturating_sub(preferred_position);
210
211 let max_offset_with_indicator = total_width.saturating_sub(max_width.saturating_sub(1));
220 let max_offset_no_indicator = total_width.saturating_sub(max_width);
221 let max_offset = if total_width > max_width {
222 max_offset_with_indicator
223 } else {
224 0
225 };
226 let mut result = target_offset.min(max_offset);
227
228 let available_worst = max_width.saturating_sub(2);
231
232 if tab_end > result + available_worst {
233 result = tab_end.saturating_sub(available_worst);
236 }
237 if tab_start < result {
238 result = tab_start;
241 }
242 let effective_max = if result > 0 {
245 max_offset
246 } else {
247 max_offset_no_indicator
248 };
249 result = result.min(effective_max);
250
251 tracing::debug!(
252 "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
253 active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
254 );
255 result
256}
257
258fn resolve_tab_names(
264 tab_targets: &[TabTarget],
265 buffers: &HashMap<BufferId, EditorState>,
266 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
267 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
268 group_names: &HashMap<LeafId, String>,
269) -> HashMap<TabTarget, String> {
270 let mut names: Vec<(TabTarget, String)> = Vec::new();
271
272 for t in tab_targets.iter() {
273 match t {
274 TabTarget::Buffer(id) => {
275 let is_regular_buffer = buffers.contains_key(id);
276 let is_composite_buffer = composite_buffers.contains_key(id);
277 if !is_regular_buffer && !is_composite_buffer {
278 continue;
279 }
280 if let Some(meta) = buffer_metadata.get(id) {
281 if meta.hidden_from_tabs {
282 continue;
283 }
284 }
285
286 let meta = buffer_metadata.get(id);
287 let is_terminal = meta
288 .and_then(|m| m.virtual_mode())
289 .map(|mode| mode == "terminal")
290 .unwrap_or(false);
291
292 let name = if is_composite_buffer {
293 meta.map(|m| m.display_name.as_str())
294 } else if is_terminal {
295 meta.map(|m| m.display_name.as_str())
296 } else {
297 buffers
298 .get(id)
299 .and_then(|state| state.buffer.file_path())
300 .and_then(|p| p.file_name())
301 .and_then(|n| n.to_str())
302 .or_else(|| meta.map(|m| m.display_name.as_str()))
303 }
304 .unwrap_or("[No Name]");
305
306 names.push((*t, name.to_string()));
307 }
308 TabTarget::Group(leaf_id) => {
309 if let Some(name) = group_names.get(leaf_id) {
310 names.push((*t, name.clone()));
311 }
312 }
313 }
314 }
315
316 let mut name_counts: HashMap<&str, usize> = HashMap::new();
318 for (_, name) in &names {
319 *name_counts.entry(name.as_str()).or_insert(0) += 1;
320 }
321
322 let mut result = HashMap::new();
324 let mut name_indices: HashMap<String, usize> = HashMap::new();
325 for (t, name) in &names {
326 if name_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
327 let idx = name_indices.entry(name.clone()).or_insert(0);
328 *idx += 1;
329 result.insert(*t, format!("{} {}", name, idx));
330 } else {
331 result.insert(*t, name.clone());
332 }
333 }
334
335 result
336}
337
338pub fn calculate_tab_widths(
342 tab_targets: &[TabTarget],
343 buffers: &HashMap<BufferId, EditorState>,
344 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
345 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
346 group_names: &HashMap<LeafId, String>,
347 preview_buffer: Option<BufferId>,
348) -> (Vec<usize>, Vec<TabTarget>) {
349 let mut tab_widths: Vec<usize> = Vec::new();
350 let mut rendered_targets: Vec<TabTarget> = Vec::new();
351 let resolved_names = resolve_tab_names(
352 tab_targets,
353 buffers,
354 buffer_metadata,
355 composite_buffers,
356 group_names,
357 );
358
359 for t in tab_targets.iter() {
360 let Some(name) = resolved_names.get(t) else {
362 continue;
363 };
364
365 let modified = match t {
367 TabTarget::Buffer(id) => {
368 if composite_buffers.contains_key(id) {
369 ""
370 } else if let Some(state) = buffers.get(id) {
371 if state.buffer.is_modified() {
372 "*"
373 } else {
374 ""
375 }
376 } else {
377 ""
378 }
379 }
380 TabTarget::Group(_) => "",
381 };
382
383 let binary_indicator = match t {
384 TabTarget::Buffer(id) => {
385 if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
386 " [BIN]"
387 } else {
388 ""
389 }
390 }
391 TabTarget::Group(_) => "",
392 };
393
394 let preview_indicator = preview_suffix(t, preview_buffer);
395
396 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
398 let close_text = "× ";
399 let tab_width = str_width(&tab_name_text) + str_width(close_text);
400
401 if !rendered_targets.is_empty() {
403 tab_widths.push(1); }
405
406 tab_widths.push(tab_width);
407 rendered_targets.push(*t);
408 }
409
410 (tab_widths, rendered_targets)
411}
412
413fn tab_styles(
421 is_active: bool,
422 is_active_split: bool,
423 is_hovered_name: bool,
424 is_hovered_close: bool,
425 is_preview: bool,
426 theme: &crate::view::theme::Theme,
427) -> (Style, Style) {
428 let mut base_style = if is_active {
429 let fg = if is_active_split {
430 theme.tab_active_fg
431 } else {
432 theme.tab_inactive_fg
433 };
434 let bg = if is_active_split {
435 theme.tab_active_bg
436 } else {
437 theme.tab_inactive_bg
438 };
439 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
440 } else if is_hovered_name {
441 Style::default()
443 .fg(theme.tab_inactive_fg)
444 .bg(theme.tab_hover_bg)
445 } else {
446 Style::default()
447 .fg(theme.tab_inactive_fg)
448 .bg(theme.tab_inactive_bg)
449 };
450 if is_preview {
451 base_style = base_style.add_modifier(Modifier::ITALIC);
452 }
453
454 let close_style = if is_hovered_close {
455 base_style.fg(theme.tab_close_hover_fg)
456 } else {
457 base_style
458 };
459 (base_style, close_style)
460}
461
462#[allow(clippy::too_many_arguments)]
471fn build_tab_spans(
472 tab_targets: &[TabTarget],
473 resolved_names: &HashMap<TabTarget, String>,
474 buffers: &HashMap<BufferId, EditorState>,
475 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
476 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
477 active_target: TabTarget,
478 hovered_tab: Option<(TabTarget, bool)>,
479 preview_buffer: Option<BufferId>,
480 is_active_split: bool,
481 theme: &crate::view::theme::Theme,
482) -> (
483 Vec<(Span<'static>, usize)>,
484 Vec<(usize, usize, usize)>,
485 Vec<TabTarget>,
486) {
487 let mut all_tab_spans: Vec<(Span<'static>, usize)> = Vec::new();
488 let mut tab_ranges: Vec<(usize, usize, usize)> = Vec::new();
489 let mut rendered_targets: Vec<TabTarget> = Vec::new();
490
491 for t in tab_targets.iter() {
492 let Some(name_owned) = resolved_names.get(t).cloned() else {
494 continue;
495 };
496 let name = name_owned.as_str();
497 rendered_targets.push(*t);
498
499 let modified = match t {
501 TabTarget::Buffer(id) if !composite_buffers.contains_key(id) => buffers
502 .get(id)
503 .filter(|state| state.buffer.is_modified())
504 .map(|_| "*")
505 .unwrap_or(""),
506 _ => "",
507 };
508 let binary_indicator = match t {
509 TabTarget::Buffer(id) if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) => {
510 " [BIN]"
511 }
512 _ => "",
513 };
514
515 let is_preview = is_preview_tab(t, preview_buffer);
519 let preview_indicator = preview_suffix(t, preview_buffer);
520 let is_active = *t == active_target;
521 let (is_hovered_name, is_hovered_close) = match hovered_tab {
522 Some((hover_target, is_close)) if hover_target == *t => (!is_close, is_close),
523 _ => (false, false),
524 };
525
526 let (base_style, close_style) = tab_styles(
527 is_active,
528 is_active_split,
529 is_hovered_name,
530 is_hovered_close,
531 is_preview,
532 theme,
533 );
534
535 let tab_name_text = format!(" {name}{modified}{preview_indicator}{binary_indicator} ");
537 let tab_name_width = str_width(&tab_name_text);
538 let close_text = "× ";
539 let close_width = str_width(close_text);
540
541 let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
542 let close_start_pos = start_pos + tab_name_width;
543 let end_pos = start_pos + tab_name_width + close_width;
544 tab_ranges.push((start_pos, end_pos, close_start_pos));
545
546 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
547 all_tab_spans.push((
548 Span::styled(close_text.to_string(), close_style),
549 close_width,
550 ));
551 }
552
553 (all_tab_spans, tab_ranges, rendered_targets)
554}
555
556fn build_visible_line(
563 all_tab_spans: Vec<(Span<'static>, usize)>,
564 area: Rect,
565 offset: usize,
566 max_width: usize,
567 show_left: bool,
568 show_right: bool,
569 theme: &crate::view::theme::Theme,
570) -> (Vec<Span<'static>>, Option<u16>) {
571 let mut current_spans: Vec<Span<'static>> = Vec::new();
572 let mut rendered_width = 0;
573 let mut skip_chars_count = offset;
574
575 if show_left {
576 current_spans.push(Span::styled(
577 SCROLL_INDICATOR_LEFT,
578 Style::default().bg(theme.tab_separator_bg),
579 ));
580 rendered_width += SCROLL_INDICATOR_WIDTH;
581 }
582
583 let right_reserve = if show_right {
584 SCROLL_INDICATOR_WIDTH
585 } else {
586 0
587 };
588 for (mut span, width) in all_tab_spans.into_iter() {
589 if skip_chars_count >= width {
590 skip_chars_count -= width;
591 continue;
592 }
593
594 let visible_chars_in_span = width - skip_chars_count;
595 if rendered_width + visible_chars_in_span > max_width.saturating_sub(right_reserve) {
596 let remaining_width = max_width
597 .saturating_sub(rendered_width)
598 .saturating_sub(right_reserve);
599 let truncated_content = span
600 .content
601 .chars()
602 .skip(skip_chars_count)
603 .take(remaining_width)
604 .collect::<String>();
605 span.content = std::borrow::Cow::Owned(truncated_content);
606 current_spans.push(span);
607 rendered_width += remaining_width;
608 break;
609 }
610
611 let visible_content = span
612 .content
613 .chars()
614 .skip(skip_chars_count)
615 .collect::<String>();
616 span.content = std::borrow::Cow::Owned(visible_content);
617 current_spans.push(span);
618 rendered_width += visible_chars_in_span;
619 skip_chars_count = 0;
620 }
621
622 let right_indicator_x = if show_right && rendered_width < max_width {
625 Some(area.x + rendered_width as u16)
626 } else {
627 None
628 };
629 if show_right && rendered_width < max_width {
630 current_spans.push(Span::styled(
631 SCROLL_INDICATOR_RIGHT,
632 Style::default().bg(theme.tab_separator_bg),
633 ));
634 rendered_width += SCROLL_INDICATOR_WIDTH;
635 }
636 if rendered_width < max_width {
637 current_spans.push(Span::styled(
638 " ".repeat(max_width.saturating_sub(rendered_width)),
639 Style::default().bg(theme.tab_separator_bg),
640 ));
641 }
642
643 (current_spans, right_indicator_x)
644}
645
646#[allow(clippy::too_many_arguments)]
650fn map_tab_hit_areas(
651 layout: &mut TabLayout,
652 rendered_targets: &[TabTarget],
653 tab_ranges: &[(usize, usize, usize)],
654 area: Rect,
655 offset: usize,
656 available: usize,
657 left_indicator_offset: usize,
658 active_target: TabTarget,
659 is_active_split: bool,
660 mut rec: Option<&mut CellThemeRecorder>,
661) {
662 let visible_start = offset;
663 let visible_end = offset + available;
664 let base_x = area.x + left_indicator_offset as u16;
665
666 for (idx, target) in rendered_targets.iter().enumerate() {
667 let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
668
669 if logical_end <= visible_start || logical_start >= visible_end {
671 continue;
672 }
673
674 let screen_start = if logical_start >= visible_start {
675 base_x + (logical_start - visible_start) as u16
676 } else {
677 base_x
678 };
679 let screen_end = if logical_end <= visible_end {
680 base_x + (logical_end - visible_start) as u16
681 } else {
682 base_x + available as u16
683 };
684 let screen_close_start =
685 if logical_close_start >= visible_start && logical_close_start < visible_end {
686 base_x + (logical_close_start - visible_start) as u16
687 } else if logical_close_start < visible_start {
688 screen_start
690 } else {
691 screen_end
693 };
694
695 let tab_width = screen_end.saturating_sub(screen_start);
696 let close_width = screen_end.saturating_sub(screen_close_start);
697
698 if let Some(r) = rec.as_deref_mut() {
702 let (fg, bg) = if *target == active_target && is_active_split {
703 ("ui.tab_active_fg", "ui.tab_active_bg")
704 } else {
705 ("ui.tab_inactive_fg", "ui.tab_inactive_bg")
706 };
707 r.run(
708 screen_start,
709 area.y,
710 tab_width,
711 Some(fg),
712 Some(bg),
713 "Tab Bar",
714 );
715 }
716
717 layout.tabs.push(TabHitArea {
718 target: *target,
719 tab_area: Rect::new(screen_start, area.y, tab_width, 1),
720 close_area: Rect::new(screen_close_start, area.y, close_width, 1),
721 });
722 }
723}
724
725impl TabsRenderer {
726 #[allow(clippy::too_many_arguments)]
742 pub fn render_for_split(
743 buf: &mut ratatui::buffer::Buffer,
744 area: Rect,
745 tab_targets: &[TabTarget],
746 buffers: &HashMap<BufferId, EditorState>,
747 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
748 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
749 active_target: TabTarget,
750 theme: &crate::view::theme::Theme,
751 is_active_split: bool,
752 tab_scroll_offset: usize,
753 hovered_tab: Option<(TabTarget, bool)>, group_names: &HashMap<LeafId, String>,
755 preview_buffer: Option<BufferId>,
756 mut rec: Option<&mut CellThemeRecorder>,
757 draw: bool,
761 ) -> TabLayout {
762 let mut layout = TabLayout::new(area);
763 if let Some(r) = rec.as_deref_mut() {
766 r.run(
767 area.x,
768 area.y,
769 area.width,
770 None,
771 Some("ui.tab_separator_bg"),
772 "Tab Bar",
773 );
774 }
775 let resolved_names = resolve_tab_names(
776 tab_targets,
777 buffers,
778 buffer_metadata,
779 composite_buffers,
780 group_names,
781 );
782
783 let (all_tab_spans, mut tab_ranges, rendered_targets) = build_tab_spans(
787 tab_targets,
788 &resolved_names,
789 buffers,
790 buffer_metadata,
791 composite_buffers,
792 active_target,
793 hovered_tab,
794 preview_buffer,
795 is_active_split,
796 theme,
797 );
798
799 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
803 let mut separator_offset = 0usize;
804 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
806 if separator_offset > 0 {
808 let (start, end, close_start) = tab_ranges[tab_idx];
809 tab_ranges[tab_idx] = (
810 start + separator_offset,
811 end + separator_offset,
812 close_start + separator_offset,
813 );
814 }
815
816 for span in chunk {
817 final_spans.push(span.clone());
818 }
819 if tab_idx < rendered_targets.len().saturating_sub(1) {
821 final_spans.push((
822 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
823 1,
824 ));
825 separator_offset += 1;
826 }
827 }
828 let tabs_total: usize = final_spans.iter().map(|(_, w)| w).sum();
834 let max_width = tabs_render_width(tabs_total, area.width as usize);
835 let pin_plus = max_width < area.width as usize;
836
837 let mut inline_plus_range: Option<(usize, usize)> = None;
838 if !pin_plus {
839 let plus_start = if !rendered_targets.is_empty() {
840 final_spans.push((
842 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
843 1,
844 ));
845 tabs_total + 1
846 } else {
847 tabs_total
848 };
849 final_spans.push((
850 Span::styled(
851 NEW_TAB_BUTTON_TEXT.to_string(),
852 Style::default()
853 .fg(theme.tab_inactive_fg)
854 .bg(theme.tab_inactive_bg),
855 ),
856 NEW_TAB_BUTTON_WIDTH,
857 ));
858 inline_plus_range = Some((plus_start, plus_start + NEW_TAB_BUTTON_WIDTH));
859 }
860
861 let total_width: usize = final_spans.iter().map(|(_, w)| w).sum();
864 let max_offset = total_width.saturating_sub(max_width);
865 let offset = tab_scroll_offset.min(total_width);
866 tracing::trace!(
867 "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
868 tab_scroll_offset, max_offset, offset, total_width, max_width
869 );
870 let show_left = offset > 0;
872 let show_right = total_width.saturating_sub(offset) > max_width;
873 let available = max_width
874 .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
875
876 let (current_spans, right_indicator_x) = build_visible_line(
878 final_spans,
879 area,
880 offset,
881 max_width,
882 show_left,
883 show_right,
884 theme,
885 );
886
887 let line = Line::from(current_spans);
888 let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
889 let paragraph = Paragraph::new(line).block(block);
890 if draw {
891 paragraph.render(area, buf);
892 }
893
894 if pin_plus {
899 let plus_w = NEW_TAB_BUTTON_WIDTH as u16;
900 let plus_x = area.x + area.width.saturating_sub(plus_w);
901 let plus_rect = Rect::new(plus_x, area.y, plus_w, 1);
902 let plus_para = Paragraph::new(Line::from(vec![Span::styled(
903 NEW_TAB_BUTTON_TEXT.to_string(),
904 Style::default()
905 .fg(theme.tab_inactive_fg)
906 .bg(theme.tab_inactive_bg),
907 )]));
908 if draw {
909 plus_para.render(plus_rect, buf);
910 }
911 layout.new_tab_area = Some(plus_rect);
912 }
913
914 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
920
921 if show_left {
923 layout.left_scroll_area =
924 Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
925 }
926 if let Some(right_x) = right_indicator_x {
927 layout.right_scroll_area =
929 Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
930 }
931
932 map_tab_hit_areas(
934 &mut layout,
935 &rendered_targets,
936 &tab_ranges,
937 area,
938 offset,
939 available,
940 left_indicator_offset,
941 active_target,
942 is_active_split,
943 rec.as_deref_mut(),
944 );
945
946 if let Some((plus_logical_start, plus_logical_end)) = inline_plus_range {
950 let visible_start = offset;
951 let visible_end = offset + available;
952 if plus_logical_end > visible_start && plus_logical_start < visible_end {
953 let screen_start = if plus_logical_start >= visible_start {
954 area.x
955 + left_indicator_offset as u16
956 + (plus_logical_start - visible_start) as u16
957 } else {
958 area.x + left_indicator_offset as u16
959 };
960 let screen_end = if plus_logical_end <= visible_end {
961 area.x
962 + left_indicator_offset as u16
963 + (plus_logical_end - visible_start) as u16
964 } else {
965 area.x + left_indicator_offset as u16 + available as u16
966 };
967 let width = screen_end.saturating_sub(screen_start);
968 if width > 0 {
969 layout.new_tab_area = Some(Rect::new(screen_start, area.y, width, 1));
970 if let Some(r) = rec.as_deref_mut() {
971 r.run(
972 screen_start,
973 area.y,
974 width,
975 Some("ui.tab_inactive_fg"),
976 Some("ui.tab_inactive_bg"),
977 "Tab Bar",
978 );
979 }
980 }
981 }
982 }
983
984 if let (Some(plus_rect), Some(r)) = (layout.new_tab_area.filter(|_| pin_plus), rec) {
986 r.run(
987 plus_rect.x,
988 area.y,
989 plus_rect.width,
990 Some("ui.tab_inactive_fg"),
991 Some("ui.tab_inactive_bg"),
992 "Tab Bar",
993 );
994 }
995
996 layout
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use crate::model::event::BufferId;
1004
1005 #[test]
1006 fn tabs_render_width_inline_when_fits() {
1007 assert_eq!(tabs_render_width(10, 40), 40);
1009 assert_eq!(tabs_render_width(33, 40), 40);
1011 assert_eq!(tabs_render_width(0, 40), 40);
1013 }
1014
1015 #[test]
1016 fn tabs_render_width_pins_when_overflow() {
1017 assert_eq!(tabs_render_width(37, 40), 37);
1019 assert_eq!(tabs_render_width(200, 40), 37);
1021 assert_eq!(tabs_render_width(100, 2), 2);
1023 }
1024
1025 #[test]
1026 fn scroll_to_show_active_first_tab() {
1027 let widths = vec![5, 5, 5];
1029 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
1030 assert_eq!(offset, 0);
1032 }
1033
1034 #[test]
1035 fn scroll_to_show_tab_already_visible() {
1036 let widths = vec![5, 5, 5];
1038 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
1039 assert_eq!(offset, 0);
1041 }
1042
1043 #[test]
1044 fn scroll_to_show_tab_on_right() {
1045 let widths = vec![10, 10, 10];
1047 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
1048 assert!(offset > 0);
1050 }
1051
1052 fn visible_range(offset: usize, total_width: usize, max_width: usize) -> (usize, usize) {
1055 let show_left = offset > 0;
1056 let show_right = total_width.saturating_sub(offset) > max_width;
1057 let available = max_width
1058 .saturating_sub(if show_left { 1 } else { 0 })
1059 .saturating_sub(if show_right { 1 } else { 0 });
1060 (offset, offset + available)
1061 }
1062
1063 #[test]
1067 fn scroll_to_show_tab_active_always_visible() {
1068 let tab_content_width = 33; let num_tabs = 15;
1073 let max_width = 40;
1074
1075 let mut tab_widths = Vec::new();
1076 for i in 0..num_tabs {
1077 if i > 0 {
1078 tab_widths.push(1); }
1080 tab_widths.push(tab_content_width);
1081 }
1082 let total_width: usize = tab_widths.iter().sum();
1083
1084 for tab_idx in 0..num_tabs {
1085 let active_width_idx = if tab_idx == 0 { 0 } else { tab_idx * 2 };
1086 let tab_start: usize = tab_widths[..active_width_idx].iter().sum();
1087 let tab_end = tab_start + tab_widths[active_width_idx];
1088
1089 let offset = scroll_to_show_tab(&tab_widths, active_width_idx, 0, max_width);
1090 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
1091
1092 assert!(
1093 tab_start >= vis_start && tab_end <= vis_end,
1094 "Tab {} (width_idx={}, {}..{}) not fully visible in range {}..{} (offset={})",
1095 tab_idx,
1096 active_width_idx,
1097 tab_start,
1098 tab_end,
1099 vis_start,
1100 vis_end,
1101 offset
1102 );
1103 }
1104 }
1105
1106 #[test]
1108 fn scroll_to_show_tab_property_varied_sizes() {
1109 let test_cases: Vec<(Vec<usize>, usize)> = vec![
1110 (vec![10, 15, 20, 10, 25], 30),
1111 (vec![5; 20], 20),
1112 (vec![40], 40), (vec![50], 40), (vec![3, 3, 3], 100), ];
1116
1117 for (tab_widths, max_width) in test_cases {
1118 let total_width: usize = tab_widths.iter().sum();
1119 for active_idx in 0..tab_widths.len() {
1120 let tab_start: usize = tab_widths[..active_idx].iter().sum();
1121 let tab_end = tab_start + tab_widths[active_idx];
1122 let tab_w = tab_widths[active_idx];
1123
1124 let offset = scroll_to_show_tab(&tab_widths, active_idx, 0, max_width);
1125 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
1126
1127 if tab_w <= max_width.saturating_sub(2) || (active_idx == 0 && tab_w <= max_width) {
1129 assert!(
1130 tab_start >= vis_start && tab_end <= vis_end,
1131 "Tab {} ({}..{}, w={}) not visible in {}..{} (offset={}, max_width={}, widths={:?})",
1132 active_idx, tab_start, tab_end, tab_w, vis_start, vis_end, offset, max_width, tab_widths
1133 );
1134 }
1135 }
1136 }
1137 }
1138
1139 #[test]
1140 fn test_tab_layout_hit_test() {
1141 let bar_area = Rect::new(0, 0, 80, 1);
1142 let mut layout = TabLayout::new(bar_area);
1143
1144 let buf1 = BufferId(1);
1145 let target1 = TabTarget::Buffer(buf1);
1146
1147 layout.tabs.push(TabHitArea {
1148 target: target1,
1149 tab_area: Rect::new(0, 0, 16, 1),
1150 close_area: Rect::new(12, 0, 4, 1),
1151 });
1152
1153 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(target1)));
1155
1156 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(target1)));
1158
1159 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
1161
1162 assert_eq!(layout.hit_test(50, 5), None);
1164 }
1165}