gpui_kit/display/
animated_number.rs1use 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#[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 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 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
121pub 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}