Shape
A shape is the structural contract between a fetcher and a
renderer. Neither side knows about the other directly — they both know
shapes. A fetcher declares the shapes it can emit via shapes(). A
renderer declares the shapes it can draw via accepts(). The runtime
pairs them at dispatch time and surfaces a placeholder if they don't
match.
fetchers emitting Text renderers accepting Text ────────────────────── ─────────────────────────
clock ──────┐ ┌────────── text_plain basic_static┤ ├────────── text_ascii git_repo_name┤── Shape::Text ──┤ ... ┤ ├──── animated_typewriter └─────────────────┘
Any fetcher on the left can drive any renderer on the right. Adding one more fetcher that emits Text unlocks all of them.Shapes are the only coupling between the two halves. Adding a new fetcher that emits an existing shape unlocks every compatible renderer for free; adding a new renderer that accepts an existing shape unlocks every fetcher emitting it.
Overview
Section titled “Overview”| shape | default renderer | also accepted by | used for |
|---|---|---|---|
Text | text_plain | text_ascii, animated_typewriter, animated_postfx, animated_figlet_morph, animated_boot, animated_scanlines, animated_splitflap, animated_wave | single-string values |
TextBlock | text_plain | list_plain | zero or more lines |
Entries | grid_table | — | key / value rows |
Ratio | gauge_circle | gauge_line | a 0..=1 progress |
NumberSeries | chart_sparkline | chart_bar | sequence of integers |
PointSeries | chart_line | chart_scatter | (x, y) samples |
Bars | chart_bar | chart_pie | labeled magnitudes |
Image | media_image | — | PNG / JPEG path |
Calendar | grid_calendar | — | month view |
Heatmap | grid_heatmap | — | 2D intensity grid |
Badge | status_badge | — | traffic-light status |
Timeline | list_timeline | — | chronological events |
The authoritative compatibility matrix is in the
reference overview — generated from
each renderer's accepts(), so it can't drift.
A single string. The workhorse — anything that reduces to one line.
Body::Text(TextData { value: String }){ "shape": "text", "data": { "value": "14:32" } }Renderers: text_plain (default), text_ascii (figlet / block letters),
animated_typewriter, animated_postfx, animated_figlet_morph,
animated_boot, animated_scanlines, animated_splitflap,
animated_wave.
Typical fetchers: clock, clock_derived, git_repo_name, git_status,
github_repo_stars, basic_static.
TextBlock
Section titled “TextBlock”Zero or more lines of text.
Body::TextBlock(TextBlockData { lines: Vec<String> }){ "shape": "text_block", "data": { "lines": ["feat: add foo", "fix: bar"] } }Renderers: text_plain (default), list_plain.
Typical fetchers: git_recent_commits, github_user, github_repo,
basic_static (when format contains newlines).
Entries
Section titled “Entries”Key/value rows with optional per-row status (colours the value).
Body::Entries(EntriesData { items: Vec<Entry { key, value, status }> }){ "shape": "entries", "data": { "items": [ { "key": "os", "value": "macOS 14.3" }, { "key": "load", "value": "1.24", "status": "ok" }, { "key": "disk", "value": "91%", "status": "warn" } ]}}Renderers: grid_table (default).
Typical fetchers: system, clock_almanac, clock_timezones,
github_repo_stars.
A single 0.0..=1.0 progress value. Never considered empty (0% is
legitimate).
Body::Ratio(RatioData { value: f64, label: Option<String>, denominator: Option<u64> }){ "shape": "ratio", "data": { "value": 0.32, "label": "year", "denominator": 365 } }Renderers: gauge_circle (default), gauge_line.
Typical fetchers: clock_ratio (year / quarter / month / week / day
progress), system_cpu, system_memory, system_disk_usage.
NumberSeries
Section titled “NumberSeries”A sequence of unsigned integers. Good for anything "counts over time".
Body::NumberSeries(NumberSeriesData { values: Vec<u64> }){ "shape": "number_series", "data": { "values": [2, 3, 1, 5, 8, 13, 4] } }Renderers: chart_sparkline (default), chart_bar.
Typical fetchers: git_commits_activity (when rendered as a sparkline),
basic_read_store.
PointSeries
Section titled “PointSeries”One or more named series of (x, y) samples. Good for continuous data.
Body::PointSeries(PointSeriesData { series: Vec<PointSeries { name: String, points: Vec<(f64, f64)> }>,}){ "shape": "point_series", "data": { "series": [ { "name": "temp", "points": [[0, 20.0], [1, 21.5], [2, 22.1]] } ]}}Renderers: chart_line (default), chart_scatter.
Typical fetchers: basic_read_store (any custom metric with an X axis).
Labeled, non-negative magnitudes.
Body::Bars(BarsData { bars: Vec<Bar { label: String, value: u64 }> }){ "shape": "bars", "data": { "bars": [ { "label": "Rust", "value": 87000 }, { "label": "TOML", "value": 8000 } ]}}Renderers: chart_bar (default), chart_pie.
Typical fetchers: git_contributors, github_languages.
Path to an image on disk. Terminal renders it via viuer (kitty / iTerm2 / sixel protocols where supported, falling back to unicode blocks).
Body::Image(ImageData { path: String }){ "shape": "image", "data": { "path": "/home/you/.cache/splashboard/avatar.png" } }Renderers: media_image (default).
Typical fetchers: github_avatar, basic_read_store.
Calendar
Section titled “Calendar”A month view. Never considered empty (the anchor month is always present).
Body::Calendar(CalendarData { year: i32, month: u8, // 1..=12 day: Option<u8>, // today / focus highlight events: Vec<u8>, // extra highlighted days (1..=31)}){ "shape": "calendar", "data": { "year": 2026, "month": 4, "day": 23, "events": [1, 15, 30] } }Renderers: grid_calendar (default).
Typical fetchers: clock (current month).
Heatmap
Section titled “Heatmap”2D intensity grid, row-major. The renderer buckets cells via explicit
thresholds or auto-quartiles; edge labels render when there's room.
Body::Heatmap(HeatmapData { cells: Vec<Vec<u32>>, thresholds: Option<Vec<u32>>, row_labels: Option<Vec<String>>, col_labels: Option<Vec<String>>,}){ "shape": "heatmap", "data": { "cells": [[0, 1, 3], [2, 5, 1], [0, 0, 4]], "thresholds": [1, 3, 5] }}Renderers: grid_heatmap (default).
Typical fetchers: git_commits_activity, github_contributions,
git_blame_heatmap.
A single traffic-light status with a short label. One indicator per widget; row-of-badges is a layout concern, not a shape.
Body::Badge(BadgeData { status: Status, label: String })// Status = Ok | Warn | Error{ "shape": "badge", "data": { "status": "warn", "label": "deploy degraded" } }Renderers: status_badge (default), grid_table (as a one-row table).
Typical fetchers: — (no built-in Badge emitter today; user-written ReadStore widgets are the main consumer).
Timeline
Section titled “Timeline”Time-stamped events, newest first. Timestamps are raw unix seconds
UTC; the renderer formats "3h ago" / "yesterday" at draw time so
cached payloads don't freeze stale relative labels.
Body::Timeline(TimelineData { events: Vec<TimelineEvent { timestamp: i64, title: String, detail: Option<String>, status: Option<Status> }>,}){ "shape": "timeline", "data": { "events": [ { "timestamp": 1700000000, "title": "merged #42", "detail": "feat: heatmap", "status": "ok" }, { "timestamp": 1699990000, "title": "opened #41" } ]}}Renderers: list_timeline (default).
Typical fetchers: github_notifications, github_recent_releases,
git_recent_commits (when rendered as a timeline).
Empty-state rules
Section titled “Empty-state rules”Most shapes have a natural "empty" state that the runtime detects before renderer dispatch, substituting the shared "nothing here yet" placeholder:
Text—value.is_empty()TextBlock— no lines or all lines blankEntries,NumberSeries,PointSeries,Bars,Timeline— empty collectionImage— empty pathHeatmap— no cellsBadge— empty label
Ratio and Calendar are never considered empty — 0% and "this
month" are both legitimate values.
Adding a new shape
Section titled “Adding a new shape”Most widgets can be expressed with an existing shape. Add a new one only when none of the 12 carries the structural information the renderer needs. The process:
- Add a new
Body::XXX(XxxData)variant insrc/payload.rswith a serde-serializable struct. - Add a matching
Shape::XXXvariant insrc/render/mod.rsand handle it inshape_of(). - Add an entry in
default_renderer_for(). - Handle the new variant in
is_empty_body(). - Implement at least one renderer that lists the new shape in its
accepts().
Shapes are a hard coupling — adding one obliges every touching side to know about it. "This renderer needs the raw fetcher data" is almost never a reason to add a shape; it's a reason to add a new renderer that draws an existing shape differently.