Skip to content

Latest commit

 

History

History
625 lines (479 loc) · 18.3 KB

File metadata and controls

625 lines (479 loc) · 18.3 KB

Common Patterns

This page is for the moment when “hello world” is no longer enough and a real app starts to form.

The goal is not to teach every API again. It is to show how to keep SLT code readable as screens, widgets, and state grow.

The default shape of a real SLT app

For most medium-sized apps, the most readable shape is:

  • one plain Rust App struct for app state
  • one top-level run(...) or run_with(...) closure
  • a few render_* helper functions for big panels or screens
  • occasional hooks for truly local persistent state

That keeps the public grammar small without turning the closure into a 400-line blob.

Application state lives in normal Rust

use slt::{Context, KeyCode};

struct App {
    count: i32,
    dark: bool,
}

fn main() -> std::io::Result<()> {
    let mut app = App {
        count: 0,
        dark: false,
    };

    slt::run(|ui: &mut Context| {
        if ui.key('q') || ui.key_code(KeyCode::Esc) {
            ui.quit();
        }
        if ui.button("+1").clicked {
            app.count += 1;
        }

        ui.checkbox("Dark mode", &mut app.dark);
        ui.text(format!("count: {}", app.count));
    })
}

Use plain structs when the state belongs to your app, your domain, or your screens.

Local persistent state with hooks

let count = ui.use_state(|| 0i32);
ui.text(format!("{}", count.get(ui)));
if ui.button("+1").clicked {
    *count.get_mut(ui) += 1;
}

Use hooks when:

  • the state is local to one render subtree
  • introducing a top-level field would add more noise than clarity
  • you want to prototype quickly

Keep hook call order stable across frames.

When to use app state vs hooks

Use app state when:

  • multiple screens need the value
  • the value matters outside rendering
  • you want explicit ownership and easier refactoring

Use hooks when:

  • the value is local to one widget subtree
  • the lifetime is purely UI-local
  • you want a lightweight scratchpad for a small interactive fragment

The mistake is not using hooks. The mistake is using hooks for everything until the screen becomes impossible to reason about.

Derived state with use_memo

let filtered = ui.use_memo(&(query.clone(), items.len()), |(query, _)| {
    items
        .iter()
        .filter(|item| item.contains(query))
        .cloned()
        .collect::<Vec<_>>()
});

Use use_memo when the computation is deterministic and should only rerun when the dependency tuple changes. Like use_state, call order must stay stable across frames.

Reduce repeated builder chains with helpers

Large SLT screens start to look noisy when the same builder chain repeats everywhere. The fix is usually not a new framework. It is a helper function.

use slt::{Border, Context};

fn panel(ui: &mut Context, title: &str, f: impl FnOnce(&mut Context)) {
    let _ = ui
        .bordered(Border::Rounded)
        .title(title)
        .p(1)
        .grow(1)
        .col(f);
}

This keeps the chaining style but moves the repetition out of the screen body.

Components

A "component" in SLT is not a framework concept. There is no Component trait, no virtual DOM, no lifecycle hooks beyond the few state primitives you already know. A component is whatever helper function you find yourself extracting when the same shape repeats.

This section walks the four building blocks that make components ergonomic:

  1. Functions for the shape itself
  2. use_state_named for state that survives across frames inside the component
  3. provide / use_context for values that propagate through deep trees
  4. .with / .with_if for conditional styling

The order matters. Reach for the simpler tool first.

Components as Functions (the canonical pattern)

The simplest reusable component is a free function that takes &mut Context and any explicit state it needs. No framework magic, no registration step.

use slt::{Border, Context, Trend};

fn metric_card(ui: &mut Context, label: &str, value: f64, trend: Trend) {
    let _ = ui.bordered(Border::Single).pad(1).col(|ui| {
        ui.text(label).dim();
        ui.text(format!("{:.1}", value)).bold();
        let arrow = match trend {
            Trend::Up => "▲",
            Trend::Down => "▼",
            Trend::Flat => "—",
        };
        ui.text(arrow);
    });
}

Usage from a screen:

fn render_dashboard(ui: &mut Context, app: &App) {
    ui.row(|ui| {
        metric_card(ui, "Revenue", app.revenue, Trend::Up);
        metric_card(ui, "Latency p99", app.p99_ms, Trend::Down);
        metric_card(ui, "Active users", app.users, Trend::Flat);
    });
}

