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 TabViewStyle {
326 pub fn top() -> Self {
328 Self::default()
329 }
330
331 pub fn bottom() -> Self {
333 Self {
334 position: TabPosition::Bottom,
335 ..Default::default()
336 }
337 }
338
339 pub fn left() -> Self {
341 Self {
342 position: TabPosition::Left,
343 tab_width: Some(16),
344 divider: "",
345 ..Default::default()
346 }
347 }
348
349 pub fn right() -> Self {
351 Self {
352 position: TabPosition::Right,
353 tab_width: Some(16),
354 divider: "",
355 ..Default::default()
356 }
357 }
358
359 pub fn minimal() -> Self {
361 Self {
362 bordered_content: false,
363 show_indicator: false,
364 divider: " ",
365 ..Default::default()
366 }
367 }
368
369 pub fn position(mut self, position: TabPosition) -> Self {
371 self.position = position;
372 self
373 }
374
375 pub fn tab_width(mut self, width: u16) -> Self {
377 self.tab_width = Some(width);
378 self
379 }
380
381 pub fn tab_height(mut self, height: u16) -> Self {
383 self.tab_height = height;
384 self
385 }
386
387 pub fn bordered_content(mut self, bordered: bool) -> Self {
389 self.bordered_content = bordered;
390 self
391 }
392
393 pub fn selected_style(mut self, style: Style) -> Self {
395 self.selected_style = style;
396 self
397 }
398
399 pub fn normal_style(mut self, style: Style) -> Self {
401 self.normal_style = style;
402 self
403 }
404
405 pub fn divider(mut self, divider: &'static str) -> Self {
407 self.divider = divider;
408 self
409 }
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum TabViewAction {
415 TabClick(usize),
417 ScrollPrev,
419 ScrollNext,
421}
422
423type DefaultContentRenderer = fn(usize, Rect, &mut Buffer);
425
426pub struct TabView<'a, F = DefaultContentRenderer>
430where
431 F: Fn(usize, Rect, &mut Buffer),
432{
433 tabs: &'a [Tab<'a>],
434 state: &'a TabViewState,
435 style: TabViewStyle,
436 content_renderer: Option<F>,
437}
438
439impl<'a> TabView<'a, DefaultContentRenderer> {
440 pub fn new(tabs: &'a [Tab<'a>], state: &'a TabViewState) -> Self {
442 Self {
443 tabs,
444 state,
445 style: TabViewStyle::default(),
446 content_renderer: None,
447 }
448 }
449}
450
451impl<'a, F> TabView<'a, F>
452where
453 F: Fn(usize, Rect, &mut Buffer),
454{
455 pub fn style(mut self, style: TabViewStyle) -> Self {
457 self.style = style;
458 self
459 }
460
461 pub fn content<G>(self, renderer: G) -> TabView<'a, G>
465 where
466 G: Fn(usize, Rect, &mut Buffer),
467 {
468 TabView {
469 tabs: self.tabs,
470 state: self.state,
471 style: self.style,
472 content_renderer: Some(renderer),
473 }
474 }
475
476 fn calculate_layout(&self, area: Rect) -> (Rect, Rect) {
478 let (direction, constraints) = match self.style.position {
479 TabPosition::Top => (
480 Direction::Vertical,
481 [
482 Constraint::Length(self.style.tab_height),
483 Constraint::Min(1),
484 ],
485 ),
486 TabPosition::Bottom => (
487 Direction::Vertical,
488 [
489 Constraint::Min(1),
490 Constraint::Length(self.style.tab_height),
491 ],
492 ),
493 TabPosition::Left => {
494 let width = self.style.tab_width.unwrap_or(16);
495 (
496 Direction::Horizontal,
497 [Constraint::Length(width), Constraint::Min(1)],
498 )
499 }
500 TabPosition::Right => {
501 let width = self.style.tab_width.unwrap_or(16);
502 (
503 Direction::Horizontal,
504 [Constraint::Min(1), Constraint::Length(width)],
505 )
506 }
507 };
508
509 let chunks = Layout::default()
510 .direction(direction)
511 .constraints(constraints)
512 .split(area);
513
514 match self.style.position {
515 TabPosition::Top | TabPosition::Left => (chunks[0], chunks[1]),
516 TabPosition::Bottom | TabPosition::Right => (chunks[1], chunks[0]),
517 }
518 }
519
520 fn render_tab_bar(&self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
522 let mut click_regions = Vec::new();
523
524 if self.style.position.is_horizontal() {
525 self.render_horizontal_tabs(area, buf, &mut click_regions);
526 } else {
527 self.render_vertical_tabs(area, buf, &mut click_regions);
528 }
529
530 click_regions
531 }
532
533 fn render_horizontal_tabs(
535 &self,
536 area: Rect,
537 buf: &mut Buffer,
538 click_regions: &mut Vec<(Rect, TabViewAction)>,
539 ) {
540 let mut x = area.x;
541 let y = area.y;
542
543 let has_overflow = self.calculate_overflow_horizontal(area.width);
545 let show_prev = self.state.scroll_offset > 0;
546 let show_next = has_overflow
547 && self.state.scroll_offset + self.visible_tabs_horizontal(area.width)
548 < self.tabs.len();
549
550 if show_prev {
552 let indicator = self.style.scroll_left;
553 let indicator_area = Rect::new(x, y, 2, 1);
554 buf.set_string(x, y, indicator, Style::default().fg(Color::Yellow));
555 click_regions.push((indicator_area, TabViewAction::ScrollPrev));
556 x += 2;
557 }
558
559 let visible_start = self.state.scroll_offset;
561 let visible_count = self.visible_tabs_horizontal(
562 area.width
563 .saturating_sub(if show_prev { 2 } else { 0 })
564 .saturating_sub(if show_next { 2 } else { 0 }),
565 );
566
567 for (idx, tab) in self
568 .tabs
569 .iter()
570 .enumerate()
571 .skip(visible_start)
572 .take(visible_count)
573 {
574 let tab_start_x = x;
576
577 let mut text = String::new();
579 if let Some(icon) = tab.icon {
580 text.push_str(icon);
581 text.push(' ');
582 }
583 text.push_str(tab.label);
584
585 let style = self.get_tab_style(idx, tab.enabled);
587
588 let text_with_padding = if self.state.selected_index == idx && self.style.show_indicator
590 {
591 format!("{} {} ", self.style.indicator, text)
592 } else {
593 format!(" {} ", text)
594 };
595
596 let text_width = text_with_padding.width() as u16;
598 buf.set_string(x, y, &text_with_padding, style);
599 x += text_width;
600
601 if let Some(badge) = tab.badge {
603 let badge_text = format!(" {} ", badge);
604 let badge_width = badge_text.width() as u16;
605 buf.set_string(x, y, &badge_text, self.style.badge_style);
606 x += badge_width;
607 }
608
609 let tab_width = x - tab_start_x;
611 if tab_width > 0 {
612 let tab_area = Rect::new(tab_start_x, y, tab_width, 1);
613 click_regions.push((tab_area, TabViewAction::TabClick(idx)));
614 }
615
616 if idx + 1 < visible_start + visible_count && idx + 1 < self.tabs.len() {
618 let divider_width = self.style.divider.width() as u16;
619 buf.set_string(
620 x,
621 y,
622 self.style.divider,
623 Style::default().fg(Color::DarkGray),
624 );
625 x += divider_width;
626 }
627 }
628
629 if show_next {
631 let indicator = self.style.scroll_right;
632 let indicator_x = area.x + area.width - 2;
633 let indicator_area = Rect::new(indicator_x, y, 2, 1);
634 buf.set_string(
635 indicator_x,
636 y,
637 indicator,
638 Style::default().fg(Color::Yellow),
639 );
640 click_regions.push((indicator_area, TabViewAction::ScrollNext));
641 }
642 }
643
644 fn render_vertical_tabs(
646 &self,
647 area: Rect,
648 buf: &mut Buffer,
649 click_regions: &mut Vec<(Rect, TabViewAction)>,
650 ) {
651 let x = area.x;
652 let mut y = area.y;
653 let width = area.width;
654
655 let visible_count = (area.height as usize).min(self.tabs.len());
657 let show_prev = self.state.scroll_offset > 0;
658 let show_next = self.state.scroll_offset + visible_count < self.tabs.len();
659
660 if show_prev {
662 let indicator = format!("{:^width$}", self.style.scroll_up, width = width as usize);
663 buf.set_string(x, y, &indicator, Style::default().fg(Color::Yellow));
664 click_regions.push((Rect::new(x, y, width, 1), TabViewAction::ScrollPrev));
665 y += 1;
666 }
667
668 let available_height = area
670 .height
671 .saturating_sub(if show_prev { 1 } else { 0 })
672 .saturating_sub(if show_next { 1 } else { 0 });
673 let visible_start = self.state.scroll_offset;
674 let visible_count = (available_height as usize).min(self.tabs.len() - visible_start);
675
676 for (idx, tab) in self
677 .tabs
678 .iter()
679 .enumerate()
680 .skip(visible_start)
681 .take(visible_count)
682 {
683 if y >= area.y + area.height - if show_next { 1 } else { 0 } {
684 break;
685 }
686
687 let mut text = String::new();
689 if self.state.selected_index == idx && self.style.show_indicator {
690 text.push_str(self.style.indicator);
691 text.push(' ');
692 } else {
693 text.push_str(" ");
694 }
695 if let Some(icon) = tab.icon {
696 text.push_str(icon);
697 text.push(' ');
698 }
699 text.push_str(tab.label);
700
701 if let Some(badge) = tab.badge {
703 text.push_str(&format!(" ({})", badge));
704 }
705
706 let max_len = width as usize;
708 let display_text = if text.chars().count() > max_len {
709 let truncated: String = text.chars().take(max_len - 1).collect();
710 format!("{}…", truncated)
711 } else {
712 format!("{:width$}", text, width = max_len)
713 };
714
715 let style = self.get_tab_style(idx, tab.enabled);
717
718 let tab_area = Rect::new(x, y, width, 1);
719 buf.set_string(x, y, &display_text, style);
720 click_regions.push((tab_area, TabViewAction::TabClick(idx)));
721
722 y += 1;
723 }
724
725 if show_next {
727 let indicator_y = area.y + area.height - 1;
728 let indicator = format!("{:^width$}", self.style.scroll_down, width = width as usize);
729 buf.set_string(
730 x,
731 indicator_y,
732 &indicator,
733 Style::default().fg(Color::Yellow),
734 );
735 click_regions.push((
736 Rect::new(x, indicator_y, width, 1),
737 TabViewAction::ScrollNext,
738 ));
739 }
740 }
741
742 fn get_tab_style(&self, idx: usize, enabled: bool) -> Style {
744 if !enabled {
745 self.style.disabled_style
746 } else if idx == self.state.selected_index
747 && self.state.focused
748 && self.state.tab_bar_focused
749 {
750 self.style.focused_style
751 } else if idx == self.state.selected_index {
752 self.style.selected_style
753 } else {
754 self.style.normal_style
755 }
756 }
757
758 fn calculate_overflow_horizontal(&self, available_width: u16) -> bool {
760 let total_width: u16 = self
761 .tabs
762 .iter()
763 .map(|t| t.display_width() as u16 + self.style.divider.width() as u16)
764 .sum();
765 total_width > available_width
766 }
767
768 fn visible_tabs_horizontal(&self, available_width: u16) -> usize {
770 let mut width = 0u16;
771 let mut count = 0;
772 for tab in self.tabs.iter().skip(self.state.scroll_offset) {
773 let tab_width = tab.display_width() as u16 + self.style.divider.width() as u16;
774 if width + tab_width > available_width {
775 break;
776 }
777 width += tab_width;
778 count += 1;
779 }
780 count.max(1)
781 }
782
783 fn render_content(&self, area: Rect, buf: &mut Buffer) {
785 let inner = if self.style.bordered_content {
786 let block = Block::default()
787 .borders(Borders::ALL)
788 .border_style(self.style.content_border_style);
789 let inner = block.inner(area);
790 block.render(area, buf);
791 inner
792 } else {
793 area
794 };
795
796 if let Some(ref renderer) = self.content_renderer {
797 renderer(self.state.selected_index, inner, buf);
798 }
799 }
800
801 pub fn render_stateful(self, area: Rect, buf: &mut Buffer) -> Vec<(Rect, TabViewAction)> {
803 let (tab_area, content_area) = self.calculate_layout(area);
804
805 let click_regions = self.render_tab_bar(tab_area, buf);
807
808 self.render_content(content_area, buf);
810
811 click_regions
812 }
813
814 pub fn render_with_registry(
816 self,
817 area: Rect,
818 buf: &mut Buffer,
819 registry: &mut ClickRegionRegistry<TabViewAction>,
820 ) {
821 let regions = self.render_stateful(area, buf);
822 for (rect, action) in regions {
823 registry.register(rect, action);
824 }
825 }
826}
827
828impl<'a, F> Widget for TabView<'a, F>
829where
830 F: Fn(usize, Rect, &mut Buffer),
831{
832 fn render(self, area: Rect, buf: &mut Buffer) {
833 let _ = self.render_stateful(area, buf);
834 }
835}
836
837pub fn handle_tab_view_key(
841 state: &mut TabViewState,
842 key: &KeyEvent,
843 position: TabPosition,
844) -> bool {
845 if state.tab_bar_focused {
847 match key.code {
848 KeyCode::Left if position.is_horizontal() => {
850 state.select_prev();
851 true
852 }
853 KeyCode::Right if position.is_horizontal() => {
854 state.select_next();
855 true
856 }
857 KeyCode::Up if position.is_vertical() => {
859 state.select_prev();
860 true
861 }
862 KeyCode::Down if position.is_vertical() => {
863 state.select_next();
864 true
865 }
866 KeyCode::Home => {
868 state.select_first();
869 true
870 }
871 KeyCode::End => {
872 state.select_last();
873 true
874 }
875 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
877 let idx = (c as usize) - ('1' as usize);
878 if idx < state.total_tabs {
879 state.select(idx);
880 }
881 true
882 }
883 KeyCode::Enter => {
885 state.toggle_focus();
886 true
887 }
888 _ => false,
889 }
890 } else {
891 match key.code {
893 KeyCode::Esc => {
894 state.toggle_focus();
895 true
896 }
897 _ => false,
898 }
899 }
900}
901
902pub fn handle_tab_view_mouse(
906 state: &mut TabViewState,
907 registry: &ClickRegionRegistry<TabViewAction>,
908 mouse: &MouseEvent,
909) -> Option<TabViewAction> {
910 use crossterm::event::{MouseButton, MouseEventKind};
911
912 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
913 if let Some(action) = registry.handle_click(mouse.column, mouse.row) {
914 match action {
915 TabViewAction::TabClick(idx) => {
916 state.select(*idx);
917 state.tab_bar_focused = true;
918 return Some(*action);
919 }
920 TabViewAction::ScrollPrev => {
921 if state.scroll_offset > 0 {
922 state.scroll_offset -= 1;
923 }
924 return Some(*action);
925 }
926 TabViewAction::ScrollNext => {
927 state.scroll_offset += 1;
928 return Some(*action);
929 }
930 }
931 }
932 }
933
934 None
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940
941 #[test]
942 fn test_tab_creation() {
943 let tab = Tab::new("Test").icon("🔧").badge("5").enabled(true);
944
945 assert_eq!(tab.label, "Test");
946 assert_eq!(tab.icon, Some("🔧"));
947 assert_eq!(tab.badge, Some("5"));
948 assert!(tab.enabled);
949 }
950
951 #[test]
952 fn test_tab_display_width() {
953 let simple = Tab::new("Test");
954 assert_eq!(simple.display_width(), 6);
956
957 let with_icon = Tab::new("Test").icon("⚙");
958 assert_eq!(with_icon.display_width(), 8);
960
961 let with_badge = Tab::new("Test").badge("3");
962 assert_eq!(with_badge.display_width(), 9);
964 }
965
966 #[test]
967 fn test_state_navigation() {
968 let mut state = TabViewState::new(5);
969 assert_eq!(state.selected_index, 0);
970
971 state.select_next();
972 assert_eq!(state.selected_index, 1);
973
974 state.select_prev();
975 assert_eq!(state.selected_index, 0);
976
977 state.select_prev(); assert_eq!(state.selected_index, 0);
979
980 state.select_last();
981 assert_eq!(state.selected_index, 4);
982
983 state.select_next(); assert_eq!(state.selected_index, 4);
985
986 state.select_first();
987 assert_eq!(state.selected_index, 0);
988 }
989
990 #[test]
991 fn test_state_direct_select() {
992 let mut state = TabViewState::new(5);
993
994 state.select(3);
995 assert_eq!(state.selected_index, 3);
996
997 state.select(10); assert_eq!(state.selected_index, 3);
999 }
1000
1001 #[test]
1002 fn test_state_focus_toggle() {
1003 let mut state = TabViewState::new(3);
1004 assert!(state.tab_bar_focused);
1005
1006 state.toggle_focus();
1007 assert!(!state.tab_bar_focused);
1008
1009 state.toggle_focus();
1010 assert!(state.tab_bar_focused);
1011 }
1012
1013 #[test]
1014 fn test_ensure_visible() {
1015 let mut state = TabViewState::new(20);
1016 state.selected_index = 15;
1017 state.ensure_visible(10);
1018 assert!(state.scroll_offset >= 6); }
1020
1021 #[test]
1022 fn test_tab_position() {
1023 assert!(TabPosition::Top.is_horizontal());
1024 assert!(TabPosition::Bottom.is_horizontal());
1025 assert!(TabPosition::Left.is_vertical());
1026 assert!(TabPosition::Right.is_vertical());
1027
1028 assert!(!TabPosition::Top.is_vertical());
1029 assert!(!TabPosition::Left.is_horizontal());
1030 }
1031
1032 #[test]
1033 fn test_style_presets() {
1034 let top = TabViewStyle::top();
1035 assert_eq!(top.position, TabPosition::Top);
1036
1037 let bottom = TabViewStyle::bottom();
1038 assert_eq!(bottom.position, TabPosition::Bottom);
1039
1040 let left = TabViewStyle::left();
1041 assert_eq!(left.position, TabPosition::Left);
1042 assert!(left.tab_width.is_some());
1043
1044 let right = TabViewStyle::right();
1045 assert_eq!(right.position, TabPosition::Right);
1046 }
1047
1048 #[test]
1049 fn test_focusable_impl() {
1050 let mut state = TabViewState::with_focus_id(3, FocusId::new(42));
1051
1052 assert_eq!(state.focus_id().id(), 42);
1053 assert!(!state.is_focused());
1054
1055 state.set_focused(true);
1056 assert!(state.is_focused());
1057 }
1058
1059 #[test]
1060 fn test_tab_view_render() {
1061 let tabs = vec![Tab::new("Tab 1"), Tab::new("Tab 2"), Tab::new("Tab 3")];
1062 let state = TabViewState::new(tabs.len());
1063 let tab_view = TabView::new(&tabs, &state);
1064
1065 let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
1066 tab_view.render(Rect::new(0, 0, 50, 10), &mut buf);
1067 }
1069
1070 #[test]
1071 fn test_key_handling_horizontal() {
1072 let mut state = TabViewState::new(5);
1073
1074 let key = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
1076 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1077 assert_eq!(state.selected_index, 1);
1078
1079 let key = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
1081 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1082 assert_eq!(state.selected_index, 0);
1083
1084 let key = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
1086 state.select(3);
1087 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1088 assert_eq!(state.selected_index, 0);
1089
1090 let key = KeyEvent::new(KeyCode::End, crossterm::event::KeyModifiers::NONE);
1092 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1093 assert_eq!(state.selected_index, 4);
1094 }
1095
1096 #[test]
1097 fn test_key_handling_vertical() {
1098 let mut state = TabViewState::new(5);
1099
1100 let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
1102 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1103 assert_eq!(state.selected_index, 1);
1104
1105 let key = KeyEvent::new(KeyCode::Up, crossterm::event::KeyModifiers::NONE);
1107 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Left));
1108 assert_eq!(state.selected_index, 0);
1109 }
1110
1111 #[test]
1112 fn test_number_key_selection() {
1113 let mut state = TabViewState::new(5);
1114
1115 let key = KeyEvent::new(KeyCode::Char('3'), crossterm::event::KeyModifiers::NONE);
1117 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1118 assert_eq!(state.selected_index, 2);
1119
1120 let key = KeyEvent::new(KeyCode::Char('1'), crossterm::event::KeyModifiers::NONE);
1122 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1123 assert_eq!(state.selected_index, 0);
1124 }
1125
1126 #[test]
1127 fn test_focus_toggle_with_enter() {
1128 let mut state = TabViewState::new(3);
1129 assert!(state.tab_bar_focused);
1130
1131 let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
1133 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1134 assert!(!state.tab_bar_focused);
1135
1136 let key = KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE);
1138 assert!(handle_tab_view_key(&mut state, &key, TabPosition::Top));
1139 assert!(state.tab_bar_focused);
1140 }
1141}