tuika 0.2.0

A composable terminal UI toolkit — flexbox layout, overlays, focus, and safe ratatui interoperability.
Documentation
//! Focus-independent tab navigation with host-owned selection state.

use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;

use crate::{Event, EventFlow, KeyCode, RenderCtx, Size, Surface, View};

use super::text::line_width;

#[derive(Clone, Debug, Default)]
/// Host-owned selected-tab state.
pub struct TabsState {
    selected: usize,
}

impl TabsState {
    /// Current selected tab index.
    pub fn selected(&self) -> usize {
        self.selected
    }

    /// Select an index, clamped to the available tab count.
    pub fn select(&mut self, index: usize, len: usize) {
        self.selected = index.min(len.saturating_sub(1));
    }

    /// Handle left/right and forward/backward tab navigation.
    pub fn handle(&mut self, event: &Event, len: usize) -> EventFlow {
        if len == 0 {
            return EventFlow::Ignored;
        }
        let Event::Key(key) = event else {
            return EventFlow::Ignored;
        };
        if !key.plain() {
            return EventFlow::Ignored;
        }
        match key.code {
            KeyCode::Left | KeyCode::BackTab => {
                self.selected = self.selected.checked_sub(1).unwrap_or(len - 1);
                EventFlow::Consumed
            }
            KeyCode::Right | KeyCode::Tab => {
                self.selected = (self.selected + 1) % len;
                EventFlow::Consumed
            }
            _ => EventFlow::Ignored,
        }
    }
}

/// A one-line tab strip derived from [`TabsState`].
pub struct Tabs {
    labels: Vec<Line<'static>>,
    selected: usize,
}

impl Tabs {
    /// Create a tab strip, snapshotting the selected index for this frame.
    pub fn new(labels: Vec<Line<'static>>, state: &TabsState) -> Self {
        Self {
            labels,
            selected: state.selected(),
        }
    }
}

impl View for Tabs {
    fn measure(&self, available: Size) -> Size {
        let labels = self
            .labels
            .iter()
            .map(line_width)
            .fold(0, u16::saturating_add);
        let gaps = (self.labels.len().saturating_sub(1) as u16).saturating_mul(2);
        Size::new(
            labels.saturating_add(gaps).min(available.width),
            u16::from(available.height > 0),
        )
    }

    fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
        if area.is_empty() {
            return;
        }
        let mut x = area.x;
        for (index, label) in self.labels.iter().enumerate() {
            if index > 0 {
                x = surface.set_string(x, area.y, "  ", ctx.theme.muted_style());
            }
            let selected = index == self.selected;
            for span in &label.spans {
                let style = if selected {
                    span.style.patch(
                        ctx.theme
                            .accent_style()
                            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
                    )
                } else {
                    span.style.patch(Style::default().fg(ctx.theme.muted))
                };
                x = surface.set_string(x, area.y, span.content.as_ref(), style);
            }
            if x >= area.right() {
                break;
            }
        }
    }
}