Trade-offs:

  • Fully explicit. Rust's ownership tells you exactly who reads and who mutates what.
  • Type-safe. The compiler enforces parameter shapes at every call site.
  • No hidden state. The function only renders; it does not remember anything between frames.

The cost is parameter count. When a component grows past four or five arguments, group related values into a small struct and pass &Props instead of repeating five-arg signatures across the codebase.

struct MetricCardProps<'a> {
    label: &'a str,
    value: f64,
    trend: Trend,
    accent: Color,
}

fn metric_card(ui: &mut Context, p: &MetricCardProps<'_>) {
    // ...
}

This is still "just a function." No ceremony.

Component-local State with use_state_named

Sometimes a component needs state that must survive across frames but does not belong to the caller. A collapsible panel knows whether it is expanded. A pagination control knows the current page. Threading those values up through every call site pollutes the caller's API.

use_state_named is the answer. It is use_state, but keyed by an explicit &'static str instead of by hook call order:

use slt::{Border, Context};

fn expandable_card(
    ui: &mut Context,
    id: &'static str,
    title: &str,
    body: impl FnOnce(&mut Context),
) {
    let expanded = ui.use_state_named::<bool>(id);
    let _ = ui.bordered(Border::Single).col(|ui| {
        let label = if *expanded.get(ui) { "▼" } else { "▶" };
        if ui.button(label).clicked {
            let v = *expanded.get(ui);
            *expanded.get_mut(ui) = !v;
        }
        ui.text(title).bold();
        if *expanded.get(ui) {
            body(ui);
        }
    });
}

Usage:

expandable_card(ui, "card.networking", "Networking", |ui| {
    ui.text("eth0  192.168.1.42");
    ui.text("wlan0 10.0.0.7");
});

expandable_card(ui, "card.disks", "Disks", |ui| {
    ui.text("nvme0n1  931 GiB");
});

When you need a default different from Default::default(), use use_state_named_with:

let page = ui.use_state_named_with::<usize>("pager", || 1);

Rules of the road:

  • IDs are &'static str. Pick something descriptive — "card.networking", "pager.users", not "x".
  • Two calls with the same id at the same scope share state. This is sometimes what you want (siblings agreeing on a value) and sometimes a bug (two unrelated cards collapsing together). When in doubt, suffix with the data key: "card.disks" vs "card.networking".
  • Unlike positional use_state, named state does not depend on call order. You can branch around a named-state read without losing the value next frame.
  • For the full method surface on the returned State<T>, see STATE_APIS.md.

Context Injection with ui.provide / ui.use_context

Some values want to propagate through a deep tree without being passed to every function. The classic examples are theme, the current user, and feature flags. Threading them through ten render helpers is parameter-drilling, and it makes refactoring painful.

provide makes a value available inside a closure. Anything inside that closure can read it via use_context:

use slt::{Color, Context};

struct AppContext {
    username: String,
    show_debug: bool,
}

fn main() -> std::io::Result<()> {
    slt::run(|ui| {
        let app = AppContext {
            username: "sb".into(),
            show_debug: true,
        };
        ui.provide(app, |ui| {
            render_home(ui);
        });
    })
}

fn render_home(ui: &mut Context) {
    let app = ui.use_context::<AppContext>();
    ui.text(format!("Hello, {}", app.username));
    if app.show_debug {
        ui.text("debug mode on").dim();
    }
}

How it behaves:

  • Scoped. The value is alive only inside the body closure passed to provide. Outside that closure, use_context::<AppContext>() panics — there is no value.
  • Shadowing. Nested provide calls of the same type shadow the outer one for the duration of the inner closure. Think of it as a LIFO stack, one stack per TypeId.
  • Optional reads. try_use_context::<T>() -> Option<&T> returns None instead of panicking. Use it when a component should work both inside and outside the provider.
fn render_footer(ui: &mut Context) {
    if let Some(app) = ui.try_use_context::<AppContext>() {
        ui.text(format!("logged in as {}", app.username)).dim();
    } else {
        ui.text("anonymous").dim();
    }
}

When not to use context:

  • The value lives in your top-level app struct and you already pass &mut App around. Do not duplicate it into context.
  • The value is needed by exactly one render helper that is two scope levels away. A parameter is clearer.

