use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crossterm::event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::{Terminal, TerminalOptions, Viewport as RatatuiViewport};
use tuika::framebuffer::{FrameBuffer, FrameBufferView};
use tuika::prelude::*;
use tuika::view::DrawView;
struct DemoHighlighter;
static HL: DemoHighlighter = DemoHighlighter;
impl Highlighter for DemoHighlighter {
fn highlight(
&self,
lang: &str,
lines: &[&str],
theme: &Theme,
) -> Option<Vec<Vec<Span<'static>>>> {
if lang != "rust" {
return None;
}
Some(
lines
.iter()
.map(|l| highlight_rust(l, &theme.code))
.collect(),
)
}
}
fn highlight_rust(line: &str, code: &CodeTheme) -> Vec<Span<'static>> {
const KW: &[&str] = &[
"fn", "let", "mut", "pub", "use", "struct", "enum", "impl", "match", "return", "for", "in",
"if", "else", "while", "loop", "const", "as", "mod", "trait", "self", "true", "false",
];
let chars: Vec<char> = line.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '/' && chars.get(i + 1) == Some(&'/') {
let rest: String = chars[i..].iter().collect();
out.push(Span::styled(
rest,
Style::default()
.fg(code.comment)
.add_modifier(Modifier::ITALIC),
));
break;
}
if c == '"' {
let start = i;
i += 1;
while i < chars.len() && chars[i] != '"' {
i += if chars[i] == '\\' { 2 } else { 1 };
}
i = (i + 1).min(chars.len());
let s: String = chars[start..i].iter().collect();
out.push(Span::styled(s, Style::default().fg(code.string)));
continue;
}
if c.is_alphanumeric() || c == '_' {
let start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let word: String = chars[start..i].iter().collect();
let style = if KW.contains(&word.as_str()) {
Style::default()
.fg(code.keyword)
.add_modifier(Modifier::BOLD)
} else if word.chars().all(|d| d.is_ascii_digit()) {
Style::default().fg(code.constant)
} else {
Style::default().fg(code.text)
};
out.push(Span::styled(word, style));
continue;
}
let start = i;
i += 1;
while i < chars.len() {
let d = chars[i];
if d.is_alphanumeric()
|| d == '_'
|| d == '"'
|| (d == '/' && chars.get(i + 1) == Some(&'/'))
{
break;
}
i += 1;
}
let s: String = chars[start..i].iter().collect();
let style = if s.trim().is_empty() {
Style::default().fg(code.text)
} else {
Style::default().fg(code.punctuation)
};
out.push(Span::styled(s, style));
}
out
}
type Build = fn(u64, &Theme) -> Element;
struct Demo {
name: &'static str,
title: &'static str,
blurb: &'static str,
rows: u16,
animated: bool,
fills_frame: bool,
build: Build,
}
impl Demo {
const fn asset_extension(&self) -> &'static str {
if self.animated { "gif" } else { "png" }
}
fn asset_filename(&self) -> String {
format!("{}.{}", self.name, self.asset_extension())
}
}
const fn demo(
name: &'static str,
title: &'static str,
blurb: &'static str,
rows: u16,
animated: bool,
build: Build,
) -> Demo {
Demo {
name,
title,
blurb,
rows,
animated,
fills_frame: false,
build,
}
}
const fn filling_demo(
name: &'static str,
title: &'static str,
blurb: &'static str,
rows: u16,
animated: bool,
build: Build,
) -> Demo {
Demo {
fills_frame: true,
..demo(name, title, blurb, rows, animated, build)
}
}
const DEMOS: &[Demo] = &[
demo(
"spinner",
"Spinner",
"frame-cycled activity glyphs",
10,
true,
scene_spinner,
),
demo(
"progress_bar",
"ProgressBar",
"determinate & indeterminate bars",
12,
true,
scene_progress,
),
demo(
"loader",
"Loader",
"spinner + message + hint row",
9,
true,
scene_loader,
),
demo(
"text",
"Text",
"styled lines and word-wrapped prose",
11,
false,
scene_text,
),
demo(
"markdown",
"Markdown",
"CommonMark streamed in, only the tail re-parses",
18,
true,
scene_markdown,
),
demo(
"markdown_table",
"Markdown table",
"GFM table: alignment, emoji, and links",
13,
false,
scene_markdown_table,
),
demo(
"code_block",
"CodeBlock",
"syntax-highlighted code with line numbers",
12,
false,
scene_code_block,
),
demo(
"diff",
"Diff",
"unified line diff with +/- gutters and line numbers",
14,
false,
scene_diff,
),
demo(
"ascii_font",
"AsciiFont",
"large figlet-style block-letter banners",
12,
false,
scene_ascii_font,
),
demo(
"qr",
"QrCode",
"QR code encoded and drawn with half-blocks",
24,
false,
scene_qr,
),
demo(
"rule",
"Rule",
"titled horizontal separators",
12,
false,
scene_rule,
),
demo(
"boxed",
"Boxed",
"borders, titles, and padding",
12,
false,
scene_boxed,
),
demo(
"flex",
"Flex",
"flexbox grow / fixed distribution",
10,
false,
scene_flex,
),
filling_demo(
"scroll",
"Scroll",
"viewport + scrollbar over long content",
13,
true,
scene_scroll,
),
filling_demo(
"item_scroll",
"ItemScroll",
"the same viewport over laid-out items, not lines",
13,
true,
scene_item_scroll,
),
demo(
"primitives",
"Primitives",
"owned dialog + form + panning custom viewport",
17,
true,
scene_primitives,
),
demo(
"select",
"SelectList",
"keyboard-navigable selection list",
11,
true,
scene_select,
),
demo("tabs", "Tabs", "host-state tab strip", 9, true, scene_tabs),
demo(
"tab_select",
"TabSelect",
"value-selecting segmented control",
8,
true,
scene_tab_select,
),
demo(
"slider",
"Slider",
"value picker over a numeric range",
10,
true,
scene_slider,
),
demo(
"timeline",
"Timeline",
"keyframed easing tracks sampled from the frame counter",
12,
true,
scene_timeline,
),
demo(
"toast",
"Toasts",
"level-colored notification stack",
9,
false,
scene_toast,
),
filling_demo(
"console",
"Console",
"captured stdout/log tail overlay",
11,
false,
scene_console,
),
demo(
"framebuffer",
"FrameBuffer",
"RGBA canvas + sprite drawn with half-blocks",
14,
true,
scene_framebuffer,
),
demo(
"status_bar",
"StatusBar",
"left / right status segments",
7,
false,
scene_status_bar,
),
demo(
"textinput",
"TextInput",
"multi-line edit model",
9,
true,
scene_textinput,
),
demo(
"hyperlink",
"Hyperlink",
"OSC 8 links — clickable URLs in the transcript",
12,
false,
scene_hyperlink,
),
demo(
"mouse",
"Mouse",
"drag-to-select, highlight, and clickable regions",
11,
true,
scene_mouse,
),
demo(
"overlay",
"Overlay",
"anchored dialog composited over the base tree",
16,
false,
scene_overlay,
),
];
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let cmd = args.first().map(String::as_str).unwrap_or("list");
match cmd {
"list" | "--list" | "-h" | "--help" => {
println!("scenes:");
for d in DEMOS {
println!(" {:<14} {}", d.name, d.blurb);
}
Ok(())
}
"check" => check(),
"tapes" => {
let Some(dir) = args.get(1) else {
eprintln!("usage: demo tapes <output-dir>");
std::process::exit(2);
};
emit_tapes(Path::new(dir))
}
name => {
let Some(d) = DEMOS.iter().find(|d| d.name == name) else {
eprintln!("unknown scene {name:?}; run `list` to see the options");
std::process::exit(2);
};
if args.iter().any(|a| a == "--dump") {
dump(d)
} else {
run(d)
}
}
}
}
const RECORD_COLS: u16 = 66;
const CELL_W: u32 = 26;
const CELL_H: u32 = 55;
const PADDING: u32 = 40;
fn emit_tapes(dir: &Path) -> io::Result<()> {
fs::create_dir_all(dir)?;
let bin = std::env::current_exe()?;
let width = u32::from(RECORD_COLS) * CELL_W + PADDING * 2;
for d in DEMOS {
let height = u32::from(d.rows) * CELL_H + PADDING * 2;
let (output, ending, fps) = if d.animated {
(
format!("Output docs/demos/{}.gif", d.name),
"Sleep 4s".to_owned(),
24,
)
} else {
(
format!("Output \"{}/{}.gif\"", dir.display(), d.name),
format!(
"Sleep 300ms\nScreenshot docs/demos/{}.png\nSleep 100ms",
d.name
),
12,
)
};
let tape = format!(
"# Generated by `demo -- tapes`; recorded by scripts/gen-demos.sh.\n\
# Do not edit or commit — regenerate from the scene registry instead.\n\
{output}\n\
\n\
Set Shell bash\n\
Set FontSize 40\n\
Set CursorBlink false\n\
Set Width {width}\n\
Set Height {height}\n\
Set Padding {PADDING}\n\
Set Framerate {fps}\n\
\n\
Hide\n\
Type \"{bin} {name}\"\n\
Enter\n\
Sleep 900ms\n\
Show\n\
{ending}\n",
name = d.name,
bin = bin.display(),
);
fs::write(dir.join(format!("{}.tape", d.name)), tape)?;
}
println!("wrote {} tapes to {}", DEMOS.len(), dir.display());
Ok(())
}
fn check() -> io::Result<()> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let demos = dir.join("docs/demos");
let mut errors: Vec<String> = Vec::new();
for d in DEMOS {
let asset = demos.join(d.asset_filename());
match fs::metadata(&asset) {
Ok(m) if m.len() > 0 => {}
Ok(_) => errors.push(format!("recording {} is empty", asset.display())),
Err(_) => errors.push(format!(
"scene `{}` has no recording at {} (run scripts/gen-demos.sh)",
d.name,
asset.display()
)),
}
}
for d in DEMOS.iter().filter(|d| !d.fills_frame) {
let lost = lost_to_clipping(d, d.rows);
if !lost.is_empty() {
errors.push(format!(
"scene `{}` is clipped by its {}-row frame; {} line(s) never make it \
into the recording, starting with `{}` — raise `rows` and re-record",
d.name,
d.rows,
lost.len(),
lost[0].trim()
));
}
}
if !DEMOS
.iter()
.any(|d| !lost_to_clipping(d, d.rows.saturating_sub(1)).is_empty())
{
errors.push(
"the clipping check no longer detects a scene squeezed by a row — it is \
passing vacuously"
.to_owned(),
);
}
for extension in ["gif", "png"] {
for stem in stems(&demos, extension) {
match DEMOS.iter().find(|d| d.name == stem.as_str()) {
None => errors.push(format!(
"docs/demos/{stem}.{extension} has no matching scene in DEMOS"
)),
Some(d) if d.asset_extension() != extension => errors.push(format!(
"docs/demos/{stem}.{extension} is stale; scene `{stem}` uses .{}",
d.asset_extension()
)),
Some(_) => {}
}
}
}
let mut sources: Vec<PathBuf> = vec![
dir.join("docs/components.md"),
dir.join("docs/features.md"),
dir.join("docs/markdown.md"),
];
let mut dirs = vec![dir.join("src/components")];
while let Some(next) = dirs.pop() {
for entry in fs::read_dir(next)?.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
sources.push(path);
}
}
}
sources.push(dir.join("src/overlay.rs"));
for path in sources {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
for (referenced, extension) in referenced_assets(&text) {
match DEMOS.iter().find(|d| d.name == referenced.as_str()) {
None => errors.push(format!(
"{} references demos/{referenced}.{extension} but there is no such scene",
path.display()
)),
Some(d) if d.asset_extension() != extension => errors.push(format!(
"{} references demos/{referenced}.{extension}, but scene `{referenced}` uses .{}",
path.display(),
d.asset_extension()
)),
Some(_) => {}
}
}
}
if errors.is_empty() {
println!(
"ok: {} scenes, recordings, and references in sync",
DEMOS.len()
);
Ok(())
} else {
for e in &errors {
eprintln!("error: {e}");
}
std::process::exit(1);
}
}
fn lost_to_clipping(d: &Demo, rows: u16) -> Vec<String> {
const SLACK: u16 = 8;
let theme = Theme::default();
let at = |rows: u16| {
let root = framed(d.title, d.blurb, (d.build)(24, &theme), &theme);
let buffer = tuika::testing::render(root.as_ref(), RECORD_COLS, rows, &theme);
tuika::testing::grid(&buffer)
.lines()
.map(|l| l.trim_end().to_owned())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
};
let shown = at(rows);
at(rows + SLACK)
.into_iter()
.filter(|l| !shown.contains(l))
.collect()
}
fn stems(dir: &Path, ext: &str) -> BTreeSet<String> {
let Ok(read) = fs::read_dir(dir) else {
return BTreeSet::new();
};
read.filter_map(Result::ok)
.filter_map(|e| {
let path = e.path();
(path.extension().and_then(|s| s.to_str()) == Some(ext))
.then(|| path.file_stem().and_then(|s| s.to_str()).map(str::to_owned))
.flatten()
})
.collect()
}
fn referenced_assets(text: &str) -> BTreeSet<(String, String)> {
const MARKER: &str = "demos/";
let mut found = BTreeSet::new();
for (idx, _) in text.match_indices(MARKER) {
let rest = &text[idx + MARKER.len()..];
let Some((end, extension)) = ["gif", "png"]
.into_iter()
.filter_map(|extension| {
rest.find(&format!(".{extension}"))
.map(|end| (end, extension))
})
.min_by_key(|(end, _)| *end)
else {
continue;
};
let stem = &rest[..end];
if !stem.is_empty() && stem.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
found.insert((stem.to_owned(), extension.to_owned()));
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn referenced_assets_finds_static_and_animated_recordings() {
let found =
referenced_assets("demos/text.png and https://example.test/docs/demos/spinner.gif");
assert_eq!(
found,
BTreeSet::from([
("spinner".to_owned(), "gif".to_owned()),
("text".to_owned(), "png".to_owned()),
])
);
}
#[test]
fn scene_format_tracks_whether_motion_is_part_of_the_demo() {
for demo in DEMOS {
assert_eq!(
demo.asset_extension(),
if demo.animated { "gif" } else { "png" }
);
assert!(!demo.title.contains('_'));
}
}
}
fn framed(name: &str, blurb: &str, body: Element, theme: &Theme) -> Element {
let bg = Style::default().bg(theme.background);
let header = Text::new(vec![Line::from(vec![
Span::styled(
name.to_string(),
theme.accent_style().add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {blurb}"), theme.muted_style()),
])]);
view! {
col(padding = Padding::all(1), gap = 0, background = bg) {
fixed(1) { node(header) }
fixed(1) { node(Rule::new().style(theme.muted_style())) }
fixed(1) { spacer() }
grow(1) { node(body) }
}
}
}
fn dump(d: &Demo) -> io::Result<()> {
let theme = Theme::default();
let root = framed(d.title, d.blurb, (d.build)(24, &theme), &theme);
let buffer = tuika::testing::render(root.as_ref(), RECORD_COLS, d.rows, &theme);
println!("{}", tuika::testing::grid(&buffer));
Ok(())
}
fn run(d: &Demo) -> io::Result<()> {
let _session = tuika::TerminalSession::enter()?;
let mut terminal = Terminal::with_options(
ratatui::backend::CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: RatatuiViewport::Fullscreen,
},
)?;
let theme = Theme::default();
let mut frame = 0u64;
loop {
terminal.draw(|f| {
let area = f.area();
let (w, h) = (area.width.min(RECORD_COLS), area.height.min(d.rows));
let scene = Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
};
f.buffer_mut()
.set_style(area, Style::default().bg(theme.background));
let root = framed(d.title, d.blurb, (d.build)(frame, &theme), &theme);
paint(f.buffer_mut(), scene, &theme, root.as_ref(), &[]);
})?;
if event::poll(Duration::from_millis(80))?
&& let CtEvent::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
&& matches!(key.code, CtKeyCode::Char('q') | CtKeyCode::Esc)
{
break;
}
frame = frame.wrapping_add(1);
}
let _ = terminal.clear();
drop(terminal);
Ok(())
}
fn labeled_row(glyph: Element, label: &str, theme: &Theme) -> Element {
let text = Text::new(vec![Line::from(Span::styled(
label.to_string(),
theme.text_style(),
))]);
view! {
row(gap = 1) {
fixed(2) { node(glyph) }
grow(1) { node(text) }
}
}
}
fn scene_spinner(frame: u64, theme: &Theme) -> Element {
view! {
col(gap = 1) {
fixed(1) { node(labeled_row(element(Spinner::new(frame).style(SpinnerStyle::Braille)), "Braille — the smooth default", theme)) }
fixed(1) { node(labeled_row(element(Spinner::new(frame).style(SpinnerStyle::Line)), "Line — ASCII fallback", theme)) }
fixed(1) { node(labeled_row(element(Spinner::new(frame).style(SpinnerStyle::Dots)), "Dots — bouncing", theme)) }
}
}
}
fn scene_progress(frame: u64, theme: &Theme) -> Element {
let animated = tuika::anim::ping_pong(frame, 140);
let _ = theme;
view! {
col(gap = 1) {
fixed(1) { node(ProgressBar::determinate(0.25).percent(true)) }
fixed(1) { node(ProgressBar::determinate(0.60).percent(true)) }
fixed(1) { node(ProgressBar::determinate(animated).percent(true)) }
fixed(1) { node(ProgressBar::indeterminate(frame)) }
}
}
}
fn scene_loader(frame: u64, theme: &Theme) -> Element {
let _ = theme;
view! {
col(gap = 1) {
fixed(1) { node(Loader::new(frame, "compiling crate…").hint("esc to cancel")) }
fixed(1) { node(Loader::new(frame, "fetching dependencies…").spinner_style(SpinnerStyle::Line)) }
}
}
}
fn scene_text(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let styled = Text::new(vec![
Line::from(vec![
Span::styled(
"error",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(": unresolved import ", theme.text_style()),
Span::styled("`tuika::Widget`", theme.accent_style()),
]),
Line::from(Span::styled(
" perhaps you meant `tuika::View`?",
theme.muted_style(),
)),
]);
let prose = tuika::components::Paragraph::new(
"Paragraph word-wraps plain text to the render width in a single style, \
re-flowing every frame so a resize just re-wraps.",
theme.text_style(),
);
view! {
col(gap = 1) {
fixed(2) { node(styled) }
grow(1) { node(prose) }
}
}
}
const MARKDOWN_DOC: &str = "\
# Streaming Markdown
Renders **CommonMark** as it *streams* — only the in-flight tail
re-parses, so settled blocks and `code` never re-tokenize.
- headings, **bold**, *italic*, `inline code`
- nested lists and tables
```rust
fn greet(name: &str) {
println!(\"hello, {name}!\");
}
```
";
fn scene_markdown(frame: u64, theme: &Theme) -> Element {
let _ = theme;
let total = MARKDOWN_DOC.chars().count();
let pos = (frame as usize * 6) % (total + 120);
let revealed: String = MARKDOWN_DOC.chars().take(pos.min(total)).collect();
let mut state = MarkdownState::new();
state.set(revealed);
let sheet = tuika::StyleSheet::from_theme(theme);
let lines = state
.lines(
64,
theme,
&sheet,
tuika::highlight::CodeHighlighter::With(&HL),
)
.to_vec();
element(Text::new(lines))
}
const MARKDOWN_TABLE_DOC: &str = "\
| Component | Status | Docs |
| :---------- | :-------: | ----------------------------: |
| `Markdown` | ✅ stable | [docs.rs](https://docs.rs/tuika) |
| `CodeBlock` | ✅ stable | \
[gallery](https://github.com/everruns/tuika/blob/main/docs/components.md) |
| **Image** | 🚧 beta | \
[features](https://github.com/everruns/tuika/blob/main/docs/features.md) |
";
fn scene_markdown_table(frame: u64, theme: &Theme) -> Element {
let _ = (frame, theme);
element(Markdown::new(MARKDOWN_TABLE_DOC))
}
fn scene_code_block(frame: u64, theme: &Theme) -> Element {
let _ = (frame, theme);
let source = "pub fn fib(n: u64) -> u64 {\n match n {\n 0 | 1 => n,\n _ => fib(n - 1) + fib(n - 2),\n }\n}";
element(
CodeBlock::new("rust", source)
.highlighter(&HL)
.line_numbers(true),
)
}
fn scene_rule(frame: u64, theme: &Theme) -> Element {
let _ = frame;
view! {
col(gap = 1) {
fixed(1) { node(Rule::new().style(theme.muted_style())) }
fixed(1) { node(Rule::new().title(Line::from(Span::styled(" Section ", theme.accent_style()))).style(theme.muted_style())) }
fixed(1) { node(Rule::new().glyph('┈').style(theme.muted_style())) }
fixed(1) { node(Rule::new().title(Line::from(Span::styled(" dotted ", theme.accent_style()))).glyph('·').style(theme.muted_style())) }
}
}
}
fn scene_boxed(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let inner = Text::new(vec![Line::from(Span::styled(
"border + padding + title",
theme.text_style(),
))]);
let plain = Text::new(vec![Line::from(Span::styled(
"rounded border",
theme.text_style(),
))]);
view! {
col(gap = 1) {
fixed(3) {
boxed(title = Line::from(Span::styled(" thick ", theme.accent_style())), border = BorderStyle::Thick, padding = Padding::symmetric(1, 0)) {
node(inner)
}
}
fixed(3) {
boxed(title = Line::from(Span::styled(" rounded ", theme.accent_style())), border = BorderStyle::Rounded) {
node(plain)
}
}
}
}
}
fn scene_flex(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let cell = |label: &str, color: Color| -> Element {
let text = Text::new(vec![Line::from(Span::styled(
label.to_string(),
Style::default()
.fg(theme.background)
.bg(color)
.add_modifier(Modifier::BOLD),
))]);
element(
tuika::components::Boxed::new(element(text))
.border(BorderStyle::Plain)
.background(Style::default().bg(color)),
)
};
view! {
col(gap = 1) {
fixed(3) {
row(gap = 1) {
grow(1) { node(cell("grow 1", theme.accent)) }
grow(2) { node(cell("grow 2", theme.accent_alt)) }
fixed(12) { node(cell("fixed 12", theme.muted)) }
}
}
fixed(1) { node(Text::new(vec![Line::from(Span::styled("row · gap 1 · grow shares leftover width", theme.muted_style()))])) }
}
}
}
fn scene_scroll(frame: u64, theme: &Theme) -> Element {
let lines: Vec<Line<'static>> = (1..=24)
.map(|i| {
Line::from(Span::styled(
format!(" line {i:>2} — content that overflows the viewport"),
theme.text_style(),
))
})
.collect();
let viewport_h = 8usize;
let content_h = lines.len();
let mut state = ScrollState::new();
let reach = tuika::anim::ping_pong(frame, 200);
let steps = (reach * (content_h.saturating_sub(viewport_h) as f32 / 3.0 + 1.0)) as u32;
let down = Event::Mouse(Mouse::at(MouseKind::ScrollDown, 0, 0));
for _ in 0..steps {
state.handle(&down, content_h, viewport_h);
}
view! {
col {
fixed(8) { node(Scroll::new(lines, &state)) }
}
}
}
fn scene_item_scroll(frame: u64, theme: &Theme) -> Element {
let items: Vec<Element> = (1..=6)
.map(|i| {
if i % 2 == 0 {
element(
Boxed::new(element(Text::new(vec![
Line::from(Span::styled(format!("panel {i}"), theme.text_style())),
Line::from(Span::styled("laid out, not drawn", theme.muted_style())),
])))
.title(Line::from(Span::styled(" note ", theme.accent_style()))),
)
} else {
element(Text::new(vec![Line::from(Span::styled(
format!(" row {i} — an ordinary line of text"),
theme.text_style(),
))]))
}
})
.collect();
let viewport_h = 8usize;
let content_h = ItemScroll::measure_height(&items, 60, 1, true);
let mut state = ScrollState::new();
let reach = tuika::anim::ping_pong(frame, 200);
let steps = (reach * (content_h.saturating_sub(viewport_h) as f32 / 3.0 + 1.0)) as u32;
let down = Event::Mouse(Mouse::at(MouseKind::ScrollDown, 0, 0));
for _ in 0..steps {
state.handle(&down, content_h, viewport_h);
}
view! {
col {
fixed(8) { node(ItemScroll::new(items, &state).gap(1)) }
}
}
}
fn scene_primitives(frame: u64, theme: &Theme) -> Element {
let mut scroll = ScrollState::default();
let pan = (tuika::anim::ping_pong(frame, 160) * 14.0).round() as usize;
scroll.set_x_offset(pan);
let mut form_state = FormState::default();
form_state.focus(((frame / 28) % 2) as usize);
let canvas = DrawView::new(
|area: Rect, surface: &mut tuika::Surface<'_>, ctx: &tuika::RenderCtx<'_>| {
for y in area.y..area.bottom() {
surface.set_string(
area.x,
y,
"界 custom grid 0123456789 →",
ctx.theme.info_style(),
);
}
},
)
.intrinsic_size(Size::new(31, 2));
let preview = Viewport::new(element(canvas), Size::new(31, 2), &scroll)
.vertical_scrollbar(false)
.horizontal_scrollbar(true);
let form = Form::new(
vec![
FormField::new("Name", element(Text::raw("Ada"))).help("host-owned value"),
FormField::new("Preview", element(preview)).error("semantic validation row"),
],
&form_state,
)
.stack_below(36);
let dialog = Dialog::new("Reusable primitives", element(form))
.size(56, 10)
.key_hints([("enter", "submit"), ("esc", "cancel")])
.dim_backdrop(true)
.focus_owner("primitives");
element(
Scene::new(element(Text::new(vec![Line::from(Span::styled(
"base screen · overlay placement resolves at render time",
theme.muted_style(),
))])))
.dialog(dialog),
)
}
fn scene_select(frame: u64, theme: &Theme) -> Element {
let items: Vec<Line<'static>> = ["Open file…", "Save", "Save As…", "Toggle theme", "Quit"]
.iter()
.map(|s| Line::from(Span::styled((*s).to_string(), theme.text_style())))
.collect();
let mut state = SelectState::new();
let target = (frame / 10) % items.len() as u64;
let down = Event::Key(Key::new(KeyCode::Down));
for _ in 0..target {
state.handle(&down, items.len());
}
view! {
col {
grow(1) { node(SelectList::new(items, &state)) }
}
}
}
fn scene_tabs(frame: u64, theme: &Theme) -> Element {
let labels: Vec<Line<'static>> = ["Chat", "Diff", "Logs", "Files"]
.iter()
.map(|s| Line::from(Span::styled((*s).to_string(), theme.text_style())))
.collect();
let mut state = TabsState::default();
let target = (frame / 16) % labels.len() as u64;
let right = Event::Key(Key::new(KeyCode::Right));
for _ in 0..target {
state.handle(&right, labels.len());
}
view! {
col(gap = 1) {
fixed(1) { node(Tabs::new(labels, &state)) }
fixed(1) { node(Text::new(vec![Line::from(Span::styled("←/→ or Tab to switch", theme.muted_style()))])) }
}
}
}
fn scene_status_bar(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let bar = StatusBar::new()
.left(vec![
Span::styled(" NORMAL ", theme.selection_style()),
Span::styled(" main.rs", theme.text_style()),
])
.right(vec![
Span::styled("utf-8 ", theme.muted_style()),
Span::styled("Ln 42, Col 7 ", theme.text_style()),
])
.background(Style::default().bg(theme.surface));
view! {
col {
fixed(1) { node(bar) }
}
}
}
fn scene_textinput(frame: u64, theme: &Theme) -> Element {
let full = "fix @src/parser.rs: trailing commas";
let n = ((frame / 3) as usize % (full.chars().count() + 12)).min(full.chars().count());
let typed: String = full.chars().take(n).collect();
let state = TextInputState::from_text(&typed);
let mention = Style::default()
.fg(theme.code.link)
.add_modifier(Modifier::BOLD);
let highlights: Vec<TextSpan> = state
.tokens(&[Trigger::new('@')])
.iter()
.map(|t| t.span(mention))
.collect();
view! {
col {
fixed(3) {
boxed(title = Line::from(Span::styled(" commit message ", theme.accent_style()))) {
node(
TextInput::new(&state)
.placeholder("describe the change", Style::default().fg(theme.dim))
.highlights(highlights)
)
}
}
}
}
}
fn scene_hyperlink(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let link = Style::default()
.fg(theme.code.link)
.add_modifier(Modifier::UNDERLINED);
let body = Text::new(vec![
Line::from(Span::styled(
"A bare URL is wrapped in place — clickable, text unchanged:",
theme.muted_style(),
)),
Line::from(vec![
Span::styled(" see ", theme.text_style()),
Span::styled("https://docs.rs/tuika", link),
Span::styled(" for the API.", theme.text_style()),
]),
Line::from(""),
Line::from(Span::styled(
"A markdown link shows its label, hiding the target:",
theme.muted_style(),
)),
Line::from(vec![
Span::styled(" the ", theme.text_style()),
Span::styled("tuika component gallery", link),
Span::styled(" demos every widget.", theme.text_style()),
]),
Line::from(""),
Line::from(Span::styled(
"Only http(s) links are emitted; anything else stays plain text.",
theme.muted_style(),
)),
]);
element(body)
}
fn scene_mouse(frame: u64, theme: &Theme) -> Element {
let phrase = "the quick brown fox jumps over the lazy dog";
let count = phrase.chars().count();
let reach = tuika::anim::ping_pong(frame, 200);
let selected = (reach * count as f32).round() as usize;
let sel: String = phrase.chars().take(selected).collect();
let rest: String = phrase.chars().skip(selected).collect();
let button = |label: &str, active: bool| -> Span<'static> {
if active {
Span::styled(
format!(" {label} "),
Style::default()
.fg(theme.background)
.bg(theme.accent)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(format!(" {label} "), theme.muted_style())
}
};
let hot = (frame / 24) % 3;
let body = Text::new(vec![
Line::from(Span::styled(
"Left-drag selects real text — copy it over SSH via OSC 52:",
theme.muted_style(),
)),
Line::from(vec![
Span::styled(" ", theme.text_style()),
Span::styled(sel, theme.selection_style()),
Span::styled(rest, theme.text_style()),
]),
Line::from(""),
Line::from(Span::styled(
"A HitMap maps screen regions to values — clicks become actions:",
theme.muted_style(),
)),
Line::from(vec![
Span::styled(" ", theme.text_style()),
button("Run", hot == 0),
Span::styled(" ", theme.text_style()),
button("Diff", hot == 1),
Span::styled(" ", theme.text_style()),
button("Cancel", hot == 2),
]),
]);
element(body)
}
fn scene_overlay(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let base = |s: &str| {
Text::new(vec![Line::from(Span::styled(
s.to_string(),
theme.muted_style(),
))])
};
let dialog = view! {
boxed(
title = Line::from(Span::styled(" confirm ", theme.accent_style())),
border = BorderStyle::Rounded,
padding = Padding::all(1)
) {
col(gap = 1) {
node(Text::new(vec![Line::from(Span::styled(
"Proceed with the action?",
theme.text_style(),
))]))
node(Text::new(vec![Line::from(Span::styled(
"enter = yes · esc = no",
theme.muted_style(),
))]))
}
}
};
view! {
col(gap = 0) {
fixed(1) { node(base("base layer stays visible around the panel")) }
fixed(1) { node(base("… app content continues behind …")) }
fixed(1) { spacer() }
fixed(7) {
row(gap = 0) {
grow(1) { spacer() }
fixed(46) { node(dialog) }
grow(1) { spacer() }
}
}
grow(1) { spacer() }
fixed(1) {
node(Text::new(vec![Line::from(Span::styled(
"OverlaySpec::centered(50, 40).min_size(34, 7)",
theme.muted_style(),
))]))
}
}
}
}
fn caption(text: &str, theme: &Theme) -> Element {
element(Text::new(vec![Line::from(Span::styled(
text.to_string(),
theme.muted_style(),
))]))
}
fn scene_diff(frame: u64, theme: &Theme) -> Element {
let _ = frame;
let old = "fn add(a: i32, b: i32) -> i32 {\n a - b\n}\n\nlet total = add(2, 3);";
let new = "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\nlet total = add(2, 3);\nprintln!(\"{total}\");";
view! {
col(gap = 1) {
fixed(1) { node(caption("unified · red = removed, green = added", theme)) }
grow(1) { node(Diff::new(old, new).line_numbers(true)) }
}
}
}
fn scene_ascii_font(frame: u64, theme: &Theme) -> Element {
let _ = frame;
view! {
col(gap = 1) {
fixed(5) { node(AsciiFont::new("TUIKA")) }
fixed(1) { node(caption("embedded 5-row block font · A-Z 0-9 punctuation", theme)) }
}
}
}
fn scene_qr(frame: u64, theme: &Theme) -> Element {
let _ = frame;
const PAYLOAD: &str = "https://everruns.com";
let qr = QrCode::encode(PAYLOAD, QrEcc::Medium)
.map(element)
.unwrap_or_else(|| caption("(payload too large)", theme));
view! {
col(gap = 1) {
grow(1) { node(qr) }
fixed(1) { node(caption(&format!("QrCode::encode(\"{PAYLOAD}\", QrEcc::Medium)"), theme)) }
}
}
}
fn scene_tab_select(frame: u64, theme: &Theme) -> Element {
let labels: Vec<Line<'static>> = ["Low", "Medium", "High", "Ultra"]
.iter()
.map(|s| Line::from(Span::styled((*s).to_string(), theme.text_style())))
.collect();
let mut state = TabSelectState::default();
let target = (frame / 18) % labels.len() as u64;
let right = Event::Key(Key::new(KeyCode::Right));
for _ in 0..target {
state.handle(&right, labels.len());
}
view! {
col(gap = 1) {
fixed(1) { node(TabSelect::new(labels, &state)) }
fixed(1) { node(caption("← → move the selection · enter/space activates", theme)) }
}
}
}
fn scene_slider(frame: u64, theme: &Theme) -> Element {
let swept = tuika::anim::ping_pong(frame, 160);
let mut volume = SliderState::new(0.0, 100.0, 0.0);
volume.set_ratio(swept);
let contrast = SliderState::new(0.0, 10.0, 6.0);
let mut balance = SliderState::new(0.0, 1.0, 0.0);
balance.set_ratio(1.0 - swept);
let row = |label: &str, s: &SliderState, theme: &Theme| -> Element {
let lbl = Text::new(vec![Line::from(Span::styled(
label.to_string(),
theme.muted_style(),
))]);
let slider = Slider::new(s).label(s);
view! {
row(gap = 1) {
fixed(9) { node(lbl) }
grow(1) { node(slider) }
}
}
};
view! {
col(gap = 1) {
fixed(1) { node(row("volume", &volume, theme)) }
fixed(1) { node(row("contrast", &contrast, theme)) }
fixed(1) { node(row("balance", &balance, theme)) }
}
}
}
fn scene_timeline(frame: u64, theme: &Theme) -> Element {
let linear = Timeline::new()
.keyframe(0, 0.0)
.keyframe(120, 1.0)
.repeat(Repeat::Loop);
let eased = Timeline::new()
.keyframe(0, 0.0)
.ease(120, 1.0, tuika::anim::ease_in_out)
.repeat(Repeat::Loop);
let pulse = Timeline::new()
.keyframe(0, 0.0)
.keyframe(60, 1.0)
.keyframe(120, 0.0)
.repeat(Repeat::Loop);
let track = |label: &str, value: f32, theme: &Theme| -> Element {
let lbl = Text::new(vec![Line::from(Span::styled(
label.to_string(),
theme.muted_style(),
))]);
view! {
row(gap = 1) {
fixed(11) { node(lbl) }
grow(1) { node(ProgressBar::determinate(value).percent(true)) }
}
}
};
view! {
col(gap = 1) {
fixed(1) { node(track("linear", linear.sample(frame), theme)) }
fixed(1) { node(track("ease_in_out", eased.sample(frame), theme)) }
fixed(1) { node(track("pulse", pulse.sample(frame), theme)) }
fixed(1) { node(caption("Timeline::new().keyframe(..).ease(..).repeat(Loop)", theme)) }
}
}
}
fn scene_toast(frame: u64, theme: &Theme) -> Element {
let _ = (frame, theme);
let mut toasts = Toasts::new(4);
toasts.push(ToastLevel::Info, "Build started");
toasts.push(ToastLevel::Success, "42 tests passed");
toasts.push(ToastLevel::Warning, "2 unused imports");
toasts.push(ToastLevel::Error, "Deploy to prod failed");
let toasts: &'static Toasts = Box::leak(Box::new(toasts));
view! {
col {
grow(1) { node(ToastList::new(toasts)) }
}
}
}
fn scene_console(frame: u64, theme: &Theme) -> Element {
let _ = (frame, theme);
let log = ConsoleLog::new(50);
for line in [
"INFO server listening on 127.0.0.1:8080",
"DEBUG loaded 12 routes in 3ms",
"INFO GET / 200 1.2ms",
"WARN slow query: users.by_email 214ms",
"INFO GET /health 200 0.3ms",
"ERROR upstream timeout after 5s",
"INFO reconnecting to upstream…",
] {
log.line(line);
}
let log: &'static ConsoleLog = Box::leak(Box::new(log));
view! {
col {
grow(1) { node(Console::new(log).title(" console ")) }
}
}
}
fn scene_framebuffer(frame: u64, theme: &Theme) -> Element {
let _ = theme;
let (w, h) = (56u32, 22u32);
let mut fb = FrameBuffer::new(w, h);
fb.shade(|x, y, _| {
let r = 30 + (x * 120 / w) as u8;
let g = 20;
let b = 60 + (y * 120 / h) as u8;
[r, g, b, 255]
});
let bx = (tuika::anim::ping_pong(frame, 120) * (w - 8) as f32) as u32;
let by = (tuika::anim::ping_pong(frame.wrapping_add(37), 84) * (h - 8) as f32) as u32;
fb.fill_rect(bx, by, 8, 8, [240, 210, 90, 255]);
fb.shade(|_x, y, [r, g, b, a]| {
if y % 2 == 0 {
[r, g, b, a]
} else {
[r / 2, g / 2, b / 2, a]
}
});
let fb: &'static FrameBuffer = Box::leak(Box::new(fb));
view! {
col(gap = 1) {
grow(1) { node(FrameBufferView::new(fb, w as u16, (h / 2) as u16)) }
fixed(1) { node(caption("FrameBuffer → half-block cells · sprite + scanline shader", theme)) }
}
}
}