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 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 if let Some(path) = annular_sector_arc_path(center, outer_radius, inner_radius, start, end) {
664 window.paint_path(path, color);
665 }
666
667 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}