1use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
41use ratatui::{
42 buffer::Buffer,
43 layout::{Constraint, Direction, Layout, Rect},
44 style::{Color, Modifier, Style},
45 widgets::{Block, Borders, Widget},
46};
47use unicode_width::UnicodeWidthStr;
48
49use crate::traits::{ClickRegionRegistry, FocusId, Focusable};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum TabPosition {
54 #[default]
56 Top,
57 Bottom,
59 Left,
61 Right,
63}
64
65impl TabPosition {
66 pub fn is_horizontal(&self) -> bool {
68 matches!(self, TabPosition::Top | TabPosition::Bottom)
69 }
70
71 pub fn is_vertical(&self) -> bool {
73 matches!(self, TabPosition::Left | TabPosition::Right)
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct Tab<'a> {
80 pub label: &'a str,
82 pub icon: Option<&'a str>,
84 pub badge: Option<&'a str>,
86 pub enabled: bool,
88}
89
90impl<'a> Tab<'a> {
91 pub fn new(label: &'a str) -> Self {
93 Self {
94 label,
95 icon: None,
96 badge: None,
97 enabled: true,
98 }
99 }
100
101 pub fn icon(mut self, icon: &'a str) -> Self {
103 self.icon = Some(icon);
104 self
105 }
106
107 pub fn badge(mut self, badge: &'a str) -> Self {
109 self.badge = Some(badge);
110 self
111 }
112
113 pub fn enabled(mut self, enabled: bool) -> Self {
115 self.enabled = enabled;
116 self
117 }
118
119 pub fn display_width(&self) -> usize {
121 let mut width = self.label.width();
122 if let Some(icon) = self.icon {
123 width += icon.width() + 1; }
125 if let Some(badge) = self.badge {
126 width += badge.width() + 2; }
128 width + 2 }
130}
131
132#[derive(Debug, Clone)]
134pub struct TabViewState {
135 pub selected_index: usize,
137 pub total_tabs: usize,
139 pub scroll_offset: usize,
141 pub tab_bar_focused: bool,
143 pub focus_id: FocusId,
145 pub focused: bool,
147}
148
149impl TabViewState {
150 pub fn new(total_tabs: usize) -> Self {
152 Self {
153 selected_index: 0,
154 total_tabs,
155 scroll_offset: 0,
156 tab_bar_focused: true,
157 focus_id: FocusId::default(),
158 focused: false,
159 }
160 }
161
162 pub fn with_focus_id(total_tabs: usize, focus_id: FocusId) -> Self {
164 Self {
165 selected_index: 0,
166 total_tabs,
167 scroll_offset: 0,
168 tab_bar_focused: true,
169 focus_id,
170 focused: false,
171 }
172 }
173
174 pub fn select_next(&mut self) {
176 if self.selected_index + 1 < self.total_tabs {
177 self.selected_index += 1;
178 }
179 }
180
181 pub fn select_prev(&mut self) {
183 if self.selected_index > 0 {
184 self.selected_index -= 1;
185 }
186 }
187
188 pub fn select(&mut self, index: usize) {
190 if index < self.total_tabs {
191 self.selected_index = index;
192 }
193 }
194
195 pub fn select_first(&mut self) {
197 self.selected_index = 0;
198 }
199
200 pub fn select_last(&mut self) {
202 if self.total_tabs > 0 {
203 self.selected_index = self.total_tabs - 1;
204 }
205 }
206
207 pub fn toggle_focus(&mut self) {
209 self.tab_bar_focused = !self.tab_bar_focused;
210 }
211
212 pub fn ensure_visible(&mut self, visible_count: usize) {
214 if visible_count == 0 {
215 return;
216 }
217
218 if self.selected_index < self.scroll_offset {
219 self.scroll_offset = self.selected_index;
220 } else if self.selected_index >= self.scroll_offset + visible_count {
221 self.scroll_offset = self.selected_index - visible_count + 1;
222 }
223 }
224
225 pub fn set_total(&mut self, total: usize) {
227 self.total_tabs = total;
228 if self.selected_index >= total && total > 0 {
229 self.selected_index = total - 1;
230 }
231 }
232}
233
234impl Default for TabViewState {
235 fn default() -> Self {
236 Self::new(0)
237 }
238}
239
240impl Focusable for TabViewState {
241 fn focus_id(&self) -> FocusId {
242 self.focus_id
243 }
244
245 fn is_focused(&self) -> bool {
246 self.focused
247 }
248
249 fn set_focused(&mut self, focused: bool) {
250 self.focused = focused;
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct TabViewStyle {
257 pub position: TabPosition,
259 pub selected_style: Style,
261 pub normal_style: Style,
263 pub focused_style: Style,
265 pub disabled_style: Style,
267 pub badge_style: Style,
269 pub content_border_style: Style,
271 pub divider: &'static str,
273 pub tab_width: Option<u16>,
275 pub tab_height: u16,
277 pub bordered_content: bool,
279 pub show_indicator: bool,
281 pub indicator: &'static str,
283 pub scroll_left: &'static str,
285 pub scroll_right: &'static str,
287 pub scroll_up: &'static str,
289 pub scroll_down: &'static str,
291}
292
293impl Default for TabViewStyle {
294 fn default() -> Self {
295 Self {
296 position: TabPosition::Top,
297 selected_style: Style::default()
298 .fg(Color::Yellow)
299 .add_modifier(Modifier::BOLD),
300 normal_style: Style::default().fg(Color::White),
301 focused_style: Style::default()
302 .fg(Color::Yellow)
303 .bg(Color::DarkGray)
304 .add_modifier(Modifier::BOLD),
305 disabled_style: Style::default().fg(Color::DarkGray),
306 badge_style: Style::default()
307 .fg(Color::Black)
308 .bg(Color::Red)
309 .add_modifier(Modifier::BOLD),
310 content_border_style: Style::default().fg(Color::Cyan),
311 divider: " │ ",
312 tab_width: None,
313 tab_height: 1,
314 bordered_content: true,
315 show_indicator: true,
316 indicator: "▸",
317 scroll_left: "◀",
318 scroll_right: "▶",
319 scroll_up: "▲",
320 scroll_down: "▼",
321 }
322 }
323}
324
325impl From<&crate::theme::Theme> for TabViewStyle {
326 fn from(theme: &crate::theme::Theme) -> Self {
327 let p = &theme.palette;
328 Self {
329 position: TabPosition::Top,
330 selected_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
331 normal_style: Style::default().fg(p.text),
332 focused_style: Style::default()
333 .fg(p.primary)
334 .bg(Color::DarkGray)
335 .add_modifier(Modifier::BOLD),
336 disabled_style: Style::default().fg(p.text_disabled),
337 badge_style: Style::default()
338 .fg(p.highlight_fg)
339 .bg(p.error)
340 .add_modifier(Modifier::BOLD),
341 content_border_style: Style::default().fg(p.border_accent),
342 divider: " │ ",
343 tab_width: None,
344 tab_height: 1,
345 bordered_content: true,
346 show_indicator: true,
347 indicator: "▸",
348 scroll_left: "◀",
349 scroll_right: "▶",
350 scroll_up: "▲",
351 scroll_down: "▼",
352 }
353 }
354}
355
356impl TabViewStyle {
357 pub fn top() -> Self {
359 Self::default()
360 }
361
362 pub fn bottom() -> Self {
364 Self {
365 position: TabPosition::Bottom,
366 ..Default::default()
367 }
368 }
369
370 pub fn left() -> Self {
372 Self {
373 position: TabPosition::Left,
374 tab_width: Some(16),
375 divider: "",
376 ..Default::default()
377 }
378 }
379
380 pub fn right() -> Self {
382 Self {
383 position: TabPosition::Right,
384 tab_width: Some(16),
385 divider: "",
386 ..Default::default()
387 }
388 }
389
390 pub fn minimal() -> Self {
392 Self {
393 bordered_content: false,
394 show_indicator: false,
395 divider: " ",
396 ..Default::default()
397 }
398 }
399
400 pub fn position(mut self, position: TabPosition) -> Self {
402 self.position = position;
403 self
404 }
405
406 pub fn tab_width(mut self, width: u16) -> Self {
408 self.tab_width = Some(width);
409 self
410 }
411
412 pub fn tab_height(mut self, height: u16) -> Self {
414 self.tab_height = height;
415 self
416 }
417
418 pub fn bordered_content(mut self, bordered: bool) -> Self {
420 self.bordered_content = bordered;
421 self
422 }
423
424 pub fn selected_style(mut self, style: Style) -> Self {
426 self.selected_style = style;
427 self
428 }
429
430 pub fn normal_style(mut self, style: Style) -> Self {
432 self.normal_style = style;
433 self
434 }
435
436 pub fn divider(mut self, divider: &'static str) -> Self {
438 self.divider = divider;
439 self
440 }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum TabViewAction {
446 TabClick(usize),
448 ScrollPrev,
450 ScrollNext,
452}
453
454type DefaultContentRenderer = fn(usize, Rect, &mut Buffer);
456
457pub struct TabView<'a, F = DefaultContentRenderer>
461where
462 F: Fn(usize, Rect, &mut Buffer),
463{
464 tabs: &'a [Tab<'a>],
465 state: &'a TabViewState,
466 style: TabViewStyle,
467 content_renderer: Option<F>,
468}
469
470impl<'a> TabView<'a, DefaultContentRenderer> {
471 pub fn new(tabs: &'a [Tab<'a>], state: &'a TabViewState) -> Self {
473 Self {
474 tabs,
475 state,
476 style: TabViewStyle::default(),
477 content_renderer: None,
478 }
479 }
480}
481
482impl<'a, F> TabView<'a, F>
483where
484 F: Fn(usize, Rect, &mut Buffer),
485{
486 pub fn style(mut self, style: TabViewStyle) -> Self {
488 self.style = style;
489 self
490 }
491
492 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
494 self.style(TabViewStyle::from(theme))
495 }
496
497 pub fn content<G>(self, renderer: G) -> TabView<'a, G>
501 where
502 G: Fn(usize, Rect, &mut Buffer),
503 {
504 TabView {
505 tabs: self.tabs,
506 state: self.state,
507 style: self.style,
508 content_renderer: Some(renderer),
509 }
510 }
511
512 fn calculate_layout(&self, area: Rect) -> (Rect, Rect) {
514 let (direction, constraints) = match self.style.position {
515 TabPosition::Top => (
516 Direction::Vertical,
517 [
518 Constraint::Length(self.style.tab_height),
519 Constraint::Min(1),
520 ],
521 ),
522 TabPosition::Bottom => (
523 Direction::Vertical,
524 [
525 Constraint::Min(1),
526 Constraint::Length(self.style.tab_height),
527 ],
528 ),
529 TabPosition::Left => {
530 let width = self.style.tab_width.unwrap_or(16);
531 (
532 Direction::Horizontal,
533 [Constraint::Length(width), Constraint::Min(1)],
534 )
535 }
536 TabPosition::Right => {
537 let width = self.style.tab_width.unwrap_or(16);
538 (
539 Direction::Horizontal,
540 [Constraint::Min(1), Constraint::Length(width)],
541 )
542 }
543 };
544
545 let chunks = Layout::default()
546 .direction(direction)
547 .constraints(constraints)
548 .split(area);
549
550 match self.style.position {
551 TabPosition::Top | TabPosition::Left => (chunks[0], chunks[1]),
552 TabPosition::Bottom | TabPosition::Right => (chunks[1], chunks[0]),
553 }
554 }
555
556 fn render_tab_bar(&self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
558 let mut click_regions = Vec::new();
559
560 if self.style.position.is_horizontal() {
561 self.render_horizontal_tabs(area, buf, &mut click_regions);
562 } else {
563 self.render_vertical_tabs(area, buf, &mut click_regions);
564 }
565
566 click_regions
567 }
568
569 fn render_horizontal_tabs(
571 &self,
572 area: Rect,
573 buf: &mut Buffer,
574 click_regions: &mut Vec<(Rect, TabViewAction)>,
575 ) {
576 let mut x = area.x;
577 let y = area.y;
578
579 let has_overflow = self.calculate_overflow_horizontal(area.width);
581 let show_prev = self.state.scroll_offset > 0;
582 let show_next = has_overflow
583 && self.state.scroll_offset + self.visible_tabs_horizontal(area.width)
584 < self.tabs.len();
585
586 if show_prev {
588 let indicator = self.style.scroll_left;
589 let indicator_area = Rect::new(x, y, 2, 1);
590 buf.set_string(x, y, indicator, Style::default().fg(Color::Yellow));
591 click_regions.push((indicator_area, TabViewAction::ScrollPrev));
592 x += 2;
593 }
594
595 let visible_start = self.state.scroll_offset;
597 let visible_count = self.visible_tabs_horizontal(
598 area.width
599 .saturating_sub(if show_prev { 2 } else { 0 })
600 .saturating_sub(if show_next { 2 } else { 0 }),
601 );
602
603 for (idx, tab) in self
604 .tabs
605 .iter()
606 .enumerate()
607 .skip(visible_start)
608 .take(visible_count)
609 {
610 let tab_start_x = x;
612
613 let mut text = String::new();
615 if let Some(icon) = tab.icon {
616 text.push_str(icon);
617 text.push(' ');
618 }
619 text.push_str(tab.label);
620
621 let style = self.get_tab_style(idx, tab.enabled);
623
624 let text_with_padding = if self.state.selected_index == idx && self.style.show_indicator
626 {
627 format!("{} {} ", self.style.indicator, text)
628 } else {
629 format!(" {} ", text)
630 };
631
632 let text_width = text_with_padding.width() as u16;
634 buf.set_string(x, y, &text_with_padding, style);
635 x += text_width;
636
637 if let Some(badge) = tab.badge {
639 let badge_text = format!(" {} ", badge);
640 let badge_width = badge_text.width() as u16;
641 buf.set_string(x, y, &badge_text, self.style.badge_style);
642 x += badge_width;
643 }
644
645 let tab_width = x - tab_start_x;
647 if tab_width > 0 {
648 let tab_area = Rect::new(tab_start_x, y, tab_width, 1);
649 click_regions.push((tab_area, TabViewAction::TabClick(idx)));
650 }
651
652 if idx + 1 < visible_start + visible_count && idx + 1 < self.tabs.len() {
654 let divider_width = self.style.divider.width() as u16;
655 buf.set_string(
656 x,
657 y,
658 self.style.divider,
659 Style::default().fg(Color::DarkGray),
660 );
661 x += divider_width;
662 }
663 }
664
665 if show_next {
667 let indicator = self.style.scroll_right;
668 let indicator_x = area.x + area.width - 2;
669 let indicator_area = Rect::new(indicator_x, y, 2, 1);
670 buf.set_string(
671 indicator_x,
672 y,
673 indicator,
674 Style::default().fg(Color::Yellow),
675 );
676 click_regions.push((indicator_area, TabViewAction::ScrollNext));
677 }
678 }
679
680 fn render_vertical_tabs(
682 &self,
683 area: Rect,
684 buf: &mut Buffer,
685 click_regions: &mut Vec<(Rect, TabViewAction)>,
686 ) {
687 let x = area.x;
688 let mut y = area.y;
689 let width = area.width;
690
691 let visible_count = (area.height as usize).min(self.tabs.len());
693 let show_prev = self.state.scroll_offset > 0;
694 let show_next = self.state.scroll_offset + visible_count < self.tabs.len();
695
696 if show_prev {
698 let indicator = format!("{:^width$}", self.style.scroll_up, width = width as usize);
699 buf.set_string(x, y, &indicator, Style::default().fg(Color::Yellow));
700 click_regions.push((Rect::new(x, y, width, 1), TabViewAction::ScrollPrev));
701 y += 1;
702 }
703
704 let available_height = area
706 .height
707 .saturating_sub(if show_prev { 1 } else { 0 })
708 .saturating_sub(if show_next { 1 } else { 0 });
709 let visible_start = self.state.scroll_offset;
710 let visible_count = (available_height as usize).min(self.tabs.len() - visible_start);
711
712 for (idx, tab) in self
713 .tabs
714 .iter()
715 .enumerate()
716 .skip(visible_start)
717 .take(visible_count)
718 {
719 if y >= area.y + area.height - if show_next { 1 } else { 0 } {
720 break;
721 }
722
723 let mut text = String::new();
725 if self.state.selected_index == idx && self.style.show_indicator {
726 text.push_str(self.style.indicator);
727 text.push(' ');
728 } else {
729 text.push_str(" ");
730 }
731 if let Some(icon) = tab.icon {
732 text.push_str(icon);
733 text.push(' ');
734 }
735 text.push_str(tab.label);
736
737 if let Some(badge) = tab.badge {
739 text.push_str(&format!(" ({})", badge));
740 }
741
742 let max_len = width as usize;
744 let display_text = if text.chars().count() > max_len {
745 let truncated: String = text.chars().take(max_len - 1).collect();
746 format!("{}…", truncated)
747 } else {
748 format!("{:width$}", text, width = max_len)
749 };
750
751 let style = self.get_tab_style(idx, tab.enabled);
753
754 let tab_area = Rect::new(x, y, width, 1);
755 buf.set_string(x, y, &display_text, style);
756 click_regions.push((tab_area, TabViewAction::TabClick(idx)));
757
758 y += 1;
759 }
760
761 if show_next {
763 let indicator_y = area.y + area.height - 1;
764 let indicator = format!("{:^width$}", self.style.scroll_down, width = width as usize);
765 buf.set_string(
766 x,
767 indicator_y,
768 &indicator,
769 Style::default().fg(Color::Yellow),
770 );
771 click_regions.push((
772 Rect::new(x, indicator_y, width, 1),
773 TabViewAction::ScrollNext,
774 ));
775 }
776 }
777
778 fn get_tab_style(&self, idx: usize, enabled: bool) -> Style {
780 if !enabled {
781 self.style.disabled_style
782 } else if idx == self.state.selected_index
783 && self.state.focused
784 && self.state.tab_bar_focused
785 {
786 self.style.focused_style
787 } else if idx == self.state.selected_index {
788 self.style.selected_style
789 } else {
790 self.style.normal_style
791 }
792 }
793
794 fn calculate_overflow_horizontal(&self, available_width: u16) -> bool {
796 let total_width: u16 = self
797 .tabs
798 .iter()
799 .map(|t| t.display_width() as u16 + self.style.divider.width() as u16)
800 .sum();
801 total_width > available_width
802 }
803
804 fn visible_tabs_horizontal(&self, available_width: u16) -> usize {
806 let mut width = 0u16;
807 let mut count = 0;
808 for tab in self.tabs.iter().skip(self.state.scroll_offset) {
809 let tab_width = tab.display_width() as u16 + self.style.divider.width() as u16;
810 if width + tab_width > available_width {
811 break;
812 }
813 width += tab_width;
814 count += 1;
815 }
816 count.max(1)
817 }
818
819 fn render_content(&self, area: Rect, buf: &mut Buffer) {
821 let inner = if self.style.bordered_content {
822 let block = Block::default()
823 .borders(Borders::ALL)
824 .border_style(self.style.content_border_style);
825 let inner = block.inner(area);
826 block.render(area, buf);
827 inner
828 } else {
829 area
830 };
831
832 if let Some(ref renderer) = self.content_renderer {
833 renderer(self.state.selected_index, inner, buf);
834 }
835 }
836
837 pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
839 let (tab_area, content_area) = self.calculate_layout(area);
840
841 let click_regions = self.render_tab_bar(tab_area, buf);
843
844 self.render_content(content_area, buf);
846
847 click_regions
848 }
849
850 pub fn render_with_registry(
852 self,
853 area: Rect,
854 buf: &mut Buffer,
855 registry: &mut ClickRegionRegistry<TabViewAction>,
856 ) {
857 let regions = self.render_stateful(area, buf);
858 for (rect, action) in regions {
859 registry.register(rect, action);
860 }
861 }
862}
863
864impl<'a, F> Widget for TabView<'a, F>
865where
866 F: Fn(usize, Rect, &mut Buffer),
867{
868 fn render(self, area: Rect, buf: &mut Buffer) {
869 let _ = self.render_stateful(area, buf);
870 }
871}
872
873pub fn handle_tab_view_key(
877 state: &mut TabViewState,
878 key: &KeyEvent,
879 position: TabPosition,
880) -> bool {
881 if state.tab_bar_focused {
883 match key.code {
884 KeyCode::Left if position.is_horizontal() => {
886 state.select_prev();
887 true
888 }
889 KeyCode::Right if position.is_horizontal() => {
890 state.select_next();
891 true
892 }
893 KeyCode::Up if position.is_vertical() => {
895 state.select_prev();
896 true
897 }
898 KeyCode::Down if position.is_vertical() => {
899 state.select_next();
900 true
901 }
902 KeyCode::Home => {
904 state.select_first();
905 true
906 }
907 KeyCode::End => {
908 state.select_last();
909 true
910 }
911 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
913 let idx = (c as usize) - ('1' as usize);
914 if idx < state.total_tabs {
915 state.select(idx);
916 }
917 true
918 }
919 KeyCode::Enter => {
921 state.toggle_focus();
922 true
923 }
924 _ => false,
925 }
926 } else {
927 match key.code {
929 KeyCode::Esc => {
930 state.toggle_focus();
931 true
932 }
933 _ => false,
934 }
935 }
936}
937
938pub fn handle_tab_view_mouse(
942 state: &mut TabViewState,
943 registry: &ClickRegionRegistry<TabViewAction>,
944 mouse: &MouseEvent,
945) -> Option<TabViewAction> {
946 use crossterm::event::{MouseButton, MouseEventKind};
947
948 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
949 if let Some(action) = registry.handle_click(mouse.column, mouse.row) {
950 match action {
951 TabViewAction::TabClick(idx) => {
952 state.select(*idx);
953 state.tab_bar_focused = true;
954 return Some(*action);
955 }
956 TabViewAction::ScrollPrev => {
957 if state.scroll_offset > 0 {
958 state.scroll_offset -= 1;
959 }
960 return Some(*action);
961 }
962 TabViewAction::ScrollNext => {
963 state.scroll_offset += 1;
964 return Some(*action);
965 }
966 }
967 }
968 }
969
970 None
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976
977 #[test]
978 fn test_tab_creation() {
979 let tab = Tab::new("Test").icon("🔧").badge("5").enabled(true);
980
981 assert_eq!(tab.label, "Test");
982 assert_eq!(tab.icon, Some("🔧"));
983 assert_eq!(tab.badge, Some("5"));
984 assert!(tab.enabled);
985 }
986
987 #[test]
988 fn test_tab_display_width() {
989 let simple = Tab::new("Test");
990 assert_eq!(simple.display_width(), 6);
992
993 let with_icon = Tab::new("Test").icon("⚙");
994 assert_eq!(with_icon.display_width(), 8);
996
997 let with_badge = Tab::new("Test").badge("3");
998 assert_eq!(with_badge.display_width(), 9);
1000 }
1001
1002 #[test]
1003 fn test_state_navigation() {
1004 let mut state = TabViewState::new(5);
1005 assert_eq!(state.selected_index, 0);
1006
1007 state.select_next();
1008 assert_eq!(state.selected_index, 1);
1009
1010 state.select_prev();
1011 assert_eq!(state.selected_index, 0);
1012
1013 state.select_prev(); assert_eq!(state.selected_index, 0);
1015
1016 state.select_last();
1017 assert_eq!(state.selected_index, 4);
1018
1019 state.select_next(); assert_eq!(state.selected_index, 4);
1021
1022 state.select_first();
1023 assert_eq!(state.selected_index, 0);
1024 }
1025
1026 #[test]
1027 fn test_state_direct_select() {
1028 let mut state = TabViewState::new(5);
1029
1030 state.select(3);
1031 assert_eq!(state.selected_index, 3);
1032
1033 state.select(10); assert_eq!(state.selected_index, 3);
1035 }
1036
1037 #[test]
1038 fn test_state_focus_toggle() {
1039 let mut state = TabViewState::new(3);
1040 assert!(state.tab_bar_focused);
1041
1042 state.toggle_focus();
1043 assert!(!state.tab_bar_focused);
1044
1045 state.toggle_focus();
1046 assert!(state.tab_bar_focused);
1047 }
1048
1049 #[test]
1050 fn test_ensure_visible() {
1051 let mut state = TabViewState::new(20);
1052 state.selected_index = 15;
1053 state.ensure_visible(10);
1054 assert!(state.scroll_offset >= 6); }
1056
1057 #[test]
1058 fn test_tab_position() {
1059 assert!(TabPosition::Top.is_horizontal());
1060 assert!(TabPosition::Bottom.is_horizontal());
1061 assert!(TabPosition::Left.is_vertical());
1062 assert!(TabPosition::Right.is_vertical());
1063
1064 assert!(!TabPosition::Top.is_vertical());
1065 assert!(!TabPosition::Left.is_horizontal());
1066 }
1067
1068 #[test]
1069 fn test_style_presets() {
1070 let top = TabViewStyle::top();
1071 assert_eq!(top.position, TabPosition::Top);
1072
1073 let bottom = TabViewStyle::bottom();
1074 assert_eq!(bottom.position, TabPosition::Bottom);
1075
1076 let left = TabViewStyle::left();
1077 assert_eq!(left.position, TabPosition::Left);
1078 assert!(left.tab_width.is_some());
1079
1080 let right = TabViewStyle::right();
1081 assert_eq!(right.position, TabPosition::Right);
1082 }
1083
1084 #[test]
1085 fn test_focusable_impl() {
1086 let mut state = TabViewState::with_focus_id(3, FocusId::new(42));
1087
1088 assert_eq!(state.focus_id().id(), 42);
1089 assert!(!state.is_focused());
1090
1091 state.set_focused(true);
1092 assert!(state.is_focused());
1093 }
1094
1095 #[test]
1096 fn test_tab_view_render() {
1097 let tabs = vec![Tab::new("Tab 1"), Tab::new("Tab 2"), Tab::new("Tab 3")];
1098 let state = TabViewState::new(tabs.len());
1099 let tab_view = TabView::new(&tabs, &state);
1100
1101 let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
1102 tab_view.render(Rect::new(0, 0, 50, 10), &mut buf);
1103 }
1105
1106 #[test]
1107 fn test_key_handling_horizontal() {
1108 let mut state = TabViewState::new(5);
1109
1110 let key = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
1112 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1113 assert_eq!(state.selected_index, 1);
1114
1115 let key = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
1117 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1118 assert_eq!(state.selected_index, 0);
1119
1120 let key = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
1122 state.select(3);
1123 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1124 assert_eq!(state.selected_index, 0);
1125
1126 let key = KeyEvent::new(KeyCode::End, crossterm::event::KeyModifiers::NONE);
1128 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1129 assert_eq!(state.selected_index, 4);
1130 }
1131
1132 #[test]
1133 fn test_key_handling_vertical() {
1134 let mut state = TabViewState::new(5);
1135
1136 let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
1138 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1139 assert_eq!(state.selected_index, 1);
1140
1141 let key = KeyEvent::new(KeyCode::Up, crossterm::event::KeyModifiers::NONE);
1143 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1144 assert_eq!(state.selected_index, 0);
1145 }
1146
1147 #[test]
1148 fn test_number_key_selection() {
1149 let mut state = TabViewState::new(5);
1150
1151 let key = KeyEvent::new(KeyCode::Char('3'), crossterm::event::KeyModifiers::NONE);
1153 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1154 assert_eq!(state.selected_index, 2);
1155
1156 let key = KeyEvent::new(KeyCode::Char('1'), crossterm::event::KeyModifiers::NONE);
1158 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1159 assert_eq!(state.selected_index, 0);
1160 }
1161
1162 #[test]
1163 fn test_focus_toggle_with_enter() {
1164 let mut state = TabViewState::new(3);
1165 assert!(state.tab_bar_focused);
1166
1167 let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
1169 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1170 assert!(!state.tab_bar_focused);
1171
1172 let key = KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE);
1174 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1175 assert!(state.tab_bar_focused);
1176 }
1177}