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
}