use std::sync::OnceLock;
use std::time::Instant;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::options::OptionSchema;
use crate::payload::Body;
use crate::theme::{self, ColorKey, Theme};
use super::{Registry, RenderOptions, Renderer, Shape, default_renderer_for, shape_of};
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct Options {
#[serde(default)]
pub inner: Option<String>,
#[serde(default)]
pub boot_lines: Option<Vec<String>>,
}
const COLOR_KEYS: &[ColorKey] = &[theme::TEXT, theme::TEXT_SECONDARY, theme::STATUS_OK];
const DEFAULT_DURATION_MS: u64 = 1800;
const BOOT_SHARE: f32 = 0.70;
const DEFAULT_LINES: &[&str] = &[
"mounting /splash",
"loading theme",
"connecting terminal",
"starting splashboard",
"ready",
];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "inner",
type_hint: "renderer name (e.g. \"text_ascii\")",
required: false,
default: Some("shape default renderer"),
description: "Renderer drawn once the boot sequence completes. Falls back to the shape's natural default.",
},
OptionSchema {
name: "boot_lines",
type_hint: "array of short strings",
required: false,
default: Some("a built-in `[ OK ] ...` list"),
description: "Boot-log lines scrolled in one by one. Each line is prefixed with a green `[ OK ]` automatically; keep each string short enough to fit the widget width.",
},
OptionSchema {
name: "duration_ms",
type_hint: "milliseconds (u64)",
required: false,
default: Some("1800"),
description: "Total duration. The boot log scrolls during the first ~70% and the inner render takes over for the remainder — stays inside the 2s ANIMATION_WINDOW.",
},
];
pub struct AnimatedBootRenderer;
const ALL_SHAPES: &[Shape] = &[
Shape::Text,
Shape::TextBlock,
Shape::Entries,
Shape::Ratio,
Shape::NumberSeries,
Shape::PointSeries,
Shape::Bars,
Shape::Image,
Shape::Calendar,
Shape::Heatmap,
Shape::Badge,
Shape::Timeline,
];
impl Renderer for AnimatedBootRenderer {
fn name(&self) -> &str {
"animated_boot"
}
fn description(&self) -> &'static str {
"Wrapper that scrolls a fake `[ OK ]` systemd-style boot log into the cell during the first ~70% of the window, then hands off to the inner renderer for the resting frame. Accepts every shape; pair with a hero renderer (e.g. `text_ascii`) on a tall cell."
}
fn accepts(&self) -> &[Shape] {
ALL_SHAPES
}
fn animates(&self) -> bool {
true
}
fn color_keys(&self) -> &[ColorKey] {
COLOR_KEYS
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
registry: &Registry,
) {
let shape = shape_of(body);
let specific: Options = opts.parse_specific();
let inner_name = specific
.inner
.as_deref()
.unwrap_or_else(|| default_renderer_for(shape));
let Some(inner) = registry.get(inner_name) else {
draw_inline_error(
frame,
area,
&format!("unknown inner renderer: {inner_name}"),
);
return;
};
if !inner.accepts().contains(&shape) {
draw_inline_error(
frame,
area,
&format!("inner {inner_name} cannot display {shape:?}"),
);
return;
}
let total = opts.duration_ms.unwrap_or(DEFAULT_DURATION_MS).max(1) as u32;
let elapsed = elapsed_since_start_ms();
let boot_ms = (total as f32 * BOOT_SHARE) as u32;
let lines = boot_lines(&specific);
if elapsed >= boot_ms || lines.is_empty() {
inner.render(frame, area, body, &inner_options(opts), theme, registry);
return;
}
draw_boot_log(frame, area, &lines, elapsed, boot_ms, theme);
}
fn natural_height(
&self,
body: &Body,
opts: &RenderOptions,
max_width: u16,
registry: &Registry,
) -> u16 {
let shape = shape_of(body);
let specific: Options = opts.parse_specific();
let inner_name = specific
.inner
.as_deref()
.unwrap_or_else(|| default_renderer_for(shape));
let Some(inner) = registry.get(inner_name) else {
return 1;
};
inner.natural_height(body, &inner_options(opts), max_width, registry)
}
}
fn draw_boot_log(
frame: &mut Frame,
area: Rect,
lines: &[String],
elapsed_ms: u32,
boot_ms: u32,
theme: &Theme,
) {
let progress = (elapsed_ms as f32 / boot_ms.max(1) as f32).clamp(0.0, 1.0);
let visible_count = ((progress * lines.len() as f32).ceil() as usize).min(lines.len());
if visible_count == 0 {
return;
}
let ok_style = Style::default()
.fg(theme.status_ok)
.add_modifier(Modifier::BOLD);
let body_style = Style::default().fg(theme.text_secondary);
let rendered: Vec<Line> = lines
.iter()
.take(visible_count)
.map(|l| {
Line::from(vec![
Span::styled("[ OK ] ", ok_style),
Span::styled(l.clone(), body_style),
])
})
.collect();
let content_height = rendered.len() as u16;
let chunks = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(content_height.min(area.height)),
Constraint::Fill(1),
])
.split(area);
let p = Paragraph::new(rendered).alignment(Alignment::Left);
frame.render_widget(p, chunks[1]);
}
fn boot_lines(specific: &Options) -> Vec<String> {
match specific.boot_lines.as_ref() {
Some(list) => list.clone(),
None => DEFAULT_LINES.iter().map(|s| s.to_string()).collect(),
}
}
fn inner_options(opts: &RenderOptions) -> RenderOptions {
RenderOptions {
duration_ms: None,
..opts.clone()
}
.without_extra("inner")
.without_extra("effect")
.without_extra("boot_lines")
.without_extra("font_sequence")
}
fn draw_inline_error(frame: &mut Frame, area: Rect, msg: &str) {
frame.render_widget(Paragraph::new(msg), area);
}
fn elapsed_since_start_ms() -> u32 {
let elapsed = process_start().elapsed().as_millis();
elapsed.min(u32::MAX as u128) as u32
}
fn process_start() -> Instant {
static START: OnceLock<Instant> = OnceLock::new();
*START.get_or_init(Instant::now)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{Payload, TextData};
use crate::render::{
RenderSpec,
test_utils::{line_text, render_to_buffer_with_spec},
};
fn joined_text(buf: &ratatui::buffer::Buffer) -> String {
(0..buf.area.height).map(|y| line_text(buf, y)).collect()
}
#[test]
fn boot_lines_falls_back_to_default_when_none() {
let specific = Options::default();
assert_eq!(boot_lines(&specific).len(), DEFAULT_LINES.len());
}
#[test]
fn boot_lines_respects_empty_override() {
let specific = Options {
boot_lines: Some(vec![]),
..Options::default()
};
assert!(boot_lines(&specific).is_empty());
}
#[test]
fn inner_options_strips_boot_fields() {
let opts = RenderOptions {
duration_ms: Some(1500),
style: Some("figlet".into()),
..RenderOptions::default()
}
.with_extra("inner", "text_ascii")
.with_extra("effect", "fade_in")
.with_extra("boot_lines", vec!["a".to_string()])
.with_extra("font_sequence", vec!["small".to_string()]);
let inner = inner_options(&opts);
let parsed: Options = inner.parse_specific();
assert!(parsed.inner.is_none());
assert!(parsed.boot_lines.is_none());
assert!(inner.duration_ms.is_none());
assert_eq!(inner.style.as_deref(), Some("figlet"));
}
#[test]
fn empty_boot_lines_hands_off_to_default_inner_renderer() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions::default().with_extra("boot_lines", Vec::<String>::new()),
};
let registry = super::super::Registry::with_builtins();
let buf = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 20, 3);
assert!(joined_text(&buf).contains("hello"));
}
#[test]
fn unknown_inner_renderer_surfaces_inline_error() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions::default().with_extra("inner", "missing_renderer"),
};
let registry = super::super::Registry::with_builtins();
let buf = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 80, 3);
assert!(joined_text(&buf).contains("unknown inner renderer: missing_renderer"));
}
#[test]
fn incompatible_inner_renderer_surfaces_shape_error() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions::default().with_extra("inner", "grid_table"),
};
let registry = super::super::Registry::with_builtins();
let buf = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 80, 3);
assert!(joined_text(&buf).contains("inner grid_table cannot display Text"));
}
#[test]
fn natural_height_falls_back_to_one_for_unknown_inner_renderer() {
let renderer = AnimatedBootRenderer;
let registry = super::super::Registry::with_builtins();
let body = Body::Text(TextData {
value: "hello\nworld".into(),
});
let opts = RenderOptions::default().with_extra("inner", "missing_renderer");
assert_eq!(renderer.natural_height(&body, &opts, 20, ®istry), 1);
}
#[test]
fn natural_height_delegates_to_shape_default_renderer() {
let renderer = AnimatedBootRenderer;
let registry = super::super::Registry::with_builtins();
let body = Body::Text(TextData {
value: "hello\nworld".into(),
});
assert_eq!(
renderer.natural_height(&body, &RenderOptions::default(), 20, ®istry),
2
);
}
#[test]
fn renderer_exposes_description_colors_and_options() {
let renderer = AnimatedBootRenderer;
assert!(renderer.description().contains("boot log"));
assert_eq!(renderer.color_keys().len(), COLOR_KEYS.len());
assert_eq!(renderer.option_schemas().len(), OPTION_SCHEMAS.len());
assert_eq!(renderer.option_schemas()[0].name, "inner");
assert_eq!(renderer.option_schemas()[1].name, "boot_lines");
assert_eq!(renderer.option_schemas()[2].name, "duration_ms");
}
#[test]
fn active_boot_phase_renders_boot_log_instead_of_inner_payload() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions {
duration_ms: Some(600_000),
..RenderOptions::default()
}
.with_extra("boot_lines", vec!["phase 1".to_string()]),
};
let registry = super::super::Registry::with_builtins();
let _ = process_start();
std::thread::sleep(std::time::Duration::from_millis(2));
let buf = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 30, 5);
let text = joined_text(&buf);
assert!(text.contains("[ OK ] phase 1"), "{text}");
assert!(!text.contains("hello"), "{text}");
}
#[test]
fn renders_without_panic() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions {
style: Some("figlet".into()),
duration_ms: Some(400),
..RenderOptions::default()
}
.with_extra("inner", "text_ascii")
.with_extra("font", "standard"),
};
let registry = super::super::Registry::with_builtins();
let _ = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 60, 12);
}
}