use std::collections::HashMap;
use std::sync::Arc;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::Line,
widgets::Paragraph,
};
use serde::Deserialize;
use crate::options::OptionSchema;
use crate::payload::{Body, Payload};
use crate::theme::{ColorKey, Theme};
mod animated_boot;
mod animated_figlet_morph;
mod animated_postfx;
mod animated_scanlines;
mod animated_splitflap;
mod animated_typewriter;
mod animated_wave;
mod chart_bar;
mod chart_line;
mod chart_pie;
mod chart_scatter;
mod chart_sparkline;
mod gauge_battery;
mod gauge_circle;
mod gauge_line;
mod gauge_segment;
mod grid_calendar;
mod grid_heatmap;
mod grid_table;
mod list_plain;
mod list_timeline;
pub mod loading;
mod media_image;
mod status_badge;
mod text_ascii;
mod text_plain;
#[cfg(test)]
pub mod test_utils;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Shape {
Text,
TextBlock,
Entries,
Ratio,
NumberSeries,
PointSeries,
Bars,
Image,
Calendar,
Heatmap,
Badge,
Timeline,
}
pub fn shape_of(body: &Body) -> Shape {
match body {
Body::Text(_) => Shape::Text,
Body::TextBlock(_) => Shape::TextBlock,
Body::Entries(_) => Shape::Entries,
Body::Ratio(_) => Shape::Ratio,
Body::NumberSeries(_) => Shape::NumberSeries,
Body::PointSeries(_) => Shape::PointSeries,
Body::Bars(_) => Shape::Bars,
Body::Image(_) => Shape::Image,
Body::Calendar(_) => Shape::Calendar,
Body::Heatmap(_) => Shape::Heatmap,
Body::Badge(_) => Shape::Badge,
Body::Timeline(_) => Shape::Timeline,
}
}
impl Shape {
pub fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::TextBlock => "text_block",
Self::Entries => "entries",
Self::Ratio => "ratio",
Self::NumberSeries => "number_series",
Self::PointSeries => "point_series",
Self::Bars => "bars",
Self::Image => "image",
Self::Calendar => "calendar",
Self::Heatmap => "heatmap",
Self::Badge => "badge",
Self::Timeline => "timeline",
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderOptions {
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub pixel_size: Option<String>,
#[serde(default)]
pub align: Option<String>,
#[serde(default)]
pub font: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub fit: Option<String>,
#[serde(default)]
pub max_width: Option<u16>,
#[serde(default)]
pub max_height: Option<u16>,
#[serde(default)]
pub padding: Option<u16>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub value_format: Option<String>,
#[serde(default)]
pub layout: Option<String>,
#[serde(default)]
pub column_align: Option<Vec<String>>,
#[serde(default)]
pub bullet: Option<String>,
#[serde(default)]
pub max_items: Option<usize>,
#[serde(default)]
pub date_format: Option<String>,
#[serde(default)]
pub shape: Option<String>,
#[serde(default)]
pub legend: Option<String>,
#[serde(default)]
pub donut: Option<bool>,
#[serde(default)]
pub horizontal: Option<bool>,
#[serde(default)]
pub stacked: Option<bool>,
#[serde(default)]
pub max_bars: Option<usize>,
#[serde(default)]
pub bar_width: Option<u16>,
#[serde(default)]
pub axes: Option<bool>,
#[serde(default)]
pub marker: Option<String>,
#[serde(default)]
pub tone: Option<String>,
#[serde(default)]
pub segments: Option<u16>,
#[serde(default)]
pub ring_thickness: Option<u16>,
#[serde(default)]
pub label_position: Option<String>,
#[serde(default)]
pub cell_char: Option<String>,
#[serde(default)]
pub week_start: Option<String>,
#[serde(default)]
pub inner: Option<String>,
#[serde(default)]
pub effect: Option<String>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub font_sequence: Option<Vec<String>>,
#[serde(default)]
pub boot_lines: Option<Vec<String>>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum RenderSpec {
Short(String),
Full {
#[serde(rename = "type")]
type_name: String,
#[serde(flatten)]
options: RenderOptions,
},
}
impl RenderSpec {
pub fn renderer_name(&self) -> &str {
match self {
RenderSpec::Short(s) => s,
RenderSpec::Full { type_name, .. } => type_name,
}
}
pub fn options(&self) -> RenderOptions {
match self {
RenderSpec::Short(_) => RenderOptions::default(),
RenderSpec::Full { options, .. } => options.clone(),
}
}
}
pub trait Renderer: Send + Sync {
fn name(&self) -> &str;
fn accepts(&self) -> &[Shape];
fn animates(&self) -> bool {
false
}
fn option_schemas(&self) -> &[OptionSchema] {
&[]
}
fn color_keys(&self) -> &[ColorKey] {
&[]
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
registry: &Registry,
);
fn natural_height(
&self,
_body: &Body,
_opts: &RenderOptions,
_max_width: u16,
_registry: &Registry,
) -> u16 {
1
}
}
#[derive(Default, Clone)]
pub struct Registry {
renderers: HashMap<String, Arc<dyn Renderer>>,
}
impl Registry {
pub fn with_builtins() -> Self {
let mut r = Self::default();
r.register(Arc::new(text_plain::TextPlainRenderer));
r.register(Arc::new(text_ascii::TextAsciiRenderer));
r.register(Arc::new(animated_typewriter::AnimatedTypewriterRenderer));
r.register(Arc::new(animated_postfx::AnimatedPostfxRenderer));
r.register(Arc::new(animated_figlet_morph::AnimatedFigletMorphRenderer));
r.register(Arc::new(animated_boot::AnimatedBootRenderer));
r.register(Arc::new(animated_scanlines::AnimatedScanlinesRenderer));
r.register(Arc::new(animated_splitflap::AnimatedSplitflapRenderer));
r.register(Arc::new(animated_wave::AnimatedWaveRenderer));
r.register(Arc::new(status_badge::StatusBadgeRenderer));
r.register(Arc::new(list_plain::ListPlainRenderer));
r.register(Arc::new(grid_table::GridTableRenderer));
r.register(Arc::new(gauge_battery::GaugeBatteryRenderer));
r.register(Arc::new(gauge_circle::GaugeCircleRenderer));
r.register(Arc::new(gauge_line::GaugeLineRenderer));
r.register(Arc::new(gauge_segment::GaugeSegmentRenderer));
r.register(Arc::new(chart_sparkline::ChartSparklineRenderer));
r.register(Arc::new(chart_line::ChartLineRenderer));
r.register(Arc::new(chart_scatter::ChartScatterRenderer));
r.register(Arc::new(chart_bar::ChartBarRenderer));
r.register(Arc::new(chart_pie::ChartPieRenderer));
r.register(Arc::new(media_image::MediaImageRenderer));
r.register(Arc::new(grid_calendar::GridCalendarRenderer));
r.register(Arc::new(grid_heatmap::GridHeatmapRenderer));
r.register(Arc::new(list_timeline::ListTimelineRenderer));
r
}
pub fn register(&mut self, r: Arc<dyn Renderer>) {
self.renderers.insert(r.name().to_string(), r);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Renderer>> {
self.renderers.get(name).cloned()
}
pub fn sorted(&self) -> Vec<Arc<dyn Renderer>> {
let mut entries: Vec<_> = self.renderers.values().cloned().collect();
entries.sort_by(|a, b| a.name().cmp(b.name()));
entries
}
}
pub fn default_renderer_for(shape: Shape) -> &'static str {
match shape {
Shape::Text => "text_plain",
Shape::TextBlock => "text_plain",
Shape::Entries => "grid_table",
Shape::Ratio => "gauge_circle",
Shape::NumberSeries => "chart_sparkline",
Shape::PointSeries => "chart_line",
Shape::Bars => "chart_bar",
Shape::Image => "media_image",
Shape::Calendar => "grid_calendar",
Shape::Heatmap => "grid_heatmap",
Shape::Badge => "status_badge",
Shape::Timeline => "list_timeline",
}
}
pub fn render_payload(
frame: &mut Frame,
area: Rect,
payload: &Payload,
spec: Option<&RenderSpec>,
registry: &Registry,
theme: &Theme,
) {
if is_empty_body(&payload.body) {
render_empty_state(frame, area, theme);
return;
}
let shape = shape_of(&payload.body);
let (name, options) = match spec {
Some(s) => (s.renderer_name().to_string(), s.options()),
None => (
default_renderer_for(shape).to_string(),
RenderOptions::default(),
),
};
let Some(renderer) = registry.get(&name) else {
render_error(frame, area, &format!("unknown renderer: {name}"), theme);
return;
};
if !renderer.accepts().contains(&shape) {
render_error(
frame,
area,
&format!("renderer {name} cannot display {shape:?}"),
theme,
);
return;
}
renderer.render(frame, area, &payload.body, &options, theme, registry);
}
pub fn is_empty_body(body: &Body) -> bool {
match body {
Body::Text(d) => d.value.is_empty(),
Body::TextBlock(d) => d.lines.is_empty() || d.lines.iter().all(|l| l.is_empty()),
Body::Entries(d) => d.items.is_empty(),
Body::NumberSeries(d) => d.values.is_empty(),
Body::PointSeries(d) => d.series.iter().all(|s| s.points.is_empty()),
Body::Bars(d) => d.bars.is_empty(),
Body::Image(d) => d.path.is_empty(),
Body::Heatmap(d) => d.cells.is_empty() || d.cells.iter().all(|r| r.is_empty()),
Body::Timeline(d) => d.events.is_empty(),
Body::Badge(_) | Body::Calendar(_) | Body::Ratio(_) => false,
}
}
fn render_empty_state(frame: &mut Frame, area: Rect, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let (lines_src, min_width): (&[&str], u16) = if area.height >= 2 {
(&["◌", "nothing here yet"], 16)
} else {
(&["◌ nothing here yet"], 18)
};
if area.width < min_width {
return;
}
let dim = Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::ITALIC);
let lines: Vec<Line> = lines_src
.iter()
.map(|s| Line::from(*s).style(dim))
.collect();
let content_height = lines.len() as u16;
let chunks = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(content_height),
Constraint::Fill(1),
])
.split(area);
let p = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(p, chunks[1]);
}
pub fn any_widget_animates(widgets: &[crate::config::WidgetConfig], registry: &Registry) -> bool {
widgets.iter().any(|w| {
let name = match &w.render {
Some(spec) => spec.renderer_name(),
None => return false,
};
registry.get(name).is_some_and(|r| r.animates())
})
}
fn render_error(frame: &mut Frame, area: Rect, msg: &str, theme: &Theme) {
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(theme.status_error)),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Deserialize)]
struct Wrapper {
render: RenderSpec,
}
#[test]
fn short_form_parses_as_renderer_name() {
let w: Wrapper = toml::from_str(r#"render = "text_plain""#).unwrap();
assert_eq!(w.render.renderer_name(), "text_plain");
assert!(w.render.options().pixel_size.is_none());
}
#[test]
fn full_form_carries_options() {
let w: Wrapper =
toml::from_str(r#"render = { type = "text_ascii", pixel_size = "quadrant" }"#).unwrap();
assert_eq!(w.render.renderer_name(), "text_ascii");
assert_eq!(w.render.options().pixel_size.as_deref(), Some("quadrant"));
}
#[test]
fn full_form_carries_postfx_options() {
let w: Wrapper = toml::from_str(
r#"render = { type = "animated_postfx", inner = "text_ascii", effect = "dissolve", duration_ms = 1200 }"#,
)
.unwrap();
let opts = w.render.options();
assert_eq!(w.render.renderer_name(), "animated_postfx");
assert_eq!(opts.inner.as_deref(), Some("text_ascii"));
assert_eq!(opts.effect.as_deref(), Some("dissolve"));
assert_eq!(opts.duration_ms, Some(1200));
}
#[test]
fn registry_resolves_all_builtins() {
let r = Registry::with_builtins();
for name in [
"text_plain",
"text_ascii",
"animated_typewriter",
"animated_postfx",
"animated_figlet_morph",
"animated_boot",
"animated_scanlines",
"animated_splitflap",
"animated_wave",
"status_badge",
"list_plain",
"list_timeline",
"grid_table",
"grid_heatmap",
"grid_calendar",
"gauge_battery",
"gauge_circle",
"gauge_line",
"gauge_segment",
"chart_sparkline",
"chart_line",
"chart_scatter",
"chart_bar",
"chart_pie",
"media_image",
] {
assert!(r.get(name).is_some(), "missing builtin renderer: {name}");
}
}
#[test]
fn default_renderer_covers_every_shape() {
for s in [
Shape::Text,
Shape::TextBlock,
Shape::Entries,
Shape::Ratio,
Shape::NumberSeries,
Shape::PointSeries,
Shape::Bars,
Shape::Image,
Shape::Calendar,
Shape::Heatmap,
Shape::Badge,
Shape::Timeline,
] {
let name = default_renderer_for(s);
let r = Registry::with_builtins();
let renderer = r.get(name).unwrap_or_else(|| panic!("no renderer {name}"));
assert!(
renderer.accepts().contains(&s),
"default renderer {name} doesn't accept its shape {s:?}"
);
}
}
#[test]
fn is_empty_body_matches_expectations() {
use crate::payload::{
BarsData, EntriesData, HeatmapData, ImageData, NumberSeriesData, PointSeriesData,
RatioData, TextBlockData, TextData,
};
assert!(is_empty_body(&Body::Text(TextData {
value: String::new(),
})));
assert!(is_empty_body(&Body::TextBlock(TextBlockData {
lines: vec![],
})));
assert!(is_empty_body(&Body::TextBlock(TextBlockData {
lines: vec![String::new()],
})));
assert!(is_empty_body(&Body::Entries(EntriesData { items: vec![] })));
assert!(is_empty_body(&Body::NumberSeries(NumberSeriesData {
values: vec![],
})));
assert!(is_empty_body(&Body::PointSeries(PointSeriesData {
series: vec![],
})));
assert!(is_empty_body(&Body::Bars(BarsData { bars: vec![] })));
assert!(is_empty_body(&Body::Image(ImageData {
path: String::new(),
})));
assert!(is_empty_body(&Body::Heatmap(HeatmapData {
cells: vec![],
thresholds: None,
row_labels: None,
col_labels: None,
})));
assert!(!is_empty_body(&Body::Text(TextData { value: "x".into() })));
assert!(!is_empty_body(&Body::TextBlock(TextBlockData {
lines: vec!["x".into()],
})));
assert!(!is_empty_body(&Body::Ratio(RatioData {
value: 0.0,
label: None,
denominator: None,
})));
}
#[test]
fn empty_body_renders_placeholder_not_specific_renderer() {
use crate::payload::{HeatmapData, Payload};
use crate::render::test_utils::{line_text, render_to_buffer_with_spec};
let p = Payload {
icon: None,
status: None,
format: None,
body: Body::Heatmap(HeatmapData {
cells: vec![],
thresholds: None,
row_labels: None,
col_labels: None,
}),
};
let registry = Registry::with_builtins();
let spec = RenderSpec::Short("grid_heatmap".into());
let buf = render_to_buffer_with_spec(&p, Some(&spec), ®istry, 40, 5);
let joined: String = (0..5).map(|y| line_text(&buf, y)).collect();
assert!(
joined.contains("nothing here yet"),
"missing caption: {joined:?}"
);
}
#[test]
fn empty_placeholder_skipped_when_area_too_narrow() {
use crate::payload::{Payload, TextData};
use crate::render::test_utils::{line_text, render_to_buffer_with_spec};
let p = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: String::new(),
}),
};
let registry = Registry::with_builtins();
let spec = RenderSpec::Short("text_plain".into());
let buf = render_to_buffer_with_spec(&p, Some(&spec), ®istry, 2, 6);
let joined: String = (0..6).map(|y| line_text(&buf, y)).collect();
assert!(
!joined.contains("no") && !joined.contains('◌'),
"narrow area should render nothing, got: {joined:?}"
);
}
#[test]
fn text_shape_has_multiple_renderers() {
let r = Registry::with_builtins();
assert!(
r.get("text_plain")
.unwrap()
.accepts()
.contains(&Shape::Text)
);
assert!(
r.get("text_ascii")
.unwrap()
.accepts()
.contains(&Shape::Text)
);
}
}