use std::fmt::Display;
use std::marker::PhantomData;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TabsMessage {
Left,
Right,
Select(usize),
First,
Last,
Confirm,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TabsOutput<T: Clone> {
Selected(T),
Confirmed(T),
SelectionChanged(usize),
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct TabsState<T: Clone> {
tabs: Vec<T>,
selected: Option<usize>,
}
impl<T: Clone + PartialEq> PartialEq for TabsState<T> {
fn eq(&self, other: &Self) -> bool {
self.tabs == other.tabs && self.selected == other.selected
}
}
impl<T: Clone> Default for TabsState<T> {
fn default() -> Self {
Self {
tabs: Vec::new(),
selected: None,
}
}
}
impl<T: Clone> TabsState<T> {
pub fn new(tabs: Vec<T>) -> Self {
let selected = if tabs.is_empty() { None } else { Some(0) };
Self { tabs, selected }
}
pub fn with_selected(tabs: Vec<T>, selected: usize) -> Self {
let selected = if tabs.is_empty() {
None
} else {
Some(selected.min(tabs.len() - 1))
};
Self { tabs, selected }
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&T> {
self.tabs.get(self.selected?)
}
pub fn set_selected(&mut self, index: Option<usize>) {
match index {
Some(i) => {
if self.tabs.is_empty() {
self.selected = None;
} else {
self.selected = Some(i.min(self.tabs.len() - 1));
}
}
None => self.selected = None,
}
}
pub fn tabs(&self) -> &[T] {
&self.tabs
}
pub fn set_tabs(&mut self, tabs: Vec<T>) {
self.tabs = tabs;
if self.tabs.is_empty() {
self.selected = None;
} else if let Some(idx) = self.selected {
if idx >= self.tabs.len() {
self.selected = Some(self.tabs.len() - 1);
}
}
}
pub fn len(&self) -> usize {
self.tabs.len()
}
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
fn move_left(&mut self) -> bool {
match self.selected {
Some(idx) if idx > 0 => {
self.selected = Some(idx - 1);
true
}
_ => false,
}
}
fn move_right(&mut self) -> bool {
match self.selected {
Some(idx) if idx < self.tabs.len().saturating_sub(1) => {
self.selected = Some(idx + 1);
true
}
_ => false,
}
}
}
impl<T: Clone + std::fmt::Display + 'static> TabsState<T> {
pub fn update(&mut self, msg: TabsMessage) -> Option<TabsOutput<T>> {
Tabs::update(self, msg)
}
}
pub struct Tabs<T: Clone>(PhantomData<T>);
impl<T: Clone + Display + 'static> Component for Tabs<T> {
type State = TabsState<T>;
type Message = TabsMessage;
type Output = TabsOutput<T>;
fn init() -> Self::State {
TabsState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.tabs.is_empty() {
return None;
}
let selected = state.selected?;
match msg {
TabsMessage::Left => {
if state.move_left() {
state.selected.map(TabsOutput::SelectionChanged)
} else {
None
}
}
TabsMessage::Right => {
if state.move_right() {
state.selected.map(TabsOutput::SelectionChanged)
} else {
None
}
}
TabsMessage::Select(index) => {
let clamped = index.min(state.tabs.len().saturating_sub(1));
if clamped != selected {
state.selected = Some(clamped);
Some(TabsOutput::SelectionChanged(clamped))
} else {
None
}
}
TabsMessage::First => {
if selected != 0 {
state.selected = Some(0);
Some(TabsOutput::SelectionChanged(0))
} else {
None
}
}
TabsMessage::Last => {
let last = state.tabs.len().saturating_sub(1);
if selected != last {
state.selected = Some(last);
Some(TabsOutput::SelectionChanged(last))
} else {
None
}
}
TabsMessage::Confirm => state.selected_item().cloned().map(TabsOutput::Confirmed),
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Left | Key::Char('h') => Some(TabsMessage::Left),
Key::Right | Key::Char('l') => Some(TabsMessage::Right),
Key::Home => Some(TabsMessage::First),
Key::End => Some(TabsMessage::Last),
Key::Enter => Some(TabsMessage::Confirm),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
let selected_idx = state.selected.unwrap_or(0);
let titles: Vec<Line> = state
.tabs
.iter()
.enumerate()
.map(|(i, tab)| {
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if i == selected_idx {
ctx.theme.selected_style(ctx.focused)
} else {
ctx.theme.normal_style()
};
Line::from(Span::styled(format!(" {} ", tab), style))
})
.collect();
let border_style = if ctx.focused && !ctx.disabled {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let highlight_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.selected_style(ctx.focused)
};
let tabs_widget = ratatui::widgets::Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
)
.select(selected_idx)
.highlight_style(highlight_style);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::TabBar)
.with_id("tabs")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled)
.with_selected(true)
.with_value(selected_idx.to_string());
let annotated = crate::annotation::Annotate::new(tabs_widget, annotation);
ctx.frame.render_widget(annotated, ctx.area);
}
}
#[cfg(test)]
mod tests;