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