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 = total_width.saturating_sub(max_width);
143 let mut result = target_offset.min(max_offset);
144
145 if tab_end > result + max_width {
148 result = tab_end.saturating_sub(max_width);
150 }
151
152 tracing::debug!(
153 "scroll_to_show_tab: idx={}, tab={}..{}, target={}, result={}, total={}, max_width={}, max_offset={}",
154 active_idx, tab_start, tab_end, target_offset, result, total_width, max_width, max_offset
155 );
156
157 result
158}
159
160pub fn calculate_tab_widths(
164 split_buffers: &[BufferId],
165 buffers: &HashMap<BufferId, EditorState>,
166 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
167 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
168) -> (Vec<usize>, Vec<BufferId>) {
169 let mut tab_widths: Vec<usize> = Vec::new();
170 let mut rendered_buffer_ids: Vec<BufferId> = Vec::new();
171
172 for id in split_buffers.iter() {
173 let is_regular_buffer = buffers.contains_key(id);
175 let is_composite_buffer = composite_buffers.contains_key(id);
176
177 if !is_regular_buffer && !is_composite_buffer {
178 continue;
179 }
180
181 if let Some(meta) = buffer_metadata.get(id) {
183 if meta.hidden_from_tabs {
184 continue;
185 }
186 }
187
188 let meta = buffer_metadata.get(id);
189 let is_terminal = meta
190 .and_then(|m| m.virtual_mode())
191 .map(|mode| mode == "terminal")
192 .unwrap_or(false);
193
194 let name = if is_composite_buffer {
196 meta.map(|m| m.display_name.as_str())
197 } else if is_terminal {
198 meta.map(|m| m.display_name.as_str())
199 } else {
200 buffers
201 .get(id)
202 .and_then(|state| state.buffer.file_path())
203 .and_then(|p| p.file_name())
204 .and_then(|n| n.to_str())
205 .or_else(|| meta.map(|m| m.display_name.as_str()))
206 }
207 .unwrap_or("[No Name]");
208
209 let modified = if is_composite_buffer {
211 ""
212 } else if let Some(state) = buffers.get(id) {
213 if state.buffer.is_modified() {
214 "*"
215 } else {
216 ""
217 }
218 } else {
219 ""
220 };
221
222 let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
223 " [BIN]"
224 } else {
225 ""
226 };
227
228 let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
230 let close_text = "× ";
231 let tab_width = str_width(&tab_name_text) + str_width(close_text);
232
233 if !rendered_buffer_ids.is_empty() {
235 tab_widths.push(1); }
237
238 tab_widths.push(tab_width);
239 rendered_buffer_ids.push(*id);
240 }
241
242 (tab_widths, rendered_buffer_ids)
243}
244
245impl TabsRenderer {
246 #[allow(clippy::too_many_arguments)]
262 pub fn render_for_split(
263 frame: &mut Frame,
264 area: Rect,
265 split_buffers: &[BufferId],
266 buffers: &HashMap<BufferId, EditorState>,
267 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
268 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
269 active_buffer: BufferId,
270 theme: &crate::view::theme::Theme,
271 is_active_split: bool,
272 tab_scroll_offset: usize,
273 hovered_tab: Option<(BufferId, bool)>, ) -> TabLayout {
275 let mut layout = TabLayout::new(area);
276 const SCROLL_INDICATOR_LEFT: &str = "<";
277 const SCROLL_INDICATOR_RIGHT: &str = ">";
278 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(); for id in split_buffers.iter() {
286 let is_regular_buffer = buffers.contains_key(id);
288 let is_composite_buffer = composite_buffers.contains_key(id);
289
290 if !is_regular_buffer && !is_composite_buffer {
291 continue;
292 }
293
294 if let Some(meta) = buffer_metadata.get(id) {
296 if meta.hidden_from_tabs {
297 continue;
298 }
299 }
300 rendered_buffer_ids.push(*id);
301
302 let meta = buffer_metadata.get(id);
303 let is_terminal = meta
304 .and_then(|m| m.virtual_mode())
305 .map(|mode| mode == "terminal")
306 .unwrap_or(false);
307
308 let name = if is_composite_buffer {
311 meta.map(|m| m.display_name.as_str())
312 } else if is_terminal {
313 meta.map(|m| m.display_name.as_str())
314 } else {
315 buffers
316 .get(id)
317 .and_then(|state| state.buffer.file_path())
318 .and_then(|p| p.file_name())
319 .and_then(|n| n.to_str())
320 .or_else(|| meta.map(|m| m.display_name.as_str()))
321 }
322 .unwrap_or("[No Name]");
323
324 let modified = if is_composite_buffer {
326 ""
327 } else if let Some(state) = buffers.get(id) {
328 if state.buffer.is_modified() {
329 "*"
330 } else {
331 ""
332 }
333 } else {
334 ""
335 };
336 let binary_indicator = if buffer_metadata.get(id).map(|m| m.binary).unwrap_or(false) {
337 " [BIN]"
338 } else {
339 ""
340 };
341
342 let is_active = *id == active_buffer;
343
344 let (is_hovered_name, is_hovered_close) = match hovered_tab {
346 Some((hover_buf, is_close)) if hover_buf == *id => (!is_close, is_close),
347 _ => (false, false),
348 };
349
350 let base_style = if is_active {
352 if is_active_split {
353 Style::default()
354 .fg(theme.tab_active_fg)
355 .bg(theme.tab_active_bg)
356 .add_modifier(Modifier::BOLD)
357 } else {
358 Style::default()
359 .fg(theme.tab_active_fg)
360 .bg(theme.tab_inactive_bg)
361 .add_modifier(Modifier::BOLD)
362 }
363 } else if is_hovered_name {
364 Style::default()
366 .fg(theme.tab_inactive_fg)
367 .bg(theme.tab_hover_bg)
368 } else {
369 Style::default()
370 .fg(theme.tab_inactive_fg)
371 .bg(theme.tab_inactive_bg)
372 };
373
374 let close_style = if is_hovered_close {
376 base_style.fg(theme.tab_close_hover_fg)
378 } else {
379 base_style
380 };
381
382 let tab_name_text = format!(" {name}{modified}{binary_indicator} ");
384 let tab_name_width = str_width(&tab_name_text);
385
386 let close_text = "× ";
388 let close_width = str_width(close_text);
389
390 let total_width = tab_name_width + close_width;
391
392 let start_pos: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
393 let close_start_pos = start_pos + tab_name_width;
394 let end_pos = start_pos + total_width;
395 tab_ranges.push((start_pos, end_pos, close_start_pos));
396
397 all_tab_spans.push((Span::styled(tab_name_text, base_style), tab_name_width));
399 all_tab_spans.push((
401 Span::styled(close_text.to_string(), close_style),
402 close_width,
403 ));
404 }
405
406 let mut final_spans: Vec<(Span<'static>, usize)> = Vec::new();
410 let mut separator_offset = 0usize;
411 let spans_per_tab = 2; for (tab_idx, chunk) in all_tab_spans.chunks(spans_per_tab).enumerate() {
413 if separator_offset > 0 {
415 let (start, end, close_start) = tab_ranges[tab_idx];
416 tab_ranges[tab_idx] = (
417 start + separator_offset,
418 end + separator_offset,
419 close_start + separator_offset,
420 );
421 }
422
423 for span in chunk {
424 final_spans.push(span.clone());
425 }
426 if tab_idx < rendered_buffer_ids.len().saturating_sub(1) {
428 final_spans.push((
429 Span::styled(" ", Style::default().bg(theme.tab_separator_bg)),
430 1,
431 ));
432 separator_offset += 1;
433 }
434 }
435 #[allow(clippy::let_and_return)]
436 let all_tab_spans = final_spans;
437
438 let mut current_spans: Vec<Span> = Vec::new();
439 let max_width = area.width as usize;
440
441 let total_width: usize = all_tab_spans.iter().map(|(_, w)| w).sum();
442 let _active_tab_idx = rendered_buffer_ids
445 .iter()
446 .position(|id| *id == active_buffer);
447
448 let mut tab_widths: Vec<usize> = Vec::new();
449 for (start, end, _close_start) in &tab_ranges {
450 tab_widths.push(end.saturating_sub(*start));
451 }
452
453 let max_offset = total_width.saturating_sub(max_width);
456 let offset = tab_scroll_offset.min(total_width);
457 tracing::trace!(
458 "render_for_split: tab_scroll_offset={}, max_offset={}, offset={}, total={}, max_width={}",
459 tab_scroll_offset, max_offset, offset, total_width, max_width
460 );
461
462 let show_left = offset > 0;
464 let show_right = total_width.saturating_sub(offset) > max_width;
465 let available = max_width
466 .saturating_sub((show_left as usize + show_right as usize) * SCROLL_INDICATOR_WIDTH);
467
468 let mut rendered_width = 0;
469 let mut skip_chars_count = offset;
470
471 if show_left {
472 current_spans.push(Span::styled(
473 SCROLL_INDICATOR_LEFT,
474 Style::default().bg(theme.tab_separator_bg),
475 ));
476 rendered_width += SCROLL_INDICATOR_WIDTH;
477 }
478
479 for (mut span, width) in all_tab_spans.into_iter() {
480 if skip_chars_count >= width {
481 skip_chars_count -= width;
482 continue;
483 }
484
485 let visible_chars_in_span = width - skip_chars_count;
486 if rendered_width + visible_chars_in_span
487 > max_width.saturating_sub(if show_right {
488 SCROLL_INDICATOR_WIDTH
489 } else {
490 0
491 })
492 {
493 let remaining_width =
494 max_width
495 .saturating_sub(rendered_width)
496 .saturating_sub(if show_right {
497 SCROLL_INDICATOR_WIDTH
498 } else {
499 0
500 });
501 let truncated_content = span
502 .content
503 .chars()
504 .skip(skip_chars_count)
505 .take(remaining_width)
506 .collect::<String>();
507 span.content = std::borrow::Cow::Owned(truncated_content);
508 current_spans.push(span);
509 rendered_width += remaining_width;
510 break;
511 } else {
512 let visible_content = span
513 .content
514 .chars()
515 .skip(skip_chars_count)
516 .collect::<String>();
517 span.content = std::borrow::Cow::Owned(visible_content);
518 current_spans.push(span);
519 rendered_width += visible_chars_in_span;
520 skip_chars_count = 0;
521 }
522 }
523
524 let right_indicator_x = if show_right && rendered_width < max_width {
526 Some(area.x + rendered_width as u16)
527 } else {
528 None
529 };
530
531 if show_right && rendered_width < max_width {
532 current_spans.push(Span::styled(
533 SCROLL_INDICATOR_RIGHT,
534 Style::default().bg(theme.tab_separator_bg),
535 ));
536 rendered_width += SCROLL_INDICATOR_WIDTH;
537 }
538
539 if rendered_width < max_width {
540 current_spans.push(Span::styled(
541 " ".repeat(max_width.saturating_sub(rendered_width)),
542 Style::default().bg(theme.tab_separator_bg),
543 ));
544 }
545
546 let line = Line::from(current_spans);
547 let block = Block::default().style(Style::default().bg(theme.tab_separator_bg));
548 let paragraph = Paragraph::new(line).block(block);
549 frame.render_widget(paragraph, area);
550
551 let left_indicator_offset = if show_left { SCROLL_INDICATOR_WIDTH } else { 0 };
557
558 if show_left {
560 layout.left_scroll_area =
561 Some(Rect::new(area.x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
562 }
563 if let Some(right_x) = right_indicator_x {
564 layout.right_scroll_area =
566 Some(Rect::new(right_x, area.y, SCROLL_INDICATOR_WIDTH as u16, 1));
567 }
568
569 for (idx, buffer_id) in rendered_buffer_ids.iter().enumerate() {
570 let (logical_start, logical_end, logical_close_start) = tab_ranges[idx];
571
572 let visible_start = offset;
576 let visible_end = offset + available;
577
578 if logical_end <= visible_start || logical_start >= visible_end {
580 continue;
581 }
582
583 let screen_start = if logical_start >= visible_start {
585 area.x + left_indicator_offset as u16 + (logical_start - visible_start) as u16
586 } else {
587 area.x + left_indicator_offset as u16
588 };
589
590 let screen_end = if logical_end <= visible_end {
591 area.x + left_indicator_offset as u16 + (logical_end - visible_start) as u16
592 } else {
593 area.x + left_indicator_offset as u16 + available as u16
594 };
595
596 let screen_close_start = if logical_close_start >= visible_start
598 && logical_close_start < visible_end
599 {
600 area.x + left_indicator_offset as u16 + (logical_close_start - visible_start) as u16
601 } else if logical_close_start < visible_start {
602 screen_start
604 } else {
605 screen_end
607 };
608
609 let tab_width = screen_end.saturating_sub(screen_start);
611 let close_width = screen_end.saturating_sub(screen_close_start);
612
613 layout.tabs.push(TabHitArea {
614 buffer_id: *buffer_id,
615 tab_area: Rect::new(screen_start, area.y, tab_width, 1),
616 close_area: Rect::new(screen_close_start, area.y, close_width, 1),
617 });
618 }
619
620 layout
621 }
622
623 #[allow(dead_code)]
626 pub fn render(
627 frame: &mut Frame,
628 area: Rect,
629 buffers: &HashMap<BufferId, EditorState>,
630 buffer_metadata: &HashMap<BufferId, BufferMetadata>,
631 composite_buffers: &HashMap<BufferId, crate::model::composite_buffer::CompositeBuffer>,
632 active_buffer: BufferId,
633 theme: &crate::view::theme::Theme,
634 ) {
635 let mut buffer_ids: Vec<_> = buffers.keys().copied().collect();
637 buffer_ids.sort_by_key(|id| id.0);
638
639 Self::render_for_split(
640 frame,
641 area,
642 &buffer_ids,
643 buffers,
644 buffer_metadata,
645 composite_buffers,
646 active_buffer,
647 theme,
648 true, 0, None, );
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::model::event::BufferId;
659
660 #[test]
661 fn scroll_to_show_active_first_tab() {
662 let widths = vec![5, 5, 5];
664 let offset = scroll_to_show_tab(&widths, 0, 10, 20);
665 assert_eq!(offset, 0);
667 }
668
669 #[test]
670 fn scroll_to_show_tab_already_visible() {
671 let widths = vec![5, 5, 5];
673 let offset = scroll_to_show_tab(&widths, 1, 0, 20);
674 assert_eq!(offset, 0);
676 }
677
678 #[test]
679 fn scroll_to_show_tab_on_right() {
680 let widths = vec![10, 10, 10];
682 let offset = scroll_to_show_tab(&widths, 2, 0, 15);
683 assert!(offset > 0);
685 }
686
687 #[test]
688 fn test_tab_layout_hit_test() {
689 let bar_area = Rect::new(0, 0, 80, 1);
690 let mut layout = TabLayout::new(bar_area);
691
692 let buf1 = BufferId(1);
693
694 layout.tabs.push(TabHitArea {
695 buffer_id: buf1,
696 tab_area: Rect::new(0, 0, 16, 1),
697 close_area: Rect::new(12, 0, 4, 1),
698 });
699
700 assert_eq!(layout.hit_test(5, 0), Some(TabHit::TabName(buf1)));
702
703 assert_eq!(layout.hit_test(13, 0), Some(TabHit::CloseButton(buf1)));
705
706 assert_eq!(layout.hit_test(50, 0), Some(TabHit::BarBackground));
708
709 assert_eq!(layout.hit_test(50, 5), None);
711 }
712}