use crate::component::Component;
use crate::element::Element;
use crate::hooks::Hooks;
use crate::hooks::input::KeyCode;
use crate::props::{FlexDirection, TextProps, ViewProps};
use crate::style::Weight;
use crate::widgets::callback::Callback;
use crate::widgets::select::use_clamped_index;
#[derive(Clone, PartialEq, Default)]
pub struct TabsProps {
pub labels: Vec<String>,
pub active: usize,
pub on_change: Option<Callback<usize>>,
}
pub struct Tabs;
impl Component for Tabs {
type Props = TabsProps;
fn render(props: &TabsProps, hooks: &mut Hooks) -> Element {
let theme = hooks.use_theme();
let focus = hooks.use_focusable();
let is_focused = focus.is_focused();
let len = props.labels.len();
let active = use_clamped_index(hooks, props.active, len);
let a = active.clone();
let on_change = props.on_change.clone();
hooks.use_input(move |ev, ctx| {
if !is_focused || len == 0 {
return;
}
let next = match ev.code {
KeyCode::Left => Some((a.get().min(len - 1) + len - 1) % len),
KeyCode::Right => Some((a.get() + 1) % len),
_ => None,
};
if let Some(next) = next {
a.set(next);
if let Some(cb) = &on_change {
cb.call(next);
}
ctx.stop_propagation();
}
});
let active_idx = active.get();
let cells = props
.labels
.iter()
.enumerate()
.map(|(i, label)| {
let selected = i == active_idx;
let color = match (selected, is_focused) {
(true, true) => theme.accent,
(true, false) => theme.foreground,
(false, _) => theme.muted,
};
Element::text(TextProps {
content: label.clone(),
color,
weight: if selected {
Weight::Bold
} else {
Weight::Normal
},
..Default::default()
})
})
.collect();
Element::view(
ViewProps {
flex_direction: FlexDirection::Row,
gap: 2,
..Default::default()
},
cells,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::input::KeyCode;
use crate::testing::TestTerminal;
use std::cell::Cell;
use std::rc::Rc;
struct Scope;
#[derive(Clone, PartialEq, Default)]
struct ScopeProps {
last: Rc<Cell<Option<usize>>>,
}
impl Component for Scope {
type Props = ScopeProps;
fn render(props: &ScopeProps, hooks: &mut Hooks) -> Element {
let scope = hooks.use_focus_scope();
let last = props.last.clone();
Element::provider(
scope,
vec![Element::component::<Tabs>(TabsProps {
labels: vec!["one".into(), "two".into(), "three".into()],
active: 0,
on_change: Some(Callback::new(move |i| last.set(Some(i)))),
})],
)
}
}
#[tokio::test]
async fn right_advances_and_wraps() {
let props = ScopeProps::default();
let mut t = TestTerminal::new(30, 1, Element::component::<Scope>(props.clone())).unwrap();
t.send_key(KeyCode::Right).unwrap();
assert_eq!(props.last.get(), Some(1));
t.send_key(KeyCode::Right).unwrap();
assert_eq!(props.last.get(), Some(2));
t.send_key(KeyCode::Right).unwrap();
assert_eq!(props.last.get(), Some(0));
}
#[tokio::test]
async fn left_from_the_start_wraps_to_the_end() {
let props = ScopeProps::default();
let mut t = TestTerminal::new(30, 1, Element::component::<Scope>(props.clone())).unwrap();
t.send_key(KeyCode::Left).unwrap();
assert_eq!(props.last.get(), Some(2));
}
#[tokio::test]
async fn active_out_of_range_clamps_and_left_still_navigates_correctly() {
struct OutOfRangeScope;
#[derive(Clone, PartialEq, Default)]
struct OutOfRangeScopeProps {
last: Rc<Cell<Option<usize>>>,
}
impl Component for OutOfRangeScope {
type Props = OutOfRangeScopeProps;
fn render(props: &OutOfRangeScopeProps, hooks: &mut Hooks) -> Element {
let scope = hooks.use_focus_scope();
let last = props.last.clone();
Element::provider(
scope,
vec![Element::component::<Tabs>(TabsProps {
labels: vec!["one".into(), "two".into(), "three".into()],
active: 99,
on_change: Some(Callback::new(move |i| last.set(Some(i)))),
})],
)
}
}
let props = OutOfRangeScopeProps {
last: Rc::new(Cell::new(None)),
};
let mut t =
TestTerminal::new(30, 1, Element::component::<OutOfRangeScope>(props.clone())).unwrap();
let out = t.frame_text();
assert!(out.contains("three"));
t.send_key(KeyCode::Left).unwrap();
assert_eq!(props.last.get(), Some(1));
}
#[tokio::test]
async fn shrinking_labels_reclamps_a_still_in_range_active_prop() {
struct ShrinkScope;
#[derive(Clone, PartialEq, Default)]
struct ShrinkScopeProps {
last: Rc<Cell<Option<usize>>>,
}
impl Component for ShrinkScope {
type Props = ShrinkScopeProps;
fn render(props: &ShrinkScopeProps, hooks: &mut Hooks) -> Element {
let scope = hooks.use_focus_scope();
let label_count = hooks.use_state(|| 5usize);
let lc = label_count.clone();
hooks.use_input(move |ev, _ctx| {
if matches!(ev.code, KeyCode::Char('s')) {
lc.set(2);
}
});
let labels = (0..label_count.get()).map(|i| format!("tab{i}")).collect();
let last = props.last.clone();
Element::provider(
scope,
vec![Element::component::<Tabs>(TabsProps {
labels,
active: 4,
on_change: Some(Callback::new(move |i| last.set(Some(i)))),
})],
)
}
}
let props = ShrinkScopeProps {
last: Rc::new(Cell::new(None)),
};
let mut t =
TestTerminal::new(30, 1, Element::component::<ShrinkScope>(props.clone())).unwrap();
t.send_key(KeyCode::Char('s')).unwrap();
t.send_key(KeyCode::Right).unwrap();
assert_eq!(
props.last.get(),
Some(0),
"active should have re-clamped to the last valid index (1) and Right should wrap to 0"
);
}
}