abstracttui 0.6.0

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
//! widgets — the widget gallery: every shipped widget, live states.
//!
//! Demonstrates: the §3 style guide in motion — Tab moves real keyboard
//! focus (buttons/input/list show the selection-pair focus state), hover
//! works with the mouse, a disabled button sits in text_faint outside the
//! focus order, and the whole gallery restyles on theme cycle.
//!
//! Keys: Tab/Shift+Tab focus · Enter/Space activate · arrows in list/tabs
//! · Ctrl+T cycle theme · F2 spin · q quit (Ctrl+C always).
//!
//! Docs: docs/api.md § "widgets — the built-in library".
//!
//! OWNER: DESIGN (gallery); interactive widgets are REACT's, visual ones
//! DESIGN's — this file exercises both through the public API only.

mod common;

use abstracttui::prelude::*;
use abstracttui::theme::themes;
use abstracttui::widgets::{SpinnerKind, TitleAlign, Tone};

const BORDER_KINDS: [(BorderKind, &str); 4] = [
    (BorderKind::Plain, "plain"),
    (BorderKind::Rounded, "rounded"),
    (BorderKind::Double, "double"),
    (BorderKind::Heavy, "heavy"),
];

fn main() -> abstracttui::base::Result<()> {
    if !abstracttui::term::have_tty() {
        println!("widgets: needs an interactive terminal — skipping cleanly");
        return Ok(());
    }
    let mut app = App::new(Size::new(100, 32));
    let quitter = app.quitter();
    app.mount(move |cx| {
        let theme = use_theme(cx);
        // Durable state: created ONCE on the mount scope, bound into the
        // widgets by signal — so list selection, typed text and counters
        // survive the theme-rebuild of the widget tree below.
        let clicks = cx.signal(0u32);
        let name = cx.signal(String::new());
        let pick = cx.signal(0usize);
        let tab = cx.signal(0usize);
        let spin = cx.signal(0u64);
        let theme_ix = cx.signal(0usize);
        // Alive-mask for the closable panels row (app-owned: whether a
        // panel can close is the app's decision — and so is restoring).
        let panes = cx.signal(0b111u32);

        Element::new()
            .style(LayoutStyle::column().padding(Edges::all(1)).gap(1))
            .shortcut(KeyChord::plain(Key::Char('q')), move |_| quitter.quit())
            .shortcut(KeyChord::new(Mods::CTRL, Key::Char('t')), move |_| {
                theme_ix.update(|i| *i = (*i + 1) % themes().len());
                set_theme_by_id(themes()[theme_ix.get_untracked()].id);
            })
            .shortcut(KeyChord::plain(Key::F(2)), move |_| {
                spin.update(|t| *t += 1)
            })
            // ONE outer Dyn reads the theme: a switch rebuilds the widget
            // tree with freshly resolved tokens (interactive widgets
            // resolve tokens at element() time — damage contract §5).
            .child(dyn_view(LayoutStyle::default().grow(1.0), move || {
                let t = theme.get().tokens;
                let label = theme.get().label;
                Element::new()
                    .style(LayoutStyle::column().gap(1))
                    // Header: logo + active-theme badge. shrink(0.0):
                    // incompressible chrome — without it, overflow
                    // pressure from the tab panels can silently steal
                    // the tagline row (§"A row vanishes" recipe).
                    .child(
                        Element::new()
                            .style(LayoutStyle::row().gap(2).h(2).shrink(0.0))
                            .child(Logo::new().tagline(true).element(&t).build())
                            .child(Badge::new(label).tone(Tone::Accent).element(&t).build())
                            .build(),
                    )
                    .child(
                        Tabs::new()
                            .tab("interactive", {
                                move || {
                                    interactive_panel(
                                        cx,
                                        &theme.get().tokens,
                                        clicks,
                                        name,
                                        pick,
                                        panes,
                                    )
                                }
                            })
                            .tab("visual", {
                                move || visual_panel(cx, &theme.get().tokens, spin.get())
                            })
                            .active(tab)
                            .layout(LayoutStyle::column().grow(1.0))
                            .element(cx, &t)
                            .build(),
                    )
                    .child(text(
                        "tab focus · enter/space activate · ctrl+t theme · f2 spin · q quit",
                    ))
                    .child(
                        Element::new()
                            .style_signal(move || {
                                let vp = abstracttui::app::current_viewport();
                                if vp.w >= common::MIN_SIZE.w && vp.h >= common::MIN_SIZE.h {
                                    LayoutStyle::default().w(0).h(0).shrink(0.0)
                                } else {
                                    common::overlay_layout()
                                }
                            })
                            .draw(move |canvas, rect| {
                                let vp = abstracttui::app::current_viewport();
                                if vp.w >= common::MIN_SIZE.w && vp.h >= common::MIN_SIZE.h {
                                    return;
                                }
                                let t = theme.get().tokens;
                                common::too_small(canvas, rect, common::MIN_SIZE, &t);
                            })
                            .build(),
                    )
                    .build()
            }))
            .build()
    })?;
    app.run()
}

