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_histogram;
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 gauge_thermometer;
mod grid_calendar;
mod grid_heatmap;
mod grid_table;
pub mod list_cards;
pub mod list_links;
mod list_plain;
mod list_ranking;
mod list_timeline;
pub mod loading;
mod media_image;
mod media_pixel;
mod status_badge;
mod text_ascii;
mod text_markdown;
mod text_plain;
#[cfg(test)]
pub mod test_utils;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Shape {
Text,
TextBlock,
MarkdownTextBlock,
LinkedTextBlock,
ImageLinkedList,
Entries,
Ratio,
NumberSeries,
PointSeries,
Bars,
Image,
PixelArt,
Calendar,
Heatmap,
Badge,
Timeline,
Error,
}
pub fn shape_of(body: &Body) -> Shape {
match body {
Body::Text(_) => Shape::Text,
Body::TextBlock(_) => Shape::TextBlock,
Body::MarkdownTextBlock(_) => Shape::MarkdownTextBlock,
Body::LinkedTextBlock(_) => Shape::LinkedTextBlock,
Body::ImageLinkedList(_) => Shape::ImageLinkedList,
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::PixelArt(_) => Shape::PixelArt,
Body::Calendar(_) => Shape::Calendar,
Body::Heatmap(_) => Shape::Heatmap,
Body::Badge(_) => Shape::Badge,
Body::Timeline(_) => Shape::Timeline,
Body::Error(_) => Shape::Error,
}
}
impl Shape {
pub fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::TextBlock => "text_block",
Self::MarkdownTextBlock => "markdown_text_block",
Self::LinkedTextBlock => "linked_text_block",
Self::ImageLinkedList => "image_linked_list",
Self::Entries => "entries",
Self::Ratio => "ratio",
Self::NumberSeries => "number_series",
Self::PointSeries => "point_series",
Self::Bars => "bars",
Self::Image => "image",
Self::PixelArt => "pixel_art",
Self::Calendar => "calendar",
Self::Heatmap => "heatmap",
Self::Badge => "badge",
Self::Timeline => "timeline",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RenderOptions {
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub align: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub padding: Option<u16>,
#[serde(default)]
pub bullet: Option<String>,
#[serde(default)]
pub max_items: Option<usize>,
#[serde(default)]
pub tone: Option<String>,
#[serde(default)]
pub value_format: Option<String>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub axes: Option<bool>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub locale: Option<String>,
#[serde(flatten, default)]
pub raw: toml::Table,
}
impl RenderOptions {
pub fn parse_specific<T: serde::de::DeserializeOwned + Default>(&self) -> T {
if self.raw.is_empty() {
return T::default();
}
toml::Value::Table(self.raw.clone())
.try_into()
.unwrap_or_default()
}
pub fn with_extra<V: serde::Serialize>(mut self, key: &str, value: V) -> Self {
if let Ok(v) = toml::Value::try_from(value) {
self.raw.insert(key.to_string(), v);
}
self
}
pub fn without_extra(mut self, key: &str) -> Self {
self.raw.remove(key);
self
}
pub fn extra_str(&self, key: &str) -> Option<&str> {
self.raw.get(key).and_then(|v| v.as_str())
}
pub fn extra_string_array(&self, key: &str) -> Option<Vec<String>> {
self.raw.get(key).and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
}
}
#[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 description(&self) -> &'static 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(text_markdown::TextMarkdownRenderer));
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(list_cards::ListCardsRenderer));
r.register(Arc::new(list_links::ListLinksRenderer));
r.register(Arc::new(list_ranking::ListRankingRenderer));
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(gauge_thermometer::GaugeThermometerRenderer));
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_histogram::ChartHistogramRenderer));
r.register(Arc::new(chart_pie::ChartPieRenderer));
r.register(Arc::new(media_image::MediaImageRenderer));
r.register(Arc::new(media_pixel::MediaPixelRenderer));
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 => "list_plain",
Shape::MarkdownTextBlock => "text_markdown",
Shape::LinkedTextBlock => "list_links",
Shape::ImageLinkedList => "list_cards",
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::PixelArt => "media_pixel",
Shape::Calendar => "grid_calendar",
Shape::Heatmap => "grid_heatmap",
Shape::Badge => "status_badge",
Shape::Timeline => "list_timeline",
Shape::Error => "text_plain",
}
}
pub fn render_payload(
frame: &mut Frame,
area: Rect,
payload: &Payload,
spec: Option<&RenderSpec>,
registry: &Registry,
theme: &Theme,
general: &crate::config::General,
) {
if let Body::Error(err) = &payload.body {
render_error_body(frame, area, err, theme);
return;
}
if is_empty_body(&payload.body) {
render_empty_state(frame, area, theme);
return;
}
let shape = shape_of(&payload.body);
let (name, mut options) = match spec {
Some(s) => (s.renderer_name().to_string(), s.options()),
None => (
default_renderer_for(shape).to_string(),
RenderOptions::default(),
),
};
if options.timezone.is_none() {
options.timezone = general.timezone.clone();
}
if options.locale.is_none() {
options.locale = general.locale.clone();
}
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);
}
fn render_error_body(
frame: &mut Frame,
area: Rect,
err: &crate::payload::ErrorData,
theme: &Theme,
) {
let text = match &err.detail {
Some(d) => format!("{}\n{}", err.message, d),
None => err.message.clone(),
};
render_error(frame, area, &text, theme);
}
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::MarkdownTextBlock(d) => d.value.is_empty(),
Body::LinkedTextBlock(d) => d.items.is_empty() || d.items.iter().all(|i| i.text.is_empty()),
Body::ImageLinkedList(d) => {
d.items.is_empty() || d.items.iter().all(|i| i.title.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::PixelArt(d) => d.pixels.is_empty() || d.pixels.iter().all(|r| r.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(_) | Body::Error(_) => 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 std::sync::Arc;
use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
use super::*;
#[derive(serde::Deserialize)]
struct Wrapper {
render: RenderSpec,
}
struct DefaultingRenderer;
impl Renderer for DefaultingRenderer {
fn name(&self) -> &str {
"test_defaults"
}
fn description(&self) -> &'static str {
"Test renderer"
}
fn accepts(&self) -> &[Shape] {
&[Shape::Text]
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
_body: &Body,
opts: &RenderOptions,
_theme: &Theme,
_registry: &Registry,
) {
let text = format!(
"{}|{}",
opts.timezone.as_deref().unwrap_or("-"),
opts.locale.as_deref().unwrap_or("-")
);
frame.render_widget(ratatui::widgets::Paragraph::new(text), area);
}
}
fn render_to_buffer_with_general(
payload: &crate::payload::Payload,
spec: Option<&RenderSpec>,
registry: &Registry,
general: &crate::config::General,
width: u16,
height: u16,
) -> Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme::default();
terminal
.draw(|f| render_payload(f, f.area(), payload, spec, registry, &theme, general))
.unwrap();
terminal.backend().buffer().clone()
}
#[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().align.is_none());
}
#[test]
fn full_form_carries_common_options() {
let w: Wrapper =
toml::from_str(r#"render = { type = "text_plain", align = "center" }"#).unwrap();
assert_eq!(w.render.renderer_name(), "text_plain");
assert_eq!(w.render.options().align.as_deref(), Some("center"));
}
#[test]
fn full_form_routes_specific_options_into_raw_blob() {
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.duration_ms, Some(1200));
let parsed: animated_postfx::Options = opts.parse_specific();
assert_eq!(parsed.inner.as_deref(), Some("text_ascii"));
assert_eq!(parsed.effect.as_deref(), Some("dissolve"));
}
#[test]
fn registry_resolves_all_builtins() {
let r = Registry::with_builtins();
for name in [
"text_plain",
"text_ascii",
"text_markdown",
"animated_typewriter",
"animated_postfx",
"animated_figlet_morph",
"animated_boot",
"animated_scanlines",
"animated_splitflap",
"animated_wave",
"status_badge",
"list_plain",
"list_cards",
"list_links",
"list_ranking",
"list_timeline",
"grid_table",
"grid_heatmap",
"grid_calendar",
"gauge_battery",
"gauge_circle",
"gauge_line",
"gauge_segment",
"gauge_thermometer",
"chart_sparkline",
"chart_line",
"chart_scatter",
"chart_bar",
"chart_histogram",
"chart_pie",
"media_image",
"media_pixel",
] {
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::MarkdownTextBlock,
Shape::LinkedTextBlock,
Shape::ImageLinkedList,
Shape::Entries,
Shape::Ratio,
Shape::NumberSeries,
Shape::PointSeries,
Shape::Bars,
Shape::Image,
Shape::PixelArt,
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();
assert!(renderer.accepts().contains(&s));
}
}
#[test]
fn shape_helpers_cover_error_variant_and_fallback_renderer() {
use crate::payload::{ErrorData, ErrorKind};
let body = Body::Error(ErrorData {
kind: ErrorKind::Fetch,
message: "boom".into(),
detail: None,
});
assert_eq!(shape_of(&body), Shape::Error);
assert_eq!(Shape::Error.as_str(), "error");
assert_eq!(default_renderer_for(Shape::Error), "text_plain");
}
#[test]
fn render_options_extra_helpers_round_trip() {
let opts = RenderOptions::default()
.with_extra("inner", "text_ascii")
.with_extra("font_sequence", vec!["small", "standard"]);
assert_eq!(opts.extra_str("inner"), Some("text_ascii"));
assert_eq!(
opts.extra_string_array("font_sequence"),
Some(vec!["small".to_string(), "standard".to_string()])
);
assert_eq!(opts.clone().without_extra("inner").extra_str("inner"), None);
}
#[test]
fn renderer_trait_defaults_and_general_fallbacks_work() {
use crate::payload::{Payload, TextData};
use crate::render::test_utils::line_text;
let renderer = DefaultingRenderer;
assert!(renderer.option_schemas().is_empty());
assert!(renderer.color_keys().is_empty());
assert_eq!(
renderer.natural_height(
&Body::Text(TextData { value: "x".into() }),
&RenderOptions::default(),
40,
&Registry::default(),
),
1
);
let mut registry = Registry::default();
registry.register(Arc::new(DefaultingRenderer));
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let spec = RenderSpec::Short("test_defaults".into());
let general = crate::config::General {
timezone: Some("UTC".into()),
locale: Some("ja_JP".into()),
..Default::default()
};
let buf = render_to_buffer_with_general(&payload, Some(&spec), ®istry, &general, 24, 1);
assert!(line_text(&buf, 0).contains("UTC|ja_JP"));
}
#[test]
fn is_empty_body_matches_expectations() {
use crate::payload::{
BarsData, EntriesData, HeatmapData, ImageData, ImageLinkedItem, ImageLinkedListData,
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::ImageLinkedList(ImageLinkedListData {
items: vec![],
})));
assert!(is_empty_body(&Body::ImageLinkedList(ImageLinkedListData {
items: vec![ImageLinkedItem {
title: String::new(),
url: None,
thumbnail_path: None,
subtitle: None,
}],
})));
assert!(!is_empty_body(&Body::ImageLinkedList(
ImageLinkedListData {
items: vec![ImageLinkedItem {
title: "a card".into(),
url: None,
thumbnail_path: None,
subtitle: 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 error_body_renders_inline_regardless_of_configured_renderer() {
use crate::payload::{Body, ErrorData, ErrorKind, Payload};
use crate::render::test_utils::{line_text, render_to_buffer_with_spec};
let p = Payload {
icon: None,
status: None,
format: None,
body: Body::Error(ErrorData {
kind: ErrorKind::Fetch,
message: "⚠ deariary 429".into(),
detail: Some("see logs".into()),
}),
};
let registry = Registry::with_builtins();
let spec = RenderSpec::Short("chart_bar".into());
let buf = render_to_buffer_with_spec(&p, Some(&spec), ®istry, 40, 4);
let joined: String = (0..4).map(|y| line_text(&buf, y)).collect();
assert!(
joined.contains("deariary 429"),
"actionable error message must reach the user: {joined:?}"
);
assert!(
!joined.contains("cannot display"),
"Body::Error should bypass the shape × renderer mismatch path: {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 single_line_empty_placeholder_renders_caption() {
use crate::payload::{Payload, TextData};
use crate::render::test_utils::line_text;
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: String::new(),
}),
};
let buf = render_to_buffer_with_general(
&payload,
None,
&Registry::with_builtins(),
&crate::config::General::default(),
24,
1,
);
assert!(line_text(&buf, 0).contains("nothing here yet"));
}
#[test]
fn unknown_renderer_and_error_without_detail_render_inline_messages() {
use crate::payload::{Body, ErrorData, ErrorKind, Payload, TextData};
use crate::render::test_utils::line_text;
let text_payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData {
value: "hello".into(),
}),
};
let unknown = RenderSpec::Short("missing".into());
let unknown_buf = render_to_buffer_with_general(
&text_payload,
Some(&unknown),
&Registry::with_builtins(),
&crate::config::General::default(),
32,
1,
);
assert!(line_text(&unknown_buf, 0).contains("unknown renderer: missing"));
let error_payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Error(ErrorData {
kind: ErrorKind::Fetch,
message: "fetch failed".into(),
detail: None,
}),
};
let error_buf = render_to_buffer_with_general(
&error_payload,
Some(&RenderSpec::Short("chart_bar".into())),
&Registry::with_builtins(),
&crate::config::General::default(),
32,
1,
);
assert!(line_text(&error_buf, 0).contains("fetch failed"));
}
#[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)
);
}
#[test]
fn any_widget_animates_only_for_explicit_animated_specs() {
let registry = Registry::with_builtins();
let plain = crate::config::WidgetConfig {
id: "plain".into(),
fetcher: "basic_static".into(),
render: None,
format: None,
refresh_interval: None,
file_format: None,
options: None,
};
let unknown = crate::config::WidgetConfig {
render: Some(RenderSpec::Short("missing".into())),
..plain.clone()
};
let animated = crate::config::WidgetConfig {
render: Some(RenderSpec::Short("animated_wave".into())),
..plain.clone()
};
assert!(!any_widget_animates(
std::slice::from_ref(&plain),
®istry
));
assert!(!any_widget_animates(&[unknown], ®istry));
assert!(any_widget_animates(&[plain, animated], ®istry));
}
}