scrin-widgets 0.2.7

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, FrameStats, Gauge, List, Paragraph, SignalPanel, StatusBar,
    StreamPanel,
};

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 selected = 0_usize;
    let mut last_timing: Option<FrameTiming> = None;
    let stream_lines: Vec<String> = (0..120)
        .map(|i| {
            format!(
                "[{i:03}] diagnostic pane emitted workload slice {:02x}",
                i * 19 % 255
            )
        })
        .collect();
    let stages: Vec<String> = [
        "collect diagnostics",
        "diff presenter",
        "dirty regions",
        "hit metadata",
        "selectable spans",
        "scroll rows",
        "frame stats",
    ]
    .into_iter()
    .map(String::from)
    .collect();

    loop {
        let timing = last_timing;
        terminal.draw_with_present_strategy(PresentStrategy::MarkedDirty, |frame| {
            let palette = match (tick / 100) % 3 {
                0 => AislingPalette::cypherpunk(),
                1 => AislingPalette::phosphor(),
                _ => AislingPalette::flare(),
            };
            frame.render_widget_timed("clear", Clear::with_bg(palette.shadow), frame.area());
            frame.mark_dirty(frame.area());

            let root = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(5),
                    Constraint::Min(10),
                    Constraint::Length(9),
                    Constraint::Length(1),
                ])
                .split(frame.area());

            render_header(frame, root[0], tick, palette, timing);
            frame.time_pane("workload", root[1], |frame| {
                render_workload(
                    frame,
                    root[1],
                    tick,
                    palette,
                    selected,
                    &stream_lines,
                    &stages,
                );
            });

            frame.render_widget_timed(
                "frame stats",
                FrameStats::from_frame(frame, timing)
                    .palette(palette)
                    .max_diagnostics(8)
                    .block(palette.block("FrameStats::from_frame")),
                root[2],
            );
            frame.mark_dirty(root[2]);

            frame.render_widget_timed(
                "status",
                StatusBar::new()
                    .left(format!("frame {}", frame.frame_count()))
                    .center("j/k move selection | q quits")
                    .right(format!("diagnostics {}", frame.diagnostics().len()))
                    .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('k') | KeyCode::Up => selected = selected.saturating_sub(1),
                        KeyCode::Char('j') | KeyCode::Down => {
                            selected = (selected + 1).min(stages.len().saturating_sub(1));
                        }
                        _ => {}
                    }
                }
            }
        }

        tick = tick.wrapping_add(1);
    }

    Ok(())
}

fn render_header(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    palette: AislingPalette,
    timing: Option<FrameTiming>,
) {
    let timing = timing
        .map(|timing| {
            format!(
                "last frame: {}us, {} bytes, {} dirty cells",
                timing.elapsed.as_micros(),
                timing.bytes_written,
                timing.dirty_cells
            )
        })
        .unwrap_or_else(|| "last frame: warming presenter".to_string());
    let block = palette.block("Scrin frame diagnostics flow");
    let inner = block.inner(area);
    frame.render_widget_timed("header:block", block, area);
    frame.render_widget_timed(
        "header:text",
        Paragraph::new(format!(
            "FrameStats combines Terminal::last_frame_timing, Frame::diagnostics, dirty regions, and interaction counts. tick {tick}\n{timing}"
        ))
        .palette(palette)
        .aisling()
        .tick(tick)
        .intensity(4),
        inner,
    );
    frame.mark_dirty(area);
}

fn render_workload(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    palette: AislingPalette,
    selected: usize,
    stream_lines: &[String],
    stages: &[String],
) {
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
        .split(area);
    let right = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage(42),
            Constraint::Length(4),
            Constraint::Min(4),
        ])
        .split(columns[1]);
    let visible = stream_lines.len().min(20 + tick as usize % 50);

    StreamPanel::new()
        .lines(stream_lines[..visible].iter().cloned())
        .show_line_numbers(true)
        .follow_tail(true)
        .tick(tick)
        .palette(palette)
        .block(palette.block("timed stream"))
        .render_with_interaction(frame, "stats:stream", columns[0]);

    List::new()
        .items(stages.iter().cloned())
        .selected(Some(selected))
        .tick(tick)
        .palette(palette)
        .block(palette.block("interaction metadata"))
        .render_with_interaction(frame, "stats:stages", right[0]);

    frame.render_widget_timed(
        "dirty gauge",
        Gauge::new(wave_ratio(tick, 140, 0))
            .label("dirty budget")
            .palette(palette)
            .block(palette.block("presenter")),
        right[1],
    );
    frame.mark_dirty(right[1]);

    frame.render_widget_timed(
        "signal",
        SignalPanel::new("diagnostics")
            .line("time_pane -> NamedFrameTiming")
            .line("render_widget_timed -> FrameDiagnostic")
            .line("MarkedDirty -> dirty region count")
            .tick(tick)
            .palette(palette),
        right[2],
    );
    frame.mark_dirty(right[2]);
}

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
}