Skip to main content

liora_components/
progress.rs

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