⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/ui_engine/widgets/stat-card/src/lib.rs
Historia
Quién tocó qué
crudo
feat(yahweh-widget-stat-card): promover patrón stat card como widget
1//! `yahweh-widget-stat-card` — tarjeta de dashboard con accent.
2//!
3//! Compone:
4//! - **`card_themed(cx)`** del [`yahweh_widget_card`] como contenedor.
5//! - **Border-l-4** con un color de accent que el caller decide
6//! (verde = OK, rojo = error, etc.).
7//! - **Label** chico arriba en el color del accent.
8//! - **Value** grande (`px(28)`) en el color principal del text.
9//! - **Description** chica en el color tenue.
10//! - **Listing opcional** de items recientes con sub-header
11//! `"recent (N de TOTAL):"`.
12//!
13//! El patrón emerge en dashboards estilo `minga-explorer` (counts
14//! del repo + sample) y `brahman-broker-explorer` (estado del
15//! probe). Cada consumer aporta sus propios accents semánticos.
16//!
17//! El widget no asume valor numérico — `value` es
18//! `Into<SharedString>`, así que sirve igual para counts (`"3"`),
19//! status text (`"UP / PROVIDER"`) o cualquier label corto.
20//!
21//! # Ejemplo
22//!
23//! ```ignore
24//! use yahweh_widget_stat_card::stat_card;
25//! use gpui::{rgb, Hsla};
26//!
27//! let cell = stat_card(
28//! cx,
29//! "Nodos AST",
30//! "247",
31//! "fragments parseados del código",
32//! rgb(0x88c0d0),
33//! theme.fg_text,
34//! theme.fg_muted,
35//! &["abc123 fn_decl".into(), "def456 expr".into()],
36//! );
37//! ```
38
39#![forbid(unsafe_code)]
40
41use gpui::{div, prelude::*, px, App, IntoElement, SharedString};
42use yahweh_widget_card::card_themed;
43
44/// Construye una stat card. Devuelve `impl IntoElement` para que el
45/// caller pueda meterla directo como child de cualquier
46/// `flex_col`/`gap` parent.
47///
48/// Args:
49/// - `cx` — `&App` (acepta `&Context<T>` por deref). El widget lee
50/// el theme global para el bg de la card.
51/// - `label` — header chico, en el color del accent.
52/// - `value` — texto principal, render grande (`px(28)`).
53/// - `description` — texto chico tenue debajo del value.
54/// - `accent` — color del border-l y del label.
55/// - `text` — color principal (para el value).
56/// - `text_dim` — color tenue (para description y sub-header de
57/// recent).
58/// - `recent_items` — slice de strings; si no vacío, se renderea
59/// como sub-listing con header `"recent (N de TOTAL):"`. Cada
60/// item ocupa una linea.
61#[allow(clippy::too_many_arguments)]
62pub fn stat_card(
63 cx: &App,
64 label: &str,
65 value: impl Into<SharedString>,
66 description: &str,
67 accent: gpui::Rgba,
68 text: gpui::Hsla,
69 text_dim: gpui::Hsla,
70 recent_items: &[String],
71) -> impl IntoElement {
72 let value: SharedString = value.into();
73 let total_for_header = recent_items.len();
74
75 let mut card = card_themed(cx)
76 .border_l_4()
77 .border_color(accent)
78 .child(
79 div()
80 .text_color(accent)
81 .text_size(px(11.))
82 .child(SharedString::from(label.to_string())),
83 )
84 .child(
85 div()
86 .text_color(text)
87 .text_size(px(28.))
88 .child(value),
89 )
90 .child(
91 div()
92 .text_color(text_dim)
93 .text_size(px(11.))
94 .child(SharedString::from(description.to_string())),
95 );
96
97 if !recent_items.is_empty() {
98 // Sub-header indicando cuántos items se muestran.
99 // El "TOTAL" es el len del slice porque el caller ya lo
100 // truncó — no tenemos acceso al total original. Si el
101 // caller quiere "5 de 247", debe formatear el label/value
102 // con el total.
103 card = card.child(
104 div()
105 .mt(px(6.))
106 .text_color(text_dim)
107 .text_size(px(10.))
108 .child(SharedString::from(format!("recent ({total_for_header}):"))),
109 );
110 for it in recent_items {
111 card = card.child(
112 div()
113 .text_color(text)
114 .text_size(px(11.))
115 .child(SharedString::from(it.clone())),
116 );
117 }
118 }
119
120 card
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use gpui::TestAppContext;
127 use yahweh_theme::Theme;
128
129 /// Smoke test: el constructor lee el theme global y devuelve un
130 /// IntoElement. Sin TestAppContext no podemos asertar render
131 /// pixels — esto valida wireup + type-check.
132 #[gpui::test]
133 fn stat_card_constructs_with_theme(cx: &mut TestAppContext) {
134 cx.update(|cx| {
135 Theme::install_default(cx);
136 let theme = Theme::global(cx);
137 let _el = stat_card(
138 cx,
139 "Test",
140 "42",
141 "una descripción",
142 gpui::rgb(0x88c0d0),
143 theme.fg_text,
144 theme.fg_muted,
145 &[],
146 );
147 });
148 }
149
150 #[gpui::test]
151 fn stat_card_with_recent_items_works(cx: &mut TestAppContext) {
152 cx.update(|cx| {
153 Theme::install_default(cx);
154 let theme = Theme::global(cx);
155 let _el = stat_card(
156 cx,
157 "Items",
158 "3",
159 "items recientes:",
160 gpui::rgb(0xa3be8c),
161 theme.fg_text,
162 theme.fg_muted,
163 &["a1b2c3 foo".into(), "d4e5f6 bar".into(), "789012 baz".into()],
164 );
165 });
166 }
167
168 #[gpui::test]
169 fn stat_card_value_accepts_string_or_number_repr(cx: &mut TestAppContext) {
170 // Type-check: value es Into<SharedString>. Tanto literal
171 // string como `format!()` deberían funcionar.
172 cx.update(|cx| {
173 Theme::install_default(cx);
174 let theme = Theme::global(cx);
175 let _ = stat_card(cx, "L", "literal", "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
176 let _ = stat_card(cx, "L", format!("{}", 42), "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
177 let _ = stat_card(cx, "L", "owned".to_string(), "d", gpui::rgb(0), theme.fg_text, theme.fg_muted, &[]);
178 });
179 }
180}