Skip to main content

gpui_kit/display/
animated_number.rs

1//! A number that counts to its new value.
2//!
3//! The count is decoration. What the component publishes — and therefore what
4//! a test, a screen reader, or an audit reads — is the target, from the first
5//! frame after it changes. A number in flight is not a fact, so
6//! `the total is 1,204` can never race the animation.
7
8use std::rc::Rc;
9
10use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
11use gpui_kit_semantics::{NodeSpec, Role, Semantic};
12use gpui_kit_theme::{ActiveTheme, TypeScale};
13
14use crate::foundation::{Ident, StyledExt};
15use crate::motion::{Easing, MotionSpec, Transition, keyed};
16
17type Format = Rc<dyn Fn(f64) -> String>;
18
19#[derive(Default)]
20struct Counter(Option<Transition<f32>>);
21
22/// A numeric readout that animates between values.
23#[derive(IntoElement)]
24pub struct AnimatedNumber {
25    ident: Ident,
26    value: f64,
27    format: Option<Format>,
28    spec: Option<MotionSpec>,
29    scale: TypeScale,
30}
31
32impl std::fmt::Debug for AnimatedNumber {
33    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        formatter
35            .debug_struct("AnimatedNumber")
36            .field("ident", &self.ident)
37            .field("value", &self.value)
38            .field("formatted", &self.formatted(self.value))
39            .finish()
40    }
41}
42
43impl AnimatedNumber {
44    pub fn new(ident: impl Into<Ident>, value: f64) -> Self {
45        Self {
46            ident: ident.into(),
47            value,
48            format: None,
49            spec: None,
50            scale: TypeScale::Title,
51        }
52    }
53
54    /// Decides the text for a value.
55    ///
56    /// The component never invents a grouping separator or a precision:
57    /// how many decimals a quantity carries, and whether thousands are
58    /// grouped, belong to whoever owns the quantity.
59    pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
60        self.format = Some(Rc::new(format));
61        self
62    }
63
64    pub fn spec(mut self, spec: MotionSpec) -> Self {
65        self.spec = Some(spec);
66        self
67    }
68
69    pub fn type_scale(mut self, scale: TypeScale) -> Self {
70        self.scale = scale;
71        self
72    }
73
74    fn formatted(&self, value: f64) -> String {
75        match &self.format {
76            Some(format) => format(value),
77            None => format!("{value}"),
78        }
79    }
80}
81
82impl RenderOnce for AnimatedNumber {
83    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
84        let theme = cx.theme().clone();
85        let spec = self.spec.unwrap_or_else(|| {
86            MotionSpec::new(theme.motion.resize_ms, Easing::Standard.curve(&theme))
87        });
88        let target = self.value as f32;
89
90        let counter = keyed::slot::<Counter>(&self.ident.semantic_id(), cx);
91        let shown = {
92            let mut counter = counter.borrow_mut();
93            let mut transition = counter
94                .0
95                .unwrap_or_else(|| Transition::new(target, spec))
96                .spec(spec);
97            transition.set(target);
98            let shown = transition.animate(window, cx);
99            counter.0 = Some(transition);
100            shown
101        };
102
103        // The published value is the target, and only the glyphs count.
104        let announced = self.formatted(self.value);
105        let painted = self.formatted(shown as f64);
106
107        div()
108            .child(
109                div()
110                    .type_scale(&theme, self.scale)
111                    .text_color(theme.colors.text)
112                    .child(SharedString::from(painted)),
113            )
114            .semantic_in(
115                cx,
116                NodeSpec::new(self.ident.semantic_id(), Role::Status).value(announced),
117            )
118    }
119}
120
121/// Groups whole thousands with a separator, the format a count usually wants.
122pub fn grouped(value: f64) -> String {
123    let rounded = value.round() as i64;
124    let negative = rounded < 0;
125    let digits = rounded.abs().to_string();
126    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3 + 1);
127    for (index, digit) in digits.chars().enumerate() {
128        if index > 0 && (digits.len() - index).is_multiple_of(3) {
129            grouped.push(',');
130        }
131        grouped.push(digit);
132    }
133    if negative {
134        format!("-{grouped}")
135    } else {
136        grouped
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn grouping_inserts_a_separator_every_three_digits() {
146        assert_eq!(grouped(0.0), "0");
147        assert_eq!(grouped(999.0), "999");
148        assert_eq!(grouped(1204.0), "1,204");
149        assert_eq!(grouped(1_204_000.0), "1,204,000");
150        assert_eq!(grouped(-4200.0), "-4,200");
151    }
152
153    #[test]
154    fn the_debug_view_reports_the_target_not_a_frame_of_it() {
155        let number = AnimatedNumber::new("total", 1204.0).format(grouped);
156        assert!(format!("{number:?}").contains("1,204"));
157    }
158}