/// REACT's widgets under the §3 state table: focus = selection pair,
/// hover = accent ink, disabled = text_faint outside the focus order —
/// plus the panel ✕ (click it: the survivors re-flex into the space).
fn interactive_panel(
    cx: Scope,
    t: &TokenSet,
    clicks: Signal<u32>,
    name: Signal<String>,
    pick: Signal<usize>,
    panes: Signal<u32>,
) -> View {
    let items: Vec<String> = [
        "abstract-dark",
        "observer-night",
        "catppuccin-mocha",
        "rose-pine",
        "tokyo-night",
        "nord",
        "one-dark",
        "dracula",
        "monokai",
        "gruvbox",
    ]
    .iter()
    .map(|s| s.to_string())
    .collect();

    // The fixed rows carry shrink(0.0): under height pressure the LIST
    // block below gives (it scrolls honestly) instead of these rows
    // silently collapsing.
    Element::new()
        .style(LayoutStyle::column().gap(1).padding(Edges::all(1)))
        .child(
            Element::new()
                .style(LayoutStyle::row().gap(2).h(1).shrink(0.0))
                .child(
                    Button::new("click me")
                        .on_click(move || clicks.update(|c| *c += 1))
                        .element(cx, t)
                        .build(),
                )
                .child(
                    Button::new("disabled")
                        .disabled(true)
                        .element(cx, t)
                        .build(),
                )
                .child(dyn_view(LayoutStyle::default().h(1), move || {
                    text(format!("clicks: {}", clicks.get()))
                }))
                .build(),
        )
        .child(
            TextInput::new()
                .value(name)
                .placeholder("type your name, Enter submits…")
                .layout(LayoutStyle::default().w(44).h(1).shrink(0.0))
                .element(cx, t)
                .build(),
        )
        .child(dyn_view(
            LayoutStyle::default().h(1).shrink(0.0),
            move || {
                text(format!("hello, {}!", {
                    let n = name.get();
                    if n.is_empty() {
                        "stranger".to_string()
                    } else {
                        n
                    }
                }))
            },
        ))
        .child(closable_panes_row(cx, t, panes))
        .child(
            Block::new()
                .title("list — arrows move, wheel scrolls")
                .fill(t.surface)
                .layout(LayoutStyle::column().grow(1.0).min_h(6))
                .child(
                    List::new(items)
                        .selection(pick)
                        .layout(LayoutStyle::default().grow(1.0))
                        .element(cx, t)
                        .build(),
                )
                .element(t)
                .build(),
        )
        .build()
}

/// The panel ✕: each pane opts into
/// `Block::on_close`, the callback clears its bit, the row's dyn
/// re-renders and the SURVIVORS RE-FLEX into the freed space. Closing
/// is the app's decision — and so is coming back (the restore button).
fn closable_panes_row(cx: Scope, t: &TokenSet, panes: Signal<u32>) -> View {
    let t = *t;
    dyn_view(LayoutStyle::default().h(4).shrink(0.0), move || {
        let mask = panes.get();
        if mask == 0 {
            return Element::new()
                .style(LayoutStyle::row().gap(2).h(4))
                .child(
                    Button::new("restore panes")
                        .on_click(move || panes.set(0b111))
                        .element(cx, &t)
                        .build(),
                )
                .child(text("all panes closed — the ✕ freed their space"))
                .build();
        }
        let mut row = Element::new().style(LayoutStyle::row().gap(1).h(4));
        for (i, label) in ["pane 1 — click ✕", "pane 2", "pane 3"].iter().enumerate() {
            if mask & (1 << i) == 0 {
                continue;
            }
            row = row.child(
                Block::new()
                    .border(BorderKind::Rounded)
                    .title(*label)
                    .fill(t.surface)
                    .on_close(move || panes.update(|m| *m &= !(1 << i)))
                    .layout(LayoutStyle::column().grow(1.0))
                    .child(text("survivors re-flex"))
                    .element(&t)
                    .build(),
            );
        }
        row.build()
    })
}

/// DESIGN's visual widgets, inside a Scroll so overflow is explorable.
fn visual_panel(cx: Scope, t: &TokenSet, spin_frame: u64) -> View {
    let mut blocks = Element::new().style(LayoutStyle::row().gap(1).h(5));
    for (i, (kind, name)) in BORDER_KINDS.iter().enumerate() {
        blocks = blocks.child(
            Block::new()
                .border(*kind)
                .title(*name)
                .title_align(TitleAlign::Left)
                .focused(i == 1) // the rounded one wears the focus ring
                .fill(t.surface)
                .layout(LayoutStyle::column().grow(1.0))
                .child(text(if i == 1 { "focused" } else { "" }))
                .element(t)
                .build(),
        );
    }

    let mut badges = Element::new().style(LayoutStyle::row().gap(1).h(1));
    for (tone, name) in [
        (Tone::Accent, "accent"),
        (Tone::Ok, "ok"),
        (Tone::Warn, "warn"),
        (Tone::Error, "error"),
        (Tone::Info, "info"),
        (Tone::Muted, "muted"),
    ] {
        badges = badges.child(Badge::new(name).tone(tone).element(t).build());
    }

    let mut spinners = Element::new().style(LayoutStyle::row().gap(3).h(1));
    for (kind, name) in [
        (SpinnerKind::Dots, "dots"),
        (SpinnerKind::Braille, "braille"),
        (SpinnerKind::Line, "line"),
    ] {
        spinners = spinners.child(
            Spinner::new()
                .kind(kind)
                .frame(spin_frame)
                .label(name)
                .element(t)
                .build(),
        );
    }

    let _ = cx;
    // Plain column — the panel's 18 rows fit the tab at the demo size,
    // and the interactive tab's List already demonstrates scrolling.
    // (A Scroll wrapper here rendered only its bar: scroll content
    // that is a plain element tree currently solves to zero width —
    // see reviews/wave12/visual-to-code-handoff.md §2.)
    Element::new()
        .style(LayoutStyle::column().gap(1).padding(Edges::all(1)))
        .child(blocks.build())
        .child(badges.build())
        .child(Separator::horizontal().label("progress").element(t).build())
        .child(Progress::new(0.35).element(t).build())
        .child(Progress::new(0.72).ramp(true).element(t).build())
        .child(Progress::new(0.93).ramp(true).element(t).build())
        .child(
            Separator::horizontal()
                .label("spinners — f2 advances")
                .element(t)
                .build(),
        )
        .child(spinners.build())
        .build()
}