scrin-widgets 0.2.10

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::{
    FrameTiming, PresentStats, Rect, Style, Terminal, TerminalOptions,
    core::buffer::{Buffer, CellStyle},
    layout::{Constraint, Direction, Layout},
    widgets::Widget,
};
use scrin_widgets::{
    AislingExt, AislingPalette, AislingTerminalExt, DiffRunSummary, Gauge, Paragraph, SignalPanel,
    StatusBar, StreamPanel, collect_diff_run_summaries, write_diff_ansi_to,
};

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 area_mode = false;
    let mut last_timing: Option<FrameTiming> = None;
    let mut last_stats: Option<PresentStats> = None;
    let mut last_runs: Vec<DiffRunSummary> = Vec::new();

    loop {
        let palette = match (tick / 120) % 3 {
            0 => AislingPalette::cypherpunk(),
            1 => AislingPalette::dream(),
            _ => AislingPalette::flare(),
        };
        let area = terminal.size();
        let dashboard = DirectPresentDashboard {
            tick,
            palette,
            area_mode,
            last_timing,
            last_stats,
            last_runs: &last_runs,
        };
        let buffer = terminal.render_widget_buffer(dashboard);
        let areas = [area];
        let area_filter = area_mode.then_some(areas.as_slice());

        let mut streamed = Vec::new();
        let stats = write_diff_ansi_to(
            &mut streamed,
            terminal.front_buffer(),
            &buffer,
            area.width,
            area.height,
            area_filter,
        )?;
        let runs =
            collect_diff_run_summaries(terminal.front_buffer(), &buffer, area.width, area.height);
        let timing = if area_mode {
            terminal.present_buffer_areas_timed(&buffer, &areas)?
        } else {
            terminal.present_buffer_timed(&buffer)?
        };

        last_timing = Some(timing);
        last_stats = Some(stats);
        last_runs = runs.into_iter().take(8).collect();

        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('a') => area_mode = !area_mode,
                        _ => {}
                    }
                }
            }
        }

        tick = tick.wrapping_add(1);
    }

    Ok(())
}

struct DirectPresentDashboard<'a> {
    tick: u64,
    palette: AislingPalette,
    area_mode: bool,
    last_timing: Option<FrameTiming>,
    last_stats: Option<PresentStats>,
    last_runs: &'a [DiffRunSummary],
}

impl Widget for DirectPresentDashboard<'_> {
    fn render(&self, buf: &mut Buffer, area: Rect) {
        let style = Style::new().fg(self.palette.high).bg(self.palette.shadow);
        buf.fill_styled(area, ' ', style);

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

        self.render_header(buf, root[0]);
        self.render_body(buf, root[1]);
        self.render_diff_report(buf, root[2]);
        StatusBar::new()
            .left(format!(
                "present: {}",
                if self.area_mode { "areas" } else { "buffer" }
            ))
            .center("A toggles present_buffer_areas_timed | q quits")
            .right(format!("tick {}", self.tick))
            .palette(self.palette)
            .render(buf, root[3]);
    }
}

impl DirectPresentDashboard<'_> {
    fn render_header(&self, buf: &mut Buffer, area: Rect) {
        let block = self.palette.block("Scrin 0.1.84 direct presentation flow");
        let inner = block.inner(area);
        block.render(buf, area);
        Paragraph::new(format!(
            "Standalone Buffer -> diff_runs/write_diff_to -> present_buffer_timed. tick {}\n{}",
            self.tick,
            timing_line(self.last_timing)
        ))
        .palette(self.palette)
        .aisling()
        .tick(self.tick)
        .intensity(4)
        .render(buf, inner);
    }

    fn render_body(&self, buf: &mut Buffer, area: Rect) {
        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(45),
                Constraint::Length(4),
                Constraint::Min(4),
            ])
            .split(columns[1]);
        let stream_lines: Vec<String> = (0..80)
            .map(|i| format!("[{i:03}] styled bulk buffer cell run {:02x}", i * 23 % 255))
            .collect();
        let visible = stream_lines.len().min(18 + self.tick as usize % 36);

        StreamPanel::new()
            .lines(stream_lines[..visible].iter().cloned())
            .show_line_numbers(true)
            .follow_tail(true)
            .tick(self.tick)
            .palette(self.palette)
            .block(self.palette.block("rendered into standalone buffer"))
            .render(buf, columns[0]);

        SignalPanel::new("0.1.84 paths")
            .line("Buffer::fill_styled")
            .line("Buffer::set_str_styled")
            .line("diff_runs -> style runs")
            .line("write_diff_to -> Vec<u8>")
            .line("present_buffer*_timed")
            .tick(self.tick)
            .palette(self.palette)
            .render(buf, right[0]);

        Gauge::new(wave_ratio(self.tick, 160, 0))
            .label("direct diff")
            .palette(self.palette)
            .block(self.palette.block("streamed ANSI"))
            .render(buf, right[1]);

        Paragraph::new(format!(
            "wide glyph metadata: {}\nCellStyle sample: {}",
            if buf.has_wide_glyphs() {
                "present"
            } else {
                "clear"
            },
            style_summary(CellStyle::from_style(
                Style::new()
                    .fg(self.palette.mid)
                    .bg(self.palette.shadow)
                    .bold()
            ))
        ))
        .palette(self.palette)
        .block(self.palette.block("buffer metadata"))
        .render(buf, right[2]);

        buf.set_str_styled(
            usize::from(right[2].x.saturating_add(1)),
            usize::from(right[2].y.saturating_add(3)),
            "wide safe: δΈ­ -> ascii overwrite",
            Style::new()
                .fg(self.palette.pulse)
                .bg(self.palette.shadow)
                .bold(),
        );
    }

    fn render_diff_report(&self, buf: &mut Buffer, area: Rect) {
        let stats = self
            .last_stats
            .map(|stats| {
                format!(
                    "bytes {} | dirty cells {} | areas {} | full {}",
                    stats.bytes_written, stats.dirty_cells, stats.areas, stats.full_repaint
                )
            })
            .unwrap_or_else(|| "warming diff writer".to_string());
        let runs = if self.last_runs.is_empty() {
            "runs: warming".to_string()
        } else {
            self.last_runs
                .iter()
                .map(|run| {
                    format!(
                        "@{},{} w{} cells{} {} {}",
                        run.x,
                        run.y,
                        run.width,
                        run.visible_cells,
                        style_summary(run.style),
                        run.text
                    )
                })
                .collect::<Vec<_>>()
                .join("\n")
        };

        Paragraph::new(format!("{stats}\n{runs}"))
            .palette(self.palette)
            .block(self.palette.block("previous diff run summaries"))
            .render(buf, area);
    }
}

fn timing_line(timing: Option<FrameTiming>) -> String {
    timing
        .map(|timing| {
            format!(
                "last present: {}us, {} bytes, {} dirty cells",
                timing.elapsed.as_micros(),
                timing.bytes_written,
                timing.dirty_cells
            )
        })
        .unwrap_or_else(|| "last present: warming".to_string())
}

fn style_summary(style: CellStyle) -> String {
    let mut flags = String::new();
    if style.bold {
        flags.push('b');
    }
    if style.italic {
        flags.push('i');
    }
    if style.underlined {
        flags.push('u');
    }
    if flags.is_empty() {
        flags.push('-');
    }
    format!("fg:{:?} bg:{:?} {flags}", style.fg, style.bg)
}

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
}