Skip to main content

gpui_kit/display/
sparkline.rs

1//! A deliberately narrow trend reading, not a charting framework.
2//!
3//! A sparkline has no axes, ticks, legend, tooltip, locale or scale policy.
4//! The caller supplies points already normalized into the inclusive `0..=1`
5//! square, plus the exact label and current, minimum and maximum text a reader
6//! should receive. `x = 0` is the leading edge, `x = 1` the trailing edge,
7//! `y = 0` the bottom and `y = 1` the top. Points outside that square or with
8//! non-finite coordinates are skipped rather than clamped into a value the
9//! caller did not supply.
10//!
11//! GPUI's existing stroked path is used directly. The path has no motion,
12//! locale lookup or layout-dependent sampling, so the same normalized points
13//! produce the same geometry for the same bounds.
14
15use 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/// One point already normalized by the caller.
30#[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/// The plotted points and the exact text that makes them accessible.
50#[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    /// How many supplied points are inside the documented normalized bounds.
74    pub fn published_points(&self) -> usize {
75        self.points
76            .iter()
77            .filter(|point| point.is_bounded())
78            .count()
79    }
80}
81
82/// The complete state of one trend reading.
83#[derive(Debug, Clone, PartialEq)]
84pub enum SparklineState {
85    Loading,
86    Ready(SparklineReading),
87    Empty,
88    Unavailable(SharedString),
89    Error(SharedString),
90    /// The reading is the last verified value; the text says why it is stale.
91    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/// A compact, accessible trend reading.
111#[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}