scrin-widgets 0.2.4

Scrin-native widgets and Aisling terminal effects for immersive TUIs.
Documentation
use std::{io, time::Duration};

use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use scrin::{
    Frame, FrameTiming, PresentStrategy, Rect, Terminal, TerminalOptions,
    layout::{Constraint, Direction, Layout},
    widgets::Clear,
};
use scrin_widgets::{
    AislingExt, AislingPalette, Gauge, GlyphRain, List, NebulaGauge, Paragraph, SignalPanel,
    Sparkline, StatusBar, StreamPanel, TabBar, Table, WaveType, Waveform,
};

const TABS: [&str; 3] = ["Flow", "Data", "Motion"];

fn main() -> io::Result<()> {
    let mut terminal = Terminal::init_with(TerminalOptions {
        mouse_capture: true,
        bracketed_paste: true,
        ..TerminalOptions::default()
    })?;
    let result = run(&mut terminal);
    terminal.restore()?;
    result
}

fn run(terminal: &mut Terminal) -> io::Result<()> {
    let mut tick = 0_u64;
    let mut active_tab = 0_usize;
    let mut selected = 0_usize;
    let mut stream_scroll = 0_u16;
    let mut last_timing: Option<FrameTiming> = None;

    let stream_lines: Vec<String> = (0..180)
        .map(|i| {
            format!(
                "[{i:03}] packet:{:02x} routed through aisling mesh",
                i * 13 % 255
            )
        })
        .collect();
    let resources: Vec<String> = [
        "loader cache",
        "glyph stream",
        "theme bridge",
        "hit regions",
        "dirty presenter",
        "status deck",
        "retained panes",
        "pointer layer",
    ]
    .into_iter()
    .map(String::from)
    .collect();
    let table_rows: Vec<[String; 3]> = (0..36)
        .map(|i| {
            [
                format!("agent_{i:02}"),
                ["idle", "warming", "routing", "done"][i % 4].to_string(),
                format!("{}ms", (i * 29) % 240),
            ]
        })
        .collect();

    loop {
        let timing = last_timing;
        terminal.draw_with_present_strategy(PresentStrategy::MarkedDirty, |frame| {
            let palette = match active_tab {
                0 => AislingPalette::cypherpunk(),
                1 => AislingPalette::dream(),
                _ => AislingPalette::phosphor(),
            };
            let root = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(4),
                    Constraint::Length(1),
                    Constraint::Min(10),
                    Constraint::Length(1),
                ])
                .split(frame.area());

            frame.render_widget_timed("clear", Clear::with_bg(palette.shadow), frame.area());
            frame.mark_dirty(frame.area());

            render_header(frame, root[0], tick, palette, timing);
            TabBar::new(TABS)
                .selected(active_tab)
                .tick(tick)
                .palette(palette)
                .render_with_interaction(frame, "flow:tabs", root[1]);

            frame.time_pane("page", root[2], |frame| match active_tab {
                0 => render_flow_page(frame, root[2], tick, palette, &stream_lines, stream_scroll),
                1 => render_data_page(frame, root[2], palette, &resources, &table_rows, selected),
                _ => render_motion_page(frame, root[2], tick, palette),
            });

            let diagnostics = frame.diagnostics().len();
            frame.render_widget_timed(
                "status",
                StatusBar::new()
                    .left(format!("tab {} / {}", active_tab + 1, TABS.len()))
                    .center(format!(
                        "frame {} · diagnostics {diagnostics}",
                        frame.frame_count()
                    ))
                    .right("1-3 tabs · arrows select/scroll · q quits")
                    .palette(palette),
                root[3],
            );
            frame.mark_dirty(root[3]);
        })?;

        last_timing = terminal.last_frame_timing();

        if event::poll(Duration::from_millis(33))? {
            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    match key.code {
                        KeyCode::Char('q') | KeyCode::Esc => break,
                        KeyCode::Char('1') => active_tab = 0,
                        KeyCode::Char('2') => active_tab = 1,
                        KeyCode::Char('3') => active_tab = 2,
                        KeyCode::Tab => active_tab = (active_tab + 1) % TABS.len(),
                        KeyCode::BackTab => {
                            active_tab = if active_tab == 0 {
                                TABS.len() - 1
                            } else {
                                active_tab - 1
                            };
                        }
                        KeyCode::Up => selected = selected.saturating_sub(1),
                        KeyCode::Down => {
                            let max = resources.len().max(table_rows.len()).saturating_sub(1);
                            selected = (selected + 1).min(max);
                        }
                        KeyCode::Left => stream_scroll = stream_scroll.saturating_sub(1),
                        KeyCode::Right => stream_scroll = stream_scroll.saturating_add(1),
                        _ => {}
                    }
                }
            }
        }

        tick = tick.wrapping_add(1);
    }

    Ok(())
}

