tui-lipan 0.2.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use tui_lipan::prelude::*;

const THEMES: &[&str] = &["Minimal", "Balanced", "Loud"];
const TARGETS: &[&str] = &[
    "Local sandbox",
    "Staging cluster",
    "Production canary",
    "Production full",
];

struct InlineChoices;

#[derive(Default)]
struct State {
    theme: usize,
    target: Option<usize>,
    target_expanded: bool,
    note: TextInput,
    applied: u32,
    recent: Vec<String>,
    status: String,
}

#[derive(Clone, Debug)]
enum Msg {
    ThemeChanged(usize),
    TargetToggle(bool),
    TargetSelected(usize),
    TargetChanged(usize),
    NoteChanged(InputEvent),
    Apply,
}

impl Component for InlineChoices {
    type Message = Msg;
    type Properties = ();
    type State = State;

    fn create_state(&self, _props: &Self::Properties) -> Self::State {
        State {
            theme: 1,
            status: "Pick values, then apply to insert a summary above".to_string(),
            ..State::default()
        }
    }

    fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
        match msg {
            Msg::ThemeChanged(index) => {
                ctx.state.theme = index.min(THEMES.len().saturating_sub(1));
                Update::full()
            }
            Msg::TargetToggle(expanded) => {
                ctx.state.target_expanded = expanded;
                Update::full()
            }
            Msg::TargetSelected(index) | Msg::TargetChanged(index) => {
                ctx.state.target = Some(index.min(TARGETS.len().saturating_sub(1)));
                ctx.state.target_expanded = false;
                Update::full()
            }
            Msg::NoteChanged(event) => {
                ctx.state.note.set_text(event.value.as_ref());
                ctx.state.note.set_cursor_keep_anchor(event.cursor);
                ctx.state.note.set_anchor(event.anchor);
                Update::full()
            }
            Msg::Apply => {
                let Some(target_idx) = ctx.state.target else {
                    ctx.state.status = "Pick a deployment target first".to_string();
                    return Update::full();
                };

                let next = ctx.state.applied.saturating_add(1);
                let theme = THEMES.get(ctx.state.theme).copied().unwrap_or(THEMES[0]);
                let target = TARGETS.get(target_idx).copied().unwrap_or(TARGETS[0]);
                let note = ctx.state.note.text().trim();

                let note_text = if note.is_empty() { "(none)" } else { note };
                ctx.state.recent.push(format!(
                    "[{next:03}] deploy target={target} theme={theme} note={note_text}"
                ));

                ctx.state.applied = next;
                ctx.state.status = format!("Applied target '{target}' with theme '{theme}'");
                Update::full()
            }
        }
    }

    fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
                ctx.quit();
                KeyUpdate::handled(Update::full())
            }
            KeyCode::Char('m') | KeyCode::Char('M') => {
                ctx.toggle_mouse_capture();
                KeyUpdate::handled(Update::full())
            }
            _ => KeyUpdate::unhandled(Update::none()),
        }
    }

    fn view(&self, ctx: &Context<Self>) -> Element {
        Frame::new()
            .header_left("Inline choices")
            .border(true)
            .border_style(BorderStyle::Rounded)
            .padding(1)
            .footer_left(format!(
                "Enter applies from note input | m toggles mouse ({}) | q/Esc quits",
                if ctx.mouse_capture_enabled() {
                    "on"
                } else {
                    "off"
                }
            ))
            .child(
                VStack::new()
                    .gap(1)
                    .child(Text::new("Theme"))
                    .child(
                        Radio::new(THEMES.iter().copied())
                            .selected(Some(ctx.state.theme))
                            .layout(RadioLayout::Horizontal)
                            .gap(2)
                            .checked_style(Style::new().fg(Color::LightGreen).bold())
                            .unchecked_style(Style::new().fg(Color::DarkGray))
                            .on_change(ctx.link().callback(Msg::ThemeChanged)),
                    )
                    .child(Text::new("Deployment target"))
                    .child(
                        Select::new()
                            .options(TARGETS.iter().copied())
                            .selected(ctx.state.target)
                            .placeholder("Choose target")
                            .expanded(ctx.state.target_expanded)
                            .list_height(Length::Px(4))
                            .list_scrollbar(true)
                            .list_scrollbar_config(
                                ScrollbarConfig::new().variant(ScrollbarVariant::Integrated),
                            )
                            .on_toggle(ctx.link().callback(Msg::TargetToggle))
                            .on_select(ctx.link().callback(Msg::TargetSelected))
                            .on_change(ctx.link().callback(Msg::TargetChanged))
                            .width(Length::Flex(1)),
                    )
                    .child(
                        Input::new(ctx.state.note.text().to_string())
                            .cursor(ctx.state.note.cursor())
                            .anchor(ctx.state.note.anchor())
                            .placeholder("Optional note")
                            .on_change(ctx.link().callback(Msg::NoteChanged))
                            .on_key(ctx.link().key_handler(|key| match key.code {
                                KeyCode::Enter => Some(Msg::Apply),
                                _ => None,
                            })),
                    )
                    .child(
                        Button::filled("Apply")
                            .on_click(ctx.link().callback(|_| Msg::Apply))
                            .focusable(true),
                    )
                    .child(Text::new(format!(
                        "Applied: {} | {}",
                        ctx.state.applied, ctx.state.status
                    )))
                    .child(
                        Text::new(
                            ctx.state
                                .recent
                                .iter()
                                .rev()
                                .take(4)
                                .cloned()
                                .collect::<Vec<_>>()
                                .join("\n"),
                        )
                        .overflow(Overflow::Wrap),
                    ),
            )
            .into()
    }
}

fn main() -> Result<()> {
    App::new()
        .inline_ephemeral(13)
        .mouse(true)
        .mount(InlineChoices)
        .run()
}