A good heuristic: reach for provide when the same value is needed by three or more render helpers across two or more scope levels.

Conditional Styling with .with_if

Conditional styling is everywhere — a row that highlights when selected, a label that turns red when invalid, a button that dims when disabled. Without help, this clutters the call site:

let mut t = ui.text("Status");
if is_error {
    t = t.bold().fg(Color::Red);
}
if is_selected {
    t = t.bg(Color::DarkGray);
}

.with_if(cond, modifier) compresses that into a chain that reads top-to-bottom:

ui.text("Status")
    .with_if(is_error, |t| {
        t.bold().fg(Color::Red);
    })
    .with_if(is_selected, |t| {
        t.bg(Color::DarkGray);
    });

The closure runs only when the condition is true. The modifier receives a mutable handle to the same builder, so you can chain any styling method inside.

For unconditional grouping — factoring shared modifier blocks out of multiple call sites — use .with(modifier):

fn dim_label(t: &mut TextBuilder<'_>) {
    t.dim().italic();
}

ui.text("uptime: 3d 4h").with(dim_label);
ui.text("region: us-west-2").with(dim_label);

Both .with and .with_if are available on text and on container builders, so the same idiom works for bordered, row, col, etc.:

ui.bordered(Border::Single)
    .with_if(panel_focused, |c| {
        c.title("(focused)").pad(2);
    })
    .col(|ui| {
        // ...
    });

When to use which

Pattern Use when Example scenarios
Function + explicit args Small components, fewer than 3-4 params metric_card, header_row, kv_pair
use_state_named Component has LOCAL state that should survive across frames collapsible panel, pagination cursor, sort direction
provide / use_context Values cross 3+ scope levels theme, logged-in user, feature flags, request id
.with_if Styling depends on a runtime condition selected row, error text, disabled state, focused panel
.with Factor shared style blocks out of multiple call sites "dim label", "code-style text", "subtle border"

Anti-patterns

These look tempting but make code harder, not easier:

  • Reaching for context when a parameter is clearer. If a value is used in one helper, just pass it. Context is for values that propagate, not for "I do not feel like typing the parameter."
  • Using use_state_named for app-level state. Page-level state, selected tab, current user — these belong in your top-level App struct so that tests, persistence, and refactors stay sane. Named state is for state that is genuinely local to a component instance.
  • Reusing the same id by accident. expandable_card(ui, "card", ...) called five times shares one boolean across all five cards. Pick ids that name the instance, not the kind.
  • Nesting provide purely to "override defaults." If you find yourself wrapping every render helper in a fresh provide, the value should probably be a parameter or a method argument instead.

For full working apps that combine these patterns, see COOKBOOK.md. For the per-method reference on State<T> and friends, see STATE_APIS.md. For the single-file AI-oriented reference, see COMPLETE_REFERENCE.md.

Split big screens into render helpers

fn render_sidebar(ui: &mut Context, app: &mut App) {
    ui.text("Navigation").bold();
    let _ = ui.list(&mut app.sidebar);
}

fn render_content(ui: &mut Context, app: &mut App) {
    ui.text(format!("Selected: {}", app.current_title()));
}

fn render_app(ui: &mut Context, app: &mut App) {
    ui.row(|ui| {
        panel(ui, "Sidebar", |ui| render_sidebar(ui, app));
        panel(ui, "Content", |ui| render_content(ui, app));
    });
}

If a closure becomes hard to scan, extract render helpers before inventing a new abstraction layer.

Forms and validation

let mut email = TextInputState::with_placeholder("you@example.com");
ui.text_input(&mut email);
email.validate(|value| {
    if value.contains('@') {
        Ok(())
    } else {
        Err("Invalid email".into())
    }
});

For larger forms, reach for FormField and FormState.

Focus and keyboard shortcuts

if ui.key('q') {
    ui.quit();
}
if ui.key_code(KeyCode::Enter) {
    submit();
}
if ui.raw_key_code(KeyCode::Esc) {
    close_overlay();
}

Use raw_* shortcuts for keys that must work regardless of modal or overlay state.

Screen helpers and navigation

ui.screen("home", &mut screens, |ui| {
    ui.text("Home").bold();
});

ui.screen("settings", &mut screens, |ui| {
    ui.text("Settings");
});

