1#![forbid(unsafe_code)]
2
3use crate::mouse::MouseResult;
9use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
10use ftui_core::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
11use ftui_core::geometry::Rect;
12use ftui_render::frame::{Frame, HitId, HitRegion};
13use ftui_style::Style;
14use ftui_text::display_width;
15#[cfg(feature = "tracing")]
16use web_time::Instant;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Tab<'a> {
21 title: String,
22 style: Style,
23 closable: bool,
24 _marker: std::marker::PhantomData<&'a ()>,
25}
26
27impl<'a> Tab<'a> {
28 #[must_use]
30 pub fn new(title: impl Into<String>) -> Self {
31 Self {
32 title: title.into(),
33 style: Style::default(),
34 closable: false,
35 _marker: std::marker::PhantomData,
36 }
37 }
38
39 #[must_use]
41 pub fn style(mut self, style: Style) -> Self {
42 self.style = style;
43 self
44 }
45
46 #[must_use]
48 pub fn closable(mut self, closable: bool) -> Self {
49 self.closable = closable;
50 self
51 }
52
53 #[must_use]
55 pub fn title(&self) -> &str {
56 &self.title
57 }
58
59 #[must_use]
61 pub const fn is_closable(&self) -> bool {
62 self.closable
63 }
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct TabsState {
69 pub active: usize,
71 pub offset: usize,
73}
74
75impl TabsState {
76 pub fn select(&mut self, index: usize, tab_count: usize) -> bool {
78 if tab_count == 0 {
79 self.active = 0;
80 self.offset = 0;
81 return false;
82 }
83 let next = index.min(tab_count.saturating_sub(1));
84 if self.active == next {
85 return false;
86 }
87 #[cfg(feature = "tracing")]
88 let old = self.active;
89 self.active = next;
90 if self.active < self.offset {
91 self.offset = self.active;
92 }
93 #[cfg(feature = "tracing")]
94 Self::log_switch("select", old, self.active);
95 true
96 }
97
98 pub fn next(&mut self, tab_count: usize) -> bool {
100 if tab_count == 0 {
101 return false;
102 }
103 self.select(
104 self.active
105 .saturating_add(1)
106 .min(tab_count.saturating_sub(1)),
107 tab_count,
108 )
109 }
110
111 pub fn previous(&mut self, tab_count: usize) -> bool {
113 if tab_count == 0 {
114 return false;
115 }
116 self.select(self.active.saturating_sub(1), tab_count)
117 }
118
119 pub fn handle_key(&mut self, key: &KeyEvent, tab_count: usize) -> bool {
125 match key.code {
126 KeyCode::Left => self.previous(tab_count),
127 KeyCode::Right => self.next(tab_count),
128 KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {
129 let idx = ch as usize - '1' as usize;
130 if idx >= tab_count {
131 false
132 } else {
133 self.select(idx, tab_count)
134 }
135 }
136 _ => false,
137 }
138 }
139
140 pub fn handle_mouse(
144 &mut self,
145 event: &MouseEvent,
146 hit: Option<(HitId, HitRegion, u64)>,
147 expected_id: HitId,
148 tab_count: usize,
149 ) -> MouseResult {
150 match event.kind {
151 MouseEventKind::Down(MouseButton::Left) => {
152 if let Some((id, HitRegion::Content, data)) = hit
153 && id == expected_id
154 {
155 let idx = data as usize;
156 if idx < tab_count {
157 if self.active == idx {
158 return MouseResult::Activated(idx);
159 }
160 self.select(idx, tab_count);
161 return MouseResult::Selected(idx);
162 }
163 }
164 MouseResult::Ignored
165 }
166 _ => MouseResult::Ignored,
167 }
168 }
169
170 #[cfg(feature = "tracing")]
171 fn log_switch(reason: &str, from: usize, to: usize) {
172 tracing::debug!(message = "tabs.switch", reason, from, to);
173 }
174}
175
176#[derive(Debug, Clone, Default)]
178pub struct Tabs<'a> {
179 tabs: Vec<Tab<'a>>,
180 style: Style,
181 active_style: Style,
182 separator: &'a str,
183 close_marker: &'a str,
184 overflow_left_marker: &'a str,
185 overflow_right_marker: &'a str,
186 hit_id: Option<HitId>,
187}
188
189impl<'a> Tabs<'a> {
190 #[must_use]
192 pub fn new(tabs: impl IntoIterator<Item = Tab<'a>>) -> Self {
193 Self {
194 tabs: tabs.into_iter().collect(),
195 style: Style::default(),
196 active_style: Style::default(),
197 separator: " ",
198 close_marker: " x",
199 overflow_left_marker: "<",
200 overflow_right_marker: ">",
201 hit_id: None,
202 }
203 }
204
205 #[must_use]
207 pub fn style(mut self, style: Style) -> Self {
208 self.style = style;
209 self
210 }
211
212 #[must_use]
214 pub fn active_style(mut self, style: Style) -> Self {
215 self.active_style = style;
216 self
217 }
218
219 #[must_use]
221 pub fn separator(mut self, separator: &'a str) -> Self {
222 self.separator = separator;
223 self
224 }
225
226 #[must_use]
228 pub fn hit_id(mut self, id: HitId) -> Self {
229 self.hit_id = Some(id);
230 self
231 }
232
233 #[must_use]
235 pub fn tabs(&self) -> &[Tab<'a>] {
236 &self.tabs
237 }
238
239 fn tab_label(&self, tab: &Tab<'_>, active: bool) -> String {
240 let mut out = String::new();
241 if active {
242 out.push('[');
243 } else {
244 out.push(' ');
245 }
246 out.push_str(tab.title());
247 if tab.is_closable() {
248 out.push_str(self.close_marker);
249 }
250 if active {
251 out.push(']');
252 } else {
253 out.push(' ');
254 }
255 out
256 }
257
258 fn visible_end(&self, state: &TabsState, width: usize) -> usize {
259 if self.tabs.is_empty() || width == 0 {
260 return state.offset;
261 }
262 let sep_width = display_width(self.separator);
263 let mut used = 0usize;
264 let mut end = state.offset;
265
266 for idx in state.offset..self.tabs.len() {
267 let w = display_width(
268 self.tab_label(&self.tabs[idx], idx == state.active)
269 .as_str(),
270 );
271 let extra = if idx == state.offset { 0 } else { sep_width };
272 if end == state.offset {
273 used = w;
275 end = idx + 1;
276 if used > width {
277 break;
278 }
279 continue;
280 }
281 if used.saturating_add(extra).saturating_add(w) > width {
282 break;
283 }
284 used = used.saturating_add(extra).saturating_add(w);
285 end = idx + 1;
286 }
287
288 end.max((state.offset + 1).min(self.tabs.len()))
289 }
290
291 fn compute_visible_range(
292 &self,
293 state: &mut TabsState,
294 area_width: usize,
295 ) -> (usize, usize, bool, bool) {
296 if self.tabs.is_empty() || area_width == 0 {
297 state.active = 0;
298 state.offset = 0;
299 return (0, 0, false, false);
300 }
301 state.active = state.active.min(self.tabs.len().saturating_sub(1));
302 state.offset = state.offset.min(self.tabs.len().saturating_sub(1));
303 if state.active < state.offset {
304 state.offset = state.active;
305 }
306
307 let left_marker_w = display_width(self.overflow_left_marker);
308 let right_marker_w = display_width(self.overflow_right_marker);
309
310 let mut available_width = area_width;
311 let mut start = state.offset;
312 let mut end = self.visible_end(state, available_width);
313
314 if state.active >= end {
316 start = state.active;
317 state.offset = start;
318 end = self.visible_end(state, available_width);
319 }
320
321 for _ in 0..3 {
323 let overflow_left = start > 0;
324 let overflow_right = end < self.tabs.len();
325
326 let mut next_width = area_width;
327 if overflow_left {
328 next_width = next_width.saturating_sub(left_marker_w);
329 }
330 if overflow_right {
331 next_width = next_width.saturating_sub(right_marker_w);
332 }
333
334 if next_width == available_width {
335 break;
336 }
337 available_width = next_width;
338
339 end = self.visible_end(state, available_width);
341
342 if state.active >= end {
344 start = state.active;
345 state.offset = start;
346 end = self.visible_end(state, available_width);
347 }
348 }
349
350 let overflow_left = start > 0;
351 let overflow_right = end < self.tabs.len();
352 (start, end, overflow_left, overflow_right)
353 }
354
355 pub fn close_active(&mut self, state: &mut TabsState) -> Option<Tab<'a>> {
357 if self.tabs.is_empty() {
358 state.active = 0;
359 state.offset = 0;
360 return None;
361 }
362 state.active = state.active.min(self.tabs.len().saturating_sub(1));
363 if !self.tabs[state.active].is_closable() {
364 return None;
365 }
366 let removed = self.tabs.remove(state.active);
367 if self.tabs.is_empty() {
368 state.active = 0;
369 state.offset = 0;
370 } else if state.active >= self.tabs.len() {
371 state.active = self.tabs.len().saturating_sub(1);
372 state.offset = state.offset.min(state.active);
373 }
374 Some(removed)
375 }
376
377 pub fn move_active_left(&mut self, state: &mut TabsState) -> bool {
379 if self.tabs.len() < 2 || state.active == 0 || state.active >= self.tabs.len() {
380 return false;
381 }
382 self.tabs.swap(state.active, state.active - 1);
383 state.active -= 1;
384 state.offset = state.offset.min(state.active);
385 true
386 }
387
388 pub fn move_active_right(&mut self, state: &mut TabsState) -> bool {
390 if self.tabs.len() < 2 || state.active + 1 >= self.tabs.len() {
391 return false;
392 }
393 self.tabs.swap(state.active, state.active + 1);
394 state.active += 1;
395 true
396 }
397}
398
399impl StatefulWidget for Tabs<'_> {
400 type State = TabsState;
401
402 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
403 #[cfg(feature = "tracing")]
404 let render_start = Instant::now();
405
406 if area.is_empty() || area.height == 0 {
407 return;
408 }
409
410 let deg = frame.buffer.degradation;
411 let base_style = if deg.apply_styling() {
412 self.style
413 } else {
414 Style::default()
415 };
416
417 clear_text_row(frame, area, base_style);
418
419 if !deg.render_content() || self.tabs.is_empty() {
420 return;
421 }
422
423 let (start, end, overflow_left, overflow_right) =
424 self.compute_visible_range(state, area.width as usize);
425
426 #[cfg(feature = "tracing")]
427 let tab_count = self.tabs.len();
428 #[cfg(feature = "tracing")]
429 let active_tab = state.active.min(self.tabs.len().saturating_sub(1));
430 #[cfg(feature = "tracing")]
431 let render_span = tracing::debug_span!(
432 "tabs.render",
433 tab_count,
434 active_tab,
435 overflow = overflow_left || overflow_right,
436 render_duration_us = tracing::field::Empty
437 );
438 #[cfg(feature = "tracing")]
439 let _render_guard = render_span.enter();
440
441 let mut left = area.x;
442 let mut right = area.right();
443 if overflow_left {
444 draw_text_span(
445 frame,
446 area.x,
447 area.y,
448 self.overflow_left_marker,
449 base_style,
450 area.right(),
451 );
452 left = left.saturating_add(display_width(self.overflow_left_marker) as u16);
453 }
454 if overflow_right {
455 right = right.saturating_sub(display_width(self.overflow_right_marker) as u16);
456 draw_text_span(
457 frame,
458 right,
459 area.y,
460 self.overflow_right_marker,
461 base_style,
462 area.right(),
463 );
464 }
465
466 let mut x = left;
467 for idx in start..end {
468 if x >= right {
469 break;
470 }
471 if idx > start && !self.separator.is_empty() {
472 x = draw_text_span(frame, x, area.y, self.separator, base_style, right);
473 if x >= right {
474 break;
475 }
476 }
477 let tab = &self.tabs[idx];
478 let label = self.tab_label(tab, idx == state.active);
479 let mut tab_style = base_style;
480 if deg.apply_styling() {
481 tab_style = self.style.merge(&tab.style);
482 if idx == state.active {
483 tab_style = self.active_style.merge(&tab_style);
484 }
485 }
486 let before = x;
487 x = draw_text_span(frame, x, area.y, &label, tab_style, right);
488 if let Some(id) = self.hit_id {
489 let width = x.saturating_sub(before).max(1);
490 frame.register_hit(
491 Rect::new(before, area.y, width, 1),
492 id,
493 HitRegion::Content,
494 idx as u64,
495 );
496 }
497 }
498
499 #[cfg(feature = "tracing")]
500 {
501 let elapsed_us = render_start.elapsed().as_micros() as u64;
502 render_span.record("render_duration_us", elapsed_us);
503 }
504 }
505}
506
507impl Widget for Tabs<'_> {
508 fn render(&self, area: Rect, frame: &mut Frame) {
509 let mut state = TabsState::default();
510 StatefulWidget::render(self, area, frame, &mut state);
511 }
512
513 fn is_essential(&self) -> bool {
514 true
515 }
516}
517
518impl ftui_a11y::Accessible for Tabs<'_> {
519 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
520 use ftui_a11y::node::{A11yNodeInfo, A11yRole};
521
522 let base_id = crate::a11y_node_id(area);
523 let tab_count = self.tabs.len();
524 let child_ids: Vec<u64> = (0..tab_count).map(|i| base_id + 1 + i as u64).collect();
525
526 let group_node = A11yNodeInfo::new(base_id, A11yRole::Group, area)
527 .with_name(format!("{tab_count} tabs"))
528 .with_children(child_ids);
529
530 let mut nodes = vec![group_node];
531 for (i, tab) in self.tabs.iter().enumerate() {
532 let tab_id = base_id + 1 + i as u64;
533 nodes.push(
534 A11yNodeInfo::new(tab_id, A11yRole::Tab, area)
535 .with_name(&tab.title)
536 .with_parent(base_id),
537 );
538 }
539 nodes
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use ftui_core::event::{KeyCode, KeyEvent};
547 use ftui_render::budget::DegradationLevel;
548 use ftui_render::grapheme_pool::GraphemePool;
549 #[cfg(feature = "tracing")]
550 use std::sync::{Arc, Mutex};
551 #[cfg(feature = "tracing")]
552 use tracing::Subscriber;
553 #[cfg(feature = "tracing")]
554 use tracing_subscriber::Layer;
555 #[cfg(feature = "tracing")]
556 use tracing_subscriber::layer::{Context, SubscriberExt};
557
558 fn row_text(frame: &Frame, y: u16) -> String {
559 let mut out = String::new();
560 for x in 0..frame.buffer.width() {
561 let ch = frame
562 .buffer
563 .get(x, y)
564 .and_then(|cell| cell.content.as_char())
565 .unwrap_or(' ');
566 out.push(ch);
567 }
568 out
569 }
570
571 #[test]
572 fn tabs_render_basic() {
573 let tabs = Tabs::new(vec![Tab::new("One"), Tab::new("Two"), Tab::new("Three")]);
574 let mut state = TabsState::default();
575 state.select(1, 3);
576 let mut pool = GraphemePool::new();
577 let mut frame = Frame::new(30, 1, &mut pool);
578 StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
579 let row = row_text(&frame, 0);
580 assert!(row.contains("[Two]"));
581 }
582
583 #[test]
584 fn tabs_keyboard_switching_arrows_and_numbers() {
585 let mut state = TabsState::default();
586 assert!(state.handle_key(&KeyEvent::new(KeyCode::Right), 4));
587 assert_eq!(state.active, 1);
588 assert!(state.handle_key(&KeyEvent::new(KeyCode::Left), 4));
589 assert_eq!(state.active, 0);
590 assert!(state.handle_key(&KeyEvent::new(KeyCode::Char('3')), 4));
591 assert_eq!(state.active, 2);
592 assert!(!state.handle_key(&KeyEvent::new(KeyCode::Char('9')), 4));
593 assert_eq!(state.active, 2);
594 }
595
596 #[test]
597 fn tabs_overflow_markers_render_when_needed() {
598 let tabs = Tabs::new((0..8).map(|i| Tab::new(format!("Tab{i}"))));
599 let mut state = TabsState::default();
600 state.select(0, 8);
601 let mut pool = GraphemePool::new();
602 let mut frame = Frame::new(12, 1, &mut pool);
603 StatefulWidget::render(&tabs, Rect::new(0, 0, 12, 1), &mut frame, &mut state);
604 assert_eq!(
605 frame.buffer.get(11, 0).and_then(|c| c.content.as_char()),
606 Some('>')
607 );
608
609 state.select(7, 8);
610 StatefulWidget::render(&tabs, Rect::new(0, 0, 12, 1), &mut frame, &mut state);
611 assert_eq!(
612 frame.buffer.get(0, 0).and_then(|c| c.content.as_char()),
613 Some('<')
614 );
615 }
616
617 #[test]
618 fn tabs_close_active_respects_closable() {
619 let mut tabs = Tabs::new(vec![
620 Tab::new("Pinned").closable(false),
621 Tab::new("Temp").closable(true),
622 ]);
623 let mut state = TabsState::default();
624 state.select(0, 2);
625 assert!(tabs.close_active(&mut state).is_none());
626 state.select(1, 2);
627 assert!(tabs.close_active(&mut state).is_some());
628 assert_eq!(tabs.tabs().len(), 1);
629 assert_eq!(tabs.tabs()[0].title(), "Pinned");
630 }
631
632 #[test]
633 fn tabs_reorder_active_left_and_right() {
634 let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
635 let mut state = TabsState::default();
636 state.select(1, 3);
637 assert!(tabs.move_active_left(&mut state));
638 assert_eq!(state.active, 0);
639 assert_eq!(tabs.tabs()[0].title(), "B");
640 assert!(tabs.move_active_right(&mut state));
641 assert_eq!(state.active, 1);
642 assert_eq!(tabs.tabs()[1].title(), "B");
643 }
644
645 #[test]
646 fn tabs_hit_regions_encode_tab_index() {
647 let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]).hit_id(HitId::new(5));
648 let mut state = TabsState::default();
649 let mut pool = GraphemePool::new();
650 let mut frame = Frame::with_hit_grid(20, 1, &mut pool);
651 StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
652 let hit_a = frame.hit_test(1, 0);
653 let hit_b = frame.hit_test(6, 0);
654 assert_eq!(hit_a.map(|(_, _, data)| data), Some(0));
655 assert_eq!(hit_b.map(|(_, _, data)| data), Some(1));
656 }
657
658 #[cfg(feature = "tracing")]
659 #[derive(Default)]
660 struct TabsTraceState {
661 saw_render_span: bool,
662 saw_switch_event: bool,
663 saw_duration_record: bool,
664 }
665
666 #[cfg(feature = "tracing")]
667 struct TabsTraceCapture {
668 state: Arc<Mutex<TabsTraceState>>,
669 }
670
671 #[cfg(feature = "tracing")]
672 impl<S> Layer<S> for TabsTraceCapture
673 where
674 S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
675 {
676 fn on_new_span(
677 &self,
678 attrs: &tracing::span::Attributes<'_>,
679 _id: &tracing::Id,
680 _ctx: Context<'_, S>,
681 ) {
682 if attrs.metadata().name() == "tabs.render" {
683 self.state.lock().expect("tabs trace lock").saw_render_span = true;
684 }
685 }
686
687 fn on_record(
688 &self,
689 id: &tracing::Id,
690 values: &tracing::span::Record<'_>,
691 ctx: Context<'_, S>,
692 ) {
693 let Some(span) = ctx.span(id) else {
694 return;
695 };
696 if span.metadata().name() != "tabs.render" {
697 return;
698 }
699 struct V {
700 saw: bool,
701 }
702 impl tracing::field::Visit for V {
703 fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
704 if field.name() == "render_duration_us" {
705 self.saw = true;
706 }
707 }
708
709 fn record_debug(
710 &mut self,
711 _field: &tracing::field::Field,
712 _value: &dyn std::fmt::Debug,
713 ) {
714 }
715 }
716 let mut v = V { saw: false };
717 values.record(&mut v);
718 if v.saw {
719 self.state
720 .lock()
721 .expect("tabs trace lock")
722 .saw_duration_record = true;
723 }
724 }
725
726 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
727 struct Msg {
728 message: Option<String>,
729 }
730 impl tracing::field::Visit for Msg {
731 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
732 if field.name() == "message" {
733 self.message = Some(value.to_string());
734 }
735 }
736
737 fn record_debug(
738 &mut self,
739 field: &tracing::field::Field,
740 value: &dyn std::fmt::Debug,
741 ) {
742 if field.name() == "message" {
743 self.message = Some(format!("{value:?}").trim_matches('"').to_string());
744 }
745 }
746 }
747 let mut msg = Msg { message: None };
748 event.record(&mut msg);
749 if msg.message.as_deref() == Some("tabs.switch") {
750 self.state.lock().expect("tabs trace lock").saw_switch_event = true;
751 }
752 }
753 }
754
755 #[cfg(feature = "tracing")]
756 #[test]
757 fn tabs_tracing_span_and_switch_event_emitted() {
758 let state = Arc::new(Mutex::new(TabsTraceState::default()));
759 let _trace_test_guard = crate::tracing_test_support::acquire();
760 let subscriber = tracing_subscriber::registry().with(TabsTraceCapture {
761 state: Arc::clone(&state),
762 });
763 let _guard = tracing::subscriber::set_default(subscriber);
764
765 let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
766 let mut tabs_state = TabsState::default();
767 let mut pool = GraphemePool::new();
768 let mut frame = Frame::new(20, 1, &mut pool);
769 tracing::callsite::rebuild_interest_cache();
770 StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut tabs_state);
771 tracing::callsite::rebuild_interest_cache();
772 assert!(tabs_state.handle_key(&KeyEvent::new(KeyCode::Right), 3));
773 tracing::callsite::rebuild_interest_cache();
774
775 let snapshot = state.lock().expect("tabs trace lock");
776 assert!(snapshot.saw_render_span, "expected tabs.render span");
777 assert!(
778 snapshot.saw_duration_record,
779 "expected render_duration_us record"
780 );
781 assert!(
782 snapshot.saw_switch_event,
783 "expected tabs.switch debug event"
784 );
785 }
786
787 #[test]
790 fn tabs_select_zero_count_resets() {
791 let mut state = TabsState {
792 active: 3,
793 offset: 2,
794 };
795 assert!(!state.select(0, 0));
796 assert_eq!(state.active, 0);
797 assert_eq!(state.offset, 0);
798 }
799
800 #[test]
801 fn tabs_select_same_tab_returns_false() {
802 let mut state = TabsState::default();
803 state.select(2, 5);
804 assert!(!state.select(2, 5));
805 }
806
807 #[test]
808 fn tabs_select_out_of_range_clamps() {
809 let mut state = TabsState::default();
810 assert!(state.select(100, 5));
811 assert_eq!(state.active, 4); }
813
814 #[test]
815 fn tabs_select_updates_offset_when_active_before_offset() {
816 let mut state = TabsState {
817 active: 3,
818 offset: 3,
819 };
820 assert!(state.select(1, 5));
821 assert_eq!(state.active, 1);
822 assert_eq!(state.offset, 1); }
824
825 #[test]
826 fn tabs_next_at_last_tab_returns_false() {
827 let mut state = TabsState::default();
828 state.select(4, 5);
829 assert!(!state.next(5));
830 assert_eq!(state.active, 4);
831 }
832
833 #[test]
834 fn tabs_next_empty_returns_false() {
835 let mut state = TabsState::default();
836 assert!(!state.next(0));
837 }
838
839 #[test]
840 fn tabs_previous_at_first_tab_returns_false() {
841 let mut state = TabsState::default();
842 assert!(!state.previous(5));
843 assert_eq!(state.active, 0);
844 }
845
846 #[test]
847 fn tabs_previous_empty_returns_false() {
848 let mut state = TabsState::default();
849 assert!(!state.previous(0));
850 }
851
852 #[test]
853 fn tabs_handle_key_unhandled_returns_false() {
854 let mut state = TabsState::default();
855 assert!(!state.handle_key(&KeyEvent::new(KeyCode::Enter), 3));
856 assert!(!state.handle_key(&KeyEvent::new(KeyCode::Escape), 3));
857 assert!(!state.handle_key(&KeyEvent::new(KeyCode::Up), 3));
858 }
859
860 #[test]
861 fn tabs_handle_key_number_at_exact_tab_count_returns_false() {
862 let mut state = TabsState::default();
863 assert!(!state.handle_key(&KeyEvent::new(KeyCode::Char('4')), 3));
865 }
866
867 #[test]
868 fn tabs_handle_key_number_one_selects_first() {
869 let mut state = TabsState::default();
870 state.select(2, 5);
871 assert!(state.handle_key(&KeyEvent::new(KeyCode::Char('1')), 5));
872 assert_eq!(state.active, 0);
873 }
874
875 use crate::mouse::MouseResult;
878 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
879
880 #[test]
881 fn tabs_mouse_click_selects() {
882 let mut state = TabsState::default();
883 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
884 let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
885 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
886 assert_eq!(result, MouseResult::Selected(2));
887 assert_eq!(state.active, 2);
888 }
889
890 #[test]
891 fn tabs_mouse_click_same_tab_activates() {
892 let mut state = TabsState::default();
893 state.select(2, 5);
894 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
895 let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
896 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
897 assert_eq!(result, MouseResult::Activated(2));
898 }
899
900 #[test]
901 fn tabs_mouse_click_wrong_id_ignored() {
902 let mut state = TabsState::default();
903 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
904 let hit = Some((HitId::new(99), HitRegion::Content, 2u64));
905 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
906 assert_eq!(result, MouseResult::Ignored);
907 }
908
909 #[test]
910 fn tabs_mouse_right_click_ignored() {
911 let mut state = TabsState::default();
912 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 5, 0);
913 let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
914 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
915 assert_eq!(result, MouseResult::Ignored);
916 }
917
918 #[test]
919 fn tabs_mouse_click_out_of_range() {
920 let mut state = TabsState::default();
921 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
922 let hit = Some((HitId::new(1), HitRegion::Content, 20u64));
923 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
924 assert_eq!(result, MouseResult::Ignored);
925 }
926
927 #[test]
928 fn tabs_mouse_no_hit_ignored() {
929 let mut state = TabsState::default();
930 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 0);
931 let result = state.handle_mouse(&event, None, HitId::new(1), 5);
932 assert_eq!(result, MouseResult::Ignored);
933 }
934
935 #[test]
938 fn tabs_close_active_empty_returns_none() {
939 let mut tabs = Tabs::new(Vec::<Tab>::new());
940 let mut state = TabsState::default();
941 assert!(tabs.close_active(&mut state).is_none());
942 }
943
944 #[test]
945 fn tabs_close_active_last_remaining_resets_state() {
946 let mut tabs = Tabs::new(vec![Tab::new("Only").closable(true)]);
947 let mut state = TabsState::default();
948 let removed = tabs.close_active(&mut state);
949 assert!(removed.is_some());
950 assert_eq!(removed.unwrap().title(), "Only");
951 assert!(tabs.tabs().is_empty());
952 assert_eq!(state.active, 0);
953 assert_eq!(state.offset, 0);
954 }
955
956 #[test]
957 fn tabs_close_active_middle_shifts_active() {
958 let mut tabs = Tabs::new(vec![
959 Tab::new("A"),
960 Tab::new("B").closable(true),
961 Tab::new("C"),
962 ]);
963 let mut state = TabsState::default();
964 state.select(1, 3); let removed = tabs.close_active(&mut state);
966 assert_eq!(removed.unwrap().title(), "B");
967 assert_eq!(tabs.tabs().len(), 2);
968 assert!(state.active < tabs.tabs().len());
970 }
971
972 #[test]
973 fn tabs_close_active_at_end_moves_active_back() {
974 let mut tabs = Tabs::new(vec![
975 Tab::new("A"),
976 Tab::new("B"),
977 Tab::new("C").closable(true),
978 ]);
979 let mut state = TabsState::default();
980 state.select(2, 3); tabs.close_active(&mut state);
982 assert_eq!(tabs.tabs().len(), 2);
983 assert_eq!(state.active, 1); }
985
986 #[test]
989 fn tabs_move_active_left_at_boundary_returns_false() {
990 let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
991 let mut state = TabsState::default(); assert!(!tabs.move_active_left(&mut state));
993 }
994
995 #[test]
996 fn tabs_move_active_right_at_boundary_returns_false() {
997 let mut tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
998 let mut state = TabsState::default();
999 state.select(1, 2); assert!(!tabs.move_active_right(&mut state));
1001 }
1002
1003 #[test]
1004 fn tabs_move_active_single_tab_returns_false() {
1005 let mut tabs = Tabs::new(vec![Tab::new("Only")]);
1006 let mut state = TabsState::default();
1007 assert!(!tabs.move_active_left(&mut state));
1008 assert!(!tabs.move_active_right(&mut state));
1009 }
1010
1011 #[test]
1014 fn tabs_render_empty() {
1015 let tabs = Tabs::new(Vec::<Tab>::new());
1016 let mut state = TabsState::default();
1017 let mut pool = GraphemePool::new();
1018 let mut frame = Frame::new(20, 1, &mut pool);
1019 StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1020 let row = row_text(&frame, 0);
1022 assert_eq!(row.trim(), "");
1023 }
1024
1025 #[test]
1026 fn tabs_render_single_tab() {
1027 let tabs = Tabs::new(vec![Tab::new("Solo")]);
1028 let mut state = TabsState::default();
1029 let mut pool = GraphemePool::new();
1030 let mut frame = Frame::new(20, 1, &mut pool);
1031 StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1032 let row = row_text(&frame, 0);
1033 assert!(row.contains("[Solo]"));
1034 }
1035
1036 #[test]
1037 fn tabs_render_empty_clears_stale_row() {
1038 let populated = Tabs::new(vec![Tab::new("LongTab"), Tab::new("Other")]);
1039 let empty = Tabs::new(Vec::<Tab>::new());
1040 let mut state = TabsState::default();
1041 let mut pool = GraphemePool::new();
1042 let mut frame = Frame::new(20, 1, &mut pool);
1043
1044 StatefulWidget::render(&populated, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1045 assert_ne!(row_text(&frame, 0), " ".repeat(20));
1046
1047 StatefulWidget::render(&empty, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1048 assert_eq!(row_text(&frame, 0), " ".repeat(20));
1049 }
1050
1051 #[test]
1052 fn tabs_render_shorter_titles_clear_stale_suffix() {
1053 let long = Tabs::new(vec![Tab::new("LongTitle"), Tab::new("Second")]);
1054 let short = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1055 let mut state = TabsState::default();
1056 let mut pool = GraphemePool::new();
1057 let mut frame = Frame::new(20, 1, &mut pool);
1058
1059 StatefulWidget::render(&long, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1060 StatefulWidget::render(&short, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1061
1062 assert_eq!(row_text(&frame, 0), "[A] B ");
1063 }
1064
1065 #[test]
1066 fn tabs_no_styling_drops_configured_styles() {
1067 let tabs = Tabs::new(vec![Tab::new("One").style(Style::new().italic())])
1068 .style(Style::new().bold())
1069 .active_style(Style::new().underline());
1070 let plain_tabs = Tabs::new(vec![Tab::new("One")]);
1071 let mut state = TabsState::default();
1072 let mut plain_state = TabsState::default();
1073 let mut pool = GraphemePool::new();
1074 let mut plain_pool = GraphemePool::new();
1075 let mut frame = Frame::new(10, 1, &mut pool);
1076 let mut plain_frame = Frame::new(10, 1, &mut plain_pool);
1077 frame.buffer.degradation = DegradationLevel::NoStyling;
1078 plain_frame.buffer.degradation = DegradationLevel::NoStyling;
1079
1080 StatefulWidget::render(&tabs, Rect::new(0, 0, 10, 1), &mut frame, &mut state);
1081 StatefulWidget::render(
1082 &plain_tabs,
1083 Rect::new(0, 0, 10, 1),
1084 &mut plain_frame,
1085 &mut plain_state,
1086 );
1087
1088 for x in 0..10 {
1089 let cell = frame.buffer.get(x, 0).expect("styled tab cell");
1090 let plain = plain_frame.buffer.get(x, 0).expect("plain tab cell");
1091 assert_eq!(cell, plain);
1092 }
1093 }
1094
1095 #[test]
1096 fn tabs_render_zero_area() {
1097 let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1098 let mut state = TabsState::default();
1099 let mut pool = GraphemePool::new();
1100 let mut frame = Frame::new(20, 1, &mut pool);
1101 StatefulWidget::render(&tabs, Rect::new(0, 0, 0, 1), &mut frame, &mut state);
1103 }
1105
1106 #[test]
1107 fn tabs_render_closable_shows_marker() {
1108 let tabs = Tabs::new(vec![Tab::new("File").closable(true)]);
1109 let mut state = TabsState::default();
1110 let mut pool = GraphemePool::new();
1111 let mut frame = Frame::new(20, 1, &mut pool);
1112 StatefulWidget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame, &mut state);
1113 let row = row_text(&frame, 0);
1114 assert!(row.contains("x"), "closable tab should show close marker");
1115 }
1116
1117 #[test]
1118 fn tabs_render_active_tab_bracketed() {
1119 let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B"), Tab::new("C")]);
1120 let mut state = TabsState::default();
1121 state.select(1, 3);
1122 let mut pool = GraphemePool::new();
1123 let mut frame = Frame::new(30, 1, &mut pool);
1124 StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
1125 let row = row_text(&frame, 0);
1126 assert!(row.contains("[B]"), "active tab should be bracketed");
1128 assert!(row.contains(" A "), "inactive tab A should be space-padded");
1129 assert!(row.contains(" C "), "inactive tab C should be space-padded");
1130 }
1131
1132 #[test]
1133 fn tabs_no_overflow_when_all_fit() {
1134 let tabs = Tabs::new(vec![Tab::new("A"), Tab::new("B")]);
1135 let mut state = TabsState::default();
1136 let mut pool = GraphemePool::new();
1137 let mut frame = Frame::new(30, 1, &mut pool);
1138 StatefulWidget::render(&tabs, Rect::new(0, 0, 30, 1), &mut frame, &mut state);
1139 let row = row_text(&frame, 0);
1140 assert!(!row.starts_with('<'), "no left overflow marker expected");
1142 assert!(!row.ends_with('>'), "no right overflow marker expected");
1143 }
1144
1145 #[test]
1148 fn tab_new_defaults() {
1149 let tab = Tab::new("test");
1150 assert_eq!(tab.title(), "test");
1151 assert!(!tab.is_closable());
1152 }
1153
1154 #[test]
1155 fn tab_closable_builder() {
1156 let tab = Tab::new("temp").closable(true);
1157 assert!(tab.is_closable());
1158 }
1159
1160 #[test]
1163 fn tabs_widget_stateless_render() {
1164 let tabs = Tabs::new(vec![Tab::new("X"), Tab::new("Y")]);
1165 let mut pool = GraphemePool::new();
1166 let mut frame = Frame::new(20, 1, &mut pool);
1167 Widget::render(&tabs, Rect::new(0, 0, 20, 1), &mut frame);
1168 let row = row_text(&frame, 0);
1169 assert!(row.contains("[X]"));
1171 }
1172
1173 #[test]
1176 fn tabs_overflow_both_sides() {
1177 let tabs = Tabs::new((0..10).map(|i| Tab::new(format!("Tab{i}"))));
1178 let mut state = TabsState::default();
1179 state.select(5, 10); let mut pool = GraphemePool::new();
1181 let mut frame = Frame::new(15, 1, &mut pool);
1182 StatefulWidget::render(&tabs, Rect::new(0, 0, 15, 1), &mut frame, &mut state);
1183 let row = row_text(&frame, 0);
1184 assert!(row.starts_with('<'), "expected left overflow marker");
1186 assert!(
1187 row.trim_end().ends_with('>'),
1188 "expected right overflow marker"
1189 );
1190 }
1191}