1use 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#[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 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 #[inline]
129 pub fn accent(mut self, accent: Accent) -> Self {
130 self.accent = accent;
131 self
132 }
133
134 #[inline]
137 pub fn value(mut self, value: impl Into<WidgetText>) -> Self {
138 self.value = Some(value.into());
139 self
140 }
141
142 #[inline]
145 pub fn unit(mut self, unit: impl Into<WidgetText>) -> Self {
146 self.unit = Some(unit.into());
147 self
148 }
149
150 #[inline]
155 pub fn delta(mut self, delta: f32) -> Self {
156 self.delta = Some(delta);
157 self
158 }
159
160 #[inline]
165 pub fn invert_delta(mut self, invert: bool) -> Self {
166 self.invert_delta = invert;
167 self
168 }
169
170 #[inline]
172 pub fn trend(mut self, trend: impl Into<WidgetText>) -> Self {
173 self.trend = Some(trend.into());
174 self
175 }
176
177 #[inline]
181 pub fn sparkline(mut self, series: &'a [f32]) -> Self {
182 self.sparkline = Some(series);
183 self
184 }
185
186 #[inline]
188 pub fn sparkline_color(mut self, color: Color32) -> Self {
189 self.sparkline_color = Some(color);
190 self
191 }
192
193 #[inline]
197 pub fn width(mut self, width: f32) -> Self {
198 self.width = Some(width);
199 self
200 }
201
202 #[inline]
205 pub fn loading(mut self, loading: bool) -> Self {
206 self.loading = loading;
207 self
208 }
209
210 #[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 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 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}