Use screen(name, &mut screens, ...) when you want declarative rendering that only runs for the active screen. Each screen gets isolated hook state and focus. Use manual push() / pop() logic on ScreenState when you need explicit navigation transitions.

Modal, overlay, and screen composition

ui.screen("home", &mut screens, |ui| {
    if ui.button("Settings").clicked {
        screens.push("settings");
    }
});

ui.screen("settings", &mut screens, |ui| {
    if ui.button("Back").clicked {
        screens.pop();
    }
});

if show_modal {
    ui.modal(|ui| {
        ui.text("Confirm?").bold();
    });
}

Use screens for view-level navigation and modal/overlay for transient UI layers.

Error boundaries and recovery

ui.error_boundary(|ui| {
    ui.text("Protected subtree");
});

Use error_boundary or error_boundary_with when you want one subtree to fail without taking down the whole app. This is especially useful for experimental widgets, user-generated content, or plugins.

Custom widgets: focus and interaction

let focused = ui.register_focusable();
let response = ui.interaction();

if response.hovered {
    ui.tooltip("Hovered");
}

Use register_focusable() when the widget needs keyboard participation. Use interaction() when the widget needs click/hover without wrapping everything in a container.

Async background messages

let tx = slt::run_async(|ui, messages: &mut Vec<String>| {
    for message in messages.drain(..) {
        ui.text(message);
    }
})?;

tx.send("Background work done".into()).await?;

Enable with the async feature.

Animation patterns

// Tween: smooth transition over N ticks
let mut fade = Tween::new(0.0, 1.0, 30);
let opacity = fade.value(ui.tick());

// Spring: physics-based, responds to target changes
let mut spring = Spring::new(0.0, 0.2, 0.85);
if hovered {
    spring.set_target(1.0);
} else {
    spring.set_target(0.0);
}
spring.tick();
let scale = spring.value();

// Stagger: offset animation across list items
let mut stagger = Stagger::new(0.0, 1.0, 20).delay(3).items(items.len());
for (i, item) in items.iter().enumerate() {
    let alpha = stagger.value(ui.tick(), i);
    ui.text(item)
        .fg(Color::Rgb(255, 255, (alpha * 255.0) as u8));
}

Animation types are standalone structs that compute values from ui.tick(). Pass computed values to style and layout methods. See docs/ANIMATION.md for the full API.

Responsive layout

match ui.breakpoint() {
    Breakpoint::Xs | Breakpoint::Sm => {
        ui.col(|ui| { /* stacked layout */ });
    }
    _ => {
        ui.row(|ui| { /* side-by-side layout */ });
    }
};

Use breakpoint() for width-dependent layout decisions. ContainerBuilder also supports responsive methods like .gap_sm(1).gap_lg(2).

Custom widgets

use slt::{Color, Context, Style, Widget};

struct Rating {
    value: u8,
    max: u8,
}

impl Widget for Rating {
    type Response = bool;

    fn ui(&mut self, ui: &mut Context) -> bool {
        let focused = ui.register_focusable();
        let mut changed = false;

        if focused {
            if ui.key('+') && self.value < self.max {
                self.value += 1;
                changed = true;
            }
            if ui.key('-') && self.value > 0 {
                self.value -= 1;
                changed = true;
            }
        }

        let stars: String = (0..self.max)
            .map(|i| if i < self.value { '★' } else { '☆' })
            .collect();
        ui.styled(
            stars,
            Style::new().fg(if focused {
                Color::Yellow
            } else {
                Color::White
            }),
        );
        changed
    }
}

If you add a new built-in widget to the library itself, also follow the checklist in CONTRIBUTING.md.

Testing and verification

use slt::TestBackend;

let mut backend = TestBackend::new(40, 10);
backend.render(|ui| {
    ui.text("Hello");
});

backend.assert_contains("Hello");

Use TestBackend for headless rendering checks and snapshot-style assertions. For runtime-contract work, add a custom Backend + frame() test too.

Heuristics that keep SLT readable

  • If a builder chain repeats three times, extract a helper.
  • If a closure becomes hard to scan, split it into render_* functions.
  • If state is shared across screens, move it into your app struct.
  • If state is local to one subtree, hooks are fine.
  • If behavior depends on previous-frame data, test at least two frames.

SLT stays pleasant when you keep the public grammar small even as the codebase grows.