1use crate::draw_utils::{
2 draw_horizontal_line, fill_horizontal_background, print_with_color_and_background_at,
3};
4use crate::model::common::ScreenBuffer;
5
6pub struct TabBar;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub enum TabNavigationAction {
12 ScrollLeft,
13 ScrollRight,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum TabHoverTarget {
18 Tab(usize),
19 CloseButton(usize),
20 NavigationLeft,
21 NavigationRight,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum TabHitTarget {
26 Tab(usize),
27 CloseButton(usize),
28 Navigation(TabNavigationAction),
29}
30
31#[derive(Debug, Clone)]
32struct TabLayout {
33 left_arrow_x: usize,
34 right_arrow_x: usize,
35 tab_area_start: usize,
36 tab_area_end: usize,
37 trailing_border_x: usize,
38 needs_scrolling: bool,
39 max_scroll_offset: usize,
40 centering_offset: usize,
41 total_tabs_width: usize,
42 tabs: Vec<TabRect>,
43}
44
45#[derive(Debug, Clone)]
46struct TabRect {
47 index: usize,
48 x: usize,
49 width: usize,
50 close_glyph_x: Option<usize>,
51}
52
53impl TabBar {
54 fn layout(
55 x1: usize,
56 x2: usize,
57 tab_labels: &[String],
58 tab_close_buttons: &[bool],
59 tab_scroll_offset: usize,
60 fg_color: &Option<String>,
61 bg_color: &Option<String>,
62 ) -> TabLayout {
63 let total_width = x2.saturating_sub(x1).saturating_add(1);
64 let left_arrow_space = 2;
65 let right_arrow_space = 2;
66 let has_border_color = crate::color_utils::should_draw_color(fg_color)
67 || crate::color_utils::should_draw_color(bg_color);
68 let border_space = if has_border_color { 4 } else { 0 };
69 let reserved_space = left_arrow_space + right_arrow_space + border_space;
70 let tab_area_width = total_width.saturating_sub(reserved_space);
71 let left_arrow_x = x1 + if has_border_color { 2 } else { 0 };
72 let tab_area_start = left_arrow_x + left_arrow_space;
73 let tab_area_end = tab_area_start + tab_area_width;
74 let right_arrow_x = tab_area_end;
75 let trailing_border_x = right_arrow_x + right_arrow_space;
76
77 let max_tab_width = 16;
78 let min_tab_width = 6;
79 let ideal_tab_width = if !tab_labels.is_empty() {
80 tab_area_width / tab_labels.len()
81 } else {
82 max_tab_width
83 };
84 let tab_width = ideal_tab_width.clamp(min_tab_width, max_tab_width);
85 let natural_tabs_width = tab_labels.len() * tab_width;
86 let needs_scrolling = (natural_tabs_width as f32) > (tab_area_width as f32 * 0.85);
87
88 let separator_width = 1;
89 let (visible_tabs, adjusted_tab_width) = if needs_scrolling {
90 let min_tab_with_separator = min_tab_width + separator_width;
91 let max_visible_tabs = tab_area_width / min_tab_with_separator.max(1);
92 let remaining_tabs = tab_labels.len().saturating_sub(tab_scroll_offset);
93 let visible_tabs = max_visible_tabs.min(remaining_tabs).max(1);
94 let adjusted_tab_width = if visible_tabs > 0 {
95 let total_separator_space = (visible_tabs.saturating_sub(1)) * separator_width;
96 (tab_area_width.saturating_sub(total_separator_space) / visible_tabs)
97 .max(min_tab_width)
98 } else {
99 min_tab_width
100 };
101 (visible_tabs, adjusted_tab_width)
102 } else {
103 let visible_tabs = tab_labels.len();
104 let adjusted_tab_width = if visible_tabs > 0 {
105 let total_separator_space = (visible_tabs.saturating_sub(1)) * separator_width;
106 tab_area_width.saturating_sub(total_separator_space) / visible_tabs
107 } else {
108 tab_width
109 };
110 (visible_tabs, adjusted_tab_width)
111 };
112
113 let total_tabs_width = if visible_tabs > 0 {
114 visible_tabs * adjusted_tab_width + (visible_tabs.saturating_sub(1)) * separator_width
115 } else {
116 0
117 };
118 let centering_offset = if needs_scrolling {
119 0
120 } else {
121 tab_area_width.saturating_sub(total_tabs_width) / 2
122 };
123 let start_offset = if needs_scrolling {
124 tab_scroll_offset
125 } else {
126 0
127 };
128
129 let mut tabs = Vec::with_capacity(visible_tabs);
130 let mut tab_x = tab_area_start + centering_offset;
131 for i in 0..visible_tabs {
132 let tab_index = start_offset + i;
133 if tab_index >= tab_labels.len() {
134 break;
135 }
136
137 let close_glyph_x = if tab_close_buttons.get(tab_index).copied().unwrap_or(false) {
138 Self::close_glyph_x(tab_x, adjusted_tab_width)
139 } else {
140 None
141 };
142
143 tabs.push(TabRect {
144 index: tab_index,
145 x: tab_x,
146 width: adjusted_tab_width,
147 close_glyph_x,
148 });
149
150 tab_x += adjusted_tab_width;
151 if i < visible_tabs.saturating_sub(1) {
152 tab_x += separator_width;
153 }
154 }
155
156 let max_scroll_offset = if !tab_labels.is_empty() {
157 tab_labels
158 .len()
159 .saturating_sub(tab_area_width / tab_width.max(1))
160 } else {
161 0
162 };
163
164 TabLayout {
165 left_arrow_x,
166 right_arrow_x,
167 tab_area_start,
168 tab_area_end,
169 trailing_border_x,
170 needs_scrolling,
171 max_scroll_offset,
172 centering_offset,
173 total_tabs_width,
174 tabs,
175 }
176 }
177
178 fn close_glyph_x(tab_x: usize, tab_width: usize) -> Option<usize> {
179 if tab_width < 2 {
180 return None;
181 }
182
183 let min_close_area = 2;
184 let preferred_close_area = if tab_width >= 8 { 3 } else { min_close_area };
185 let close_area_width = preferred_close_area.min(tab_width.saturating_sub(2));
186
187 Some(if close_area_width >= 3 {
188 tab_x + tab_width - close_area_width + 1
189 } else {
190 tab_x + tab_width - 2
191 })
192 }
193
194 pub fn draw(
196 y: usize,
197 x1: usize,
198 x2: usize,
199 fg_color: &Option<String>,
200 bg_color: &Option<String>,
201 title_fg_color: &Option<String>,
202 title_bg_color: &Option<String>,
203 tab_labels: &[String],
204 tab_close_buttons: &[bool],
205 active_tab_index: usize,
206 box_selected: bool,
207 tab_scroll_offset: usize,
208 hovered_target: Option<&TabHoverTarget>,
209 buffer: &mut ScreenBuffer,
210 ) {
211 let layout = Self::layout(
212 x1,
213 x2,
214 tab_labels,
215 tab_close_buttons,
216 tab_scroll_offset,
217 fg_color,
218 bg_color,
219 );
220 let left_arrow_space = 2;
221 let right_arrow_space = 2;
222
223 if (crate::color_utils::should_draw_color(fg_color)
225 || crate::color_utils::should_draw_color(bg_color))
226 && x1 < x2
227 {
228 draw_horizontal_line(y, x1, x1 + 1, fg_color, bg_color, buffer);
229 }
230
231 if layout.needs_scrolling && tab_scroll_offset > 0 {
233 let (arrow_fg, arrow_bg) = Self::hover_colors(
235 title_fg_color,
236 bg_color,
237 matches!(hovered_target, Some(TabHoverTarget::NavigationLeft)),
238 );
239 print_with_color_and_background_at(
240 y,
241 layout.left_arrow_x,
242 &arrow_fg,
243 &arrow_bg,
244 "◀",
245 buffer,
246 );
247 print_with_color_and_background_at(
248 y,
249 layout.left_arrow_x + 1,
250 &arrow_fg,
251 &arrow_bg,
252 " ",
253 buffer,
254 );
255 } else {
256 draw_horizontal_line(
258 y,
259 layout.left_arrow_x,
260 layout.left_arrow_x + left_arrow_space - 1,
261 fg_color,
262 bg_color,
263 buffer,
264 );
265 }
266
267 if !layout.tabs.is_empty() {
269 for (visible_index, tab) in layout.tabs.iter().enumerate() {
270 Self::draw_single_tab(
271 y,
272 tab.x,
273 tab.width,
274 &tab_labels[tab.index],
275 tab.index == active_tab_index,
276 box_selected,
277 tab.close_glyph_x.is_some(),
278 matches!(hovered_target, Some(TabHoverTarget::Tab(index)) if *index == tab.index),
279 matches!(hovered_target, Some(TabHoverTarget::CloseButton(index)) if *index == tab.index),
280 title_fg_color,
281 title_bg_color,
282 buffer,
283 );
284
285 if visible_index < layout.tabs.len().saturating_sub(1) {
287 print_with_color_and_background_at(
288 y,
289 tab.x + tab.width,
290 fg_color,
291 bg_color,
292 "│",
293 buffer,
294 );
295 }
296 }
297 }
298
299 if layout.tab_area_start < layout.tab_area_start + layout.centering_offset {
301 draw_horizontal_line(
302 y,
303 layout.tab_area_start,
304 layout.tab_area_start + layout.centering_offset - 1,
305 fg_color,
306 bg_color,
307 buffer,
308 );
309 }
310
311 let tabs_end = layout.tab_area_start + layout.centering_offset + layout.total_tabs_width;
312 if tabs_end < layout.tab_area_end {
313 draw_horizontal_line(
314 y,
315 tabs_end,
316 layout.tab_area_end - 1,
317 fg_color,
318 bg_color,
319 buffer,
320 );
321 }
322
323 if layout.needs_scrolling && tab_scroll_offset < layout.max_scroll_offset {
324 let (arrow_fg, arrow_bg) = Self::hover_colors(
326 title_fg_color,
327 bg_color,
328 matches!(hovered_target, Some(TabHoverTarget::NavigationRight)),
329 );
330 print_with_color_and_background_at(
331 y,
332 layout.right_arrow_x,
333 &arrow_fg,
334 &arrow_bg,
335 " ",
336 buffer,
337 );
338 print_with_color_and_background_at(
339 y,
340 layout.right_arrow_x + 1,
341 &arrow_fg,
342 &arrow_bg,
343 "▶",
344 buffer,
345 );
346 } else {
347 draw_horizontal_line(
349 y,
350 layout.right_arrow_x,
351 layout.right_arrow_x + right_arrow_space - 1,
352 fg_color,
353 bg_color,
354 buffer,
355 );
356 }
357
358 if (crate::color_utils::should_draw_color(fg_color)
360 || crate::color_utils::should_draw_color(bg_color))
361 && layout.trailing_border_x < x2
362 {
363 draw_horizontal_line(y, layout.trailing_border_x, x2, fg_color, bg_color, buffer);
364 }
365 }
366
367 fn draw_single_tab(
369 y: usize,
370 x: usize,
371 width: usize,
372 label: &str,
373 is_active: bool,
374 box_selected: bool,
375 has_close_button: bool,
376 is_hovered: bool,
377 is_close_hovered: bool,
378 title_fg_color: &Option<String>,
379 title_bg_color: &Option<String>,
380 buffer: &mut ScreenBuffer,
381 ) {
382 let active_fg = Some(crate::color_utils::default_selected_title_fg_color().to_string());
388 let active_bg = Some(if box_selected {
389 crate::color_utils::default_focused_title_bg_color().to_string()
390 } else {
391 crate::color_utils::default_selected_title_bg_color().to_string()
392 });
393 let (base_tab_fg, base_tab_bg): (&Option<String>, &Option<String>) = if is_active {
394 (&active_fg, &active_bg)
395 } else {
396 (title_fg_color, title_bg_color)
397 };
398 let (tab_fg, tab_bg) = Self::hover_colors(base_tab_fg, base_tab_bg, is_hovered);
399
400 let close_button_space = if has_close_button { 2 } else { 0 }; let available_label_width = width.saturating_sub(close_button_space + 2); let mut display_label = label.to_string();
406 let max_label_chars = available_label_width;
407
408 if display_label.chars().count() > max_label_chars {
409 let truncate_chars = max_label_chars.saturating_sub(1);
410 display_label = display_label
411 .chars()
412 .take(truncate_chars)
413 .collect::<String>();
414 display_label.push('…');
415 }
416
417 fill_horizontal_background(y, x, x + width - 1, &tab_fg, &tab_bg, buffer);
419
420 if has_close_button {
421 let min_close_area = 2; let preferred_close_area = if width >= 8 { 3 } else { min_close_area }; let close_area_width = preferred_close_area.min(width.saturating_sub(2)); let close_button_x = x + width - close_area_width;
428 let label_space = width.saturating_sub(close_area_width + 1); let mut final_label = display_label.clone();
432 if final_label.chars().count() > label_space {
433 let truncate_chars = label_space.saturating_sub(1); if truncate_chars > 0 {
435 final_label = final_label.chars().take(truncate_chars).collect::<String>();
436 final_label.push('…');
437 } else {
438 final_label = "…".to_string();
439 }
440 }
441
442 let label_content = format!(" {}", final_label);
444 print_with_color_and_background_at(y, x, &tab_fg, &tab_bg, &label_content, buffer);
445
446 let (close_fg, close_bg) = Self::hover_colors(&tab_fg, &tab_bg, is_close_hovered);
448 if close_area_width >= 3 {
449 print_with_color_and_background_at(
451 y,
452 close_button_x,
453 &close_fg,
454 &close_bg,
455 if is_close_hovered { " X" } else { " ×" },
456 buffer,
457 );
458 } else {
459 print_with_color_and_background_at(
461 y,
462 x + width - 2,
463 &close_fg,
464 &close_bg,
465 if is_close_hovered { "X" } else { "×" },
466 buffer,
467 );
468 }
469 } else {
470 let tab_content = format!(" {} ", display_label);
472 let tab_char_count = tab_content.chars().count();
473 let display_chars = tab_char_count.min(width);
474
475 let display_text = if display_chars < tab_char_count {
476 tab_content.chars().take(display_chars).collect::<String>()
477 } else {
478 tab_content
479 };
480
481 print_with_color_and_background_at(y, x, &tab_fg, &tab_bg, &display_text, buffer);
483 }
484 }
485
486 pub fn calculate_tab_click_index(
488 click_x: usize,
489 x1: usize,
490 x2: usize,
491 tab_labels: &[String],
492 tab_scroll_offset: usize,
493 fg_color: &Option<String>,
494 bg_color: &Option<String>,
495 ) -> Option<usize> {
496 match Self::calculate_tab_hit_target(
497 click_x,
498 x1,
499 x2,
500 tab_labels,
501 &[],
502 tab_scroll_offset,
503 fg_color,
504 bg_color,
505 ) {
506 Some(TabHitTarget::Tab(index)) => Some(index),
507 _ => None,
508 }
509 }
510
511 pub fn calculate_tab_navigation_click(
513 click_x: usize,
514 x1: usize,
515 x2: usize,
516 tab_labels: &[String],
517 tab_scroll_offset: usize,
518 fg_color: &Option<String>,
519 bg_color: &Option<String>,
520 ) -> Option<TabNavigationAction> {
521 match Self::calculate_tab_hit_target(
522 click_x,
523 x1,
524 x2,
525 tab_labels,
526 &[],
527 tab_scroll_offset,
528 fg_color,
529 bg_color,
530 ) {
531 Some(TabHitTarget::Navigation(action)) => Some(action),
532 _ => None,
533 }
534 }
535
536 pub fn calculate_tab_close_click(
538 click_x: usize,
539 x1: usize,
540 x2: usize,
541 tab_labels: &[String],
542 tab_close_buttons: &[bool],
543 tab_scroll_offset: usize,
544 fg_color: &Option<String>,
545 bg_color: &Option<String>,
546 ) -> Option<usize> {
547 match Self::calculate_tab_hit_target(
548 click_x,
549 x1,
550 x2,
551 tab_labels,
552 tab_close_buttons,
553 tab_scroll_offset,
554 fg_color,
555 bg_color,
556 ) {
557 Some(TabHitTarget::CloseButton(index)) => Some(index),
558 _ => None,
559 }
560 }
561
562 pub fn calculate_tab_hit_target(
563 x: usize,
564 x1: usize,
565 x2: usize,
566 tab_labels: &[String],
567 tab_close_buttons: &[bool],
568 tab_scroll_offset: usize,
569 fg_color: &Option<String>,
570 bg_color: &Option<String>,
571 ) -> Option<TabHitTarget> {
572 if tab_labels.is_empty() {
573 return None;
574 }
575
576 let layout = Self::layout(
577 x1,
578 x2,
579 tab_labels,
580 tab_close_buttons,
581 tab_scroll_offset,
582 fg_color,
583 bg_color,
584 );
585
586 if layout.needs_scrolling {
587 if tab_scroll_offset > 0 && x == layout.left_arrow_x {
588 return Some(TabHitTarget::Navigation(TabNavigationAction::ScrollLeft));
589 }
590 if tab_scroll_offset < layout.max_scroll_offset && x == layout.right_arrow_x + 1 {
591 return Some(TabHitTarget::Navigation(TabNavigationAction::ScrollRight));
592 }
593 }
594
595 if x < layout.tab_area_start || x >= layout.tab_area_end {
596 return None;
597 }
598
599 for tab in &layout.tabs {
600 if tab.close_glyph_x == Some(x) {
601 return Some(TabHitTarget::CloseButton(tab.index));
602 }
603 if x >= tab.x && x < tab.x + tab.width {
604 return Some(TabHitTarget::Tab(tab.index));
605 }
606 }
607
608 None
609 }
610
611 pub fn calculate_tab_hover_target(
612 hover_x: usize,
613 x1: usize,
614 x2: usize,
615 tab_labels: &[String],
616 tab_close_buttons: &[bool],
617 tab_scroll_offset: usize,
618 fg_color: &Option<String>,
619 bg_color: &Option<String>,
620 ) -> Option<TabHoverTarget> {
621 Self::calculate_tab_hit_target(
622 hover_x,
623 x1,
624 x2,
625 tab_labels,
626 tab_close_buttons,
627 tab_scroll_offset,
628 fg_color,
629 bg_color,
630 )
631 .map(|target| match target {
632 TabHitTarget::Tab(index) => TabHoverTarget::Tab(index),
633 TabHitTarget::CloseButton(index) => TabHoverTarget::CloseButton(index),
634 TabHitTarget::Navigation(TabNavigationAction::ScrollLeft) => {
635 TabHoverTarget::NavigationLeft
636 }
637 TabHitTarget::Navigation(TabNavigationAction::ScrollRight) => {
638 TabHoverTarget::NavigationRight
639 }
640 })
641 }
642
643 fn hover_colors(
644 fg_color: &Option<String>,
645 bg_color: &Option<String>,
646 is_hovered: bool,
647 ) -> (Option<String>, Option<String>) {
648 if is_hovered {
649 (
650 Some(crate::color_utils::default_hover_fg_color().to_string()),
651 Some(crate::color_utils::default_hover_bg_color().to_string()),
652 )
653 } else {
654 (fg_color.clone(), bg_color.clone())
655 }
656 }
657}