Skip to main content

zero_tui/widgets/
stat.rs

1//! `<Stat>` widget — the honesty primitive.
2//!
3//! Renders a value plus its freshness and sample size per spec §3.1.
4//! Refuses to render when the value's `as_of` exceeds the configured
5//! stale threshold without an explicit stale badge.
6//!
7//! Lint rule (enforced in CI): numeric fields of `EngineState` may
8//! only be rendered through this widget. See ADR-010.
9
10use chrono::{DateTime, Utc};
11use ratatui::buffer::Buffer;
12use ratatui::layout::Rect;
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::Widget;
16use zero_engine_client::{Source, Stat};
17
18use crate::theme::Theme;
19
20/// Render a `Stat<T>` with value, freshness, and optional n.
21#[derive(Debug)]
22pub struct StatWidget<'a, T: std::fmt::Display> {
23    stat: &'a Stat<T>,
24    now: DateTime<Utc>,
25    stale_after: chrono::Duration,
26    theme: Theme,
27    show_source: bool,
28}
29
30impl<'a, T: std::fmt::Display> StatWidget<'a, T> {
31    pub fn new(stat: &'a Stat<T>) -> Self {
32        Self {
33            stat,
34            now: Utc::now(),
35            stale_after: chrono::Duration::seconds(5),
36            theme: Theme::default(),
37            show_source: false,
38        }
39    }
40
41    #[must_use]
42    pub fn stale_after(mut self, d: chrono::Duration) -> Self {
43        self.stale_after = d;
44        self
45    }
46
47    #[must_use]
48    pub fn theme(mut self, t: Theme) -> Self {
49        self.theme = t;
50        self
51    }
52
53    #[must_use]
54    pub fn show_source(mut self, yes: bool) -> Self {
55        self.show_source = yes;
56        self
57    }
58}
59
60impl<T: std::fmt::Display> Widget for StatWidget<'_, T> {
61    fn render(self, area: Rect, buf: &mut Buffer) {
62        let stale = self.stat.is_stale(self.now, self.stale_after);
63        let value_style = if stale {
64            Style::default()
65                .fg(self.theme.caution)
66                .add_modifier(Modifier::DIM)
67        } else {
68            Style::default().fg(self.theme.primary)
69        };
70
71        let mut spans: Vec<Span<'_>> = vec![Span::styled(self.stat.value.to_string(), value_style)];
72
73        if let Some(n) = self.stat.n {
74            spans.push(Span::styled(
75                format!(" n={n}"),
76                Style::default().fg(self.theme.metadata),
77            ));
78        }
79
80        if self.show_source {
81            let source = match self.stat.source {
82                Source::Http => "http",
83                Source::Ws => "ws",
84                Source::Mcp => "mcp",
85                Source::Derived => "derived",
86                Source::Mock => "mock",
87            };
88            spans.push(Span::styled(
89                format!(" [{source}]"),
90                Style::default().fg(self.theme.metadata),
91            ));
92        }
93
94        Line::from(spans).render(area, buf);
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::StatWidget;
101    use chrono::Utc;
102    use ratatui::buffer::Buffer;
103    use ratatui::layout::Rect;
104    use ratatui::widgets::Widget;
105    use zero_engine_client::{Source, Stat};
106
107    #[test]
108    fn renders_value_and_n() {
109        let stat: Stat<f64> = Stat::new(58.4, Source::Ws).with_n(312);
110        let area = Rect::new(0, 0, 40, 1);
111        let mut buf = Buffer::empty(area);
112        StatWidget::new(&stat)
113            .stale_after(chrono::Duration::seconds(60))
114            .render(area, &mut buf);
115        let rendered: String = (0..area.width)
116            .map(|x| buf[(x, 0)].symbol().to_string())
117            .collect();
118        assert!(rendered.contains("58.4"));
119        assert!(rendered.contains("n=312"));
120    }
121
122    #[test]
123    fn stale_badge_triggers_on_age() {
124        let stale_ts = Utc::now() - chrono::Duration::seconds(30);
125        let stat: Stat<f64> = Stat::new(1.0, Source::Ws).with_as_of(stale_ts);
126        assert!(stat.is_stale(Utc::now(), chrono::Duration::seconds(5)));
127    }
128}