Skip to main content

elegance/
stat_card.rs

1//! Compact stat tile for dashboards: label, value, delta chip, and inline sparkline.
2//!
3//! Use [`StatCard`] for at-a-glance numeric KPIs in observability or admin
4//! UIs. A small upper-cased label sits above the headline value, paired
5//! with an optional unit suffix and a coloured delta chip indicating
6//! direction of change. A subtitle line carries comparison context (e.g.
7//! `"vs last 7 days"`), and an optional 44 pt filled-area sparkline tinted
8//! by the card's accent shows the recent trend at a glance.
9
10use egui::{
11    epaint::{Mesh, PathShape, PathStroke},
12    pos2, Align, Color32, CornerRadius, FontId, FontSelection, Frame, Layout, Margin, Pos2, Rect,
13    Response, Sense, Shape, Stroke, StrokeKind, TextWrapMode, Ui, Vec2, Widget, WidgetInfo,
14    WidgetText, WidgetType,
15};
16
17use crate::theme::{with_alpha, Accent, Palette, Theme};
18
19#[derive(Copy, Clone, PartialEq, Eq)]
20enum DeltaDir {
21    Up,
22    Down,
23    Flat,
24}
25
26impl DeltaDir {
27    fn from_value(delta: f32) -> Self {
28        if delta > 0.005 {
29            Self::Up
30        } else if delta < -0.005 {
31            Self::Down
32        } else {
33            Self::Flat
34        }
35    }
36
37    fn arrow(self) -> char {
38        match self {
39            Self::Up => '\u{2191}',
40            Self::Down => '\u{2193}',
41            Self::Flat => '\u{2192}',
42        }
43    }
44}
45
46/// A dashboard stat tile.
47///
48/// Renders an upper-cased label, a headline value (with optional unit
49/// suffix), a coloured delta chip, a short comparison subtitle, and an
50/// optional filled-area sparkline of recent values, all inside a rounded
51/// card surface. The accent colour tints the sparkline. While the
52/// underlying metric is still loading, call [`StatCard::loading`] to
53/// render a shimmer placeholder in place of the value and sparkline.
54///
55/// ```no_run
56/// # use elegance::{StatCard, Accent};
57/// # egui::__run_test_ui(|ui| {
58/// let series = [12.0, 14.0, 13.0, 15.0, 17.0, 16.0, 18.0, 22.0_f32];
59/// ui.add(
60///     StatCard::new("Active deploys")
61///         .accent(Accent::Blue)
62///         .value("24")
63///         .delta(0.12)
64///         .trend("vs last 7 days")
65///         .sparkline(&series),
66/// );
67/// # });
68/// ```
69#[must_use = "Add the stat card with `ui.add(...)`."]
70pub struct StatCard<'a> {
71    label: WidgetText,
72    accent: Accent,
73    value: Option<WidgetText>,
74    unit: Option<WidgetText>,
75    delta: Option<f32>,
76    invert_delta: bool,
77    trend: Option<WidgetText>,
78    sparkline: Option<&'a [f32]>,
79    sparkline_color: Option<Color32>,
80    width: Option<f32>,
81    loading: bool,
82    info_tooltip: Option<WidgetText>,
83}
84
85impl std::fmt::Debug for StatCard<'_> {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("StatCard")
88            .field("label", &self.label.text())
89            .field("accent", &self.accent)
90            .field("value", &self.value.as_ref().map(|v| v.text()))
91            .field("unit", &self.unit.as_ref().map(|v| v.text()))
92            .field("delta", &self.delta)
93            .field("invert_delta", &self.invert_delta)
94            .field("trend", &self.trend.as_ref().map(|v| v.text()))
95            .field("sparkline_len", &self.sparkline.map(|s| s.len()))
96            .field("width", &self.width)
97            .field("loading", &self.loading)
98            .finish()
99    }
100}
101
102impl<'a> StatCard<'a> {
103    /// Create a new stat card with the given label.
104    ///
105    /// The label is rendered in upper-case (matching the mockup's
106    /// `"ACTIVE DEPLOYS"` treatment) regardless of the case the caller
107    /// passes in. Defaults: blue accent, no value, no delta, no trend, no
108    /// sparkline.
109    pub fn new(label: impl Into<WidgetText>) -> Self {
110        Self {
111            label: label.into(),
112            accent: Accent::Blue,
113            value: None,
114            unit: None,
115            delta: None,
116            invert_delta: false,
117            trend: None,
118            sparkline: None,
119            sparkline_color: None,
120            width: None,
121            loading: false,
122            info_tooltip: None,
123        }
124    }
125
126    /// Set the accent colour driving the sparkline tint. Default:
127    /// [`Accent::Blue`].
128    #[inline]
129    pub fn accent(mut self, accent: Accent) -> Self {
130        self.accent = accent;
131        self
132    }
133
134    /// Set the headline value text. The caller is responsible for any
135    /// numeric formatting (rounding, locale separators, etc.).
136    #[inline]
137    pub fn value(mut self, value: impl Into<WidgetText>) -> Self {
138        self.value = Some(value.into());
139        self
140    }
141
142    /// Set a small unit suffix rendered next to the value (`%`, `ms`,
143    /// `req/s`, ...). Default: none.
144    #[inline]
145    pub fn unit(mut self, unit: impl Into<WidgetText>) -> Self {
146        self.unit = Some(unit.into());
147        self
148    }
149
150    /// Set the fractional change to display as a coloured delta chip
151    /// (e.g. `0.12` for a `+12.0%` rise). Sign drives the arrow direction
152    /// (up / down / flat); magnitude is rendered as a percentage with one
153    /// decimal. Default: no chip.
154    #[inline]
155    pub fn delta(mut self, delta: f32) -> Self {
156        self.delta = Some(delta);
157        self
158    }
159
160    /// When set, a *negative* delta is treated as the good direction (chip
161    /// renders green) and a positive delta as bad (chip renders red).
162    /// Useful for metrics where down is good, like latency or error rate.
163    /// Default: false.
164    #[inline]
165    pub fn invert_delta(mut self, invert: bool) -> Self {
166        self.invert_delta = invert;
167        self
168    }
169
170    /// Set the small subtitle below the value (e.g. `"vs last 7 days"`).
171    #[inline]
172    pub fn trend(mut self, trend: impl Into<WidgetText>) -> Self {
173        self.trend = Some(trend.into());
174        self
175    }
176
177    /// Render an inline filled-area sparkline of recent values beneath
178    /// the trend line. The series is read at the point of `ui.add(...)`
179    /// and not retained. At least two points are required.
180    #[inline]
181    pub fn sparkline(mut self, series: &'a [f32]) -> Self {
182        self.sparkline = Some(series);
183        self
184    }
185
186    /// Override the sparkline's tint. Defaults to the accent colour.
187    #[inline]
188    pub fn sparkline_color(mut self, color: Color32) -> Self {
189        self.sparkline_color = Some(color);
190        self
191    }
192
193    /// Pin the card width. Defaults to the parent's available width,
194    /// which lets the card flow inside grid cells or `horizontal_wrapped`
195    /// rows.
196    #[inline]
197    pub fn width(mut self, width: f32) -> Self {
198        self.width = Some(width);
199        self
200    }
201
202    /// Render a shimmer skeleton in place of the value, trend, and
203    /// sparkline. Use while the underlying metric is loading.
204    #[inline]
205    pub fn loading(mut self, loading: bool) -> Self {
206        self.loading = loading;
207        self
208    }
209
210    /// Attach a tooltip shown on card hover, plus a small painted info
211    /// indicator next to the label.
212    #[inline]
213    pub fn info_tooltip(mut self, tooltip: impl Into<WidgetText>) -> Self {
214        self.info_tooltip = Some(tooltip.into());
215        self
216    }
217}
218
219impl Widget for StatCard<'_> {
220    fn ui(self, ui: &mut Ui) -> Response {
221        let theme = Theme::current(ui.ctx());
222        let p = &theme.palette;
223        let t = &theme.typography;
224
225        let StatCard {
226            label,
227            accent,
228            value,
229            unit,
230            delta,
231            invert_delta,
232            trend,
233            sparkline,
234            sparkline_color,
235            width,
236            loading,
237            info_tooltip,
238        } = self;
239
240        let card_radius = CornerRadius::same(theme.card_radius as u8);
241        let inner_margin = Margin {
242            left: 18,
243            right: 18,
244            top: 16,
245            bottom: 14,
246        };
247        let card_width = width.unwrap_or_else(|| ui.available_width()).max(180.0);
248
249        let value_size = (t.heading * 1.75).max(t.body * 1.6);
250        let unit_size = t.body;
251        let small_size = t.small;
252        let label_size = t.small;
253
254        let line_color = sparkline_color.unwrap_or_else(|| p.accent_fill(accent));
255        let label_text = label.text().to_uppercase();
256        let a11y_label = label_text.clone();
257        let has_info = info_tooltip.is_some();
258
259        let frame_response = ui
260            .scope(|ui| {
261                ui.set_min_width(card_width);
262                ui.set_max_width(card_width);
263
264                ui.with_layout(Layout::top_down(Align::Min), |ui| {
265                    Frame::new()
266                        .fill(p.card)
267                        .stroke(Stroke::new(1.0, p.border))
268                        .corner_radius(card_radius)
269                        .inner_margin(inner_margin)
270                        .show(ui, |ui| {
271                            ui.spacing_mut().item_spacing = Vec2::ZERO;
272
273                            ui.horizontal(|ui| {
274                                let g = WidgetText::from(
275                                    egui::RichText::new(&label_text)
276                                        .color(p.text_muted)
277                                        .size(label_size),
278                                )
279                                .into_galley(
280                                    ui,
281                                    Some(TextWrapMode::Truncate),
282                                    ui.available_width(),
283                                    FontSelection::FontId(FontId::proportional(label_size)),
284                                );
285                                let size = g.size();
286                                let (lrect, _) = ui.allocate_exact_size(size, Sense::hover());
287                                if ui.is_rect_visible(lrect) {
288                                    ui.painter().galley(lrect.min, g, p.text_muted);
289                                }
290                                if has_info {
291                                    ui.add_space(4.0);
292                                    paint_info_glyph(ui, p.text_faint);
293                                }
294                            });
295
296                            ui.add_space(8.0);
297
298                            if loading {
299                                skeleton_bar(ui, ui.available_width() * 0.4, value_size * 0.95, p);
300                            } else if let Some(v) = value {
301                                paint_value_row(
302                                    ui,
303                                    p,
304                                    v,
305                                    unit,
306                                    delta,
307                                    invert_delta,
308                                    value_size,
309                                    unit_size,
310                                    small_size,
311                                );
312                            }
313
314                            ui.add_space(2.0);
315
316                            if !loading {
317                                if let Some(trend) = trend {
318                                    let g = WidgetText::from(
319                                        egui::RichText::new(trend.text())
320                                            .color(p.text_faint)
321                                            .size(small_size),
322                                    )
323                                    .into_galley(
324                                        ui,
325                                        Some(TextWrapMode::Truncate),
326                                        ui.available_width(),
327                                        FontSelection::FontId(FontId::proportional(small_size)),
328                                    );
329                                    let size = g.size();
330                                    let (tr, _) = ui.allocate_exact_size(size, Sense::hover());
331                                    if ui.is_rect_visible(tr) {
332                                        ui.painter().galley(tr.min, g, p.text_faint);
333                                    }
334                                }
335                            }
336
337                            if loading {
338                                ui.add_space(14.0);
339                                skeleton_bar(ui, ui.available_width(), 44.0, p);
340                            } else if let Some(series) = sparkline {
341                                ui.add_space(14.0);
342                                let (rect, _) = ui.allocate_exact_size(
343                                    Vec2::new(ui.available_width(), 44.0),
344                                    Sense::hover(),
345                                );
346                                if ui.is_rect_visible(rect) {
347                                    paint_sparkline(ui, rect, series, line_color);
348                                }
349                            }
350                        })
351                        .response
352                })
353                .inner
354            })
355            .inner;
356
357        if loading {
358            crate::request_repaint_at_rate(ui.ctx(), 30.0);
359        }
360
361        let mut response = frame_response;
362        if let Some(tooltip) = info_tooltip {
363            let text = tooltip.text().to_string();
364            response = response.on_hover_text(text);
365        }
366        response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &a11y_label));
367        response
368    }
369}
370
371#[allow(clippy::too_many_arguments)]
372fn paint_value_row(
373    ui: &mut Ui,
374    palette: &Palette,
375    value: WidgetText,
376    unit: Option<WidgetText>,
377    delta: Option<f32>,
378    invert_delta: bool,
379    value_size: f32,
380    unit_size: f32,
381    small_size: f32,
382) {
383    let value_galley = WidgetText::from(
384        egui::RichText::new(value.text())
385            .color(palette.text)
386            .size(value_size)
387            .strong(),
388    )
389    .into_galley(
390        ui,
391        Some(TextWrapMode::Extend),
392        f32::INFINITY,
393        FontSelection::FontId(FontId::proportional(value_size)),
394    );
395    let value_v = value_galley.size();
396
397    let unit_galley = unit.map(|u| {
398        WidgetText::from(
399            egui::RichText::new(u.text())
400                .color(palette.text_muted)
401                .size(unit_size),
402        )
403        .into_galley(
404            ui,
405            Some(TextWrapMode::Extend),
406            f32::INFINITY,
407            FontSelection::FontId(FontId::proportional(unit_size)),
408        )
409    });
410    let unit_v = unit_galley.as_ref().map(|g| g.size()).unwrap_or(Vec2::ZERO);
411
412    let row_height = value_v.y.max(unit_v.y);
413    let avail_w = ui.available_width();
414    let (row_rect, _) = ui.allocate_exact_size(Vec2::new(avail_w, row_height), Sense::hover());
415    if !ui.is_rect_visible(row_rect) {
416        return;
417    }
418
419    let mut x = row_rect.left();
420    let value_pos = pos2(x, row_rect.bottom() - value_v.y);
421    ui.painter().galley(value_pos, value_galley, palette.text);
422    x += value_v.x + 2.0;
423
424    if let Some(g) = unit_galley {
425        // Pull the unit slightly below the value's baseline so descender
426        // metrics line up visually instead of mathematically.
427        let pos = pos2(x, row_rect.bottom() - unit_v.y - 2.0);
428        ui.painter().galley(pos, g, palette.text_muted);
429        x += unit_v.x;
430    }
431
432    if let Some(d) = delta {
433        x += 10.0;
434        paint_delta_chip(
435            ui,
436            pos2(x, row_rect.center().y),
437            palette,
438            DeltaDir::from_value(d),
439            d.abs(),
440            invert_delta,
441            small_size,
442        );
443    }
444}
445
446fn paint_delta_chip(
447    ui: &mut Ui,
448    anchor_left_center: Pos2,
449    palette: &Palette,
450    dir: DeltaDir,
451    magnitude: f32,
452    invert: bool,
453    small_size: f32,
454) {
455    let good = if invert { DeltaDir::Down } else { DeltaDir::Up };
456    let (fg, bg, border) = match dir {
457        DeltaDir::Flat => (
458            palette.text_muted,
459            with_alpha(palette.text_muted, 22),
460            with_alpha(palette.text_muted, 51),
461        ),
462        d if d == good => (
463            palette.success,
464            with_alpha(palette.success, 26),
465            with_alpha(palette.success, 64),
466        ),
467        _ => (
468            palette.danger,
469            with_alpha(palette.danger, 26),
470            with_alpha(palette.danger, 64),
471        ),
472    };
473
474    let label = format!("{} {:.1}%", dir.arrow(), magnitude * 100.0);
475    let galley = WidgetText::from(
476        egui::RichText::new(label)
477            .color(fg)
478            .size(small_size)
479            .strong(),
480    )
481    .into_galley(
482        ui,
483        Some(TextWrapMode::Extend),
484        f32::INFINITY,
485        FontSelection::FontId(FontId::proportional(small_size)),
486    );
487
488    let pad = Vec2::new(8.0, 3.0);
489    let chip_size = galley.size() + pad * 2.0;
490    let chip_min = pos2(
491        anchor_left_center.x,
492        anchor_left_center.y - chip_size.y * 0.5,
493    );
494    let chip_rect = Rect::from_min_size(chip_min, chip_size);
495    let radius = CornerRadius::same((chip_size.y * 0.5).round() as u8);
496
497    let painter = ui.painter();
498    painter.rect_filled(chip_rect, radius, bg);
499    painter.rect_stroke(
500        chip_rect,
501        radius,
502        Stroke::new(1.0, border),
503        StrokeKind::Inside,
504    );
505    let text_pos = pos2(
506        chip_rect.min.x + pad.x,
507        chip_rect.center().y - galley.size().y * 0.5,
508    );
509    painter.galley(text_pos, galley, fg);
510}
511
512fn paint_info_glyph(ui: &mut Ui, color: Color32) {
513    let radius = 5.5;
514    let size = Vec2::splat(radius * 2.0 + 1.0);
515    let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
516    if !ui.is_rect_visible(rect) {
517        return;
518    }
519    let center = rect.center();
520    let painter = ui.painter();
521    painter.circle_stroke(center, radius, Stroke::new(1.0, color));
522    painter.circle_filled(center + Vec2::new(0.0, -2.5), 0.85, color);
523    painter.line_segment(
524        [center + Vec2::new(0.0, -0.5), center + Vec2::new(0.0, 2.4)],
525        Stroke::new(1.0, color),
526    );
527}
528
529fn skeleton_bar(ui: &mut Ui, width: f32, height: f32, palette: &Palette) {
530    let (rect, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
531    if !ui.is_rect_visible(rect) {
532        return;
533    }
534    let phase = (ui.input(|i| i.time) % 1.4) as f32 / 1.4;
535    let pulse = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5;
536    let alpha = (20.0 + 21.0 * pulse).round() as u8;
537    let fill = with_alpha(palette.text_muted, alpha);
538    let r = (height.min(8.0) * 0.5).round() as u8;
539    ui.painter().rect_filled(rect, CornerRadius::same(r), fill);
540}
541
542fn paint_sparkline(ui: &mut Ui, rect: Rect, series: &[f32], color: Color32) {
543    if series.len() < 2 {
544        return;
545    }
546    let plot = rect.shrink2(Vec2::splat(2.0));
547
548    let mut min = f32::INFINITY;
549    let mut max = f32::NEG_INFINITY;
550    for &v in series {
551        if v < min {
552            min = v;
553        }
554        if v > max {
555            max = v;
556        }
557    }
558    let span = max - min;
559    let pts: Vec<Pos2> = series
560        .iter()
561        .enumerate()
562        .map(|(i, &v)| {
563            let t = i as f32 / (series.len() - 1) as f32;
564            let x = plot.left() + t * plot.width();
565            let y = if span < 1e-6 {
566                plot.center().y
567            } else {
568                plot.top() + (1.0 - (v - min) / span) * plot.height()
569            };
570            pos2(x, y)
571        })
572        .collect();
573
574    // Vertex-coloured strip from the curve down to the bar's bottom edge,
575    // alpha fading from 35 % at the SVG top to 0 % at the bottom. This
576    // mirrors the linear gradient in the HTML mockup without needing a
577    // shader.
578    let mut mesh = Mesh::default();
579    let top_y = rect.top();
580    let bottom_y = rect.bottom();
581    let h = (bottom_y - top_y).max(1.0);
582    let (cr, cg, cb) = (color.r(), color.g(), color.b());
583    for p in &pts {
584        let y_ratio = ((p.y - top_y) / h).clamp(0.0, 1.0);
585        let alpha = ((1.0 - y_ratio) * 0.35 * 255.0).round().clamp(0.0, 255.0) as u8;
586        let top_color = Color32::from_rgba_unmultiplied(cr, cg, cb, alpha);
587        let bottom_color = Color32::from_rgba_unmultiplied(cr, cg, cb, 0);
588        mesh.colored_vertex(*p, top_color);
589        mesh.colored_vertex(pos2(p.x, bottom_y), bottom_color);
590    }
591    for i in 0..pts.len() - 1 {
592        let a = (i * 2) as u32;
593        let b = (i * 2 + 1) as u32;
594        let c = ((i + 1) * 2) as u32;
595        let d = ((i + 1) * 2 + 1) as u32;
596        mesh.add_triangle(a, b, c);
597        mesh.add_triangle(b, d, c);
598    }
599    ui.painter().add(Shape::mesh(mesh));
600
601    let line = PathShape::line(pts.clone(), PathStroke::new(1.75, color));
602    ui.painter().add(line);
603
604    if let Some(last) = pts.last() {
605        ui.painter().circle_filled(*last, 2.5, color);
606    }
607}