Skip to main content

gpui_kit/display/
progress_circle.rs

1//! The ring form of [`crate::display::progress::ProgressBar`].
2//!
3//! Same state, same rules, same node: a position is published only when the
4//! extent of the work is known, and an unknown extent is drawn as an unknown
5//! extent rather than as a ring that happens to be a quarter full.
6
7use std::f32::consts::{FRAC_PI_2, TAU};
8
9use gpui::{
10    AnimationExt as _, AnyElement, App, Hsla, IntoElement, ParentElement, PathBuilder, Pixels,
11    Point, RenderOnce, SharedString, Styled, Window, canvas, div, point, px,
12};
13use gpui_kit_semantics::Semantic;
14use gpui_kit_theme::{ActiveTheme, ControlSize, TypeScale};
15
16use crate::display::progress::ProgressValue;
17use crate::foundation::{Ident, Sizable, StyledExt};
18use crate::motion::{self, MotionSpec};
19
20/// How much larger the ring is than the control step it is sized from.
21const RING_SCALE: f32 = 1.4;
22
23/// A ring for work in a place too tight for a bar.
24#[derive(Debug, IntoElement)]
25pub struct ProgressCircle {
26    ident: Ident,
27    label: Option<SharedString>,
28    /// What to show in the middle of the ring, when anything belongs there.
29    centre: Option<SharedString>,
30    value: ProgressValue,
31    size: ControlSize,
32}
33
34impl ProgressCircle {
35    pub fn new(ident: impl Into<Ident>) -> Self {
36        Self {
37            ident: ident.into(),
38            label: None,
39            centre: None,
40            value: ProgressValue::default(),
41            size: ControlSize::Md,
42        }
43    }
44
45    /// What the work is, for a reader who has only the tree to go on.
46    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
47        self.label = Some(label.into());
48        self
49    }
50
51    /// How much of the work is done, between zero and one.
52    pub fn fraction(mut self, fraction: f32) -> Self {
53        self.value.set_fraction(fraction);
54        self
55    }
56
57    /// Reports `done` out of `total`, and stays indeterminate when the total
58    /// is zero, because no fraction exists to report.
59    pub fn count(mut self, done: usize, total: usize) -> Self {
60        self.value.set_count(done, total);
61        self
62    }
63
64    /// What the node publishes as its value, such as `"3 of 12"`.
65    pub fn display(mut self, display: impl Into<SharedString>) -> Self {
66        self.value.display = Some(display.into());
67        self
68    }
69
70    /// A short reading inside the ring. It is the caller's words: the circle
71    /// invents no percentage of its own.
72    pub fn centre(mut self, centre: impl Into<SharedString>) -> Self {
73        self.centre = Some(centre.into());
74        self
75    }
76}
77
78impl Sizable for ProgressCircle {
79    fn control_size(mut self, size: ControlSize) -> Self {
80        self.size = size;
81        self
82    }
83}
84
85impl RenderOnce for ProgressCircle {
86    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
87        let theme = cx.theme().clone();
88        let metrics = theme.control.get(self.size);
89        let diameter = (metrics.height * RING_SCALE).round();
90        let stroke = theme.borders.thick;
91        let radius = (diameter - stroke) / 2.0;
92        let track = theme.colors.hairline_strong;
93        let accent = theme.colors.accent;
94
95        // The published position is the caller's number from the frame it
96        // changes; only the arc takes its time getting there.
97        let drawn = self.value.fraction.map(|fraction| {
98            motion::tracked(
99                &self.ident.semantic_id(),
100                fraction,
101                motion::resize(&theme),
102                window,
103                cx,
104            )
105        });
106
107        let muted = accent.opacity(theme.opacity.muted);
108        // An unknown extent turns a short arc around the ring. A *still* part
109        // of a ring would be read as a position, and there is none to read —
110        // but a part that travels at a constant rate is the one shape nobody
111        // reads as a position, because a position does not lap itself. Under
112        // reduced motion there is no travel to rely on, so it falls back to
113        // tinting the whole ring, which claims nothing either.
114        let still = motion::reduce_motion(cx);
115        let ring: AnyElement = if drawn.is_none() && !still {
116            let period = MotionSpec::new(
117                motion::Activity::Working.period_ms(&theme),
118                motion::Activity::Working.curve(&theme),
119            );
120            div()
121                .size(px(diameter))
122                .with_animation(
123                    self.ident.child("turn").element_id(),
124                    period.repeating(),
125                    move |element, phase| {
126                        element.child(ring_canvas(
127                            diameter,
128                            radius,
129                            stroke,
130                            track,
131                            accent,
132                            muted,
133                            None,
134                            Some(phase),
135                        ))
136                    },
137                )
138                .into_any_element()
139        } else {
140            ring_canvas(diameter, radius, stroke, track, accent, muted, drawn, None)
141                .into_any_element()
142        };
143
144        let centre = self.centre.clone().map(|reading| {
145            div()
146                .absolute()
147                .inset_0()
148                .flex()
149                .items_center()
150                .justify_center()
151                .type_scale(&theme, TypeScale::Caption)
152                .text_color(theme.colors.text_muted)
153                .child(reading)
154        });
155
156        div()
157            .flex_none()
158            .relative()
159            .size(px(diameter))
160            .child(ring)
161            .children(centre)
162            .semantic_in(
163                cx,
164                self.value.spec(self.ident.semantic_id(), self.label, cx),
165            )
166    }
167}
168
169/// How much of the ring the travelling arc covers when the extent is unknown.
170///
171/// Short enough that the gap is unmistakable — a nearly closed ring would read
172/// as work nearly done — and long enough to be seen moving.
173const TRAVELLING_ARC: f32 = 0.25;
174
175/// The ring itself, at one phase of its travel.
176///
177/// Built per frame rather than once, because the arc's position is what
178/// carries "still going" and this renderer's transforms do not reach a canvas.
179#[allow(clippy::too_many_arguments)]
180fn ring_canvas(
181    diameter: f32,
182    radius: f32,
183    stroke: f32,
184    track: Hsla,
185    accent: Hsla,
186    muted: Hsla,
187    drawn: Option<f32>,
188    // Where the travelling arc has got to, or `None` for the still ring that
189    // reduced motion falls back to.
190    phase: Option<f32>,
191) -> impl IntoElement {
192    canvas(
193        |_, _, _| {},
194        move |bounds, _, window, _| {
195            let centre = bounds.center();
196            arc(window, centre, radius, stroke, 0.0, 1.0, track);
197            match (drawn, phase) {
198                (Some(fraction), _) if fraction > 0.0 => {
199                    arc(window, centre, radius, stroke, 0.0, fraction, accent)
200                }
201                (Some(_), _) => {}
202                (None, Some(phase)) => arc(
203                    window,
204                    centre,
205                    radius,
206                    stroke,
207                    phase,
208                    phase + TRAVELLING_ARC,
209                    accent,
210                ),
211                (None, None) => arc(window, centre, radius, stroke, 0.0, 1.0, muted),
212            }
213        },
214    )
215    .size(px(diameter))
216}
217
218/// Strokes the part of a circle between two turns, clockwise from the top.
219///
220/// The arc is sampled rather than swept with an elliptical segment so a
221/// partial ring and a full one are the same shape built the same way.
222fn arc(
223    window: &mut Window,
224    centre: Point<Pixels>,
225    radius: f32,
226    width: f32,
227    from: f32,
228    to: f32,
229    color: Hsla,
230) {
231    if radius <= 0.0 || to <= from {
232        return;
233    }
234    let steps = (((to - from) * 96.0).ceil() as usize).max(2);
235    let at = |turn: f32| {
236        let angle = turn * TAU - FRAC_PI_2;
237        point(
238            centre.x + px(radius * angle.cos()),
239            centre.y + px(radius * angle.sin()),
240        )
241    };
242
243    let mut builder = PathBuilder::stroke(px(width));
244    builder.move_to(at(from));
245    for step in 1..=steps {
246        builder.line_to(at(from + (to - from) * step as f32 / steps as f32));
247    }
248    if let Ok(path) = builder.build() {
249        window.paint_path(path, color);
250    }
251}