use std::collections::HashMap;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Block;
use crate::config::{Config, DashboardConfig, SettingsConfig, WidgetConfig};
use crate::fetcher::{RegisteredFetcher, Registry as FetcherRegistry};
use crate::layout as layout_engine;
use crate::layout::WidgetId;
use crate::payload::{Body, ImageData, Payload, TextBlockData, TextData};
use crate::render::{
Registry as RenderRegistry, RenderOptions, RenderSpec, Shape, default_renderer_for,
};
use crate::samples;
use crate::theme::Theme;
pub(crate) struct TemplatePreview {
config: Config,
payloads: HashMap<WidgetId, Payload>,
specs: HashMap<WidgetId, RenderSpec>,
theme: Theme,
renderers: RenderRegistry,
}
impl TemplatePreview {
pub(crate) fn from_body(body: &str) -> Option<Self> {
Self::from_body_with_theme(body, Theme::default())
}
pub(crate) fn from_body_with_theme(body: &str, theme: Theme) -> Option<Self> {
let dashboard = DashboardConfig::parse(body).ok()?;
let config = Config::from_parts(SettingsConfig::default(), dashboard);
let fetchers = FetcherRegistry::with_builtins();
let renderers = RenderRegistry::with_builtins();
let payloads = build_payloads(&config.widgets, &fetchers, &renderers);
let specs = build_specs(&config.widgets);
Some(Self {
config,
payloads,
specs,
theme,
renderers,
})
}
pub(crate) fn draw(&self, frame: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
frame.render_widget(
Block::default().style(Style::default().bg(self.theme.bg)),
area,
);
let root = self.config.to_layout();
let loading: HashMap<WidgetId, Shape> = HashMap::new();
layout_engine::draw(
frame,
area,
&root,
&self.payloads,
&self.specs,
&self.renderers,
&self.theme,
&self.config.general,
&loading,
);
}
}
fn build_payloads(
widgets: &[WidgetConfig],
fetchers: &FetcherRegistry,
renderers: &RenderRegistry,
) -> HashMap<WidgetId, Payload> {
widgets
.iter()
.filter_map(|w| {
let fetcher = fetchers.get(&w.fetcher)?;
let shape = resolve_shape(w, &fetcher, renderers);
let body = override_from_format(w, shape)
.or_else(|| fetcher.sample_body(shape))
.or_else(|| samples::canonical_sample(shape))
.unwrap_or_else(|| {
Body::Image(ImageData {
path: "/dev/null".into(),
})
});
Some((
w.id.clone(),
Payload {
icon: None,
status: None,
format: w.format.clone(),
body,
},
))
})
.collect()
}
fn resolve_shape(
w: &WidgetConfig,
fetcher: &RegisteredFetcher,
renderers: &RenderRegistry,
) -> Shape {
let shapes = fetcher.shapes();
let accepted = w
.render
.as_ref()
.and_then(|spec| renderers.get(spec.renderer_name()))
.map(|r| r.accepts().to_vec());
if let Some(accepted) = accepted {
if let Some(shape) = shapes.iter().find(|s| accepted.contains(s)) {
return *shape;
}
if let Some(&first) = accepted.first() {
return first;
}
}
fetcher.default_shape()
}
fn override_from_format(w: &WidgetConfig, shape: Shape) -> Option<Body> {
if w.fetcher != "basic_static" {
return None;
}
let fmt = w.format.as_ref()?;
match shape {
Shape::Text => Some(Body::Text(TextData { value: fmt.clone() })),
Shape::TextBlock => Some(Body::TextBlock(TextBlockData {
lines: fmt.lines().map(String::from).collect(),
})),
_ => None,
}
}
fn build_specs(widgets: &[WidgetConfig]) -> HashMap<WidgetId, RenderSpec> {
widgets
.iter()
.map(|w| {
let spec = w.render.clone().unwrap_or_else(|| {
RenderSpec::Short(default_renderer_for(Shape::Text).to_string())
});
(w.id.clone(), deanimate(spec))
})
.collect()
}
fn deanimate(spec: RenderSpec) -> RenderSpec {
match spec {
RenderSpec::Short(ref name) if name == "animated_typewriter" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_typewriter" => RenderSpec::Full {
type_name: "text_plain".into(),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_postfx" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_postfx" => RenderSpec::Full {
type_name: inner_renderer_name(options),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_boot" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_boot" => RenderSpec::Full {
type_name: inner_renderer_name(options),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_scanlines" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_scanlines" => RenderSpec::Full {
type_name: inner_renderer_name(options),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_splitflap" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_splitflap" => RenderSpec::Full {
type_name: inner_renderer_name(options),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_wave" => {
RenderSpec::Short("text_plain".into())
}
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_wave" => RenderSpec::Full {
type_name: inner_renderer_name(options),
options: options.clone(),
},
RenderSpec::Short(ref name) if name == "animated_figlet_morph" => RenderSpec::Full {
type_name: "text_ascii".into(),
options: RenderOptions {
style: Some("figlet".into()),
..RenderOptions::default()
}
.with_extra("font", "ansi_shadow"),
},
RenderSpec::Full {
ref type_name,
ref options,
} if type_name == "animated_figlet_morph" => {
let resting_font = font_sequence(options)
.and_then(|seq| seq.last().cloned())
.unwrap_or_else(|| "ansi_shadow".into());
RenderSpec::Full {
type_name: "text_ascii".into(),
options: RenderOptions {
style: Some("figlet".into()),
color: options.color.clone(),
align: options.align.clone(),
..RenderOptions::default()
}
.with_extra("font", resting_font),
}
}
other => other,
}
}
fn inner_renderer_name(opts: &RenderOptions) -> String {
#[derive(serde::Deserialize, Default)]
struct Wrapper {
#[serde(default)]
inner: Option<String>,
}
let parsed: Wrapper = opts.parse_specific();
parsed.inner.unwrap_or_else(|| "text_plain".into())
}
fn font_sequence(opts: &RenderOptions) -> Option<Vec<String>> {
#[derive(serde::Deserialize, Default)]
struct Wrapper {
#[serde(default)]
font_sequence: Option<Vec<String>>,
}
let parsed: Wrapper = opts.parse_specific();
parsed.font_sequence
}
#[cfg(test)]
mod tests {
use super::*;
use crate::templates;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn widget(id: &str, fetcher: &str) -> WidgetConfig {
WidgetConfig {
id: id.into(),
fetcher: fetcher.into(),
..Default::default()
}
}
#[test]
fn every_template_builds_a_preview() {
for t in templates::TEMPLATES {
assert!(
TemplatePreview::from_body(t.body).is_some(),
"{} failed to build preview",
t.name
);
}
}
#[test]
fn draw_does_not_panic_on_small_area() {
let preview = TemplatePreview::from_body(templates::TEMPLATES[0].body).unwrap();
let backend = TestBackend::new(40, 16);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| preview.draw(frame, frame.area()))
.unwrap();
}
#[test]
fn every_theme_preset_renders_home_splash() {
let body = templates::find("home_splash").unwrap().body;
for name in crate::theme::presets::KNOWN.iter().copied() {
let theme = crate::theme::presets::by_name(name).unwrap();
let preview =
TemplatePreview::from_body_with_theme(body, theme).expect("preview builds");
let backend = TestBackend::new(100, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| preview.draw(frame, frame.area()))
.unwrap_or_else(|e| panic!("{name} failed to render: {e}"));
}
}
#[test]
fn invalid_template_returns_none() {
assert!(TemplatePreview::from_body("not [valid = toml").is_none());
}
#[test]
fn draw_returns_early_for_zero_area() {
let preview = TemplatePreview::from_body(templates::TEMPLATES[0].body).unwrap();
let backend = TestBackend::new(40, 16);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| preview.draw(frame, Rect::new(0, 0, 0, 0)))
.unwrap();
}
#[test]
fn build_payloads_uses_static_format_and_sample_bodies() {
let fetchers = FetcherRegistry::with_builtins();
let renderers = RenderRegistry::with_builtins();
let mut static_widget = widget("static", "basic_static");
static_widget.format = Some("alpha\nbeta".into());
static_widget.render = Some(RenderSpec::Short("list_plain".into()));
let mut clock_widget = widget("clock", "clock");
clock_widget.render = Some(RenderSpec::Short("grid_table".into()));
let payloads = build_payloads(
&[
static_widget,
clock_widget,
widget("missing", "no_such_fetcher"),
],
&fetchers,
&renderers,
);
assert_eq!(payloads.len(), 2);
assert!(matches!(
&payloads.get("static").unwrap().body,
Body::TextBlock(TextBlockData { lines })
if lines == &vec![String::from("alpha"), String::from("beta")]
));
assert!(matches!(
payloads.get("clock").map(|payload| &payload.body),
Some(Body::Entries(_))
));
assert!(!payloads.contains_key("missing"));
}
#[test]
fn resolve_shape_prefers_shared_shape_and_falls_back_to_renderer_or_default() {
let fetchers = FetcherRegistry::with_builtins();
let renderers = RenderRegistry::with_builtins();
let mut shared = widget("shared", "basic_static");
shared.render = Some(RenderSpec::Short("list_plain".into()));
assert_eq!(
resolve_shape(&shared, &fetchers.get("basic_static").unwrap(), &renderers),
Shape::TextBlock
);
let mut mismatch = widget("mismatch", "git_repo_name");
mismatch.render = Some(RenderSpec::Short("status_badge".into()));
assert_eq!(
resolve_shape(
&mismatch,
&fetchers.get("git_repo_name").unwrap(),
&renderers
),
Shape::Badge
);
assert_eq!(
resolve_shape(
&widget("defaulted", "clock"),
&fetchers.get("clock").unwrap(),
&renderers
),
Shape::Text
);
}
#[test]
fn resolve_shape_uses_renderer_first_shape_when_no_shapes_overlap() {
let fetchers = FetcherRegistry::with_builtins();
let renderers = RenderRegistry::with_builtins();
let mut mismatch = widget("mismatch", "clock");
mismatch.render = Some(RenderSpec::Short("status_badge".into()));
assert_eq!(
resolve_shape(&mismatch, &fetchers.get("clock").unwrap(), &renderers),
Shape::Badge
);
}
#[test]
fn build_specs_defaults_and_deanimates_wrapped_renderers() {
let plain = widget("plain", "basic_static");
let mut typewriter = widget("typewriter", "basic_static");
typewriter.render = Some(RenderSpec::Short("animated_typewriter".into()));
let mut postfx = widget("postfx", "basic_static");
postfx.render = Some(RenderSpec::Full {
type_name: "animated_postfx".into(),
options: RenderOptions {
align: Some("center".into()),
..RenderOptions::default()
}
.with_extra("inner", "list_plain"),
});
let specs = build_specs(&[plain, typewriter, postfx]);
assert!(matches!(
specs.get("plain"),
Some(RenderSpec::Short(name)) if name == "text_plain"
));
assert!(matches!(
specs.get("typewriter"),
Some(RenderSpec::Short(name)) if name == "text_plain"
));
assert!(matches!(
specs.get("postfx"),
Some(RenderSpec::Full { type_name, options })
if type_name == "list_plain" && options.align.as_deref() == Some("center")
));
}
#[test]
fn override_from_format_returns_none_for_non_text_shapes() {
let mut static_widget = widget("static", "basic_static");
static_widget.format = Some("alpha".into());
assert!(override_from_format(&static_widget, Shape::Badge).is_none());
}
#[test]
fn deanimate_unwraps_wrappers_and_figlet_morph() {
for name in [
"animated_typewriter",
"animated_postfx",
"animated_boot",
"animated_scanlines",
"animated_splitflap",
"animated_wave",
] {
match deanimate(RenderSpec::Short(name.into())) {
RenderSpec::Short(inner) => assert_eq!(inner, "text_plain"),
other => panic!("expected short text_plain for {name}, got {other:?}"),
}
}
for type_name in [
"animated_postfx",
"animated_boot",
"animated_scanlines",
"animated_splitflap",
"animated_wave",
] {
let spec = RenderSpec::Full {
type_name: type_name.into(),
options: RenderOptions::default().with_extra("inner", "status_badge"),
};
assert!(matches!(
deanimate(spec),
RenderSpec::Full { type_name, .. } if type_name == "status_badge"
));
}
assert!(matches!(
deanimate(RenderSpec::Full {
type_name: "animated_boot".into(),
options: RenderOptions::default(),
}),
RenderSpec::Full { type_name, .. } if type_name == "text_plain"
));
assert!(matches!(
deanimate(RenderSpec::Short("animated_figlet_morph".into())),
RenderSpec::Full { type_name, options }
if type_name == "text_ascii"
&& options.style.as_deref() == Some("figlet")
&& options.extra_str("font") == Some("ansi_shadow")
));
let spec = RenderSpec::Full {
type_name: "animated_figlet_morph".into(),
options: RenderOptions {
align: Some("right".into()),
color: Some("panel_title".into()),
..RenderOptions::default()
}
.with_extra("font_sequence", vec!["small", "doom"]),
};
assert!(matches!(
deanimate(spec),
RenderSpec::Full { type_name, options }
if type_name == "text_ascii"
&& options.style.as_deref() == Some("figlet")
&& options.align.as_deref() == Some("right")
&& options.color.as_deref() == Some("panel_title")
&& options.extra_str("font") == Some("doom")
));
assert!(matches!(
deanimate(RenderSpec::Full {
type_name: "animated_figlet_morph".into(),
options: RenderOptions::default(),
}),
RenderSpec::Full { type_name, options }
if type_name == "text_ascii"
&& options.extra_str("font") == Some("ansi_shadow")
));
}
#[test]
fn deanimate_full_typewriter_preserves_render_options() {
let spec = RenderSpec::Full {
type_name: "animated_typewriter".into(),
options: RenderOptions {
align: Some("right".into()),
color: Some("panel_title".into()),
..RenderOptions::default()
}
.with_extra("speed", "2"),
};
assert!(matches!(
deanimate(spec),
RenderSpec::Full { type_name, options }
if type_name == "text_plain"
&& options.align.as_deref() == Some("right")
&& options.color.as_deref() == Some("panel_title")
&& options.extra_str("speed") == Some("2")
));
}
}