1use gpui::{
16 AnyElement, App, InteractiveElement, IntoElement, ParentElement, PathBuilder, RenderOnce,
17 SharedString, Styled, Window, canvas, div, point, px,
18};
19use gpui_kit_semantics::{NodeSpec, Role, Semantic};
20use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TypeScale};
21
22use crate::display::badge::Tone;
23use crate::display::empty::{EmptyKind, EmptyState};
24use crate::display::loading::PulseLoader;
25use crate::display::status::StatusDot;
26use crate::foundation::{Ident, StyledExt};
27use crate::strings::{ActiveStrings, StringKey};
28
29#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct SparklinePoint {
32 pub x: f32,
33 pub y: f32,
34}
35
36impl SparklinePoint {
37 pub fn new(x: f32, y: f32) -> Self {
38 Self { x, y }
39 }
40
41 pub fn is_bounded(self) -> bool {
42 self.x.is_finite()
43 && self.y.is_finite()
44 && (0.0..=1.0).contains(&self.x)
45 && (0.0..=1.0).contains(&self.y)
46 }
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub struct SparklineReading {
52 pub points: Vec<SparklinePoint>,
53 pub current: SharedString,
54 pub minimum: SharedString,
55 pub maximum: SharedString,
56}
57
58impl SparklineReading {
59 pub fn new(
60 points: impl IntoIterator<Item = SparklinePoint>,
61 current: impl Into<SharedString>,
62 minimum: impl Into<SharedString>,
63 maximum: impl Into<SharedString>,
64 ) -> Self {
65 Self {
66 points: points.into_iter().collect(),
67 current: current.into(),
68 minimum: minimum.into(),
69 maximum: maximum.into(),
70 }
71 }
72
73 pub fn published_points(&self) -> usize {
75 self.points
76 .iter()
77 .filter(|point| point.is_bounded())
78 .count()
79 }
80}
81
82#[derive(Debug, Clone, PartialEq)]
84pub enum SparklineState {
85 Loading,
86 Ready(SparklineReading),
87 Empty,
88 Unavailable(SharedString),
89 Error(SharedString),
90 Stale {
92 reading: SparklineReading,
93 reason: SharedString,
94 },
95}
96
97impl SparklineState {
98 pub fn name(&self) -> &'static str {
99 match self {
100 Self::Loading => "loading",
101 Self::Ready(_) => "ready",
102 Self::Empty => "empty",
103 Self::Unavailable(_) => "unavailable",
104 Self::Error(_) => "error",
105 Self::Stale { .. } => "stale",
106 }
107 }
108}
109
110#[derive(Debug, IntoElement)]
112pub struct Sparkline {
113 ident: Ident,
114 label: SharedString,
115 state: SparklineState,
116}
117
118impl Sparkline {
119 pub fn new(
120 ident: impl Into<Ident>,
121 label: impl Into<SharedString>,
122 state: SparklineState,
123 ) -> Self {
124 Self {
125 ident: ident.into(),
126 label: label.into(),
127 state,
128 }
129 }
130}
131
132impl RenderOnce for Sparkline {
133 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
134 let theme = cx.theme().clone();
135 let (body, spec): (AnyElement, NodeSpec) = match &self.state {
136 SparklineState::Loading => (
137 div()
138 .flex()
139 .items_center()
140 .justify_center()
141 .w_full()
142 .p(px(24.0))
143 .child(
144 PulseLoader::new(self.ident.child("loading"))
145 .label(cx.strings().text(StringKey::Loading)),
146 )
147 .into_any_element(),
148 NodeSpec::new(self.ident.semantic_id(), Role::Region)
149 .text(self.label.clone())
150 .value("loading")
151 .busy(true)
152 .read_only(true),
153 ),
154 SparklineState::Ready(reading) => (
155 reading_body(&self.ident, &self.label, reading, None, cx),
156 reading_spec(&self.ident, &self.label, reading, cx),
157 ),
158 SparklineState::Stale { reading, reason } => (
159 reading_body(&self.ident, &self.label, reading, Some(reason.clone()), cx),
160 reading_spec(&self.ident, &self.label, reading, cx),
161 ),
162 SparklineState::Empty => (
163 EmptyState::new(
164 self.ident.child("empty"),
165 cx.strings().text(StringKey::SparklineEmpty),
166 )
167 .kind(EmptyKind::Empty)
168 .into_any_element(),
169 NodeSpec::new(self.ident.semantic_id(), Role::Region)
170 .text(self.label.clone())
171 .value("empty")
172 .read_only(true),
173 ),
174 SparklineState::Unavailable(reason) => (
175 EmptyState::new(
176 self.ident.child("unavailable"),
177 cx.strings().text(StringKey::SparklineUnavailable),
178 )
179 .kind(EmptyKind::Unavailable)
180 .detail(reason.clone())
181 .into_any_element(),
182 NodeSpec::new(self.ident.semantic_id(), Role::Region)
183 .text(self.label.clone())
184 .value("unavailable")
185 .read_only(true),
186 ),
187 SparklineState::Error(reason) => (
188 EmptyState::new(
189 self.ident.child("error"),
190 cx.strings().text(StringKey::SparklineError),
191 )
192 .kind(EmptyKind::Failed)
193 .detail(reason.clone())
194 .into_any_element(),
195 NodeSpec::new(self.ident.semantic_id(), Role::Region)
196 .text(self.label.clone())
197 .value("error")
198 .description(reason.clone())
199 .invalid(true)
200 .read_only(true),
201 ),
202 };
203 div()
204 .id(self.ident.element_id())
205 .column()
206 .w_full()
207 .p_token(&theme, Space::Sm)
208 .radius(&theme, Radius::Card)
209 .frame(&theme, Surface::Raised, Elevation::Raised)
210 .child(body)
211 .semantic_in(cx, spec)
212 }
213}
214
215fn reading_spec(
216 ident: &Ident,
217 label: &SharedString,
218 reading: &SparklineReading,
219 cx: &App,
220) -> NodeSpec {
221 let range = cx.strings().format(
222 StringKey::SparklineRange,
223 &[reading.minimum.as_ref(), reading.maximum.as_ref()],
224 );
225 NodeSpec::new(ident.semantic_id(), Role::Image)
226 .text(label.clone())
227 .value(reading.current.clone())
228 .description(range)
229 .read_only(true)
230}
231
232fn reading_body(
233 ident: &Ident,
234 label: &SharedString,
235 reading: &SparklineReading,
236 stale: Option<SharedString>,
237 cx: &App,
238) -> AnyElement {
239 let theme = cx.theme().clone();
240 let current = cx
241 .strings()
242 .format(StringKey::SparklineCurrent, &[reading.current.as_ref()]);
243 let minimum = cx
244 .strings()
245 .format(StringKey::SparklineMinimum, &[reading.minimum.as_ref()]);
246 let maximum = cx
247 .strings()
248 .format(StringKey::SparklineMaximum, &[reading.maximum.as_ref()]);
249 let points: Vec<SparklinePoint> = reading
250 .points
251 .iter()
252 .copied()
253 .filter(|point| point.is_bounded())
254 .collect();
255 let stroke = theme.borders.thick;
256 let color = theme.colors.accent;
257
258 div()
259 .column()
260 .w_full()
261 .gap_token(&theme, Space::Xs)
262 .child(
263 div()
264 .row()
265 .items_baseline()
266 .justify_between()
267 .gap_token(&theme, Space::Sm)
268 .child(
269 div()
270 .type_scale(&theme, TypeScale::Label)
271 .text_color(theme.colors.text)
272 .child(label.clone()),
273 )
274 .child(
275 div()
276 .type_scale(&theme, TypeScale::Caption)
277 .text_color(theme.colors.text_muted)
278 .child(current),
279 ),
280 )
281 .children(stale.map(|reason| {
282 div()
283 .row()
284 .items_center()
285 .gap_token(&theme, Space::Xs)
286 .type_scale(&theme, TypeScale::Caption)
287 .text_color(theme.colors.warning)
288 .child(StatusDot::new(Tone::Warning))
289 .child(reason.clone())
290 .semantic_in(
291 cx,
292 NodeSpec::new(ident.child("stale").semantic_id(), Role::Status)
293 .parent(ident.semantic_id())
294 .text(reason)
295 .value("stale"),
296 )
297 }))
298 .child(spark_canvas(points, stroke, color))
299 .child(
300 div()
301 .row()
302 .justify_between()
303 .type_scale(&theme, TypeScale::Caption)
304 .text_color(theme.colors.text_faint)
305 .child(minimum)
306 .child(maximum),
307 )
308 .into_any_element()
309}
310
311fn spark_canvas(points: Vec<SparklinePoint>, stroke: f32, color: gpui::Hsla) -> impl IntoElement {
312 canvas(
313 |_, _, _| {},
314 move |bounds, _, window, _| {
315 if points.len() < 2 {
316 return;
317 }
318 let inset = stroke / 2.0;
319 let width = (f32::from(bounds.size.width) - stroke).max(0.0);
320 let height = (f32::from(bounds.size.height) - stroke).max(0.0);
321 if width <= 0.0 || height <= 0.0 {
322 return;
323 }
324 let at = |sample: SparklinePoint| {
325 point(
326 bounds.origin.x + px(inset + sample.x * width),
327 bounds.origin.y + px(inset + (1.0 - sample.y) * height),
328 )
329 };
330 let mut builder = PathBuilder::stroke(px(stroke));
331 builder.move_to(at(points[0]));
332 for sample in points.iter().copied().skip(1) {
333 builder.line_to(at(sample));
334 }
335 if let Ok(path) = builder.build() {
336 window.paint_path(path, color);
337 }
338 },
339 )
340 .w_full()
341 .h(px(72.0))
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn points_outside_the_documented_square_are_not_published() {
350 let reading = SparklineReading::new(
351 [
352 SparklinePoint::new(0.0, 0.2),
353 SparklinePoint::new(0.5, 1.2),
354 SparklinePoint::new(1.0, 0.8),
355 ],
356 "8 req/s",
357 "2 req/s",
358 "9 req/s",
359 );
360 assert_eq!(reading.published_points(), 2);
361 }
362
363 #[test]
364 fn non_finite_points_are_not_bounded() {
365 assert!(!SparklinePoint::new(f32::NAN, 0.5).is_bounded());
366 assert!(!SparklinePoint::new(0.5, f32::INFINITY).is_bounded());
367 }
368}