scrin-widgets 0.2.6

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, PresentStrategy, Rect, Terminal, TerminalOptions,
    core::buffer::Cell,
    layout::{Constraint, Direction, Layout},
    widgets::Clear,
};
use scrin_widgets::{AislingExt, AislingPalette, List, Paragraph, StatusBar, StreamPanel, Table};

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 probe = (4_u16, 5_u16);
    let mut selected = 0_usize;
    let mut stream_scroll = 0_u16;

    let stream_lines: Vec<String> = (0..90)
        .map(|i| {
            format!(
                "[{i:03}] selectable transcript row -> payload {:02x}",
                i * 17 % 255
            )
        })
        .collect();
    let list_items: Vec<String> = [
        "hit region",
        "selectable span",
        "scroll row",
        "selection group",
        "logical row",
        "probe cursor",
        "dirty rect",
    ]
    .into_iter()
    .map(String::from)
    .collect();
    let rows: Vec<[String; 3]> = (0..18)
        .map(|i| {
            [
                format!("row_{i:02}"),
                ["warm", "focus", "copy", "route"][i % 4].to_string(),
                format!("span:{}", i + 1),
            ]
        })
        .collect();

    loop {
        terminal.draw_with_present_strategy(PresentStrategy::MarkedDirty, |frame| {
            let palette = AislingPalette::cypherpunk();
            let area = frame.area();
            probe.0 = probe.0.min(area.width.saturating_sub(1));
            probe.1 = probe.1.min(area.height.saturating_sub(1));

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

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

            render_header(frame, root[0], tick, palette);

            let body = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
                .split(root[1]);
            let right = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Percentage(36),
                    Constraint::Percentage(34),
                    Constraint::Percentage(30),
                ])
                .split(body[1]);

            let visible = stream_lines.len().min(18 + tick as usize % 24);
            StreamPanel::new()
                .lines(stream_lines[..visible].iter().cloned())
                .show_line_numbers(true)
                .follow_tail(stream_scroll == 0)
                .scroll_offset(stream_scroll)
                .tick(tick)
                .palette(palette)
                .block(palette.block("stream: hit + span + scroll"))
                .render_with_interaction(frame, "meta:stream", body[0]);

            List::new()
                .items(list_items.iter().cloned())
                .selected(Some(selected.min(list_items.len().saturating_sub(1))))
                .tick(tick)
                .palette(palette)
                .block(palette.block("list metadata"))
                .render_with_interaction(frame, "meta:list", right[0]);

            Table::new(["id", "state", "copy"])
                .rows(rows.iter().cloned())
                .selected(Some(selected.min(rows.len().saturating_sub(1))))
                .tick(tick)
                .palette(palette)
                .block(palette.block("table metadata"))
                .render_with_interaction(frame, "meta:table", right[1]);

            let report = metadata_report(frame, probe);
            frame.render_widget_timed(
                "metadata report",
                Paragraph::new(report)
                    .palette(palette)
                    .block(palette.block("probe report")),
                right[2],
            );
            frame.mark_dirty(right[2]);
            draw_probe(frame, probe, palette);

            frame.render_widget_timed(
                "status",
                StatusBar::new()
                    .left("arrows move probe")
                    .center("j/k select rows | [/] scroll stream")
                    .right("q quits")
                    .palette(palette),
                root[2],
            );
            frame.mark_dirty(root[2]);
        })?;

        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::Left => probe.0 = probe.0.saturating_sub(1),
                        KeyCode::Right => probe.0 = probe.0.saturating_add(1),
                        KeyCode::Up => probe.1 = probe.1.saturating_sub(1),
                        KeyCode::Down => probe.1 = probe.1.saturating_add(1),
                        KeyCode::Char('k') => selected = selected.saturating_sub(1),
                        KeyCode::Char('j') => {
                            let max = list_items.len().max(rows.len()).saturating_sub(1);
                            selected = (selected + 1).min(max);
                        }
                        KeyCode::Char('[') => stream_scroll = stream_scroll.saturating_add(1),
                        KeyCode::Char(']') => stream_scroll = stream_scroll.saturating_sub(1),
                        _ => {}
                    }
                }
            }
        }

        tick = tick.wrapping_add(1);
    }

    Ok(())
}

fn render_header(frame: &mut Frame<'_>, area: Rect, tick: u64, palette: AislingPalette) {
    let block = palette.block("Scrin interaction metadata flow");
    let inner = block.inner(area);
    frame.render_widget_timed("header:block", block, area);
    frame.render_widget_timed(
        "header:text",
        Paragraph::new(format!(
            "StreamPanel, List, and Table now register HitRegion, SelectableSpan, and ScrollRowHit metadata.\nMove the probe over rows to see logical mappings. tick {tick}"
        ))
        .palette(palette)
        .aisling()
        .tick(tick)
        .intensity(4),
        inner,
    );
    frame.mark_dirty(area);
}

fn metadata_report(frame: &Frame<'_>, probe: (u16, u16)) -> String {
    let layer = frame.interaction_layer();
    let hit = layer
        .hit_test(probe.0, probe.1)
        .map(|region| format!("{} [{:?}] row {:?}", region.label, region.role, region.row))
        .unwrap_or_else(|| "none".to_string());
    let scroll = layer
        .scroll_hit_test(probe.0, probe.1)
        .map(|hit| format!("{} -> logical row {}", hit.region_id, hit.logical_row))
        .unwrap_or_else(|| "none".to_string());
    let selectable = layer
        .selectable_at(probe.0, probe.1)
        .map(|(span, point)| format!("{} @ line {} col {}", span.text, point.line, point.column))
        .unwrap_or_else(|| "none".to_string());

    format!(
        "probe: {}, {}\nregions: {}\nspans: {}\nscroll regions: {}\nhit: {hit}\nscroll: {scroll}\nselectable: {selectable}",
        probe.0,
        probe.1,
        layer.regions.len(),
        layer.selectable_spans.len(),
        layer.scroll_regions.len(),
    )
}

fn draw_probe(frame: &mut Frame<'_>, probe: (u16, u16), palette: AislingPalette) {
    frame.buffer().set(
        usize::from(probe.0),
        usize::from(probe.1),
        Cell::new('@', palette.pulse, Some(palette.shadow)).with_bold(true),
    );
    frame.mark_dirty(Rect::new(probe.0, probe.1, 1, 1));
}