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