Skip to main content

liora_components/
progress.rs

1//! Progress module.
2//!
3//! This public module implements the Liora linear and circular progress indicators. It keeps the reusable
4//! component logic inside `liora-components` rather than Gallery or Docs so
5//! downstream GPUI applications can compose the same behavior with their own
6//! app state, assets, and release policy.
7//!
8//! ## Usage model
9//!
10//! Components in this module render native GPUI element trees. Stateless builder
11//! values can be constructed inline, while controls with focus, selection,
12//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
13//! the parent view so state survives GPUI render passes.
14//!
15//! ## Design contract
16//!
17//! The implementation should use Liora theme tokens from `liora-core` and
18//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
19//! the component, and avoid app-specific Gallery/Docs resources in this SDK
20//! crate.
21
22use crate::gpui_compat::PixelsExt;
23use crate::gpui_compat::element_id;
24use crate::motion::{MotionDuration, MotionEasing, motion_animation};
25use gpui::{
26    AnimationExt, App, FillOptions, FontWeight, Hsla, IntoElement, ParentElement, PathBuilder,
27    PathStyle, Pixels, Point, RenderOnce, SharedString, Styled, Window, canvas, div,
28    linear_color_stop, linear_gradient, point, prelude::*, px,
29};
30use liora_core::{Config, stable_unique_id};
31use liora_icons::Icon;
32use liora_icons_lucide::IconName;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35/// Options that control progress type behavior.
36pub enum ProgressType {
37    #[default]
38    /// Renders progress as a horizontal line.
39    Line,
40    /// Uses circular geometry.
41    Circle,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45/// Options that control progress status behavior.
46pub enum ProgressStatus {
47    /// Uses success semantic color tokens.
48    Success,
49    /// Uses warning semantic color tokens.
50    Warning,
51    /// Marks progress with exception/error styling.
52    Exception,
53}
54
55/// Fluent native GPUI component for rendering Liora progress.
56pub struct Progress {
57    percentage: f32,
58    type_: ProgressType,
59    stroke_width: Pixels,
60    status: Option<ProgressStatus>,
61    color: Option<Hsla>,
62    gradient: Option<Vec<Hsla>>,
63    complete_color: Option<Hsla>,
64    show_text: bool,
65    text_inside: bool,
66    text_inside_center: bool,
67    animated: bool,
68    circle_size: Pixels,
69    track_color: Option<Hsla>,
70    circle_inner_color: Option<Hsla>,
71    text: Option<SharedString>,
72    text_color: Option<Hsla>,
73    text_size: Option<Pixels>,
74    text_weight: FontWeight,
75}
76
77impl Progress {
78    /// Creates `Progress` initialized from the supplied percentage.
79    pub fn new(percentage: f32) -> Self {
80        Self {
81            percentage: percentage.clamp(0.0, 100.0),
82            type_: ProgressType::Line,
83            stroke_width: px(6.0),
84            status: None,
85            color: None,
86            gradient: None,
87            complete_color: None,
88            show_text: true,
89            text_inside: false,
90            text_inside_center: false,
91            animated: true,
92            circle_size: px(120.0),
93            track_color: None,
94            circle_inner_color: None,
95            text: None,
96            text_color: None,
97            text_size: None,
98            text_weight: FontWeight::BOLD,
99        }
100    }
101
102    /// Selects the type used by the component.
103    pub fn type_(mut self, t: ProgressType) -> Self {
104        self.type_ = t;
105        self
106    }
107
108    /// Sets the line value used by the component.
109    pub fn line(mut self) -> Self {
110        self.type_ = ProgressType::Line;
111        self
112    }
113
114    /// Uses circular shape or clipping.
115    pub fn circle(mut self) -> Self {
116        self.type_ = ProgressType::Circle;
117        self.stroke_width = px(8.0);
118        self
119    }
120
121    /// Sets the stroke width used for rendered chart paths.
122    pub fn stroke_width(mut self, w: impl Into<Pixels>) -> Self {
123        self.stroke_width = w.into();
124        self
125    }
126
127    /// Sets the ring width value used by the component.
128    pub fn ring_width(self, width: impl Into<Pixels>) -> Self {
129        self.stroke_width(width)
130    }
131
132    /// Sets the thick value used by the component.
133    pub fn thick(self) -> Self {
134        self.stroke_width(px(20.0))
135    }
136
137    /// Sets the status value used by the component.
138    pub fn status(mut self, s: ProgressStatus) -> Self {
139        self.status = Some(s);
140        self
141    }
142
143    /// Applies an explicit color instead of the theme-derived default.
144    pub fn color(mut self, c: Hsla) -> Self {
145        self.color = Some(c);
146        self.gradient = None;
147        self.complete_color = None;
148        self
149    }
150
151    /// Applies the primary semantic visual variant.
152    pub fn primary(mut self) -> Self {
153        self.color = None;
154        self.gradient = None;
155        self.complete_color = None;
156        self.status = None;
157        self
158    }
159
160    /// Sets the gradient value used by the component.
161    pub fn gradient(mut self, colors: Vec<Hsla>) -> Self {
162        self.gradient = if colors.is_empty() {
163            None
164        } else {
165            Some(colors)
166        };
167        self.color = None;
168        self
169    }
170
171    /// Sets the complete color used by the rendered component.
172    pub fn complete_color(mut self, color: Hsla) -> Self {
173        self.complete_color = Some(color);
174        self
175    }
176
177    /// Configures whether text is visible in the rendered component.
178    pub fn show_text(mut self, show: bool) -> Self {
179        self.show_text = show;
180        self
181    }
182
183    /// Sets the text inside value used by the component.
184    pub fn text_inside(mut self, inside: bool) -> Self {
185        self.text_inside = inside;
186        self
187    }
188
189    /// Sets the text inside center value used by the component.
190    pub fn text_inside_center(mut self, center: bool) -> Self {
191        self.text_inside_center = center;
192        self
193    }
194
195    /// Sets the text inside centered value used by the component.
196    pub fn text_inside_centered(mut self) -> Self {
197        self.text_inside = true;
198        self.text_inside_center = true;
199        self
200    }
201
202    /// Enables animation for progress or motion visuals.
203    pub fn animated(mut self, animated: bool) -> Self {
204        self.animated = animated;
205        self
206    }
207
208    /// Sets the circle size value used by the component.
209    pub fn circle_size(mut self, size: impl Into<Pixels>) -> Self {
210        self.circle_size = size.into();
211        self
212    }
213
214    /// Sets the track color used by the rendered component.
215    pub fn track_color(mut self, color: Hsla) -> Self {
216        self.track_color = Some(color);
217        self
218    }
219
220    /// Sets the ring color used by the rendered component.
221    pub fn ring_color(self, color: Hsla) -> Self {
222        self.track_color(color)
223    }
224
225    /// Sets the progress color used by the rendered component.
226    pub fn progress_color(self, color: Hsla) -> Self {
227        self.color(color)
228    }
229
230    /// Sets the circle inner color used by the rendered component.
231    pub fn circle_inner_color(mut self, color: Hsla) -> Self {
232        self.circle_inner_color = Some(color);
233        self
234    }
235
236    /// Sets the inner color used by the rendered component.
237    pub fn inner_color(self, color: Hsla) -> Self {
238        self.circle_inner_color(color)
239    }
240
241    /// Applies the text-only visual variant.
242    pub fn text(mut self, text: impl Into<SharedString>) -> Self {
243        self.text = Some(text.into());
244        self
245    }
246
247    /// Sets the center text value used by the component.
248    pub fn center_text(self, text: impl Into<SharedString>) -> Self {
249        self.text(text)
250    }
251
252    /// Applies the foreground text color.
253    pub fn text_color(mut self, color: Hsla) -> Self {
254        self.text_color = Some(color);
255        self
256    }
257
258    /// Sets the font size used for text content.
259    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
260        self.text_size = Some(size.into());
261        self
262    }
263
264    /// Sets the text weight value used by the component.
265    pub fn text_weight(mut self, weight: FontWeight) -> Self {
266        self.text_weight = weight;
267        self
268    }
269}
270
271fn render_gradient_segments(
272    mut bar: gpui::Div,
273    colors: Vec<Hsla>,
274    complete_color: Option<Hsla>,
275    progress: f32,
276) -> gpui::Div {
277    let mut colors = colors;
278    if progress >= 0.999 {
279        if let Some(color) = complete_color.or_else(|| colors.last().copied()) {
280            if let Some(last) = colors.last_mut() {
281                *last = color;
282            } else {
283                colors.push(color);
284            }
285        }
286    }
287
288    if colors.len() == 1 {
289        return bar.bg(colors[0]);
290    }
291
292    bar = bar.flex().flex_row();
293    for pair in colors.windows(2) {
294        bar = bar.child(div().h_full().flex_1().bg(linear_gradient(
295            90.0,
296            linear_color_stop(pair[0], 0.0),
297            linear_color_stop(pair[1], 1.0),
298        )));
299    }
300    bar
301}
302
303impl RenderOnce for Progress {
304    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
305        let theme = cx.global::<Config>().theme.clone();
306
307        let status_color = match self.status {
308            Some(ProgressStatus::Success) => theme.success.base,
309            Some(ProgressStatus::Warning) => theme.warning.base,
310            Some(ProgressStatus::Exception) => theme.danger.base,
311            None => self.color.unwrap_or(theme.primary.base),
312        };
313        let gradient = if self.status.is_none() {
314            self.gradient.clone()
315        } else {
316            None
317        };
318        let percent_text = self
319            .text
320            .clone()
321            .unwrap_or_else(|| format!("{}%", self.percentage.round() as i32).into());
322        let id = stable_unique_id(
323            format!(
324                "liora-progress:{:?}:{:.3}:{:.3}:{:.3}:{:?}:{:?}:{:?}:{}:{}:{}:{}:{:?}:{:?}:{:?}:{:?}:{:?}:{:?}:{:?}",
325                self.type_,
326                self.percentage,
327                self.stroke_width.as_f32(),
328                self.circle_size.as_f32(),
329                self.status,
330                self.color,
331                self.gradient,
332                self.show_text,
333                self.text_inside,
334                self.text_inside_center,
335                self.animated,
336                self.text,
337                self.text_size,
338                self.text_color,
339                self.text_weight,
340                self.track_color,
341                self.circle_inner_color,
342                self.complete_color,
343            ),
344            "liora-progress",
345            window,
346            cx,
347        );
348
349        if self.type_ == ProgressType::Line {
350            let target = self.percentage / 100.0;
351            let inside_center = self.show_text && self.text_inside && self.text_inside_center;
352            let center_text_color = if self.percentage >= 50.0 {
353                theme.neutral.inverted
354            } else {
355                theme.neutral.text_2
356            };
357            let mut bar = div()
358                .h_full()
359                .rounded_full()
360                .overflow_hidden()
361                .when(gradient.is_none(), |s| s.bg(status_color))
362                .when_some(gradient, |s, colors| {
363                    render_gradient_segments(s, colors, self.complete_color, target)
364                })
365                .when(
366                    self.show_text
367                        && self.text_inside
368                        && !self.text_inside_center
369                        && self.percentage > 0.0,
370                    |s| {
371                        s.min_w(px(36.0))
372                            .flex()
373                            .items_center()
374                            .justify_end()
375                            .px_2()
376                            .child(
377                                div()
378                                    .text_xs()
379                                    .text_color(theme.neutral.inverted)
380                                    .whitespace_nowrap()
381                                    .child(percent_text.clone()),
382                            )
383                    },
384                );
385
386            if !self.animated {
387                bar = bar.w(gpui::relative(target));
388            }
389
390            let bar = if self.animated {
391                bar.with_animation(
392                    element_id(format!("{}-line-fill", id)),
393                    motion_animation(MotionDuration::Normal, MotionEasing::EaseOut),
394                    move |bar, delta| bar.w(gpui::relative(target * delta.clamp(0.0, 1.0))),
395                )
396                .into_any_element()
397            } else {
398                bar.into_any_element()
399            };
400
401            let track = div()
402                .relative()
403                .flex_1()
404                .h(self.stroke_width)
405                .bg(self.track_color.unwrap_or(theme.neutral.hover))
406                .rounded_full()
407                .overflow_hidden()
408                .child(bar)
409                .when(inside_center, |s| {
410                    s.child(
411                        div()
412                            .absolute()
413                            .top_0()
414                            .left_0()
415                            .size_full()
416                            .flex()
417                            .items_center()
418                            .justify_center()
419                            .text_xs()
420                            .text_color(center_text_color)
421                            .whitespace_nowrap()
422                            .child(percent_text.clone()),
423                    )
424                });
425
426            div()
427                .flex()
428                .flex_row()
429                .items_center()
430                .gap_2()
431                .w_full()
432                .child(track)
433                .when(self.show_text && !self.text_inside, |s| {
434                    s.child(
435                        div()
436                            .flex()
437                            .items_center()
438                            .justify_start()
439                            .w(px(40.0))
440                            .child(match self.status {
441                                Some(ProgressStatus::Success) => Icon::new(IconName::CircleCheck)
442                                    .size(px(16.0))
443                                    .color(theme.success.base)
444                                    .into_any_element(),
445                                Some(ProgressStatus::Exception) => Icon::new(IconName::CircleX)
446                                    .size(px(16.0))
447                                    .color(theme.danger.base)
448                                    .into_any_element(),
449                                _ => div()
450                                    .text_xs()
451                                    .text_color(theme.neutral.text_2)
452                                    .child(percent_text)
453                                    .into_any_element(),
454                            }),
455                    )
456                })
457                .into_any_element()
458        } else {
459            let target = self.percentage / 100.0;
460            let track_color = self.track_color.unwrap_or(theme.neutral.hover);
461            let inner_color = self.circle_inner_color.unwrap_or(theme.neutral.card);
462            let text_color = self.text_color.unwrap_or(theme.neutral.text_1);
463            let text_size = self.text_size.unwrap_or(px(theme.font_size.xl));
464            let show_text = self.show_text;
465            let text_weight = self.text_weight;
466
467            let base = div()
468                .relative()
469                .flex_none()
470                .w(self.circle_size)
471                .h(self.circle_size);
472
473            if self.animated {
474                let circle_size = self.circle_size;
475                let stroke_width = self.stroke_width;
476                let progress_color = resolved_progress_color(
477                    status_color,
478                    gradient.as_deref(),
479                    self.complete_color,
480                    target,
481                );
482                let gradient = gradient.clone();
483                let complete_color = self.complete_color;
484                let center_text = percent_text.clone();
485                base.with_animation(
486                    element_id(format!("{}-circle-fill", id)),
487                    motion_animation(MotionDuration::Normal, MotionEasing::EaseOut),
488                    move |base, delta| {
489                        let progress = target * delta.clamp(0.0, 1.0);
490                        let base = base.child(render_circle_canvas(
491                            progress,
492                            circle_size,
493                            stroke_width,
494                            track_color,
495                            progress_color,
496                            gradient.clone(),
497                            complete_color,
498                            inner_color,
499                        ));
500                        if show_text {
501                            base.child(render_circle_center_text(
502                                center_text.clone(),
503                                text_color,
504                                text_size,
505                                text_weight,
506                            ))
507                        } else {
508                            base
509                        }
510                    },
511                )
512                .into_any_element()
513            } else {
514                let mut base = base.child(render_circle_canvas(
515                    target,
516                    self.circle_size,
517                    self.stroke_width,
518                    track_color,
519                    resolved_progress_color(
520                        status_color,
521                        gradient.as_deref(),
522                        self.complete_color,
523                        target,
524                    ),
525                    gradient,
526                    self.complete_color,
527                    inner_color,
528                ));
529                if show_text {
530                    base = base.child(render_circle_center_text(
531                        percent_text,
532                        text_color,
533                        text_size,
534                        text_weight,
535                    ));
536                }
537                base.into_any_element()
538            }
539        }
540    }
541}
542
543fn render_circle_center_text(
544    text: SharedString,
545    text_color: Hsla,
546    text_size: Pixels,
547    text_weight: FontWeight,
548) -> impl IntoElement {
549    div()
550        .absolute()
551        .top_0()
552        .left_0()
553        .size_full()
554        .flex()
555        .items_center()
556        .justify_center()
557        .text_color(text_color)
558        .text_size(text_size)
559        .font_weight(text_weight)
560        .whitespace_nowrap()
561        .child(text)
562}
563
564fn render_circle_canvas(
565    progress: f32,
566    size: Pixels,
567    stroke_width: Pixels,
568    track_color: Hsla,
569    progress_color: Hsla,
570    gradient: Option<Vec<Hsla>>,
571    complete_color: Option<Hsla>,
572    inner_color: Hsla,
573) -> impl IntoElement {
574    canvas(
575        |_, _, _| (),
576        move |bounds, _, window, _| {
577            let width = bounds.right() - bounds.left();
578            let height = bounds.bottom() - bounds.top();
579            let outer_radius = (width.min(height).as_f32() / 2.0).max(1.0);
580            let ring_width = stroke_width.as_f32().clamp(1.0, outer_radius);
581            let inner_radius = (outer_radius - ring_width).max(0.0);
582            let center = point(bounds.left() + width / 2.0, bounds.top() + height / 2.0);
583
584            // Fill annular geometry instead of stroking an arc.  GPUI path strokes
585            // can expose hard pixel stair-steps on circular caps; polygonal ring
586            // fills with sub-pixel feather layers produce noticeably smoother edges
587            // while staying fully native to the GPUI renderer.
588            paint_smooth_annular_sector(
589                window,
590                center,
591                outer_radius,
592                inner_radius,
593                0.0,
594                1.0,
595                track_color,
596            );
597            if let Some(colors) = gradient.as_deref() {
598                paint_gradient_annular_sector(
599                    window,
600                    center,
601                    outer_radius,
602                    inner_radius,
603                    progress,
604                    colors,
605                    complete_color,
606                );
607            } else {
608                paint_smooth_annular_sector(
609                    window,
610                    center,
611                    outer_radius,
612                    inner_radius,
613                    0.0,
614                    progress,
615                    progress_color,
616                );
617            }
618
619            if inner_radius > 0.0 {
620                paint_smooth_circle(window, center, inner_radius, inner_color);
621            }
622        },
623    )
624    .absolute()
625    .top_0()
626    .left_0()
627    .w(size)
628    .h(size)
629}
630
631fn resolved_progress_color(
632    fallback: Hsla,
633    gradient: Option<&[Hsla]>,
634    complete_color: Option<Hsla>,
635    target: f32,
636) -> Hsla {
637    if target >= 0.999 {
638        complete_color
639            .or_else(|| gradient.and_then(|colors| colors.last().copied()))
640            .unwrap_or(fallback)
641    } else {
642        gradient
643            .and_then(|colors| colors.first().copied())
644            .unwrap_or(fallback)
645    }
646}
647
648fn paint_gradient_annular_sector(
649    window: &mut Window,
650    center: Point<Pixels>,
651    outer_radius: f32,
652    inner_radius: f32,
653    progress: f32,
654    colors: &[Hsla],
655    complete_color: Option<Hsla>,
656) {
657    let progress = progress.clamp(0.0, 1.0);
658    if progress <= f32::EPSILON || colors.is_empty() {
659        return;
660    }
661    if colors.len() == 1 {
662        let color = if progress >= 0.999 {
663            complete_color.unwrap_or(colors[0])
664        } else {
665            colors[0]
666        };
667        paint_smooth_annular_sector(
668            window,
669            center,
670            outer_radius,
671            inner_radius,
672            0.0,
673            progress,
674            color,
675        );
676        return;
677    }
678    let segment_count = colors.len().saturating_sub(1).max(1);
679    for index in 0..segment_count {
680        let start = index as f32 / segment_count as f32;
681        let end = (index + 1) as f32 / segment_count as f32;
682        if start >= progress {
683            break;
684        }
685        let segment_end = end.min(progress);
686        let color = if progress >= 0.999 && index + 1 == segment_count {
687            complete_color.unwrap_or(colors[index + 1])
688        } else {
689            colors[index].blend(colors[index + 1].opacity(0.62))
690        };
691        paint_smooth_annular_sector(
692            window,
693            center,
694            outer_radius,
695            inner_radius,
696            start,
697            segment_end,
698            color,
699        );
700    }
701}
702fn paint_smooth_annular_sector(
703    window: &mut Window,
704    center: Point<Pixels>,
705    outer_radius: f32,
706    inner_radius: f32,
707    start_progress: f32,
708    end_progress: f32,
709    color: Hsla,
710) {
711    let start = start_progress.clamp(0.0, 1.0);
712    let end = end_progress.clamp(0.0, 1.0);
713    if end <= start || outer_radius <= 0.0 || outer_radius <= inner_radius {
714        return;
715    }
716
717    // Use lyon's native arc commands with a tighter tessellation tolerance instead
718    // of a hand-sampled polygon. This keeps the ring boundary curved at the GPU
719    // geometry level and avoids visible segment stair-steps on the circular edge.
720    if let Some(path) = annular_sector_arc_path(center, outer_radius, inner_radius, start, end) {
721        window.paint_path(path, color);
722    }
723
724    // A very thin translucent fringe blends the final raster edge into the
725    // surrounding pixels. It is intentionally tiny so it smooths without making
726    // the ring look blurry or changing the requested ring width.
727    let feather = 0.45;
728    if let Some(path) = annular_sector_arc_path(
729        center,
730        outer_radius + feather,
731        outer_radius.max(inner_radius + 0.1),
732        start,
733        end,
734    ) {
735        window.paint_path(path, color.opacity(0.16));
736    }
737    if inner_radius > feather {
738        if let Some(path) = annular_sector_arc_path(
739            center,
740            inner_radius,
741            (inner_radius - feather).max(0.0),
742            start,
743            end,
744        ) {
745            window.paint_path(path, color.opacity(0.10));
746        }
747    }
748}
749
750fn paint_smooth_circle(window: &mut Window, center: Point<Pixels>, radius: f32, color: Hsla) {
751    if let Some(path) = circle_fill_path(center, radius) {
752        window.paint_path(path, color);
753    }
754    if let Some(path) = annular_sector_arc_path(center, radius + 0.45, radius, 0.0, 1.0) {
755        window.paint_path(path, color.opacity(0.24));
756    }
757}
758
759fn annular_sector_arc_path(
760    center: Point<Pixels>,
761    outer_radius: f32,
762    inner_radius: f32,
763    start_progress: f32,
764    end_progress: f32,
765) -> Option<gpui::Path<Pixels>> {
766    if !outer_radius.is_finite()
767        || !inner_radius.is_finite()
768        || outer_radius <= 0.0
769        || inner_radius < 0.0
770        || outer_radius <= inner_radius
771    {
772        return None;
773    }
774
775    let start_deg = -90.0 + start_progress.clamp(0.0, 1.0) * 360.0;
776    let end_deg = -90.0 + end_progress.clamp(0.0, 1.0) * 360.0;
777    let sweep_deg = (end_deg - start_deg).clamp(0.0, 360.0);
778    if sweep_deg <= f32::EPSILON {
779        return None;
780    }
781
782    if sweep_deg >= 359.999 {
783        return ring_fill_path(center, outer_radius, inner_radius);
784    }
785
786    let outer_start = polar_degrees(center, outer_radius, start_deg);
787    let outer_end = polar_degrees(center, outer_radius, end_deg);
788    let inner_start = polar_degrees(center, inner_radius, start_deg);
789    let inner_end = polar_degrees(center, inner_radius, end_deg);
790    let large_arc = sweep_deg > 180.0;
791    let mut builder = high_quality_fill_builder();
792    builder.move_to(outer_start);
793    builder.arc_to(
794        point(px(outer_radius), px(outer_radius)),
795        px(0.0),
796        large_arc,
797        true,
798        outer_end,
799    );
800    builder.line_to(inner_end);
801    builder.arc_to(
802        point(px(inner_radius), px(inner_radius)),
803        px(0.0),
804        large_arc,
805        false,
806        inner_start,
807    );
808    builder.close();
809    builder.build().ok()
810}
811
812fn ring_fill_path(
813    center: Point<Pixels>,
814    outer_radius: f32,
815    inner_radius: f32,
816) -> Option<gpui::Path<Pixels>> {
817    if outer_radius <= 0.0 || inner_radius < 0.0 || outer_radius <= inner_radius {
818        return None;
819    }
820
821    let outer_top = polar_degrees(center, outer_radius, -90.0);
822    let outer_bottom = polar_degrees(center, outer_radius, 90.0);
823    let inner_top = polar_degrees(center, inner_radius, -90.0);
824    let inner_bottom = polar_degrees(center, inner_radius, 90.0);
825
826    let mut builder = high_quality_fill_builder();
827    builder.move_to(outer_top);
828    builder.arc_to(
829        point(px(outer_radius), px(outer_radius)),
830        px(0.0),
831        false,
832        true,
833        outer_bottom,
834    );
835    builder.arc_to(
836        point(px(outer_radius), px(outer_radius)),
837        px(0.0),
838        false,
839        true,
840        outer_top,
841    );
842    builder.line_to(inner_top);
843    builder.arc_to(
844        point(px(inner_radius), px(inner_radius)),
845        px(0.0),
846        false,
847        false,
848        inner_bottom,
849    );
850    builder.arc_to(
851        point(px(inner_radius), px(inner_radius)),
852        px(0.0),
853        false,
854        false,
855        inner_top,
856    );
857    builder.close();
858    builder.build().ok()
859}
860
861fn circle_fill_path(center: Point<Pixels>, radius: f32) -> Option<gpui::Path<Pixels>> {
862    if radius <= 0.0 || !radius.is_finite() {
863        return None;
864    }
865
866    let top = polar_degrees(center, radius, -90.0);
867    let bottom = polar_degrees(center, radius, 90.0);
868    let mut builder = high_quality_fill_builder();
869    builder.move_to(top);
870    builder.arc_to(point(px(radius), px(radius)), px(0.0), false, true, bottom);
871    builder.arc_to(point(px(radius), px(radius)), px(0.0), false, true, top);
872    builder.close();
873    builder.build().ok()
874}
875
876fn high_quality_fill_builder() -> PathBuilder {
877    PathBuilder::fill().with_style(PathStyle::Fill(FillOptions::default().with_tolerance(0.01)))
878}
879
880fn polar_degrees(center: Point<Pixels>, radius: f32, degrees: f32) -> Point<Pixels> {
881    let radians = degrees.to_radians();
882    point(
883        center.x + px(radius * radians.cos()),
884        center.y + px(radius * radians.sin()),
885    )
886}
887
888impl IntoElement for Progress {
889    type Element = gpui::Component<Self>;
890    fn into_element(self) -> Self::Element {
891        gpui::Component::new(self)
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn progress_thick_sets_stroke_width() {
901        assert_eq!(Progress::new(42.0).thick().stroke_width, px(20.0));
902    }
903
904    #[test]
905    fn progress_circle_builder_tracks_shape_size_and_ring_styles() {
906        let progress = Progress::new(42.0)
907            .circle()
908            .circle_size(px(144.0))
909            .ring_width(px(12.0))
910            .ring_color(gpui::black())
911            .progress_color(gpui::white())
912            .inner_color(gpui::white().opacity(0.5))
913            .gradient(vec![gpui::blue(), gpui::green()])
914            .complete_color(gpui::green());
915        assert_eq!(progress.type_, ProgressType::Circle);
916        assert_eq!(progress.circle_size, px(144.0));
917        assert_eq!(progress.stroke_width, px(12.0));
918        assert_eq!(progress.track_color, Some(gpui::black()));
919        assert_eq!(progress.gradient, Some(vec![gpui::blue(), gpui::green()]));
920        assert_eq!(progress.complete_color, Some(gpui::green()));
921        assert_eq!(
922            progress.circle_inner_color,
923            Some(gpui::white().opacity(0.5))
924        );
925    }
926
927    #[test]
928    fn progress_animation_defaults_on_and_can_disable() {
929        assert!(Progress::new(42.0).animated);
930        assert!(!Progress::new(42.0).animated(false).animated);
931    }
932
933    #[test]
934    fn progress_clamps_percentage_to_valid_range() {
935        assert_eq!(Progress::new(-12.0).percentage, 0.0);
936        assert_eq!(Progress::new(128.0).percentage, 100.0);
937    }
938
939    #[test]
940    fn progress_gradient_complete_color_resolution_matches_completion_state() {
941        let fallback = gpui::black();
942        let colors = [gpui::blue(), gpui::green()];
943        assert_eq!(
944            resolved_progress_color(fallback, Some(&colors), Some(gpui::red()), 1.0),
945            gpui::red()
946        );
947        assert_eq!(
948            resolved_progress_color(fallback, Some(&colors), None, 1.0),
949            gpui::green()
950        );
951        assert_eq!(
952            resolved_progress_color(fallback, Some(&colors), Some(gpui::red()), 0.5),
953            gpui::blue()
954        );
955    }
956
957    #[test]
958    fn progress_custom_text_tracks_style() {
959        let progress = Progress::new(86.0)
960            .circle()
961            .center_text("Deploy")
962            .text_color(gpui::white())
963            .text_size(px(22.0))
964            .text_weight(FontWeight::NORMAL);
965        assert_eq!(
966            progress.text.as_ref().map(|text| text.as_ref()),
967            Some("Deploy")
968        );
969        assert_eq!(progress.text_size, Some(px(22.0)));
970        assert_eq!(progress.text_weight, FontWeight::NORMAL);
971    }
972
973    #[test]
974    fn progress_uses_native_paths_and_animation() {
975        let source = include_str!("progress.rs");
976        assert!(source.contains("PathBuilder::fill"));
977        assert!(source.contains("arc_to("));
978        assert!(source.contains("high_quality_fill_builder"));
979        assert!(source.contains("paint_smooth_annular_sector"));
980        assert!(source.contains("with_animation("));
981        assert!(source.contains("render_circle_canvas"));
982        assert!(source.contains("paint_gradient_annular_sector"));
983        assert!(source.contains("complete_color"));
984        assert!(
985            source.contains("render_gradient_segments(s, colors, self.complete_color, target)")
986        );
987    }
988}