Skip to main content

gpui_kit/display/
progress.rs

1//! Progress that reports what is actually known.
2
3use gpui::{
4    App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
5    prelude::FluentBuilder, px, relative,
6};
7use gpui_kit_semantics::{NodeSpec, Role, Semantic};
8use gpui_kit_theme::{ActiveTheme, Space};
9
10use crate::foundation::Ident;
11use crate::motion::{self, AnimationExt as _, MotionSpec};
12use crate::strings::{ActiveStrings, StringKey};
13
14/// What a progress surface knows about the work, and how it says so.
15///
16/// `ProgressBar` and [`crate::display::progress_circle::ProgressCircle`] draw
17/// different shapes over exactly this state, so the two cannot drift into
18/// telling different stories about the same work.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub(crate) struct ProgressValue {
21    /// `None` means the extent of the work is unknown.
22    pub fraction: Option<f32>,
23    /// What to show beside the label, such as `"3 of 12"`, when the caller
24    /// worded it itself.
25    pub display: Option<SharedString>,
26    /// The work counted, when the caller gave a count rather than a wording.
27    /// It stays a pair of numbers until render, because the sentence around
28    /// them belongs to the installed catalogue and not to this struct.
29    pub count: Option<(usize, usize)>,
30}
31
32impl ProgressValue {
33    pub(crate) fn set_fraction(&mut self, fraction: f32) {
34        self.fraction = Some(fraction.clamp(0.0, 1.0));
35    }
36
37    /// Reports `done` out of `total`, and stays indeterminate when the total
38    /// is zero, because no fraction exists to report.
39    pub(crate) fn set_count(&mut self, done: usize, total: usize) {
40        self.fraction = (total > 0).then(|| (done as f32 / total as f32).clamp(0.0, 1.0));
41        self.count = Some((done, total));
42    }
43
44    pub(crate) fn is_indeterminate(&self) -> bool {
45        self.fraction.is_none()
46    }
47
48    /// The node both surfaces publish: busy always, a position only when the
49    /// extent is known.
50    /// What a reader sees beside the label: the caller's own wording first,
51    /// then a counted position worded by the catalogue.
52    pub(crate) fn shown(&self, cx: &App) -> Option<SharedString> {
53        self.display.clone().or_else(|| {
54            self.count.map(|(done, total)| {
55                cx.strings().format(
56                    StringKey::CountOfTotal,
57                    &[&done.to_string(), &total.to_string()],
58                )
59            })
60        })
61    }
62
63    pub(crate) fn spec(&self, id: SharedString, label: Option<SharedString>, cx: &App) -> NodeSpec {
64        let mut spec = NodeSpec::new(id, Role::Progress).busy(true);
65        if let Some(fraction) = self.fraction {
66            spec = spec.range(0.0, 1.0, fraction);
67        }
68        if let Some(label) = label {
69            spec = spec.text(label);
70        }
71        if let Some(display) = self.shown(cx) {
72            spec = spec.value(display);
73        }
74        spec
75    }
76}
77
78/// A horizontal bar for work whose extent is known.
79///
80/// A bar without a value is indeterminate and says so, rather than crawling
81/// to ninety percent and waiting there.
82#[derive(Debug, IntoElement)]
83pub struct ProgressBar {
84    ident: Ident,
85    label: Option<SharedString>,
86    value: ProgressValue,
87}
88
89impl ProgressBar {
90    pub fn new(ident: impl Into<Ident>) -> Self {
91        Self {
92            ident: ident.into(),
93            label: None,
94            value: ProgressValue::default(),
95        }
96    }
97
98    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
99        self.label = Some(label.into());
100        self
101    }
102
103    /// How much of the work is done, between zero and one.
104    pub fn fraction(mut self, fraction: f32) -> Self {
105        self.value.set_fraction(fraction);
106        self
107    }
108
109    /// Reports `done` out of `total`, and stays indeterminate when the total
110    /// is zero, because no fraction exists to report.
111    pub fn count(mut self, done: usize, total: usize) -> Self {
112        self.value.set_count(done, total);
113        self
114    }
115
116    pub fn display(mut self, display: impl Into<SharedString>) -> Self {
117        self.value.display = Some(display.into());
118        self
119    }
120}
121
122impl RenderOnce for ProgressBar {
123    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
124        let theme = cx.theme().clone();
125        let indeterminate = self.value.is_indeterminate();
126        // The published range stays the caller's number from the frame it
127        // changes; only the fill takes its time getting there.
128        let drawn = self.value.fraction.map(|fraction| {
129            motion::tracked(
130                &self.ident.semantic_id(),
131                fraction,
132                motion::resize(&theme),
133                window,
134                cx,
135            )
136        });
137
138        let spec = self
139            .value
140            .spec(self.ident.semantic_id(), self.label.clone(), cx);
141        let display = self.value.shown(cx);
142
143        div()
144            .flex()
145            .flex_col()
146            .gap(px(theme.space(Space::Xs)))
147            .w_full()
148            .when_some(self.label.clone(), |element, label| {
149                element.child(
150                    div()
151                        .flex()
152                        .flex_row()
153                        .justify_between()
154                        .text_size(px(theme.typography.body.size))
155                        .text_color(theme.colors.text_muted)
156                        .child(label)
157                        .when_some(display, |element, display| {
158                            element.child(div().text_color(theme.colors.text).child(display))
159                        }),
160                )
161            })
162            .child(
163                div()
164                    .relative()
165                    .w_full()
166                    .h(px(4.0))
167                    .rounded_full()
168                    .overflow_hidden()
169                    .bg(theme.colors.hairline_strong)
170                    .when_some(drawn, |element, fraction| {
171                        element.child(
172                            div()
173                                .absolute()
174                                .left_0()
175                                .top_0()
176                                .bottom_0()
177                                .rounded_full()
178                                .bg(theme.colors.accent)
179                                .w(relative(fraction)),
180                        )
181                    })
182                    // An unknown extent sweeps a segment across the track and
183                    // tints nothing behind it. A partly filled bar would be
184                    // read as a position and there is none, and a filled one
185                    // reads as the position at the end: the still frame of a
186                    // held animation showed exactly that, a bar that looked
187                    // finished beside a run that had not started.
188                    .when(indeterminate, |element| {
189                        let period = MotionSpec::new(
190                            theme.motion.pulse_ms * 2,
191                            motion::CubicBezier::new(0.4, 0.0, 0.6, 1.0),
192                        );
193                        element.child(
194                            div()
195                                .absolute()
196                                .top_0()
197                                .bottom_0()
198                                .w(relative(0.3))
199                                .rounded_full()
200                                .bg(theme.colors.accent)
201                                .with_animation(
202                                    self.ident.child("sweep").element_id(),
203                                    period.repeating(),
204                                    |element, delta| element.left(relative(delta * 1.3 - 0.3)),
205                                ),
206                        )
207                    }),
208            )
209            .semantic_in(cx, spec)
210    }
211}