fn render_header(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    palette: AislingPalette,
    timing: Option<FrameTiming>,
) {
    let block = palette.block("scrin 0.1.83 flow");
    let inner = block.inner(area);
    frame.render_widget_timed("header:block", block, area);

    let timing = timing
        .map(|t| {
            format!(
                "{}us · dirty {} · areas {}",
                t.elapsed.as_micros(),
                t.dirty_cells,
                t.areas
            )
        })
        .unwrap_or_else(|| "warming presenter".to_string());
    frame.render_widget_timed(
        "header:text",
        Paragraph::new(format!(
            "theme tokens bridge Scrin's new surfaces into Aisling palettes · tick {tick}\n{timing}"
        ))
        .palette(palette)
        .aisling()
        .tick(tick)
        .intensity(5),
        inner,
    );
    frame.mark_dirty(area);
}

fn render_flow_page(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    palette: AislingPalette,
    lines: &[String],
    scroll: u16,
) {
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
        .split(area);
    let visible = lines.len().min(24 + tick as usize % lines.len());

    StreamPanel::new()
        .lines(lines[..visible].iter().cloned())
        .show_line_numbers(true)
        .follow_tail(scroll == 0)
        .scroll_offset(scroll)
        .tick(tick)
        .palette(palette)
        .block(palette.block("registered stream"))
        .render_with_interaction(frame, "flow:stream", columns[0]);

    let right = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(8),
            Constraint::Length(4),
            Constraint::Min(4),
        ])
        .split(columns[1]);
    frame.render_widget_timed(
        "signal",
        SignalPanel::new("scrin relay")
            .line("present: MarkedDirty")
            .line("theme: ThemeTokens")
            .line("regions: hit-testable")
            .line("frame: timed")
            .tick(tick)
            .palette(palette),
        right[0],
    );
    frame.render_widget_timed(
        "gauge",
        NebulaGauge::new(wave_ratio(tick, 120, 0))
            .tick(tick)
            .label("dirty budget")
            .palette(palette)
            .block(palette.block("presenter")),
        right[1],
    );
    frame.render_widget_timed(
        "note",
        Paragraph::new(
            "The stream widget renders through Frame, registers row hit regions, and marks its area dirty for Scrin's presentation strategy.",
        )
        .palette(palette)
        .block(palette.block("flow")),
        right[2],
    );
    frame.mark_dirty(columns[1]);
}

fn render_data_page(
    frame: &mut Frame<'_>,
    area: Rect,
    palette: AislingPalette,
    resources: &[String],
    table_rows: &[[String; 3]],
    selected: usize,
) {
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(36), Constraint::Percentage(64)])
        .split(area);

    List::new()
        .items(resources.iter().cloned())
        .selected(Some(selected.min(resources.len().saturating_sub(1))))
        .palette(palette)
        .block(palette.block("List::hit_regions"))
        .render_with_interaction(frame, "flow:list", columns[0]);

    Table::new(["agent", "state", "latency"])
        .rows(table_rows.iter().cloned())
        .selected(Some(selected.min(table_rows.len().saturating_sub(1))))
        .palette(palette)
        .block(palette.block("Table::hit_regions"))
        .render_with_interaction(frame, "flow:table", columns[1]);
}

fn render_motion_page(frame: &mut Frame<'_>, area: Rect, tick: u64, palette: AislingPalette) {
    let root = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Percentage(54), Constraint::Percentage(46)])
        .split(area);
    let top = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
        .split(root[0]);

    frame.render_widget_timed(
        "glyph rain",
        GlyphRain::new(tick)
            .density(44)
            .palette(palette)
            .block(palette.block("theme-backed block")),
        top[0],
    );
    frame.render_widget_timed(
        "waveform",
        Waveform::new(5.0, 0.72)
            .tick(tick)
            .wave_type(WaveType::Triangle)
            .palette(palette)
            .block(palette.block("timed waveform")),
        top[1],
    );

    let bottom = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
        .split(root[1]);
    frame.render_widget_timed(
        "sparkline",
        Sparkline::new((0..48).map(|i| ((i * 7 + tick as u16) % 29) + 2).collect())
            .palette(palette)
            .block(palette.block("sparkline")),
        bottom[0],
    );
    frame.render_widget_timed(
        "gauge",
        Gauge::new(wave_ratio(tick, 96, 29))
            .label("retained rhythm")
            .palette(palette)
            .block(palette.block("plain gauge")),
        bottom[1],
    );
    frame.mark_dirty(area);
}

fn wave_ratio(tick: u64, period: u64, offset: u64) -> f64 {
    let phase = ((tick + offset) % period) as f64 / period as f64;
    1.0 - (phase - 0.5).abs() * 2.0
}