1use crate::app::BufferMetadata;
4use crate::model::event::BufferId;
5use crate::primitives::display_width::str_width;
6use crate::state::EditorState;
7use crate::view::ui::layout::point_in_rect;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Paragraph};
12use ratatui::Frame;
13use std::collections::HashMap;
14
15#[derive(Debug, Clone)]
17pub struct TabHitArea {
18 pub buffer_id: BufferId,
20 pub tab_area: Rect,
22 pub close_area: Rect,
24}
25
26#[derive(Debug, Clone, Default)]
31pub struct TabLayout {
32 pub tabs: Vec<TabHitArea>,
34 pub bar_area: Rect,
36 pub left_scroll_area: Option<Rect>,
38 pub right_scroll_area: Option<Rect>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum TabHit {
45 TabName(BufferId),
47 CloseButton(BufferId),
49 BarBackground,
51 ScrollLeft,
53 ScrollRight,
55}
56
57impl TabLayout {
58 pub fn new(bar_area: Rect) -> Self {
60 Self {
61 tabs: Vec::new(),
62 bar_area,
63 left_scroll_area: None,
64 right_scroll_area: None,
65 }
66 }
67
68 pub fn hit_test(&self, x: u16, y: u16) -> Option<TabHit> {
70 if let Some(left_area) = self.left_scroll_area {
72 tracing::debug!(
73 "Tab hit_test: checking left_scroll_area {:?} against ({}, {})",
74 left_area,
75 x,
76 y
77 );
78 if point_in_rect(left_area, x, y) {
79 tracing::debug!("Tab hit_test: HIT ScrollLeft");
80 return Some(TabHit::ScrollLeft);
81 }
82 }
83 if let Some(right_area) = self.right_scroll_area {
84 tracing::debug!(
85 "Tab hit_test: checking right_scroll_area {:?} against ({}, {})",
86 right_area,
87 x,
88 y
89 );
90 if point_in_rect(right_area, x, y) {
91 tracing::debug!("Tab hit_test: HIT ScrollRight");
92 return Some(TabHit::ScrollRight);
93 }
94 }
95
96 for tab in &self.tabs {
97 if point_in_rect(tab.close_area, x, y) {
99 return Some(TabHit::CloseButton(tab.buffer_id));
100 }
101 if point_in_rect(tab.tab_area, x, y) {
103 return Some(TabHit::TabName(tab.buffer_id));
104 }
105 }
106
107 if point_in_rect(self.bar_area, x, y) {
109 return Some(TabHit::BarBackground);
110 }
111
112 None
113 }
114}
115
116pub struct TabsRenderer;
118
119pub fn scroll_to_show_tab(
123 tab_widths: &[usize],
124 active_idx: usize,
125 _current_offset: usize,
126 max_width: usize,
127) -> usize {
128 if tab_widths.is_empty() || max_width == 0 || active_idx >= tab_widths.len() {
129 return 0;
130 }
131
132 let total_width: usize = tab_widths.iter().sum();
133 let tab_start: usize = tab_widths[..active_idx].iter().sum();
134 let tab_width = tab_widths[active_idx];
135 let tab_end = tab_start + tab_width;
136
137 let preferred_position = max_width / 4;
139 let target_offset = tab_start.saturating_sub(preferred_position);
140
141 let max_offset_with_indicator = total_width.saturating_sub(max_width.saturating_sub(1));
150 let max_offset_no_indicator = total_width.saturating_sub(max_width);
151 let max_offset = if total_width > max_width {
152 max_offset_with_indicator
153 } else {
154 0
155 };
156 let mut result = target_offset.min(max_offset);
157
158 let available_worst = max_width.saturating_sub(2);
161
162 if tab_end > result + available_worst {
163 result = tab_end.saturating_sub(available_worst);
166 }
167 if tab_start < result {
168 result = tab_start;
171 }
172 let effective_max = if result > 0 {
175 max_offset
176 } else {
177 max_offset_no_indicator
178 };
179 result = result.min(effective_max);
180
181 tracing::debug!(
182 "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
183 active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
184 );
185 result
186}
187
188fn resolve_tab_names(
192 split_buffers: &[BufferId],
193 buffers: &HashMap<BufferId, EditorState>,
194 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
195 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
196) -> HashMap<BufferId, String> {
197 let mut names: Vec<(BufferId, String)> = Vec::new();
198
199 for id in split_buffers.iter() {
200 let is_regular_buffer = buffers.contains_key(id);
201 let is_composite_buffer = composite_buffers.contains_key(id);
202 if !is_regular_buffer && !is_composite_buffer {
203 continue;
204 }
205 if let Some(meta) = buffer_metadata.get(id) {
206 if meta.hidden_from_tabs {
207 continue;
208 }
209 }
210
211 let meta = buffer_metadata.get(id);
212 let is_terminal = meta
213 .and_then(|m| m.virtual_mode())
214 .map(|mode| mode == "terminal")
215 .unwrap_or(false);
216
217 let name = if is_composite_buffer {
218 meta.map(|m| m.display_name.as_str())
219 } else if is_terminal {
220 meta.map(|m| m.display_name.as_str())
221 } else {
222 buffers
223 .get(id)
224 .and_then(|state| state.buffer.file_path())
225 .and_then(|p| p.file_name())
226 .and_then(|n| n.to_str())
227 .or_else(|| meta.map(|m| m.display_name.as_str()))
228 }
229 .unwrap_or("[No Name]");
230
231 names.push((*id, name.to_string()));
232 }
233
234 let mut name_counts: HashMap<&str, usize> = HashMap::new();
236 for (_, name) in &names {
237 *name_counts.entry(name.as_str()).or_insert(0) += 1;
238 }
239
240 let mut result = HashMap::new();
242 let mut name_indices: HashMap<String, usize> = HashMap::new();
243 for (id, name) in &names {
244 if name_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
245 let idx = name_indices.entry(name.clone()).or_insert(0);
246 *idx += 1;
247 result.insert(*id, format!("{} {}", name, idx));
248 } else {
249 result.insert(*id, name.clone());
250 }
251 }
252
253 result
254}
255
256pub fn calculate_tab_widths(
260 split_buffers: &[BufferId],
261 buffers: &HashMap<BufferId, EditorState>,
262 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
263 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
264) -> (Vec<usize>, Vec<BufferId>) {
265 let mut tab_widths: Vec<usize> = Vec::new();
266 let mut rendered_buffer_ids: Vec<BufferId> = Vec::new();
267 let resolved_names =
268 resolve_tab_names(split_buffers, buffers, buffer_metadata, composite_buffers);
269
270 for id in split_buffers.iter() {
271 let is_regular_buffer = buffers.contains_key(id);
273 let is_composite_buffer = composite_buffers.contains_key(id);
274
275 if !is_regular_buffer && !is_composite_buffer {
276 continue;
277 }
278
279 if let Some(meta) = buffer_metadata.get(id) {
281 if meta.hidden_from_tabs {
282 continue;
283 }
284 }
285
286 let name = resolved_names
287 .get(id)
288 .map(|s| s.as_str())
289 .unwrap_or("[No Name]");
290
291 let modified = if is_composite_buffer {
293 ""
294 } else if let Some(state) = buffers.get(id) {
295 if state.buffer.is_modified() {
296 "*"
297 } else {
298 ""
299 }
300 } else {
301 ""
302 };
303
304 let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
305 " [BIN]"
306 } else {
307 ""
308 };
309
310 let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
312 let close_text = "× ";
313 let tab_width = str_width(&tab_name_text) + str_width(close_text);
314
315 if !rendered_buffer_ids.is_empty() {
317 tab_widths.push(1); }
319
320 tab_widths.push(tab_width);
321 rendered_buffer_ids.push(*id);
322 }
323
324 (tab_widths, rendered_buffer_ids)
325}
326
327impl TabsRenderer {
328 #[allow(clippy::too_many_arguments)]
344 pub fn render_for_split(
345 frame: &mut Frame,
346 area: Rect,
347 split_buffers: &[BufferId],
348 buffers: &HashMap<BufferId, EditorState>,
349 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
350 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
351 active_buffer: BufferId,
352 theme: &crate::view::theme::Theme,
353 is_active_split: bool,
354 tab_scroll_offset: usize,
355 hovered_tab: Option<(BufferId, bool)>, ) -> TabLayout {
357 let mut layout = TabLayout::new(area);
358 const SCROLL_INDICATOR_LEFT: &str = "<";
359 const SCROLL_INDICATOR_RIGHT: &str = ">";
360 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_buffer_ids: Vec<BufferId> = Vec::new(); let resolved_names =
366 resolve_tab_names(split_buffers, buffers, buffer_metadata, composite_buffers);
367
368 for id in split_buffers.iter() {
370 let is_regular_buffer = buffers.contains_key(id);
372 let is_composite_buffer = composite_buffers.contains_key(id);
373
374 if !is_regular_buffer && !is_composite_buffer {
375 continue;
376 }
377
378 if let Some(meta) = buffer_metadata.get(id) {
380 if meta.hidden_from_tabs {
381 continue;
382 }
383 }
384 rendered_buffer_ids.push(*id);
385
386 let name = resolved_names
387 .get(id)
388 .map(|s| s.as_str())
389 .unwrap_or("[No Name]");
390
391 let modified = if is_composite_buffer {
393 ""
394 } else if let Some(state) = buffers.get(id) {
395 if state.buffer.is_modified() {
396 "*"
397 } else {
398 ""
399 }
400 } else {
401 ""
402 };
403 let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
404 " [BIN]"
405 } else {
406 ""
407 };
408
409 let is_active = *id == active_buffer;
410
411 let (is_hovered_name, is_hovered_close) = match hovered_tab {
413 Some((hover_buf, is_close)) if hover_buf == *id => (!is_close, is_close),
414 _ => (false, false),
415 };
416
417 let base_style = if is_active {
419 if is_active_split {
420 Style::default()
421 .fg(theme.tab_active_fg)
422 .bg(theme.tab_active_bg)
423 .add_modifier(Modifier::BOLD)
424 } else {
425 Style::default()
426 .fg(theme.tab_active_fg)
427 .bg(theme.tab_inactive_bg)
428 .add_modifier(Modifier::BOLD)
429 }
430 } else if is_hovered_name {
431 Style::default()
433 .fg(theme.tab_inactive_fg)
434 .bg(theme.tab_hover_bg)
435 } else {
436 Style::default()
437 .fg(theme.tab_inactive_fg)
438 .bg(theme.tab_inactive_bg)
439 };
440
441 let close_style = if is_hovered_close {
443 base_style.fg(theme.tab_close_hover_fg)
445 } else {
446 base_style
447 };
448
449 let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
451 let tab_name_width = str_width(&tab_name_text);
452
453 let close_text = "× ";
455 let close_width = str_width(close_text);
456
457 let total_width = tab_name_width + close_width;
458
459 let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
460 let close_start_pos = start_pos + tab_name_width;
461 let end_pos = start_pos + total_width;
462 tab_ranges.push((start_pos, end_pos, close_start_pos));
463
464 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
466 all_tab_spans.push((
468 Span::styled(close_text.to_string(), close_style),
469 close_width,
470 ));
471 }
472
473 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
477 let mut separator_offset = 0usize;
478 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
480 if separator_offset > 0 {
482 let (start, end, close_start) = tab_ranges[tab_idx];
483 tab_ranges[tab_idx] = (
484 start + separator_offset,
485 end + separator_offset,
486 close_start + separator_offset,
487 );
488 }
489
490 for span in chunk {
491 final_spans.push(span.clone());
492 }
493 if tab_idx < rendered_buffer_ids.len().saturating_sub(1) {
495 final_spans.push((
496 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
497 1,
498 ));
499 separator_offset += 1;
500 }
501 }
502 #[allow(clippy::let_and_return)]
503 let all_tab_spans = final_spans;
504
505 let mut current_spans: Vec<Span> = Vec::new();
506 let max_width = area.width as usize;
507
508 let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
509 let _active_tab_idx = rendered_buffer_ids
512 .iter()
513 .position(|id| *id == active_buffer);
514
515 let mut tab_widths: Vec<usize> = Vec::new();
516 for (start, end, _close_start) in &tab_ranges {
517 tab_widths.push(end.saturating_sub(*start));
518 }
519
520 let max_offset = total_width.saturating_sub(max_width);
523 let offset = tab_scroll_offset.min(total_width);
524 tracing::trace!(
525 "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
526 tab_scroll_offset, max_offset, offset, total_width, max_width
527 );
528 let show_left = offset > 0;
530 let show_right = total_width.saturating_sub(offset) > max_width;
531 let available = max_width
532 .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
533
534 let mut rendered_width = 0;
535 let mut skip_chars_count = offset;
536
537 if show_left {
538 current_spans.push(Span::styled(
539 SCROLL_INDICATOR_LEFT,
540 Style::default().bg(theme.tab_separator_bg),
541 ));
542 rendered_width += SCROLL_INDICATOR_WIDTH;
543 }
544
545 for (mut span, width) in all_tab_spans.into_iter() {
546 if skip_chars_count >= width {
547 skip_chars_count -= width;
548 continue;
549 }
550
551 let visible_chars_in_span = width - skip_chars_count;
552 if rendered_width + visible_chars_in_span
553 > max_width.saturating_sub(if show_right {
554 SCROLL_INDICATOR_WIDTH
555 } else {
556 0
557 })
558 {
559 let remaining_width =
560 max_width
561 .saturating_sub(rendered_width)
562 .saturating_sub(if show_right {
563 SCROLL_INDICATOR_WIDTH
564 } else {
565 0
566 });
567 let truncated_content = span
568 .content
569 .chars()
570 .skip(skip_chars_count)
571 .take(remaining_width)
572 .collect::<String>();
573 span.content = std::borrow::Cow::Owned(truncated_content);
574 current_spans.push(span);
575 rendered_width += remaining_width;
576 break;
577 } else {
578 let visible_content = span
579 .content
580 .chars()
581 .skip(skip_chars_count)
582 .collect::<String>();
583 span.content = std::borrow::Cow::Owned(visible_content);
584 current_spans.push(span);
585 rendered_width += visible_chars_in_span;
586 skip_chars_count = 0;
587 }
588 }
589
590 let right_indicator_x = if show_right && rendered_width < max_width {
592 Some(area.x + rendered_width as u16)
593 } else {
594 None
595 };
596
597 if show_right && rendered_width < max_width {
598 current_spans.push(Span::styled(
599 SCROLL_INDICATOR_RIGHT,
600 Style::default().bg(theme.tab_separator_bg),
601 ));
602 rendered_width += SCROLL_INDICATOR_WIDTH;
603 }
604
605 if rendered_width < max_width {
606 current_spans.push(Span::styled(
607 " ".repeat(max_width.saturating_sub(rendered_width)),
608 Style::default().bg(theme.tab_separator_bg),
609 ));
610 }
611
612 let line = Line::from(current_spans);
613 let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
614 let paragraph = Paragraph::new(line).block(block);
615 frame.render_widget(paragraph, area);
616
617 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
623
624 if show_left {
626 layout.left_scroll_area =
627 Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
628 }
629 if let Some(right_x) = right_indicator_x {
630 layout.right_scroll_area =
632 Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
633 }
634
635 for (idx, buffer_id) in rendered_buffer_ids.iter().enumerate() {
636 let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
637
638 let visible_start = offset;
642 let visible_end = offset + available;
643
644 if logical_end <= visible_start || logical_start >= visible_end {
646 continue;
647 }
648
649 let screen_start = if logical_start >= visible_start {
651 area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
652 } else {
653 area.x + left_indicator_offset as u16
654 };
655
656 let screen_end = if logical_end <= visible_end {
657 area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
658 } else {
659 area.x + left_indicator_offset as u16 + available as u16
660 };
661
662 let screen_close_start = if logical_close_start >= visible_start
664 && logical_close_start < visible_end
665 {
666 area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
667 } else if logical_close_start < visible_start {
668 screen_start
670 } else {
671 screen_end
673 };
674
675 let tab_width = screen_end.saturating_sub(screen_start);
677 let close_width = screen_end.saturating_sub(screen_close_start);
678
679 layout.tabs.push(TabHitArea {
680 buffer_id: *buffer_id,
681 tab_area: Rect::new(screen_start, area.y, tab_width, 1),
682 close_area: Rect::new(screen_close_start, area.y, close_width, 1),
683 });
684 }
685
686 layout
687 }
688
689 #[allow(dead_code)]
692 pub fn render(
693 frame: &mut Frame,
694 area: Rect,
695 buffers: &HashMap<BufferId, EditorState>,
696 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
697 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
698 active_buffer: BufferId,
699 theme: &crate::view::theme::Theme,
700 ) {
701 let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
703 buffer_ids.sort_by_key(|id| id.0);
704
705 Self::render_for_split(
706 frame,
707 area,
708 &buffer_ids,
709 buffers,
710 buffer_metadata,
711 composite_buffers,
712 active_buffer,
713 theme,
714 true, 0, None, );
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::model::event::BufferId;
725
726 #[test]
727 fn scroll_to_show_active_first_tab() {
728 let widths = vec![5, 5, 5];
730 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
731 assert_eq!(offset, 0);
733 }
734
735 #[test]
736 fn scroll_to_show_tab_already_visible() {
737 let widths = vec![5, 5, 5];
739 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
740 assert_eq!(offset, 0);
742 }
743
744 #[test]
745 fn scroll_to_show_tab_on_right() {
746 let widths = vec![10, 10, 10];
748 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
749 assert!(offset > 0);
751 }
752
753 fn visible_range(offset: usize, total_width: usize, max_width: usize) -> (usize, usize) {
756 let show_left = offset > 0;
757 let show_right = total_width.saturating_sub(offset) > max_width;
758 let available = max_width
759 .saturating_sub(if show_left { 1 } else { 0 })
760 .saturating_sub(if show_right { 1 } else { 0 });
761 (offset, offset + available)
762 }
763
764 #[test]
768 fn scroll_to_show_tab_active_always_visible() {
769 let tab_content_width = 33; let num_tabs = 15;
774 let max_width = 40;
775
776 let mut tab_widths = Vec::new();
777 for i in 0..num_tabs {
778 if i > 0 {
779 tab_widths.push(1); }
781 tab_widths.push(tab_content_width);
782 }
783 let total_width: usize = tab_widths.iter().sum();
784
785 for tab_idx in 0..num_tabs {
786 let active_width_idx = if tab_idx == 0 { 0 } else { tab_idx * 2 };
787 let tab_start: usize = tab_widths[..active_width_idx].iter().sum();
788 let tab_end = tab_start + tab_widths[active_width_idx];
789
790 let offset = scroll_to_show_tab(&tab_widths, active_width_idx, 0, max_width);
791 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
792
793 assert!(
794 tab_start >= vis_start && tab_end <= vis_end,
795 "Tab {} (width_idx={}, {}..{}) not fully visible in range {}..{} (offset={})",
796 tab_idx,
797 active_width_idx,
798 tab_start,
799 tab_end,
800 vis_start,
801 vis_end,
802 offset
803 );
804 }
805 }
806
807 #[test]
809 fn scroll_to_show_tab_property_varied_sizes() {
810 let test_cases: Vec<(Vec<usize>, usize)> = vec![
811 (vec![10, 15, 20, 10, 25], 30),
812 (vec![5; 20], 20),
813 (vec![40], 40), (vec![50], 40), (vec![3, 3, 3], 100), ];
817
818 for (tab_widths, max_width) in test_cases {
819 let total_width: usize = tab_widths.iter().sum();
820 for active_idx in 0..tab_widths.len() {
821 let tab_start: usize = tab_widths[..active_idx].iter().sum();
822 let tab_end = tab_start + tab_widths[active_idx];
823 let tab_w = tab_widths[active_idx];
824
825 let offset = scroll_to_show_tab(&tab_widths, active_idx, 0, max_width);
826 let (vis_start, vis_end) = visible_range(offset, total_width, max_width);
827
828 if tab_w <= max_width.saturating_sub(2) || (active_idx == 0 && tab_w <= max_width) {
830 assert!(
831 tab_start >= vis_start && tab_end <= vis_end,
832 "Tab {} ({}..{}, w={}) not visible in {}..{} (offset={}, max_width={}, widths={:?})",
833 active_idx, tab_start, tab_end, tab_w, vis_start, vis_end, offset, max_width, tab_widths
834 );
835 }
836 }
837 }
838 }
839
840 #[test]
841 fn test_tab_layout_hit_test() {
842 let bar_area = Rect::new(0, 0, 80, 1);
843 let mut layout = TabLayout::new(bar_area);
844
845 let buf1 = BufferId(1);
846
847 layout.tabs.push(TabHitArea {
848 buffer_id: buf1,
849 tab_area: Rect::new(0, 0, 16, 1),
850 close_area: Rect::new(12, 0, 4, 1),
851 });
852
853 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(buf1)));
855
856 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(buf1)));
858
859 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
861
862 assert_eq!(layout.hit_test(50, 5), None);
864 }
865}