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::*;

#[derive(Clone, Copy)]
struct CommandItem {
    label: &'static str,
    hint: &'static str,
}

const COMMANDS: &[CommandItem] = &[
    CommandItem {
        label: "Build workspace",
        hint: "cargo build",
    },
    CommandItem {
        label: "Run tests",
        hint: "cargo test",
    },
    CommandItem {
        label: "Lint code",
        hint: "cargo clippy",
    },
    CommandItem {
        label: "Format code",
        hint: "cargo fmt",
    },
    CommandItem {
        label: "Run inline demo",
        hint: "cargo run --example inline",
    },
    CommandItem {
        label: "Open docs",
        hint: "cargo doc --open",
    },
    CommandItem {
        label: "Check dependencies",
        hint: "cargo tree --depth 1",
    },
    CommandItem {
        label: "Profile startup",
        hint: "cargo run --release",
    },
    CommandItem {
        label: "List examples",
        hint: "cargo run --example showcase",
    },
];

struct InlineListPicker;

#[derive(Default)]
struct State {
    query: TextInput,
    selected: usize,
    executed: u32,
    status: String,
    recent: Vec<String>,
}

#[derive(Clone, Debug)]
enum Msg {
    QueryChanged(InputEvent),
    Selected(ListEvent),
    ScrollTo(usize),
    Activate,
}

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

    fn create_state(&self, _props: &Self::Properties) -> Self::State {
        State {
            status: "Pick a command and press Enter to insert it above".to_string(),
            ..State::default()
        }
    }

    fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
        match msg {
            Msg::QueryChanged(event) => {
                ctx.state.query.set_text(event.value.as_ref());
                ctx.state.query.set_cursor_keep_anchor(event.cursor);
                ctx.state.query.set_anchor(event.anchor);
                ctx.state.selected = 0;
                Update::full()
            }
            Msg::Selected(event) => {
                ctx.state.selected = event.index;
                Update::full()
            }
            Msg::ScrollTo(index) => {
                ctx.state.selected = index;
                Update::full()
            }
            Msg::Activate => {
                let filtered = filtered_commands(ctx.state.query.text());
                if filtered.is_empty() {
                    ctx.state.status = "No command matches this query".to_string();
                    return Update::full();
                }

                let selected = ctx.state.selected.min(filtered.len().saturating_sub(1));
                let command = filtered[selected];
                let next = ctx.state.executed.saturating_add(1);

                ctx.state
                    .recent
                    .push(format!("[{next:03}] {} -> {}", command.label, command.hint));

                ctx.state.executed = next;
                ctx.state.status = format!("Queued: {}", command.hint);
                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 {
        let filtered = filtered_commands(ctx.state.query.text());
        let selected = clamp_selected(ctx.state.selected, filtered.len());

        let items = filtered.iter().map(|item| {
            ListItem::new(item.label).description_spans([
                Span::new(" ").fg(Color::DarkGray),
                Span::new(item.hint).fg(Color::DarkGray),
            ])
        });

        Frame::new()
            .header_left("Inline list picker")
            .border(true)
            .border_style(BorderStyle::Rounded)
            .padding(1)
            .footer_left(format!(
                "Enter = insert above | m toggles mouse ({}) | q/Esc quits",
                if ctx.mouse_capture_enabled() {
                    "on"
                } else {
                    "off"
                }
            ))
            .child(
                VStack::new()
                    .gap(1)
                    .child(
                        Input::new(ctx.state.query.text().to_string())
                            .cursor(ctx.state.query.cursor())
                            .anchor(ctx.state.query.anchor())
                            .placeholder("Filter commands")
                            .border(true)
                            .on_change(ctx.link().callback(Msg::QueryChanged))
                            .on_key(ctx.link().key_handler(|key| match key.code {
                                KeyCode::Enter => Some(Msg::Activate),
                                _ => None,
                            })),
                    )
                    .child(
                        List::new()
                            .title("Commands")
                            .border(true)
                            .scrollbar(true)
                            .scrollbar_config(
                                ScrollbarConfig::new().variant(ScrollbarVariant::Integrated),
                            )
                            .show_scroll_indicators(true)
                            .selection_symbol(Some(""))
                            .empty_text("No commands")
                            .items(items)
                            .selected(selected)
                            .on_select(ctx.link().callback(Msg::Selected))
                            .on_scroll_to(ctx.link().callback(Msg::ScrollTo))
                            .on_activate(ctx.link().callback(|_| Msg::Activate))
                            .height(Length::Flex(1)),
                    )
                    .child(Text::new(format!(
                        "Runs: {} | {}",
                        ctx.state.executed, ctx.state.status
                    )))
                    .child(
                        Text::new(
                            ctx.state
                                .recent
                                .iter()
                                .rev()
                                .take(5)
                                .cloned()
                                .collect::<Vec<_>>()
                                .join("\n"),
                        )
                        .overflow(Overflow::Wrap),
                    ),
            )
            .into()
    }
}

fn filtered_commands(query: &str) -> Vec<CommandItem> {
    let needle = query.trim().to_lowercase();
    COMMANDS
        .iter()
        .copied()
        .filter(|item| {
            needle.is_empty()
                || item.label.to_lowercase().contains(&needle)
                || item.hint.to_lowercase().contains(&needle)
        })
        .collect()
}

fn clamp_selected(selected: usize, len: usize) -> usize {
    if len == 0 {
        0
    } else {
        selected.min(len.saturating_sub(1))
    }
}

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