use std::collections::HashMap;
use std::io::{self, Stdout, stdout};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use ratatui::{
Frame, Terminal, TerminalOptions, Viewport,
backend::{Backend, CrosstermBackend, TestBackend},
buffer::Buffer,
crossterm::{
cursor::{Hide, Show},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
},
layout::{Alignment, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
};
use tokio::task::JoinSet;
use crate::cache::{Cache, CacheBackend, CacheEntry, CacheEntryKind, MemCache};
use crate::config::{Config, General, WidgetConfig};
use crate::daemon;
use crate::fetcher::{self, FetchContext, Registry, ShapeMismatch};
use crate::layout::{self, Layout, WidgetId};
use crate::payload::Payload;
use crate::render::{self, RenderSpec, Shape};
use crate::theme::Theme;
use crate::trust::{self, TrustStore};
fn split_by_fetcher_kind(
widgets: &[WidgetConfig],
registry: &Registry,
) -> (Vec<WidgetConfig>, Vec<WidgetConfig>) {
widgets
.iter()
.cloned()
.partition(|w| registry.get_cached(&w.fetcher).is_some())
}
pub fn derive_shapes(
widgets: &[WidgetConfig],
fetchers: &Registry,
renderers: &render::Registry,
) -> HashMap<WidgetId, Shape> {
widgets
.iter()
.filter_map(|w| derive_shape(w, fetchers, renderers).map(|s| (w.id.clone(), s)))
.collect()
}
fn derive_shape(
w: &WidgetConfig,
fetchers: &Registry,
renderers: &render::Registry,
) -> Option<Shape> {
if let Some(spec) = &w.render
&& let Some(renderer) = renderers.get(spec.renderer_name())
{
let accepted = renderer.accepts();
if let Some(fetcher) = fetchers.get(&w.fetcher)
&& let Some(shape) = fetcher.shapes().into_iter().find(|s| accepted.contains(s))
{
return Some(shape);
}
if let Some(&shape) = accepted.first() {
return Some(shape);
}
}
fetchers.get(&w.fetcher).map(|f| f.default_shape())
}
fn partition_by_known_fetcher(
widgets: &[WidgetConfig],
registry: &Registry,
) -> (Vec<WidgetConfig>, Vec<WidgetConfig>) {
widgets
.iter()
.cloned()
.partition(|w| registry.get(&w.fetcher).is_some())
}
fn partition_by_shape_support(
widgets: &[WidgetConfig],
shapes: &HashMap<WidgetId, Shape>,
fetchers: &Registry,
) -> (Vec<WidgetConfig>, Vec<(WidgetConfig, ShapeMismatch)>) {
let mut valid = Vec::new();
let mut invalid = Vec::new();
for w in widgets {
let Some(fetcher) = fetchers.get(&w.fetcher) else {
valid.push(w.clone());
continue;
};
let Some(&shape) = shapes.get(&w.id) else {
valid.push(w.clone());
continue;
};
if fetcher.shapes().contains(&shape) {
valid.push(w.clone());
} else {
invalid.push((
w.clone(),
ShapeMismatch {
fetcher: fetcher.name().to_string(),
requested: shape,
},
));
}
}
(valid, invalid)
}
fn compute_realtime_payloads(
registry: &Registry,
widgets: &[WidgetConfig],
general: &General,
shapes: &HashMap<WidgetId, Shape>,
) -> HashMap<WidgetId, Payload> {
let mut out = HashMap::new();
compute_realtime_payloads_into(registry, widgets, general, shapes, &mut out);
out
}
fn compute_realtime_payloads_into(
registry: &Registry,
widgets: &[WidgetConfig],
general: &General,
shapes: &HashMap<WidgetId, Shape>,
out: &mut HashMap<WidgetId, Payload>,
) {
for w in widgets {
let Some(fetcher) = registry.get_realtime(&w.fetcher) else {
continue;
};
let ctx = fetch_context(
w,
general,
shapes.get(&w.id).copied(),
Duration::from_secs(0),
);
out.insert(w.id.clone(), fetcher.compute(&ctx));
}
}
fn compute_loading(
cached: &[WidgetConfig],
payloads: &HashMap<WidgetId, Payload>,
entries: &HashMap<WidgetId, CacheEntry>,
shapes: &HashMap<WidgetId, Shape>,
wait: bool,
) -> HashMap<WidgetId, Shape> {
cached
.iter()
.filter(|w| is_loading(w, payloads, entries, wait))
.filter_map(|w| shapes.get(&w.id).copied().map(|s| (w.id.clone(), s)))
.collect()
}
fn is_loading(
w: &WidgetConfig,
payloads: &HashMap<WidgetId, Payload>,
entries: &HashMap<WidgetId, CacheEntry>,
wait: bool,
) -> bool {
if !payloads.contains_key(&w.id) {
return true;
}
wait && entries.get(&w.id).is_some_and(|e| !e.is_fresh())
}
fn has_loading_widget(
cached: &[WidgetConfig],
payloads: &HashMap<WidgetId, Payload>,
entries: &HashMap<WidgetId, CacheEntry>,
wait: bool,
) -> bool {
cached
.iter()
.any(|w| is_loading(w, payloads, entries, wait))
}
fn loop_should_exit(daemon_done: bool, animation_pending: bool, deadline_reached: bool) -> bool {
deadline_reached || (daemon_done && !animation_pending)
}
fn animation_still_due(animation_due: bool, real_animated: bool, loading_pending: bool) -> bool {
animation_due && (real_animated || loading_pending)
}
const FAST_DEADLINE: Duration = Duration::from_millis(1500);
const WAIT_DEADLINE: Duration = Duration::from_secs(5);
const DAEMON_DEADLINE: Duration = Duration::from_secs(30);
const FRAME_TICK: Duration = Duration::from_millis(50);
const ANIMATION_WINDOW: Duration = Duration::from_secs(2);
const DEFAULT_VIEWPORT_LINES: u16 = 16;
pub async fn run(
config: &Config,
source: &crate::config::DashboardSource,
config_ident: Option<(&Path, &str)>,
wait: bool,
) -> io::Result<()> {
let layout = config.to_layout();
let cache = Cache::open_default();
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let specs = render_specs(&config.widgets);
let theme = Theme::from_config(&config.theme);
let padding = config.general.padding.map(|p| p.xy()).unwrap_or((0, 0));
let decision = TrustStore::load().decide(config_ident);
let (fetchable, gated) = trust::partition_by_trust(&config.widgets, ®istry, decision);
let (fetchable, unknown) = partition_by_known_fetcher(&fetchable, ®istry);
let shapes = derive_shapes(&config.widgets, ®istry, &render_registry);
let (fetchable, shape_invalid) = partition_by_shape_support(&fetchable, &shapes, ®istry);
let (cached_widgets, realtime_widgets) = split_by_fetcher_kind(&fetchable, ®istry);
let entries = load_entries(
as_backend(&cache),
®istry,
&cached_widgets,
&config.general,
&shapes,
);
let mut payloads = entries_to_payloads(&entries);
payloads.extend(compute_realtime_payloads(
®istry,
&realtime_widgets,
&config.general,
&shapes,
));
for w in &gated {
payloads.insert(w.id.clone(), trust::requires_trust_placeholder());
}
for w in &unknown {
payloads.insert(
w.id.clone(),
fetcher::unknown_fetcher_placeholder(&w.fetcher),
);
}
for (w, err) in &shape_invalid {
payloads.insert(w.id.clone(), fetcher::shape_mismatch_placeholder(err));
}
let wait =
wait || config.general.wait_for_fresh || (!cached_widgets.is_empty() && entries.is_empty());
let real_animated = render::any_widget_animates(&config.widgets, &render_registry);
let animated = real_animated || has_loading_widget(&cached_widgets, &payloads, &entries, wait);
let loop_window = match (wait, animated) {
(false, false) => None,
(false, true) => Some(ANIMATION_WINDOW),
(true, _) => Some(WAIT_DEADLINE),
};
let requested_height = config
.general
.height
.unwrap_or_else(|| config.computed_height().max(DEFAULT_VIEWPORT_LINES));
let term_rows = ratatui::crossterm::terminal::size()
.map(|(_, h)| h)
.unwrap_or(requested_height);
let max_inline = term_rows.saturating_sub(1).max(1);
let clipping = requested_height > max_inline;
let viewport_lines = if clipping {
max_inline
} else {
requested_height
}
.max(1);
let hidden_rows = if clipping {
requested_height.saturating_sub(viewport_lines.saturating_sub(1))
} else {
0
};
let backend = CrosstermBackend::new(stdout());
let mut terminal = make_terminal(backend, viewport_lines)?;
if loop_window.is_none() {
let loading = compute_loading(&cached_widgets, &payloads, &entries, &shapes, wait);
finalize_draw(
&mut terminal,
&layout,
&payloads,
&specs,
&render_registry,
&theme,
&config.general,
padding,
requested_height,
viewport_lines,
&loading,
)?;
let _ = daemon::spawn_fetch_daemon(source);
finalize_splash(&mut terminal);
return Ok(());
}
let window = loop_window.unwrap();
match daemon::spawn_fetch_daemon(source) {
Ok(mut child) => {
let buckets = WidgetBuckets {
cached: &cached_widgets,
gated: &gated,
unknown: &unknown,
shape_invalid: &shape_invalid,
};
let start = std::time::Instant::now();
let deadline = start + window;
let animation_deadline = animated.then(|| start + ANIMATION_WINDOW);
let mut daemon_done = !wait;
loop {
let entries = refresh_payloads(
as_backend(&cache),
®istry,
&buckets,
&config.general,
&shapes,
&mut payloads,
);
let loading = compute_loading(&cached_widgets, &payloads, &entries, &shapes, wait);
draw(
&mut terminal,
&layout,
&payloads,
&specs,
&render_registry,
&theme,
&config.general,
padding,
hidden_rows,
&loading,
)?;
let now = std::time::Instant::now();
let remaining = deadline.saturating_duration_since(now);
if remaining.is_zero() {
if !daemon_done {
let _ = child.start_kill();
}
break;
}
let animation_pending = animation_still_due(
animation_deadline.is_some_and(|d| now < d),
real_animated,
!loading.is_empty(),
);
if loop_should_exit(daemon_done, animation_pending, false) {
break;
}
let tick = remaining.min(FRAME_TICK);
if !daemon_done {
if tokio::time::timeout(tick, child.wait()).await.is_ok() {
daemon_done = true;
}
} else {
tokio::time::sleep(tick).await;
}
}
let entries = refresh_payloads(
as_backend(&cache),
®istry,
&buckets,
&config.general,
&shapes,
&mut payloads,
);
let loading = compute_loading(&cached_widgets, &payloads, &entries, &shapes, wait);
finalize_draw(
&mut terminal,
&layout,
&payloads,
&specs,
&render_registry,
&theme,
&config.general,
padding,
requested_height,
viewport_lines,
&loading,
)?;
}
Err(_) => {
let fetch_deadline = if wait { WAIT_DEADLINE } else { FAST_DEADLINE };
let fresh = fetch_all(
®istry,
as_backend(&cache),
&cached_widgets,
&entries,
&config.general,
&shapes,
fetch_deadline,
)
.await;
for (id, payload) in fresh {
payloads.insert(id, payload);
}
let loading = compute_loading(&cached_widgets, &payloads, &entries, &shapes, wait);
finalize_draw(
&mut terminal,
&layout,
&payloads,
&specs,
&render_registry,
&theme,
&config.general,
padding,
requested_height,
viewport_lines,
&loading,
)?;
}
}
finalize_splash(&mut terminal);
Ok(())
}
const WATCH_FRAME_TICK: Duration = Duration::from_millis(50);
const WATCH_REALTIME_TICK: Duration = Duration::from_millis(200);
const WATCH_FETCH_TICK: Duration = Duration::from_secs(2);
pub async fn watch(config: &Config, config_ident: Option<(&Path, &str)>) -> io::Result<()> {
let layout = config.to_layout();
let disk_cache = Cache::open_default();
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let specs = render_specs(&config.widgets);
let theme = Theme::from_config(&config.theme);
let padding = config.general.padding.map(|p| p.xy()).unwrap_or((0, 0));
let decision = TrustStore::load().decide(config_ident);
let (fetchable, gated) = trust::partition_by_trust(&config.widgets, ®istry, decision);
let (fetchable, unknown) = partition_by_known_fetcher(&fetchable, ®istry);
let shapes = derive_shapes(&config.widgets, ®istry, &render_registry);
let (fetchable, shape_invalid) = partition_by_shape_support(&fetchable, &shapes, ®istry);
let (cached_widgets, realtime_widgets) = split_by_fetcher_kind(&fetchable, ®istry);
let cache_keys = cache_keys_for(&cached_widgets, ®istry, &config.general, &shapes);
let mem_cache = MemCache::warm_from(disk_cache.as_ref(), cache_keys.iter().map(String::as_str));
let entries = load_entries(
Some(&mem_cache),
®istry,
&cached_widgets,
&config.general,
&shapes,
);
let mut payloads = entries_to_payloads(&entries);
payloads.extend(compute_realtime_payloads(
®istry,
&realtime_widgets,
&config.general,
&shapes,
));
for w in &gated {
payloads.insert(w.id.clone(), trust::requires_trust_placeholder());
}
for w in &unknown {
payloads.insert(
w.id.clone(),
fetcher::unknown_fetcher_placeholder(&w.fetcher),
);
}
for (w, err) in &shape_invalid {
payloads.insert(w.id.clone(), fetcher::shape_mismatch_placeholder(err));
}
let fetch_ident = config_ident.map(|(p, h)| (p.to_path_buf(), h.to_string()));
let fetch_loop = tokio::spawn(watch_fetch_loop(
config.clone(),
fetch_ident,
mem_cache.clone(),
));
let real_animated = render::any_widget_animates(&config.widgets, &render_registry);
let requested_height = config
.general
.height
.unwrap_or_else(|| config.computed_height().max(DEFAULT_VIEWPORT_LINES));
install_watch_panic_hook();
let mut terminal = WatchTerminal::enter()?;
let start = Instant::now();
let mut last_realtime = Instant::now();
let mut cached_entries = entries;
let mut cache_gen = mem_cache.generation();
let mut prev_status: Option<WatchStatus> = None;
let result: io::Result<()> = loop {
let mut resized = false;
let mut quit = false;
let drain: io::Result<()> = (|| {
while event::poll(Duration::ZERO)? {
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => quit |= is_quit_key(&k),
Event::Resize(_, _) => resized = true,
_ => {}
}
}
Ok(())
})();
if let Err(e) = drain {
break Err(e);
}
if quit {
break Ok(());
}
let realtime_recomputed = last_realtime.elapsed() >= WATCH_REALTIME_TICK;
if realtime_recomputed {
compute_realtime_payloads_into(
®istry,
&realtime_widgets,
&config.general,
&shapes,
&mut payloads,
);
last_realtime = Instant::now();
}
let cur_gen = mem_cache.generation();
let cache_changed = cur_gen != cache_gen;
if cache_changed {
cached_entries = load_entries(
Some(&mem_cache),
®istry,
&cached_widgets,
&config.general,
&shapes,
);
for (id, entry) in &cached_entries {
payloads.insert(id.clone(), entry.payload.clone());
}
cache_gen = cur_gen;
}
let loading = compute_loading(&cached_widgets, &payloads, &cached_entries, &shapes, false);
let status = WatchStatus {
next_refresh_secs: next_refresh_secs(&cached_entries),
loading: loading.len(),
};
let animating = real_animated && start.elapsed() < ANIMATION_WINDOW;
let status_changed = prev_status != Some(status);
if watch_should_draw(
resized,
cache_changed || realtime_recomputed || status_changed,
animating,
!loading.is_empty(),
) {
if let Err(e) = draw_watch(
&mut terminal.inner,
&layout,
&payloads,
&specs,
&render_registry,
&theme,
&config.general,
padding,
requested_height,
&loading,
&status,
) {
break Err(e);
}
prev_status = Some(status);
}
tokio::time::sleep(WATCH_FRAME_TICK).await;
};
fetch_loop.abort();
drop(terminal);
if let Some(disk) = disk_cache.as_ref() {
mem_cache.flush_to(disk);
}
result
}
async fn watch_fetch_loop(config: Config, ident: Option<(PathBuf, String)>, cache: MemCache) {
loop {
let ident_ref = ident.as_ref().map(|(p, h)| (p.as_path(), h.as_str()));
fetch_and_persist(&config, ident_ref, Some(&cache as &dyn CacheBackend)).await;
tokio::time::sleep(WATCH_FETCH_TICK).await;
}
}
fn watch_should_draw(resized: bool, changed: bool, animating: bool, loading_active: bool) -> bool {
resized || changed || animating || loading_active
}
fn is_quit_key(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('q'))
|| (matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL))
}
struct WatchTerminal {
inner: Terminal<CrosstermBackend<Stdout>>,
}
impl WatchTerminal {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
if let Err(e) = execute!(stdout(), EnterAlternateScreen, Hide) {
let _ = disable_raw_mode();
return Err(e);
}
match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(inner) => Ok(Self { inner }),
Err(e) => {
let _ = execute!(stdout(), LeaveAlternateScreen, Show);
let _ = disable_raw_mode();
Err(e)
}
}
}
}
impl Drop for WatchTerminal {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen, Show);
}
}
fn install_watch_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen, Show);
original(info);
}));
}
pub async fn fetch_and_persist(
config: &Config,
config_ident: Option<(&Path, &str)>,
cache: Option<&dyn CacheBackend>,
) {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let decision = TrustStore::load().decide(config_ident);
let (fetchable, _gated) = trust::partition_by_trust(&config.widgets, ®istry, decision);
let (fetchable, _unknown) = partition_by_known_fetcher(&fetchable, ®istry);
let shapes = derive_shapes(&config.widgets, ®istry, &render_registry);
let (fetchable, _shape_invalid) = partition_by_shape_support(&fetchable, &shapes, ®istry);
let (cached_widgets, _realtime) = split_by_fetcher_kind(&fetchable, ®istry);
let entries = load_entries(cache, ®istry, &cached_widgets, &config.general, &shapes);
let _ = fetch_all(
®istry,
cache,
&cached_widgets,
&entries,
&config.general,
&shapes,
DAEMON_DEADLINE,
)
.await;
}
struct WidgetBuckets<'a> {
cached: &'a [WidgetConfig],
gated: &'a [WidgetConfig],
unknown: &'a [WidgetConfig],
shape_invalid: &'a [(WidgetConfig, ShapeMismatch)],
}
fn refresh_payloads(
cache: Option<&dyn CacheBackend>,
registry: &Registry,
buckets: &WidgetBuckets<'_>,
general: &General,
shapes: &HashMap<WidgetId, Shape>,
payloads: &mut HashMap<WidgetId, Payload>,
) -> HashMap<WidgetId, CacheEntry> {
let entries = load_entries(cache, registry, buckets.cached, general, shapes);
for (id, payload) in entries_to_payloads(&entries) {
payloads.insert(id, payload);
}
for w in buckets.gated {
payloads.insert(w.id.clone(), trust::requires_trust_placeholder());
}
for w in buckets.unknown {
payloads.insert(
w.id.clone(),
fetcher::unknown_fetcher_placeholder(&w.fetcher),
);
}
for (w, err) in buckets.shape_invalid {
payloads.insert(w.id.clone(), fetcher::shape_mismatch_placeholder(err));
}
entries
}
fn fetch_context(
w: &WidgetConfig,
general: &General,
shape: Option<Shape>,
deadline: Duration,
) -> FetchContext {
FetchContext {
widget_id: w.id.clone(),
format: w.format.clone(),
timeout: deadline,
file_format: w.file_format.clone(),
shape,
options: w.options.clone(),
timezone: general.timezone.clone(),
locale: general.locale.clone(),
}
}
fn as_backend(cache: &Option<Cache>) -> Option<&dyn CacheBackend> {
cache.as_ref().map(|c| c as &dyn CacheBackend)
}
fn cache_keys_for(
widgets: &[WidgetConfig],
registry: &Registry,
general: &General,
shapes: &HashMap<WidgetId, Shape>,
) -> Vec<String> {
widgets
.iter()
.filter_map(|w| {
let fetcher = registry.get_cached(&w.fetcher)?;
let ctx = fetch_context(
w,
general,
shapes.get(&w.id).copied(),
Duration::from_secs(0),
);
Some(fetcher.cache_key(&ctx))
})
.collect()
}
fn load_entries(
cache: Option<&dyn CacheBackend>,
registry: &Registry,
widgets: &[WidgetConfig],
general: &General,
shapes: &HashMap<WidgetId, Shape>,
) -> HashMap<WidgetId, CacheEntry> {
let Some(c) = cache else {
return HashMap::new();
};
widgets
.iter()
.filter_map(|w| {
let fetcher = registry.get_cached(&w.fetcher)?;
let shape = shapes.get(&w.id).copied();
let key = fetcher.cache_key(&fetch_context(w, general, shape, Duration::from_secs(0)));
c.load(&key).map(|e| (w.id.clone(), e))
})
.collect()
}
fn entries_to_payloads(entries: &HashMap<WidgetId, CacheEntry>) -> HashMap<WidgetId, Payload> {
entries
.iter()
.map(|(k, v)| (k.clone(), v.payload.clone()))
.collect()
}
const ERROR_CACHE_TTL_SECS: u64 = 30;
fn resolve_refresh_interval(w: &WidgetConfig, fetcher: &dyn fetcher::Fetcher) -> u64 {
w.refresh_interval
.unwrap_or_else(|| fetcher.refresh_interval())
}
async fn fetch_all(
registry: &Registry,
cache: Option<&dyn CacheBackend>,
widgets: &[WidgetConfig],
cached: &HashMap<WidgetId, CacheEntry>,
general: &General,
shapes: &HashMap<WidgetId, Shape>,
deadline: Duration,
) -> HashMap<WidgetId, Payload> {
let mut set = JoinSet::new();
for w in widgets {
if !should_refresh(cached, w) {
continue;
}
let Some(fetcher) = registry.get_cached(&w.fetcher) else {
continue;
};
let ctx = fetch_context(w, general, shapes.get(&w.id).copied(), deadline);
let cache_key = fetcher.cache_key(&ctx);
let lock = match cache {
Some(c) => match c.try_lock(&cache_key) {
Some(l) => Some(l),
None => continue,
},
None => None,
};
let id = w.id.clone();
let ttl = resolve_refresh_interval(w, fetcher.as_ref());
let fetcher_name = fetcher.name().to_string();
set.spawn(async move {
let _lock = lock;
match tokio::time::timeout(deadline, fetcher.fetch(&ctx)).await {
Ok(Ok(payload)) => (id, cache_key, ttl, CacheEntryKind::Ok, payload),
Ok(Err(e)) => {
tracing::error!(
widget = %id,
fetcher = %fetcher_name,
cache_key = %cache_key,
error = %e,
"fetch failed"
);
(
id,
cache_key,
ERROR_CACHE_TTL_SECS,
CacheEntryKind::Err,
fetcher::error_placeholder(&e.to_string()),
)
}
Err(_) => {
tracing::warn!(
widget = %id,
fetcher = %fetcher_name,
cache_key = %cache_key,
timeout_ms = deadline.as_millis() as u64,
"fetch timed out"
);
(
id,
cache_key,
ERROR_CACHE_TTL_SECS,
CacheEntryKind::Timeout,
fetcher::timeout_placeholder(),
)
}
}
});
}
let mut out = HashMap::new();
while let Some(joined) = set.join_next().await {
let Ok((id, key, ttl, kind, payload)) = joined else {
continue;
};
if let Some(c) = cache {
let _ = c.store(&key, &CacheEntry::new_with_kind(payload.clone(), ttl, kind));
}
out.insert(id, payload);
}
out
}
fn should_refresh(cached: &HashMap<WidgetId, CacheEntry>, w: &WidgetConfig) -> bool {
match cached.get(&w.id) {
Some(e) => !e.is_fresh(),
None => true,
}
}
fn make_terminal<B: Backend>(backend: B, viewport_lines: u16) -> Result<Terminal<B>, B::Error> {
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(viewport_lines),
},
)
}
#[allow(clippy::too_many_arguments)]
fn draw<B: Backend>(
terminal: &mut Terminal<B>,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
hidden_rows: u16,
loading: &HashMap<WidgetId, Shape>,
) -> Result<(), B::Error> {
terminal.draw(|frame| {
draw_frame(
frame,
frame.area(),
root,
payloads,
specs,
registry,
theme,
general,
padding,
hidden_rows,
loading,
)
})?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn draw_watch<B: Backend>(
terminal: &mut Terminal<B>,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
requested_height: u16,
loading: &HashMap<WidgetId, Shape>,
status: &WatchStatus,
) -> Result<(), B::Error> {
terminal.draw(|frame| {
let full = frame.area();
paint_viewport_bg(frame, full, theme);
let footer_height = u16::from(full.height >= 2);
let body = Rect {
height: requested_height.min(full.height - footer_height),
..full
};
draw_frame(
frame, body, root, payloads, specs, registry, theme, general, padding, 0, loading,
);
if footer_height > 0 {
let footer = Rect {
y: full.y + full.height - 1,
height: 1,
..full
};
render_watch_footer(frame, footer, status, theme);
}
})?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WatchStatus {
next_refresh_secs: Option<u64>,
loading: usize,
}
fn next_refresh_secs(entries: &HashMap<WidgetId, CacheEntry>) -> Option<u64> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
entries
.values()
.map(|e| {
e.refreshed_at
.saturating_add(e.ttl_seconds)
.saturating_sub(now)
})
.min()
}
fn watch_footer_meta(status: &WatchStatus) -> String {
if status.loading > 0 {
let plural = if status.loading == 1 { "" } else { "s" };
return format!("loading {} widget{plural}…", status.loading);
}
match status.next_refresh_secs {
None => "realtime".to_string(),
Some(0) => "refreshing…".to_string(),
Some(secs) => format!("next refresh in {}", fmt_duration_compact(secs)),
}
}
fn fmt_duration_compact(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s => format!("{}h", s / 3600),
}
}
fn render_watch_footer(frame: &mut Frame, area: Rect, status: &WatchStatus, theme: &Theme) {
let bg = theme.bg_subtle;
let label = Style::default().bg(bg).fg(theme.text_secondary);
let key = Style::default()
.bg(bg)
.fg(theme.text)
.add_modifier(Modifier::BOLD);
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" ⟳ {}", watch_footer_meta(status)),
label,
))),
area,
);
let controls = Line::from(vec![
Span::styled("q", key),
Span::styled(" / ", label),
Span::styled("Ctrl-C", key),
Span::styled(" quit ", label),
]);
frame.render_widget(Paragraph::new(controls).alignment(Alignment::Right), area);
}
#[allow(clippy::too_many_arguments)]
fn draw_final<B: Backend>(
terminal: &mut Terminal<B>,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
hidden_rows: u16,
loading: &HashMap<WidgetId, Shape>,
) -> Result<(), B::Error> {
let mut captured: Option<Rect> = None;
terminal.draw(|frame| {
let area = frame.area();
captured = Some(area);
draw_frame(
frame,
area,
root,
payloads,
specs,
registry,
theme,
general,
padding,
hidden_rows,
loading,
);
})?;
if let Some(area) = captured
&& area.height > 0
{
terminal.set_cursor_position(ratatui::layout::Position {
x: area.x,
y: area.y + area.height - 1,
})?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn finalize_draw<B: Backend>(
terminal: &mut Terminal<B>,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
requested_height: u16,
viewport_lines: u16,
loading: &HashMap<WidgetId, Shape>,
) -> Result<(), B::Error> {
let scrollback_rows = requested_height.saturating_sub(viewport_lines);
if scrollback_rows == 0 {
return draw_final(
terminal, root, payloads, specs, registry, theme, general, padding, 0, loading,
);
}
if !general.flush_to_scrollback {
return draw_final(
terminal,
root,
payloads,
specs,
registry,
theme,
general,
padding,
scrollback_rows + 1,
loading,
);
}
flush_full_to_scrollback(
terminal,
root,
payloads,
specs,
registry,
theme,
general,
padding,
requested_height,
viewport_lines,
loading,
)
}
#[allow(clippy::too_many_arguments)]
fn flush_full_to_scrollback<B: Backend>(
terminal: &mut Terminal<B>,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
requested_height: u16,
viewport_lines: u16,
loading: &HashMap<WidgetId, Shape>,
) -> Result<(), B::Error> {
let width = terminal.size()?.width;
let full_buf = render_full_into_buffer(
width,
requested_height,
root,
payloads,
specs,
registry,
theme,
general,
padding,
loading,
);
let scrollback_rows = requested_height - viewport_lines;
terminal.insert_before(scrollback_rows, |buf| {
copy_rows(&full_buf, 0..scrollback_rows, buf);
})?;
let mut captured: Option<Rect> = None;
terminal.draw(|frame| {
let area = frame.area();
captured = Some(area);
copy_rows(
&full_buf,
scrollback_rows..requested_height,
frame.buffer_mut(),
);
})?;
if let Some(area) = captured
&& area.height > 0
{
terminal.set_cursor_position(Position {
x: area.x,
y: area.y + area.height - 1,
})?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_full_into_buffer(
width: u16,
height: u16,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
loading: &HashMap<WidgetId, Shape>,
) -> Buffer {
let backend = TestBackend::new(width, height);
let mut inner = Terminal::new(backend).expect("TestBackend::new is infallible");
inner
.draw(|frame| {
render::list_links::without_osc8(|| {
let area = frame.area();
paint_viewport_bg(frame, area, theme);
let content = shrink(area, padding);
layout::draw(
frame, content, root, payloads, specs, registry, theme, general, loading,
);
});
})
.expect("TestBackend draw is infallible");
inner.backend().buffer().clone()
}
fn copy_rows(src: &Buffer, src_y_range: std::ops::Range<u16>, dst: &mut Buffer) {
let src_area = src.area;
let dst_area = dst.area;
let copy_width = src_area.width.min(dst_area.width);
for (i, offset) in src_y_range.enumerate() {
let src_y = src_area.top() + offset;
let dst_y = dst_area.top() + i as u16;
if dst_y >= dst_area.bottom() {
break;
}
for x in 0..copy_width {
let src_pos = Position::new(src_area.left() + x, src_y);
let dst_pos = Position::new(dst_area.left() + x, dst_y);
if let (Some(src_cell), Some(dst_cell)) = (src.cell(src_pos), dst.cell_mut(dst_pos)) {
*dst_cell = src_cell.clone();
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_frame(
frame: &mut Frame,
area: Rect,
root: &Layout,
payloads: &HashMap<WidgetId, Payload>,
specs: &HashMap<WidgetId, RenderSpec>,
registry: &render::Registry,
theme: &Theme,
general: &General,
padding: (u16, u16),
hidden_rows: u16,
loading: &HashMap<WidgetId, Shape>,
) {
paint_viewport_bg(frame, area, theme);
let content = shrink(area, padding);
if hidden_rows > 0 && content.height > 1 {
let layout_area = Rect {
height: content.height - 1,
..content
};
let hint_area = Rect {
y: content.y + content.height - 1,
height: 1,
..content
};
layout::draw(
frame,
layout_area,
root,
payloads,
specs,
registry,
theme,
general,
loading,
);
render_clip_hint(frame, hint_area, hidden_rows, theme);
} else {
layout::draw(
frame, content, root, payloads, specs, registry, theme, general, loading,
);
}
}
fn shrink(area: Rect, (x, y): (u16, u16)) -> Rect {
let dx = x.min(area.width / 2);
let dy = y.min(area.height / 2);
Rect {
x: area.x + dx,
y: area.y + dy,
width: area.width.saturating_sub(dx.saturating_mul(2)),
height: area.height.saturating_sub(dy.saturating_mul(2)),
}
}
fn paint_viewport_bg(frame: &mut Frame, area: Rect, theme: &Theme) {
if theme.bg == Color::Reset {
return;
}
frame.render_widget(Block::default().style(Style::default().bg(theme.bg)), area);
}
fn render_clip_hint(frame: &mut Frame, area: Rect, hidden_rows: u16, theme: &Theme) {
let text = format!("… +{hidden_rows} rows (terminal too short)");
let style = Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::ITALIC);
let p = Paragraph::new(Line::from(text).style(style)).alignment(Alignment::Right);
frame.render_widget(p, area);
}
fn finalize_splash<B: Backend>(terminal: &mut Terminal<B>) {
let _ = terminal.show_cursor();
println!();
}
fn render_specs(widgets: &[WidgetConfig]) -> HashMap<WidgetId, RenderSpec> {
widgets
.iter()
.filter_map(|w| w.render.clone().map(|s| (w.id.clone(), s)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Layout as LayoutTree;
use crate::payload::{Body, ErrorKind, TextBlockData, TextData};
use async_trait::async_trait;
use ratatui::{Terminal, backend::TestBackend};
use std::ffi::{OsStr, OsString};
fn text_payload(line: &str) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Text(TextData { value: line.into() }),
}
}
fn text_block_payload(lines: &[&str]) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::TextBlock(TextBlockData {
lines: lines.iter().map(|s| (*s).to_string()).collect(),
}),
}
}
fn single_widget_tree(id: &str) -> LayoutTree {
LayoutTree::widget(id)
}
fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
(0..buf.area.width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect()
}
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: Option<&OsStr>) -> Self {
let previous = std::env::var_os(key);
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn draw_watch_pins_footer_to_bottom_row_and_keeps_dashboard_top_anchored() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert("x".into(), text_payload("hello"));
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(50, 20);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
let status = WatchStatus {
next_refresh_secs: Some(45),
loading: 0,
};
draw_watch(
&mut terminal,
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
3,
&loading,
&status,
)
.unwrap();
let buf = terminal.backend().buffer().clone();
assert!(
row_text(&buf, 0).contains("hello"),
"dashboard top-anchored"
);
let footer = row_text(&buf, 19);
assert!(footer.contains("quit"), "key binding in footer: {footer:?}");
assert!(
footer.contains("next refresh in 45s"),
"meta info in footer: {footer:?}"
);
assert_eq!(
buf.cell((0, 19)).unwrap().bg,
theme.bg_subtle,
"footer painted on the subtle surface"
);
}
#[test]
fn watch_footer_meta_prioritises_loading_then_refresh_countdown() {
assert_eq!(
watch_footer_meta(&WatchStatus {
next_refresh_secs: Some(10),
loading: 2,
}),
"loading 2 widgets…",
"loading wins over the countdown"
);
assert_eq!(
watch_footer_meta(&WatchStatus {
next_refresh_secs: Some(1),
loading: 1,
}),
"loading 1 widget…"
);
assert_eq!(
watch_footer_meta(&WatchStatus {
next_refresh_secs: Some(90),
loading: 0,
}),
"next refresh in 1m"
);
assert_eq!(
watch_footer_meta(&WatchStatus {
next_refresh_secs: Some(0),
loading: 0,
}),
"refreshing…"
);
assert_eq!(
watch_footer_meta(&WatchStatus {
next_refresh_secs: None,
loading: 0,
}),
"realtime",
"no cached widgets → realtime-only dashboard"
);
}
#[test]
fn next_refresh_secs_is_none_for_a_realtime_only_dashboard() {
assert_eq!(next_refresh_secs(&HashMap::new()), None);
}
#[test]
fn next_refresh_secs_saturates_to_zero_for_a_long_stale_entry() {
let mut entries = HashMap::new();
entries.insert(
"a".into(),
CacheEntry {
refreshed_at: 0,
ttl_seconds: 60,
kind: CacheEntryKind::Ok,
payload: text_payload("x"),
},
);
assert_eq!(next_refresh_secs(&entries), Some(0));
}
#[test]
fn next_refresh_secs_reports_remaining_ttl_for_a_fresh_entry() {
let mut entries = HashMap::new();
entries.insert("a".into(), CacheEntry::new(text_payload("x"), 600));
let remaining = next_refresh_secs(&entries).expect("fresh entry has a countdown");
assert!(
(595..=600).contains(&remaining),
"expected ~600s remaining, got {remaining}"
);
}
#[test]
fn next_refresh_secs_takes_the_soonest_across_entries() {
let mut entries = HashMap::new();
entries.insert(
"stale".into(),
CacheEntry {
refreshed_at: 0,
ttl_seconds: 60,
kind: CacheEntryKind::Ok,
payload: text_payload("x"),
},
);
entries.insert("fresh".into(), CacheEntry::new(text_payload("y"), 600));
assert_eq!(
next_refresh_secs(&entries),
Some(0),
"min wins: the stale entry is due before the fresh one"
);
}
#[test]
fn fmt_duration_compact_picks_a_unit_per_magnitude() {
assert_eq!(fmt_duration_compact(0), "0s");
assert_eq!(fmt_duration_compact(45), "45s");
assert_eq!(fmt_duration_compact(59), "59s");
assert_eq!(fmt_duration_compact(60), "1m");
assert_eq!(fmt_duration_compact(3599), "59m");
assert_eq!(fmt_duration_compact(3600), "1h");
assert_eq!(fmt_duration_compact(7200), "2h");
}
#[test]
fn draw_frame_hint_lands_on_bottom_row_when_clipping() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert("x".into(), text_payload("top"));
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(50, 4);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
terminal
.draw(|f| {
draw_frame(
f,
f.area(),
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
7,
&loading,
)
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let bottom = row_text(&buf, 3);
assert!(
bottom.contains("…") && bottom.contains("+7"),
"expected clip hint in bottom row: {bottom:?}"
);
assert!(row_text(&buf, 0).contains("top"), "layout still rendered");
}
#[test]
fn draw_frame_without_clipping_paints_no_hint() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert("x".into(), text_payload("only"));
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(50, 3);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
terminal
.draw(|f| {
draw_frame(
f,
f.area(),
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
0,
&loading,
)
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let joined: String = (0..3).map(|y| row_text(&buf, y)).collect();
assert!(!joined.contains("…"), "no marker expected: {joined:?}");
assert!(!joined.contains("terminal too short"));
}
#[test]
fn clip_hint_includes_marker_and_count() {
let backend = TestBackend::new(60, 1);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme::default();
terminal
.draw(|f| render_clip_hint(f, f.area(), 42, &theme))
.unwrap();
let row = row_text(&terminal.backend().buffer().clone(), 0);
assert!(row.contains("…"), "marker missing: {row:?}");
assert!(row.contains("+42"), "count missing: {row:?}");
}
#[test]
fn draw_wrapper_renders_payload_into_terminal() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert("x".into(), text_payload("via draw"));
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(40, 3);
let mut terminal = make_terminal(backend, 3).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
draw(
&mut terminal,
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
0,
&loading,
)
.unwrap();
let buf = terminal.backend().buffer().clone();
let joined: String = (0..buf.area.height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("via draw"));
}
#[test]
fn finalize_splash_shows_the_cursor_for_the_shell() {
let backend = TestBackend::new(20, 3);
let mut terminal = make_terminal(backend, 3).unwrap();
terminal.hide_cursor().unwrap();
assert!(format!("{:?}", terminal.backend()).contains("cursor: false"));
finalize_splash(&mut terminal);
assert!(format!("{:?}", terminal.backend()).contains("cursor: true"));
}
#[test]
fn copy_rows_copies_vertical_slice() {
let mut src = Buffer::empty(Rect::new(0, 0, 4, 4));
src.set_string(0, 0, "AAAA", Style::default());
src.set_string(0, 1, "BBBB", Style::default());
src.set_string(0, 2, "CCCC", Style::default());
src.set_string(0, 3, "DDDD", Style::default());
let mut dst = Buffer::empty(Rect::new(0, 0, 4, 2));
copy_rows(&src, 1..3, &mut dst);
assert_eq!(row_text(&dst, 0), "BBBB");
assert_eq!(row_text(&dst, 1), "CCCC");
}
#[test]
fn copy_rows_honours_destination_origin() {
let mut src = Buffer::empty(Rect::new(0, 0, 3, 1));
src.set_string(0, 0, "XYZ", Style::default());
let mut dst = Buffer::empty(Rect::new(2, 5, 3, 1));
copy_rows(&src, 0..1, &mut dst);
assert_eq!(dst.cell((2, 5)).unwrap().symbol(), "X");
assert_eq!(dst.cell((4, 5)).unwrap().symbol(), "Z");
}
#[test]
fn copy_rows_stops_when_destination_fills_up() {
let mut src = Buffer::empty(Rect::new(0, 0, 4, 3));
src.set_string(0, 0, "AAAA", Style::default());
src.set_string(0, 1, "BBBB", Style::default());
src.set_string(0, 2, "CCCC", Style::default());
let mut dst = Buffer::empty(Rect::new(0, 0, 4, 1));
copy_rows(&src, 0..3, &mut dst);
assert_eq!(row_text(&dst, 0), "AAAA");
}
#[test]
fn render_full_into_buffer_paints_full_height() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert(
"x".into(),
text_block_payload(&["r0", "r1", "r2", "r3", "r4", "r5"]),
);
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let theme = Theme::default();
let loading = HashMap::new();
let buf = render_full_into_buffer(
20,
6,
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
&loading,
);
assert!(row_text(&buf, 0).starts_with("r0"));
assert!(row_text(&buf, 5).starts_with("r5"));
}
#[test]
fn paint_viewport_bg_leaves_reset_background_untouched() {
let backend = TestBackend::new(2, 1);
let mut terminal = Terminal::new(backend).unwrap();
let theme = Theme {
bg: Color::Reset,
..Theme::default()
};
terminal
.draw(|f| paint_viewport_bg(f, f.area(), &theme))
.unwrap();
assert_eq!(
terminal.backend().buffer().cell((0, 0)).unwrap().bg,
Color::Reset
);
}
#[test]
fn finalize_draw_flushes_top_rows_and_reveals_bottom_when_opted_in() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert(
"x".into(),
text_block_payload(&["r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7"]),
);
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(20, 10);
let mut terminal = make_terminal(backend, 4).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
let general = General {
flush_to_scrollback: true,
..General::default()
};
finalize_draw(
&mut terminal,
&root,
&payloads,
&specs,
®istry,
&theme,
&general,
(0, 0),
8,
4,
&loading,
)
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..buf.area.height)
.map(|y| row_text(&buf, y).trim_end().to_string())
.collect();
let combined = rows.join("\n");
assert!(
rows.iter().any(|r| r.contains("r7")),
"bottom row r7 missing: {rows:?}"
);
assert!(
rows.iter().any(|r| r.contains("r0")),
"scrollback row r0 missing: {rows:?}"
);
assert!(
!combined.contains("…"),
"clip hint should be suppressed after flush: {combined:?}"
);
}
#[test]
fn finalize_draw_defaults_to_clip_hint_when_taller_than_viewport() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert(
"x".into(),
text_block_payload(&["r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7"]),
);
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(20, 10);
let mut terminal = make_terminal(backend, 4).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
finalize_draw(
&mut terminal,
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
8,
4,
&loading,
)
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..buf.area.height)
.map(|y| row_text(&buf, y).trim_end().to_string())
.collect();
let combined = rows.join("\n");
assert!(
combined.contains("…"),
"clip hint should fire when flush is off: {combined:?}"
);
assert!(
!rows.iter().any(|r| r.contains("r7")),
"bottom rows should be clipped (not flushed to scrollback): {rows:?}"
);
}
#[test]
fn finalize_draw_without_clipping_falls_through_to_draw_final() {
let root = single_widget_tree("x");
let mut payloads = HashMap::new();
payloads.insert("x".into(), text_payload("fits"));
let specs = HashMap::new();
let registry = render::Registry::with_builtins();
let backend = TestBackend::new(20, 10);
let mut terminal = make_terminal(backend, 3).unwrap();
let theme = Theme::default();
let loading = HashMap::new();
finalize_draw(
&mut terminal,
&root,
&payloads,
&specs,
®istry,
&theme,
&General::default(),
(0, 0),
3,
3,
&loading,
)
.unwrap();
let buf = terminal.backend().buffer().clone();
let joined: String = (0..buf.area.height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("fits"));
assert!(!joined.contains("…"));
}
fn widget(id: &str, fetcher: &str) -> WidgetConfig {
WidgetConfig {
id: id.into(),
fetcher: fetcher.into(),
refresh_interval: Some(60),
..Default::default()
}
}
#[test]
fn should_refresh_when_no_cache_entry() {
let cached = HashMap::new();
assert!(should_refresh(&cached, &widget("a", "basic_static")));
}
#[test]
fn should_refresh_when_entry_is_stale() {
let mut cached = HashMap::new();
cached.insert(
"a".into(),
CacheEntry {
refreshed_at: 0,
ttl_seconds: 60,
kind: CacheEntryKind::Ok,
payload: text_payload("x"),
},
);
assert!(should_refresh(&cached, &widget("a", "basic_static")));
}
#[test]
fn should_skip_when_entry_is_fresh() {
let mut cached = HashMap::new();
cached.insert("a".into(), CacheEntry::new(text_payload("x"), 60));
assert!(!should_refresh(&cached, &widget("a", "basic_static")));
}
#[test]
fn resolve_refresh_interval_prefers_config_override() {
let registry = Registry::with_builtins();
let fetcher = registry.get_cached("basic_static").unwrap();
let mut w = widget("a", "basic_static");
w.refresh_interval = Some(99);
assert_eq!(resolve_refresh_interval(&w, fetcher.as_ref()), 99);
}
#[test]
fn resolve_refresh_interval_falls_back_to_fetcher_declared_value() {
let registry = Registry::with_builtins();
let fetcher = registry.get_cached("basic_static").unwrap();
let mut w = widget("a", "basic_static");
w.refresh_interval = None;
assert_eq!(
resolve_refresh_interval(&w, fetcher.as_ref()),
fetcher.refresh_interval()
);
}
fn static_widget(id: &str, text: &str) -> WidgetConfig {
WidgetConfig {
id: id.into(),
fetcher: "basic_static".into(),
format: Some(text.into()),
refresh_interval: Some(60),
..Default::default()
}
}
#[tokio::test]
async fn fetch_all_populates_cache_and_returns_fresh_payloads() {
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let registry = Registry::with_builtins();
let widgets = vec![static_widget("greeting", "Hi!")];
let fresh = fetch_all(
®istry,
Some(&cache),
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert!(fresh.contains_key("greeting"));
let key = registry
.get_cached("basic_static")
.unwrap()
.cache_key(&fetch_context(
&widgets[0],
&General::default(),
None,
Duration::from_secs(0),
));
let loaded = cache.load(&key).unwrap();
match loaded.payload.body {
Body::TextBlock(t) => assert_eq!(t.lines, vec!["Hi!".to_string()]),
_ => panic!("expected text_block body"),
}
}
#[tokio::test]
async fn widgets_sharing_fetcher_and_params_share_cache_file() {
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let registry = Registry::with_builtins();
let widgets = [
static_widget("greeting_project_a", "Hello!"),
static_widget("greeting_project_b", "Hello!"),
];
let _ = fetch_all(
®istry,
Some(&cache),
&widgets[..1],
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
let entries = load_entries(
Some(&cache),
®istry,
&widgets[1..],
&General::default(),
&HashMap::new(),
);
assert!(entries.contains_key("greeting_project_b"));
}
#[tokio::test]
async fn widgets_with_different_params_do_not_collide() {
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let registry = Registry::with_builtins();
let first = vec![static_widget("a", "one")];
let second = vec![static_widget("b", "two")];
let _ = fetch_all(
®istry,
Some(&cache),
&first,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
let entries = load_entries(
Some(&cache),
®istry,
&second,
&General::default(),
&HashMap::new(),
);
assert!(
entries.is_empty(),
"second widget should not read first widget's cache"
);
}
#[tokio::test]
async fn fetch_all_skips_fresh_cached_widgets() {
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let registry = Registry::with_builtins();
let widgets = vec![WidgetConfig {
id: "greeting".into(),
fetcher: "basic_static".into(),
format: Some("Hi!".into()),
refresh_interval: Some(60),
..Default::default()
}];
let mut cached = HashMap::new();
cached.insert("greeting".into(), CacheEntry::new(text_payload("old"), 60));
let fresh = fetch_all(
®istry,
Some(&cache),
&widgets,
&cached,
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert!(fresh.is_empty());
}
#[tokio::test]
async fn fetch_all_skips_widgets_whose_key_is_already_locked() {
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let registry = Registry::with_builtins();
let widgets = vec![static_widget("greeting", "Hi!")];
let key = registry
.get_cached("basic_static")
.unwrap()
.cache_key(&fetch_context(
&widgets[0],
&General::default(),
None,
Duration::from_secs(0),
));
let _held = cache.try_lock(&key).expect("first acquire");
let fresh = fetch_all(
®istry,
Some(&cache),
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert!(
fresh.is_empty(),
"held lock should cause the fetch to be skipped"
);
assert!(
cache.load(&key).is_none(),
"no cache should be written when fetch is skipped"
);
}
#[tokio::test]
async fn fetch_all_ignores_unknown_fetcher() {
let registry = Registry::with_builtins();
let widgets = vec![widget("x", "no_such_fetcher")];
let fresh = fetch_all(
®istry,
None,
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert!(fresh.is_empty());
}
#[tokio::test]
async fn fetch_all_returns_payloads_without_cache_storage() {
let registry = Registry::with_builtins();
let widgets = vec![static_widget("greeting", "Hi!")];
let fresh = fetch_all(
®istry,
None,
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert_eq!(fresh.get("greeting"), Some(&text_block_payload(&["Hi!"])));
}
#[tokio::test]
async fn fetch_all_ignores_panicking_fetcher_tasks() {
struct Panics;
#[async_trait]
impl fetcher::Fetcher for Panics {
fn name(&self) -> &str {
"panics"
}
fn refresh_interval(&self) -> u64 {
60
}
fn safety(&self) -> fetcher::Safety {
fetcher::Safety::Safe
}
fn description(&self) -> &'static str {
"test fixture"
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
async fn fetch(
&self,
_: &fetcher::FetchContext,
) -> Result<Payload, fetcher::FetchError> {
panic!("join error fixture")
}
}
let fixture = Panics;
assert_eq!(fetcher::Fetcher::name(&fixture), "panics");
assert_eq!(fetcher::Fetcher::safety(&fixture), fetcher::Safety::Safe);
assert_eq!(fetcher::Fetcher::description(&fixture), "test fixture");
assert_eq!(fetcher::Fetcher::shapes(&fixture), &[Shape::Text]);
let mut registry = Registry::default();
registry.register(std::sync::Arc::new(fixture));
let widgets = vec![widget("x", "panics")];
let fresh = fetch_all(
®istry,
None,
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
assert!(fresh.is_empty());
}
#[tokio::test]
async fn fetch_all_lifts_err_to_placeholder_and_cache() {
struct Boom;
#[async_trait]
impl fetcher::Fetcher for Boom {
fn name(&self) -> &str {
"boom"
}
fn refresh_interval(&self) -> u64 {
60
}
fn safety(&self) -> fetcher::Safety {
fetcher::Safety::Safe
}
fn description(&self) -> &'static str {
"test fixture"
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
async fn fetch(
&self,
_: &fetcher::FetchContext,
) -> Result<Payload, fetcher::FetchError> {
Err(fetcher::FetchError::Failed("boom".into()))
}
}
let fixture = Boom;
assert_eq!(fetcher::Fetcher::safety(&fixture), fetcher::Safety::Safe);
assert_eq!(fetcher::Fetcher::description(&fixture), "test fixture");
assert_eq!(fetcher::Fetcher::shapes(&fixture), &[Shape::Text]);
let mut registry = Registry::default();
registry.register(std::sync::Arc::new(fixture));
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let widgets = vec![widget("x", "boom")];
let fresh = fetch_all(
®istry,
Some(&cache),
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_secs(1),
)
.await;
let payload = fresh.get("x").expect("error must surface as a payload");
assert!(matches!(
&payload.body,
Body::Error(err)
if err.kind == crate::payload::ErrorKind::Fetch
&& err.message.contains("boom")
));
let key = registry
.get_cached("boom")
.unwrap()
.cache_key(&fetch_context(
&widgets[0],
&General::default(),
None,
Duration::from_secs(0),
));
let stored = cache.load(&key).expect("error entry must be cached");
assert_eq!(stored.kind, CacheEntryKind::Err);
assert_eq!(stored.ttl_seconds, ERROR_CACHE_TTL_SECS);
}
#[tokio::test]
async fn fetch_all_lifts_timeout_to_placeholder_and_cache() {
struct Slow;
#[async_trait]
impl fetcher::Fetcher for Slow {
fn name(&self) -> &str {
"slow"
}
fn refresh_interval(&self) -> u64 {
60
}
fn safety(&self) -> fetcher::Safety {
fetcher::Safety::Safe
}
fn description(&self) -> &'static str {
"test fixture"
}
fn shapes(&self) -> &[Shape] {
&[Shape::Text]
}
async fn fetch(
&self,
_: &fetcher::FetchContext,
) -> Result<Payload, fetcher::FetchError> {
tokio::time::sleep(Duration::from_secs(30)).await;
unreachable!("deadline must fire before the sleep resolves")
}
}
let fixture = Slow;
assert_eq!(fetcher::Fetcher::safety(&fixture), fetcher::Safety::Safe);
assert_eq!(fetcher::Fetcher::description(&fixture), "test fixture");
assert_eq!(fetcher::Fetcher::shapes(&fixture), &[Shape::Text]);
let mut registry = Registry::default();
registry.register(std::sync::Arc::new(fixture));
let dir = tempfile::tempdir().unwrap();
let cache = Cache::open(dir.path().to_path_buf()).unwrap();
let widgets = vec![widget("x", "slow")];
let fresh = fetch_all(
®istry,
Some(&cache),
&widgets,
&HashMap::new(),
&General::default(),
&HashMap::new(),
Duration::from_millis(10),
)
.await;
let payload = fresh.get("x").expect("timeout must surface as a payload");
assert!(matches!(
&payload.body,
Body::Error(err)
if err.kind == crate::payload::ErrorKind::Timeout
&& err.message.contains("timed out")
));
let key = registry
.get_cached("slow")
.unwrap()
.cache_key(&fetch_context(
&widgets[0],
&General::default(),
None,
Duration::from_secs(0),
));
let stored = cache.load(&key).expect("timeout entry must be cached");
assert_eq!(stored.kind, CacheEntryKind::Timeout);
}
#[test]
fn fetch_and_persist_only_writes_safe_cached_widgets() {
let _lock = lock_env();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".splashboard.toml");
std::fs::write(&config_path, "").unwrap();
let hash = crate::trust::hash_file(&config_path).unwrap();
let home = dir.path().join("home");
let _home = EnvGuard::set("SPLASHBOARD_HOME", Some(home.as_os_str()));
let _trust_all = EnvGuard::set("SPLASHBOARD_TRUST_ALL", None);
let cached = static_widget("cached", "Hi!");
let config = Config {
widgets: vec![
cached.clone(),
widget("clock", "clock"),
widget("missing", "no_such_fetcher"),
WidgetConfig {
id: "invalid".into(),
fetcher: "basic_static".into(),
render: Some(RenderSpec::Short("grid_calendar".into())),
format: Some("bad".into()),
refresh_interval: Some(60),
..Default::default()
},
WidgetConfig {
id: "feed".into(),
fetcher: "rss".into(),
options: Some(
toml::toml! {
url = "https://example.com/feed.xml"
}
.into(),
),
refresh_interval: Some(60),
..Default::default()
},
],
..Default::default()
};
let disk = Cache::open_default();
let backend = disk.as_ref().map(|c| c as &dyn CacheBackend);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(fetch_and_persist(
&config,
Some((&config_path, &hash)),
backend,
));
let cache = Cache::open_default().unwrap();
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let shapes = derive_shapes(&config.widgets, ®istry, &render_registry);
let key = registry
.get_cached("basic_static")
.unwrap()
.cache_key(&fetch_context(
&cached,
&General::default(),
shapes.get(&cached.id).copied(),
Duration::from_secs(0),
));
let entry = cache.load(&key).expect("cached widget must be persisted");
assert_eq!(entry.kind, CacheEntryKind::Ok);
assert_eq!(entry.payload, text_block_payload(&["Hi!"]));
let json_files = std::fs::read_dir(crate::paths::cache_dir().unwrap())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
.count();
assert_eq!(json_files, 1);
}
fn widget_with_render(id: &str, fetcher: &str, renderer: Option<&str>) -> WidgetConfig {
WidgetConfig {
id: id.into(),
fetcher: fetcher.into(),
render: renderer.map(|r| RenderSpec::Short(r.into())),
..Default::default()
}
}
fn single_shape(id: &str, shape: Shape) -> HashMap<WidgetId, Shape> {
let mut m = HashMap::new();
m.insert(id.into(), shape);
m
}
#[test]
fn derive_shape_prefers_renderer_accepted_shape_over_fetcher_default() {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let w = widget_with_render("t", "clock", Some("grid_calendar"));
assert_eq!(
derive_shape(&w, ®istry, &render_registry),
Some(Shape::Calendar)
);
}
#[test]
fn derive_shape_falls_back_to_fetcher_default_when_no_render_spec() {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let w = widget_with_render("t", "clock", None);
assert_eq!(
derive_shape(&w, ®istry, &render_registry),
Some(Shape::Text)
);
}
#[test]
fn derive_shape_uses_renderer_single_accept_when_present() {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let w = widget_with_render("s", "github_repo", Some("text_plain"));
assert_eq!(
derive_shape(&w, ®istry, &render_registry),
Some(Shape::Text)
);
}
#[test]
fn derive_shape_falls_back_to_fetcher_default_when_renderer_unknown() {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let w = widget_with_render("t", "clock", Some("definitely_not_a_renderer"));
assert_eq!(
derive_shape(&w, ®istry, &render_registry),
Some(Shape::Text)
);
}
#[test]
fn derive_shape_uses_renderer_first_shape_when_fetcher_unknown() {
let registry = Registry::with_builtins();
let render_registry = render::Registry::with_builtins();
let w = widget_with_render("t", "definitely_not_a_fetcher", Some("grid_calendar"));
assert_eq!(
derive_shape(&w, ®istry, &render_registry),
Some(Shape::Calendar)
);
}
#[test]
fn partition_by_shape_support_keeps_widget_with_compatible_renderer() {
let registry = Registry::with_builtins();
let widgets = vec![widget_with_render(
"d",
"system_monitor_disk",
Some("gauge_circle"),
)];
let shapes = single_shape("d", Shape::Ratio);
let (valid, invalid) = partition_by_shape_support(&widgets, &shapes, ®istry);
assert_eq!(valid.len(), 1);
assert!(invalid.is_empty());
}
#[test]
fn partition_by_shape_support_passes_through_when_shape_is_missing() {
let registry = Registry::with_builtins();
let widgets = vec![widget_with_render("clock", "clock", Some("text_plain"))];
let (valid, invalid) = partition_by_shape_support(&widgets, &HashMap::new(), ®istry);
assert_eq!(valid.len(), 1);
assert!(invalid.is_empty());
}
#[test]
fn partition_by_shape_support_rejects_incompatible_renderer() {
let registry = Registry::with_builtins();
let widgets = vec![widget_with_render(
"d",
"system_monitor_disk",
Some("grid_calendar"),
)];
let shapes = single_shape("d", Shape::Calendar);
let (valid, invalid) = partition_by_shape_support(&widgets, &shapes, ®istry);
assert!(valid.is_empty());
assert_eq!(invalid.len(), 1);
assert_eq!(invalid[0].0.id, "d");
assert_eq!(invalid[0].1.requested, Shape::Calendar);
}
#[test]
fn partition_by_shape_support_passes_through_unknown_fetcher() {
let registry = Registry::with_builtins();
let widgets = vec![widget_with_render(
"x",
"no_such_fetcher",
Some("grid_calendar"),
)];
let shapes = single_shape("x", Shape::Calendar);
let (valid, invalid) = partition_by_shape_support(&widgets, &shapes, ®istry);
assert_eq!(valid.len(), 1);
assert!(invalid.is_empty());
}
#[test]
fn partition_by_known_fetcher_separates_unknown_names() {
let registry = Registry::with_builtins();
let widgets = vec![
widget("ok", "clock"),
widget("typo", "no_such_fetcher"),
widget("static", "basic_static"),
];
let (known, unknown) = partition_by_known_fetcher(&widgets, ®istry);
let known_ids: Vec<_> = known.iter().map(|w| w.id.clone()).collect();
let unknown_ids: Vec<_> = unknown.iter().map(|w| w.id.clone()).collect();
assert_eq!(known_ids, vec!["ok".to_string(), "static".to_string()]);
assert_eq!(unknown_ids, vec!["typo".to_string()]);
}
#[test]
fn refresh_payloads_surfaces_unknown_fetcher_placeholder() {
let registry = Registry::with_builtins();
let unknown_widgets = vec![widget("typo", "no_such_fetcher")];
let buckets = WidgetBuckets {
cached: &[],
gated: &[],
unknown: &unknown_widgets,
shape_invalid: &[],
};
let mut payloads: HashMap<WidgetId, Payload> = HashMap::new();
let _ = refresh_payloads(
None,
®istry,
&buckets,
&General::default(),
&HashMap::new(),
&mut payloads,
);
let payload = payloads.get("typo").expect("unknown fetcher must surface");
assert!(matches!(
&payload.body,
Body::Error(err)
if err.kind == crate::payload::ErrorKind::UnknownFetcher
&& err.message.contains("no_such_fetcher")
));
}
#[test]
fn refresh_payloads_reapplies_gated_unknown_and_shape_mismatch_placeholders() {
let dir = tempfile::tempdir().unwrap();
let cache = Some(Cache::open(dir.path().to_path_buf()).unwrap());
let registry = Registry::with_builtins();
let cached_widget = static_widget("cached", "ignored");
let key = registry
.get_cached("basic_static")
.unwrap()
.cache_key(&fetch_context(
&cached_widget,
&General::default(),
None,
Duration::from_secs(0),
));
cache
.as_ref()
.unwrap()
.store(&key, &CacheEntry::new(text_payload("fresh"), 60))
.unwrap();
let gated = vec![widget("locked", "rss")];
let unknown = vec![widget("typo", "no_such_fetcher")];
let shape_invalid = vec![(
widget("bad", "basic_static"),
ShapeMismatch {
fetcher: "basic_static".into(),
requested: Shape::Calendar,
},
)];
let buckets = WidgetBuckets {
cached: std::slice::from_ref(&cached_widget),
gated: &gated,
unknown: &unknown,
shape_invalid: &shape_invalid,
};
let mut payloads = HashMap::from([
("locked".into(), text_payload("stale")),
("typo".into(), text_payload("stale")),
("bad".into(), text_payload("stale")),
]);
let entries = refresh_payloads(
as_backend(&cache),
®istry,
&buckets,
&General::default(),
&HashMap::new(),
&mut payloads,
);
assert!(entries.contains_key("cached"));
assert_eq!(payloads.get("cached"), Some(&text_payload("fresh")));
assert!(matches!(
payloads.get("locked").map(|payload| &payload.body),
Some(Body::Error(err)) if err.kind == ErrorKind::RequiresTrust
));
assert!(matches!(
payloads.get("typo").map(|payload| &payload.body),
Some(Body::Error(err)) if err.kind == ErrorKind::UnknownFetcher
));
assert!(matches!(
payloads.get("bad").map(|payload| &payload.body),
Some(Body::Error(err)) if err.kind == ErrorKind::ShapeMismatch
));
}
#[test]
fn partition_by_shape_support_accepts_multi_shape_fetchers_renderer_shape() {
let registry = Registry::with_builtins();
let widgets = vec![
widget_with_render("a", "clock", Some("text_plain")),
widget_with_render("b", "clock", Some("grid_table")),
widget_with_render("c", "clock", Some("grid_calendar")),
];
let mut shapes = HashMap::new();
shapes.insert("a".into(), Shape::Text);
shapes.insert("b".into(), Shape::Entries);
shapes.insert("c".into(), Shape::Calendar);
let (valid, invalid) = partition_by_shape_support(&widgets, &shapes, ®istry);
assert_eq!(valid.len(), 3);
assert!(invalid.is_empty());
}
#[test]
fn split_by_fetcher_kind_and_compute_realtime_payloads_use_only_realtime_widgets() {
let registry = Registry::with_builtins();
let widgets = vec![widget("clock", "clock"), static_widget("static", "cached")];
let (cached, realtime) = split_by_fetcher_kind(&widgets, ®istry);
assert_eq!(
cached.iter().map(|w| w.id.as_str()).collect::<Vec<_>>(),
vec!["static"]
);
assert_eq!(
realtime.iter().map(|w| w.id.as_str()).collect::<Vec<_>>(),
vec!["clock"]
);
let payloads = compute_realtime_payloads(
®istry,
&widgets,
&General::default(),
&single_shape("clock", Shape::Calendar),
);
assert_eq!(payloads.len(), 1);
assert!(matches!(
payloads.get("clock").map(|payload| &payload.body),
Some(Body::Calendar(_))
));
}
#[test]
fn load_entries_without_cache_returns_empty() {
let registry = Registry::with_builtins();
let widgets = vec![static_widget("static", "cached")];
let entries = load_entries(
None,
®istry,
&widgets,
&General::default(),
&HashMap::new(),
);
assert!(entries.is_empty());
}
#[test]
fn cache_keys_for_returns_empty_when_no_cached_widgets() {
let registry = Registry::with_builtins();
let widgets = vec![
widget("realtime", "clock"),
widget("unknown", "mystery_fetcher"),
];
let keys = cache_keys_for(&widgets, ®istry, &General::default(), &HashMap::new());
assert!(keys.is_empty());
}
#[test]
fn cache_keys_for_derives_one_distinct_key_per_cached_widget() {
let registry = Registry::with_builtins();
let widgets = vec![
static_widget("first", "one"),
static_widget("second", "two"),
widget("clock", "clock"),
];
let keys = cache_keys_for(&widgets, ®istry, &General::default(), &HashMap::new());
assert_eq!(keys.len(), 2, "realtime widget must be excluded");
assert_ne!(
keys[0], keys[1],
"differing params must yield distinct cache keys"
);
}
#[test]
fn cache_keys_for_matches_the_key_load_entries_reads() {
let registry = Registry::with_builtins();
let widgets = vec![static_widget("greeting", "Hello!")];
let keys = cache_keys_for(&widgets, ®istry, &General::default(), &HashMap::new());
let expected = registry
.get_cached("basic_static")
.unwrap()
.cache_key(&fetch_context(
&widgets[0],
&General::default(),
None,
Duration::from_secs(0),
));
assert_eq!(keys, vec![expected]);
}
#[test]
fn render_specs_collect_only_widgets_with_explicit_renderers() {
let widgets = vec![
widget_with_render("plain", "clock", None),
widget_with_render("fancy", "clock", Some("text_plain")),
];
let specs = render_specs(&widgets);
assert_eq!(specs.len(), 1);
assert_eq!(
specs.get("fancy").map(RenderSpec::renderer_name),
Some("text_plain")
);
assert!(!specs.contains_key("plain"));
}
#[test]
fn animation_still_due_only_while_something_animates() {
assert!(
animation_still_due(true, true, false),
"real animation pending → still due even with no loading"
);
assert!(
animation_still_due(true, false, true),
"loading spinners pending → still due"
);
assert!(
!animation_still_due(true, false, false),
"deadline not reached but nothing to animate → not due"
);
assert!(
!animation_still_due(false, true, true),
"deadline reached → animation no longer due regardless"
);
}
#[test]
fn loop_should_exit_requires_both_axes_to_settle() {
assert!(
loop_should_exit(true, false, false),
"daemon done, no animation → exit"
);
assert!(
!loop_should_exit(true, true, false),
"animation still playing → keep ticking"
);
assert!(
!loop_should_exit(false, false, false),
"daemon still running → wait"
);
assert!(!loop_should_exit(false, true, false), "both pending → wait");
assert!(
loop_should_exit(false, true, true),
"hard deadline always wins"
);
assert!(
loop_should_exit(true, true, true),
"hard deadline always wins"
);
}
#[test]
fn watch_should_draw_only_when_there_is_a_reason() {
assert!(!watch_should_draw(false, false, false, false));
assert!(watch_should_draw(true, false, false, false), "resized");
assert!(watch_should_draw(false, true, false, false), "data changed");
assert!(
watch_should_draw(false, false, true, false),
"intro animation playing"
);
assert!(
watch_should_draw(false, false, false, true),
"loading spinner active"
);
}
#[test]
fn is_quit_key_matches_q_and_ctrl_c_only() {
assert!(is_quit_key(&KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE
)));
assert!(is_quit_key(&KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL
)));
assert!(!is_quit_key(&KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::NONE
)));
assert!(!is_quit_key(&KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE
)));
assert!(!is_quit_key(&KeyEvent::new(
KeyCode::Esc,
KeyModifiers::NONE
)));
}
#[test]
fn compute_loading_surfaces_cached_widgets_without_payload() {
let cached = vec![widget("pending", "github_my_prs"), widget("ready", "clock")];
let mut payloads: HashMap<WidgetId, Payload> = HashMap::new();
payloads.insert("ready".into(), text_payload("14:00"));
let mut shapes: HashMap<WidgetId, Shape> = HashMap::new();
shapes.insert("pending".into(), Shape::Entries);
shapes.insert("ready".into(), Shape::Text);
let entries = HashMap::new();
let loading = compute_loading(&cached, &payloads, &entries, &shapes, false);
assert_eq!(loading.len(), 1);
assert_eq!(loading.get("pending"), Some(&Shape::Entries));
assert!(!loading.contains_key("ready"));
}
#[test]
fn compute_loading_flags_stale_entry_only_under_wait() {
let cached = vec![widget("stale", "github_my_prs")];
let mut payloads: HashMap<WidgetId, Payload> = HashMap::new();
payloads.insert("stale".into(), text_payload("last-run"));
let mut shapes: HashMap<WidgetId, Shape> = HashMap::new();
shapes.insert("stale".into(), Shape::Entries);
let mut entries = HashMap::new();
entries.insert(
"stale".into(),
CacheEntry {
refreshed_at: 0,
ttl_seconds: 60,
kind: CacheEntryKind::Ok,
payload: text_payload("last-run"),
},
);
let without_wait = compute_loading(&cached, &payloads, &entries, &shapes, false);
assert!(
without_wait.is_empty(),
"stale cache should render without spinner off the wait path"
);
let with_wait = compute_loading(&cached, &payloads, &entries, &shapes, true);
assert_eq!(with_wait.get("stale"), Some(&Shape::Entries));
}
#[test]
fn compute_loading_keeps_fresh_entry_off_spinner_even_under_wait() {
let cached = vec![widget("fresh", "github_my_prs")];
let mut payloads: HashMap<WidgetId, Payload> = HashMap::new();
payloads.insert("fresh".into(), text_payload("now"));
let mut shapes: HashMap<WidgetId, Shape> = HashMap::new();
shapes.insert("fresh".into(), Shape::Entries);
let mut entries = HashMap::new();
entries.insert("fresh".into(), CacheEntry::new(text_payload("now"), 600));
let loading = compute_loading(&cached, &payloads, &entries, &shapes, true);
assert!(loading.is_empty());
}
#[test]
fn has_loading_widget_true_when_any_cached_widget_lacks_payload() {
let cached = vec![widget("a", "github_my_prs"), widget("b", "clock")];
let mut payloads: HashMap<WidgetId, Payload> = HashMap::new();
payloads.insert("a".into(), text_payload("x"));
let entries = HashMap::new();
assert!(has_loading_widget(&cached, &payloads, &entries, false));
payloads.insert("b".into(), text_payload("y"));
assert!(!has_loading_widget(&cached, &payloads, &entries, false));
}
}