use std::io;
use std::time::Duration;
use crossterm::event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use ratatui::text::{Line, Span};
use ratatui::{Terminal, TerminalOptions, Viewport};
use tuika::prelude::*;
const COLS: u16 = 82;
const ROWS: u16 = 26;
const HOLD: u64 = 26;
const DOC: &str = "\
# Release notes
Ship **bold** ideas and *careful* fixes. Read the [handbook](https://tuika.dev) \
or visit https://everruns.dev for the rest. Run `cargo test` before you push.
- Centralized styling — one sheet, every component
- Themes stay the color tokens; the sheet is the rules";
fn variants(theme: &Theme) -> Vec<(&'static str, StyleSheet)> {
let base = StyleSheet::from_theme(theme);
vec![
("default", base),
(
"vivid",
StyleSheet {
heading: StyleBundle::new().fg(Color::Rgb(210, 120, 200)).bold(),
link: StyleBundle::new()
.fg(Color::Rgb(120, 200, 120))
.bold()
.underlined(),
inline_code: StyleBundle::new()
.fg(Color::Rgb(240, 220, 150))
.bg(Color::Rgb(48, 40, 30)),
strong: StyleBundle::new().fg(Color::Rgb(230, 150, 100)).bold(),
list_marker: StyleBundle::new().fg(Color::Rgb(120, 200, 210)),
panel: StyleBundle::new().fg(theme.accent_alt).bg(theme.surface),
..base
},
),
(
"mono",
StyleSheet {
heading: StyleBundle::new().fg(theme.text).bold(),
link: StyleBundle::new().fg(theme.text).underlined(),
inline_code: StyleBundle::new().fg(theme.text).bg(theme.surface),
strong: StyleBundle::new().bold(),
emphasis: StyleBundle::new().italic(),
list_marker: StyleBundle::new().fg(theme.muted),
panel: StyleBundle::new().fg(theme.dim),
..base
},
),
]
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let theme = Theme::default();
if args.first().map(String::as_str) == Some("bg") {
println!("{}", hex(theme.background));
return Ok(());
}
if let Some(pos) = args.iter().position(|a| a == "--dump") {
let name = args.get(pos + 1).map(String::as_str).unwrap_or("vivid");
let (label, sheet) = pick(&theme, name);
let buffer = render_frame(6, &theme, sheet, label);
println!("{}", tuika::testing::grid(&buffer));
return Ok(());
}
if args.first().map(String::as_str) == Some("run") {
let held = args.get(1).cloned();
return run_interactive(&theme, held.as_deref());
}
eprintln!(
"usage: styling (run [variant] | --dump [variant] | bg)\nvariants: default, vivid, mono"
);
Ok(())
}
fn pick(theme: &Theme, name: &str) -> (&'static str, StyleSheet) {
let vars = variants(theme);
vars.iter()
.find(|(label, _)| *label == name)
.copied()
.unwrap_or(vars[0])
}
fn render_frame(frame: u64, theme: &Theme, sheet: StyleSheet, label: &str) -> Buffer {
let area = Rect::new(0, 0, COLS, ROWS);
let mut buffer = Buffer::empty(area);
let root = scene(frame, theme, label);
paint_with_sheet(&mut buffer, area, theme, sheet, root.as_ref(), &[]);
buffer
}
fn run_interactive(theme: &Theme, held: Option<&str>) -> io::Result<()> {
let _session = tuika::TerminalSession::enter()?;
let mut terminal = Terminal::with_options(
ratatui::backend::CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)?;
let vars = variants(theme);
let mut frame = 0u64;
loop {
let (label, sheet) = match held {
Some(name) => pick(theme, name),
None => vars[(frame / HOLD) as usize % vars.len()],
};
terminal.draw(|f| {
let area = f.area();
let root = scene(frame, theme, label);
paint_with_sheet(f.buffer_mut(), area, theme, sheet, root.as_ref(), &[]);
})?;
if event::poll(Duration::from_millis(90))?
&& 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 scene(frame: u64, theme: &Theme, label: &str) -> Element {
let bg = ratatui::style::Style::default().bg(theme.background);
view! {
col(padding = Padding::all(1), gap = 0, background = bg) {
fixed(1) { node(header(theme, label)) }
fixed(1) { node(Rule::new().style(theme.muted_style())) }
fixed(1) { node(spacer()) }
grow(1) {
row(gap = 2) {
grow(3) { node(doc_panel()) }
grow(2) { node(activity_panel(frame, theme)) }
}
}
}
}
}
fn header(theme: &Theme, label: &str) -> Element {
element(Text::new(vec![Line::from(vec![
Span::styled(
"▲ tuika styling",
theme.accent_style().add_modifier(Modifier::BOLD),
),
Span::styled(" stylesheet = ", theme.muted_style()),
Span::styled(label.to_string(), theme.accent_style()),
])]))
}
fn doc_panel() -> Element {
view! {
boxed(border = BorderStyle::Rounded, padding = Padding::symmetric(1, 0)) {
node(element(Markdown::new(DOC)))
}
}
}
fn activity_panel(frame: u64, theme: &Theme) -> Element {
let spin = |style: SpinnerStyle, label: &str| -> Element {
view! {
row(gap = 1) {
fixed(2) { node(Spinner::new(frame).style(style)) }
grow(1) { node(Text::new(vec![Line::from(Span::styled(label.to_string(), theme.text_style()))])) }
}
}
};
let animated = tuika::anim::ping_pong(frame.wrapping_mul(2), 48);
view! {
boxed(title = Line::from(Span::styled(" activity ", theme.accent_style())),
border = BorderStyle::Rounded, padding = Padding::symmetric(1, 0)) {
col(gap = 1) {
fixed(1) { node(spin(SpinnerStyle::Braille, "restyling tree…")) }
fixed(1) { node(Rule::new().style(theme.muted_style())) }
fixed(1) { node(ProgressBar::determinate(animated).percent(true)) }
fixed(1) { node(ProgressBar::indeterminate(frame)) }
}
}
}
}
fn spacer() -> Element {
element(Text::raw(""))
}
fn hex(color: Color) -> String {
match color {
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
_ => "#000000".to_string(),
}
}