Skip to main content

liora_components/
progress.rs

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