use std::sync::OnceLock;
use std::time::Instant;
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
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>,
}
const COLOR_KEYS: &[ColorKey] = &[theme::PANEL_TITLE, theme::TEXT];
const DEFAULT_DURATION_MS: u64 = 1600;
const FLAP_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?*#@%&/=+-<>:;^~.,'\"";
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 as the resting frame once every cell has settled. Falls back to the shape's natural default.",
},
OptionSchema {
name: "duration_ms",
type_hint: "milliseconds (u64)",
required: false,
default: Some("1600"),
description: "Total cycling duration. Earlier cells settle first; the last cell lands right as the window closes. Kept inside the 2s ANIMATION_WINDOW by default.",
},
];
pub struct AnimatedSplitflapRenderer;
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 AnimatedSplitflapRenderer {
fn name(&self) -> &str {
"animated_splitflap"
}
fn description(&self) -> &'static str {
"Wrapper that flips each cell through random A-Z / 0-9 / punctuation glyphs before settling on the inner renderer's final character, like a train station departures board. Columns settle left-to-right so the text resolves in reading order. Accepts every shape."
}
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 {
frame.render_widget(
Paragraph::new(format!("unknown inner renderer: {inner_name}")),
area,
);
return;
};
if !inner.accepts().contains(&shape) {
frame.render_widget(
Paragraph::new(format!("inner {inner_name} cannot display {shape:?}")),
area,
);
return;
}
inner.render(frame, area, body, &inner_options(opts), theme, registry);
let total = opts.duration_ms.unwrap_or(DEFAULT_DURATION_MS).max(1) as u32;
let elapsed = elapsed_since_start_ms();
if elapsed >= total {
return;
}
apply_flap(frame, area, elapsed, total, 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 apply_flap(frame: &mut Frame, area: Rect, elapsed_ms: u32, total_ms: u32, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let buf = frame.buffer_mut();
let settle_style = Style::default()
.fg(theme.panel_title)
.add_modifier(Modifier::BOLD);
for y in 0..area.height {
for x in 0..area.width {
let settle_at = cell_settle_ms(x, y, area.width, area.height, total_ms);
if elapsed_ms >= settle_at {
continue;
}
let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) else {
continue;
};
if cell.symbol().trim().is_empty() {
continue;
}
let glyph = flap_glyph(x, y, elapsed_ms);
cell.set_symbol(&glyph.to_string());
cell.set_style(settle_style);
}
}
}
fn cell_settle_ms(x: u16, y: u16, width: u16, height: u16, total_ms: u32) -> u32 {
let width = width.max(1) as u32;
let height = height.max(1) as u32;
let col_share = 0.80;
let row_share = 0.15;
let col = (x as u32 * total_ms) / width;
let row = (y as u32 * total_ms) / height;
let settle = (col as f32 * col_share + row as f32 * row_share) as u32 + total_ms / 12;
settle.min(total_ms)
}
fn flap_glyph(x: u16, y: u16, elapsed_ms: u32) -> char {
let tick = (elapsed_ms / 45) as usize + (x as usize * 7 + y as usize * 13);
FLAP_CHARS[tick % FLAP_CHARS.len()] as char
}
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 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::{
Registry, RenderSpec,
test_utils::{line_text, render_to_buffer, render_to_buffer_with_spec},
};
use crate::theme::{self, Theme};
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
fn text_payload(value: &str) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: value.to_string(),
}),
}
}
#[test]
fn exposes_wrapper_contract() {
let renderer = AnimatedSplitflapRenderer;
assert_eq!(renderer.name(), "animated_splitflap");
assert!(renderer.description().contains("departures board"));
assert!(renderer.animates());
assert_eq!(renderer.color_keys().len(), 2);
assert_eq!(renderer.color_keys()[0].name, theme::PANEL_TITLE.name);
assert_eq!(renderer.color_keys()[1].name, theme::TEXT.name);
assert_eq!(renderer.option_schemas().len(), OPTION_SCHEMAS.len());
assert_eq!(renderer.option_schemas()[0].name, "inner");
assert_eq!(renderer.option_schemas()[1].name, "duration_ms");
assert!(renderer.accepts().contains(&Shape::Text));
assert!(renderer.accepts().contains(&Shape::Timeline));
}
#[test]
fn left_cells_settle_before_right_cells() {
let left = cell_settle_ms(0, 0, 20, 5, 1600);
let right = cell_settle_ms(19, 0, 20, 5, 1600);
assert!(left < right, "left={left} right={right}");
}
#[test]
fn flap_glyph_is_printable_ascii() {
let g = flap_glyph(3, 2, 900);
assert!(g.is_ascii_graphic(), "got {g:?}");
}
#[test]
fn natural_height_delegates_and_unknown_inner_falls_back_to_one() {
let registry = Registry::with_builtins();
let renderer = AnimatedSplitflapRenderer;
let opts = RenderOptions {
style: Some("figlet".into()),
..RenderOptions::default()
}
.with_extra("inner", "text_ascii");
let body = text_payload("ready").body;
let expected = registry.get("text_ascii").unwrap().natural_height(
&body,
&inner_options(&opts),
40,
®istry,
);
assert_eq!(
renderer.natural_height(&body, &opts, 40, ®istry),
expected
);
let missing = RenderOptions::default().with_extra("inner", "missing_renderer");
assert_eq!(renderer.natural_height(&body, &missing, 40, ®istry), 1);
}
#[test]
fn render_reports_unknown_and_incompatible_inner() {
let payload = text_payload("ready");
let registry = Registry::with_builtins();
let unknown = RenderSpec::Full {
type_name: "animated_splitflap".into(),
options: RenderOptions::default().with_extra("inner", "missing_renderer"),
};
let buf = render_to_buffer_with_spec(&payload, Some(&unknown), ®istry, 40, 3);
assert!(line_text(&buf, 0).contains("unknown inner renderer: missing_renderer"));
let incompatible = RenderSpec::Full {
type_name: "animated_splitflap".into(),
options: RenderOptions::default().with_extra("inner", "gauge_circle"),
};
let buf = render_to_buffer_with_spec(&payload, Some(&incompatible), ®istry, 40, 3);
assert!(line_text(&buf, 0).contains("cannot display Text"));
}
#[test]
fn render_matches_static_inner_once_window_expires() {
let payload = text_payload("ready");
let spec = RenderSpec::Full {
type_name: "animated_splitflap".into(),
options: RenderOptions {
duration_ms: Some(1),
..RenderOptions::default()
},
};
let expected = render_to_buffer(&payload, 20, 3);
let _ = elapsed_since_start_ms();
std::thread::sleep(std::time::Duration::from_millis(2));
let actual =
render_to_buffer_with_spec(&payload, Some(&spec), &Registry::with_builtins(), 20, 3);
assert_eq!(actual, expected);
}
#[test]
fn apply_flap_changes_non_blank_cells_and_preserves_blanks() {
let backend = TestBackend::new(3, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame.render_widget(Paragraph::new("AB "), area);
apply_flap(frame, area, 0, 1_000, &Theme::default());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
assert_ne!(buf.cell((1, 0)).unwrap().symbol(), "B");
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), " ");
assert_eq!(
buf.cell((1, 0)).unwrap().style().fg,
Some(Theme::default().panel_title)
);
}
#[test]
fn renders_without_panic() {
let payload = text_payload("ready");
let spec = RenderSpec::Full {
type_name: "animated_splitflap".into(),
options: RenderOptions {
style: Some("figlet".into()),
duration_ms: Some(400),
..RenderOptions::default()
}
.with_extra("inner", "text_ascii"),
};
let registry = Registry::with_builtins();
let _ = render_to_buffer_with_spec(&payload, Some(&spec), ®istry, 40, 10);
}
#[test]
fn apply_flap_skips_a_zero_sized_area() {
let backend = TestBackend::new(4, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let zero = Rect {
x: 0,
y: 0,
width: 0,
height: 0,
};
apply_flap(frame, zero, 0, 1_000, &Theme::default());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
assert!((0..2).all(|y| line_text(&buf, y).trim().is_empty()));
}
#[test]
fn apply_flap_leaves_settled_cells_untouched_past_the_window() {
let backend = TestBackend::new(3, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
frame.render_widget(Paragraph::new("AB "), area);
apply_flap(frame, area, 1_000, 1_000, &Theme::default());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "A");
assert_eq!(buf.cell((1, 0)).unwrap().symbol(), "B");
}
#[test]
fn apply_flap_ignores_cells_outside_the_buffer() {
let backend = TestBackend::new(3, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let oversized = Rect {
x: 0,
y: 0,
width: 12,
height: 4,
};
apply_flap(frame, oversized, 0, 1_000, &Theme::default());
})
.unwrap();
}
}