1use gpui::{
4 prelude::*, px, AnimationExt, App, IntoElement, Pixels, RenderOnce, SharedString, Styled,
5 Window,
6};
7use herogpui_core::{element_id, Color, Size};
8use herogpui_theme::ActiveTheme;
9
10use crate::a11y::A11y as _;
11
12#[derive(Clone)]
13struct ProgressBarMotion {
14 target: f32,
15 generation: usize,
16 from: f32,
17 width: std::rc::Rc<std::cell::Cell<f32>>,
18}
19
20impl ProgressBarMotion {
21 fn retarget(&mut self, target: f32, animate: bool) -> bool {
22 let mut changed = false;
23 if (self.target - target).abs() > f32::EPSILON {
24 self.target = target;
25 self.generation = self.generation.wrapping_add(1);
26 self.from = self.width.get();
27 changed = true;
28 }
29 if !animate && (self.width.get() - target).abs() > f32::EPSILON {
30 self.from = target;
31 self.width.set(target);
32 changed = true;
33 }
34 changed
35 }
36}
37
38struct ProgressBarMotionFrame {
39 id: gpui::ElementId,
41 generation: usize,
42 from: f32,
43 to: f32,
44 width: std::rc::Rc<std::cell::Cell<f32>>,
45 animate: bool,
46}
47
48impl ProgressBarMotionFrame {
49 fn render(self, fill: gpui::Div) -> gpui::AnyElement {
50 if !self.animate {
51 self.width.set(self.to);
52 return fill.w(gpui::relative(self.to)).into_any_element();
53 }
54
55 let width = self.width;
56 let from = self.from;
57 let to = self.to;
58 fill.with_animation(
59 element_id::indexed(&self.id, "fill-width", self.generation),
60 gpui::Animation::new(std::time::Duration::from_millis(
61 crate::anim::PROGRESS_BAR_FILL_MS,
62 ))
63 .with_easing(|t| crate::anim::Curve::Out.at(t)),
64 move |fill, delta| {
65 let next = from + (to - from) * delta;
66 width.set(next);
67 fill.w(gpui::relative(next))
68 },
69 )
70 .into_any_element()
71 }
72}
73
74fn progress_bar_motion(
75 id: &gpui::ElementId,
76 target: f32,
77 animate: bool,
78 window: &mut Window,
79 cx: &mut App,
80) -> ProgressBarMotionFrame {
81 let state = window.use_keyed_state(element_id::scoped(id, "fill-motion"), cx, |_, _| {
82 ProgressBarMotion {
83 target,
84 generation: 0,
85 from: target,
86 width: std::rc::Rc::new(std::cell::Cell::new(target)),
87 }
88 });
89 let mut current = state.read(cx).clone();
90 if current.retarget(target, animate) {
91 state.update(cx, |stored, _| *stored = current.clone());
92 }
93 let should_animate =
94 animate && current.generation != 0 && (current.width.get() - target).abs() > f32::EPSILON;
95 ProgressBarMotionFrame {
96 id: id.clone(),
97 generation: current.generation,
98 from: current.from,
99 to: target,
100 width: current.width,
101 animate: should_animate,
102 }
103}
104
105#[derive(IntoElement)]
107pub struct ProgressBar {
108 id: gpui::ElementId,
109 value: f32,
110 min_value: f32,
111 max_value: f32,
112 size: Size,
113 color: Color,
114 is_indeterminate: bool,
115 label: Option<String>,
116 show_value: bool,
117 value_label: Option<SharedString>,
118 value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
121 format: Option<herogpui_core::NumberFormat>,
123 sx: Option<Box<gpui::StyleRefinement>>,
125 radius: Option<Pixels>,
128 a11y_role: crate::a11y::Role,
132}
133
134impl ProgressBar {
135 pub(crate) fn as_meter(mut self) -> Self {
142 self.a11y_role = crate::a11y::Role::Meter;
143 self
144 }
145
146 pub fn new(id: impl Into<gpui::ElementId>) -> Self {
147 Self {
148 id: id.into(),
149 value: 0.0,
150 min_value: 0.0,
151 max_value: 100.0,
152 size: Size::Md,
153 color: Color::Accent,
154 is_indeterminate: false,
155 label: None,
156 show_value: false,
157 value_label: None,
158 value_content: None,
159 format: None,
160 sx: None,
161 radius: None,
162 a11y_role: crate::a11y::Role::ProgressIndicator,
163 }
164 }
165
166 pub(crate) fn sx_refinement(mut self, sx: Box<gpui::StyleRefinement>) -> Self {
170 self.sx = Some(sx);
171 self
172 }
173
174 pub fn value(mut self, v: f32) -> Self {
175 self.value = v;
176 self
177 }
178
179 pub fn min_value(mut self, v: f32) -> Self {
180 self.min_value = v;
181 self
182 }
183
184 pub fn max_value(mut self, v: f32) -> Self {
185 self.max_value = v;
186 self
187 }
188
189 pub fn is_indeterminate(mut self, v: bool) -> Self {
192 self.is_indeterminate = v;
193 self
194 }
195
196 pub fn value_content(
199 mut self,
200 render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
201 ) -> Self {
202 self.value_content = Some(std::sync::Arc::new(render));
203 self
204 }
205
206 pub fn value_label(mut self, text: impl Into<SharedString>) -> Self {
208 self.value_label = Some(text.into());
209 self
210 }
211
212 pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
214 self.format = Some(format);
215 self
216 }
217
218 pub fn size(mut self, s: Size) -> Self {
219 self.size = s;
220 self
221 }
222
223 pub fn color(mut self, c: Color) -> Self {
224 self.color = c;
225 self
226 }
227
228 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
233 self.radius = Some(radius.into());
234 self
235 }
236
237 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
242 self.sx = Some(crate::util::capture_sx(style));
243 self
244 }
245
246 pub fn label(mut self, l: impl Into<String>) -> Self {
248 self.label = Some(l.into());
249 self
250 }
251
252 pub fn show_value_label(mut self, v: bool) -> Self {
253 self.show_value = v;
254 self
255 }
256}
257
258impl RenderOnce for ProgressBar {
259 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
260 let colors = cx.colors();
261 let progress_fill_color = if self.color == Color::Default {
262 colors.default.foreground
263 } else {
264 cx.role(self.color).color
265 };
266 let progress_track_color = colors.default.color;
267 let text_color = colors.foreground;
268 let (h, radius) = match self.size {
269 Size::Sm => (px(4.), crate::util::micro_radius(cx)),
270 Size::Md => (px(8.), crate::util::hairline_radius(cx)),
271 Size::Lg => (px(12.), crate::util::mark_radius(cx)),
272 };
273 let radius = self.radius.unwrap_or(radius);
276 let value = if self.min_value <= self.max_value {
281 self.value.clamp(self.min_value, self.max_value)
282 } else {
283 self.value
284 };
285 let fraction = fraction_of(self.value, self.min_value, self.max_value);
286 let announced_value_text: Option<SharedString> = (!self.is_indeterminate).then(|| {
290 self.value_label.clone().unwrap_or_else(|| {
291 let format = self
292 .format
293 .clone()
294 .unwrap_or_else(herogpui_core::NumberFormat::percent);
295 let n = if format.style == herogpui_core::NumberStyle::Percent {
296 fraction as f64
297 } else {
298 value as f64
299 };
300 SharedString::from(format.format(n))
301 })
302 });
303 let fill_motion = progress_bar_motion(
304 &self.id,
305 if self.is_indeterminate { 0.4 } else { fraction },
306 !self.is_indeterminate && !ActiveTheme::reduce_motion(cx),
307 window,
308 cx,
309 );
310
311 let range = if self.is_indeterminate {
314 crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
315 } else {
316 crate::a11y::Range::new(
317 self.min_value as f64,
318 self.max_value as f64,
319 self.value as f64,
320 )
321 .text(announced_value_text.clone())
322 };
323 let mut el = gpui::div()
327 .flex()
328 .flex_col()
329 .gap(px(4.))
330 .w_full()
331 .id(self.id.clone())
332 .a11y_named(
333 self.a11y_role,
334 &crate::a11y::Name::maybe(self.label.clone().map(SharedString::from)),
335 )
336 .a11y_range(&range);
337
338 if self.label.is_some() || self.show_value {
341 let value_text = announced_value_text.unwrap_or_default();
344 let percentage = if self.is_indeterminate {
345 0.0
346 } else {
347 fraction * 100.
348 };
349 el = el.child(
350 gpui::div()
351 .flex()
352 .justify_between()
353 .text_size(px(14.))
354 .line_height(px(20.))
355 .font_weight(gpui::FontWeight::MEDIUM)
356 .text_color(text_color)
357 .child(self.label.clone().unwrap_or_default())
358 .when(self.show_value, |l| {
359 let output = match &self.value_content {
364 Some(render) => render(percentage, &value_text, self.is_indeterminate),
365 None => value_text.to_string().into_any_element(),
366 };
367 l.child(
368 gpui::div()
369 .font_features(crate::util::tabular_font_features())
370 .child(output),
371 )
372 }),
373 );
374 }
375
376 let track = gpui::div()
379 .w_full()
380 .h(h)
381 .overflow_hidden()
382 .rounded(radius)
383 .bg(progress_track_color);
384
385 let track = if self.is_indeterminate && !ActiveTheme::reduce_motion(cx) {
388 let indeterminate_id = element_id::scoped(&self.id, "indeterminate");
389 track
390 .child(
391 gpui::div()
392 .relative()
393 .h_full()
394 .w(gpui::relative(0.4))
395 .rounded(radius)
396 .bg(progress_fill_color)
397 .with_animation(
398 indeterminate_id,
399 gpui::Animation::new(std::time::Duration::from_millis(
400 crate::anim::PROGRESS_BAR_INDETERMINATE_MS,
401 ))
402 .with_easing(crate::anim::progress_bar_indeterminate_ease())
403 .repeat(),
404 |el, delta| el.left(gpui::relative(delta * 1.8 - 0.4)),
405 ),
406 )
407 .into_any_element()
408 } else if self.is_indeterminate {
409 track
410 .child(
411 gpui::div()
412 .h_full()
413 .rounded(radius)
414 .bg(progress_fill_color)
415 .w(gpui::relative(0.4)),
416 )
417 .into_any_element()
418 } else {
419 let indicator = gpui::div().h_full().rounded(radius).bg(progress_fill_color);
420 track
421 .child(fill_motion.render(indicator))
422 .into_any_element()
423 };
424
425 el = el.child(track);
426 crate::util::apply_sx(el, &self.sx)
427 }
428}
429
430fn fraction_of(value: f32, min: f32, max: f32) -> f32 {
433 let span = max - min;
434 if span.abs() < f32::EPSILON {
435 return 0.0;
436 }
437 ((value - min) / span).clamp(0.0, 1.0)
438}
439
440#[cfg(test)]
441mod tests {
442 use super::ProgressBarMotion;
443 use std::cell::Cell;
444 use std::rc::Rc;
445
446 fn close(left: f32, right: f32) -> bool {
447 (left - right).abs() < f32::EPSILON
448 }
449
450 #[test]
451 fn progress_bar_motion_reverses_from_current_width_and_snaps_without_motion() {
452 let width = Rc::new(Cell::new(0.25));
453 let mut motion = ProgressBarMotion {
454 target: 0.25,
455 generation: 0,
456 from: 0.25,
457 width: width.clone(),
458 };
459
460 assert!(motion.retarget(0.75, true));
461 assert_eq!(motion.generation, 1);
462 assert!(close(motion.from, 0.25));
463 assert!(close(width.get(), 0.25));
464
465 width.set(0.5);
466 assert!(motion.retarget(0.1, true));
467 assert_eq!(motion.generation, 2);
468 assert!(close(motion.from, 0.5));
469 assert!(close(width.get(), 0.5));
470
471 assert!(motion.retarget(0.9, false));
472 assert_eq!(motion.generation, 3);
473 assert!(close(motion.from, 0.9));
474 assert!(close(width.get(), 0.9));
475 }
476
477 #[test]
478 fn indeterminate_circle_rotates_track_and_fill_as_one_surface() {
479 let source = include_str!("progress.rs")
480 .rsplit("/// Circular progress ring")
481 .next()
482 .expect("the ProgressCircle implementation is always present");
483 assert!(source.contains("let progress_visual = gpui::div()"));
484 assert!(source.contains(".child(track).child(arc)"));
485 assert!(source.contains("progress_visual\n .with_animation("));
486 assert!(source.contains("rotation.set(crate::anim::progress_circle_spin_turn(delta))"));
487 }
488
489 #[test]
490 fn determinate_circle_interpolates_the_retained_arc_fraction() {
491 let source = include_str!("progress.rs")
492 .rsplit("/// Circular progress ring")
493 .next()
494 .expect("the ProgressCircle implementation is always present");
495 assert!(source.contains("PROGRESS_CIRCLE_FILL_MS"));
496 assert!(source.contains("\"fill-fraction\""));
497 assert!(source.contains("let paint_fraction = fraction_motion.value()"));
498 assert!(source.contains("let fraction = paint_fraction.get()"));
499 assert!(source.contains("fraction_motion.animates(reduce_motion)"));
500 }
501
502 #[test]
503 fn progress_output_uses_shared_tabular_numeric_features() {
504 let source = include_str!("progress.rs")
505 .split("/// Circular progress ring")
506 .next()
507 .expect("the ProgressBar implementation is always present");
508 assert!(source.contains("tabular_font_features"));
509 assert!(source.contains("font_features(crate::util::tabular_font_features())"));
510 }
511
512 #[test]
513 fn indeterminate_bar_animation_is_instance_scoped() {
514 let source = include_str!("progress.rs");
515 assert!(
516 source.contains("element_id::scoped(&self.id, \"indeterminate\")"),
517 "each indeterminate bar needs an animation id under its own component id"
518 );
519 assert!(
520 !source.contains("\"progress-bar-indeterminate\""),
521 "a shared literal animation id lets sibling bars collide"
522 );
523 }
524}
525
526#[derive(IntoElement)]
528pub struct ProgressCircle {
529 value: f32,
530 min_value: f32,
531 max_value: f32,
532 color: Color,
533 size_px: Pixels,
534 is_indeterminate: bool,
535 show_value: bool,
536 value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
539 format: Option<herogpui_core::NumberFormat>,
541 id: Option<gpui::ElementId>,
542 sx: Option<Box<gpui::StyleRefinement>>,
544}
545
546impl ProgressCircle {
547 pub fn new() -> Self {
548 Self {
549 value: 0.0,
550 min_value: 0.0,
551 max_value: 100.0,
552 color: Color::Accent,
553 size_px: px(28.),
554 is_indeterminate: false,
555 show_value: false,
556 value_content: None,
557 format: None,
558 id: None,
559 sx: None,
560 }
561 }
562
563 pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
567 self.id = Some(id.into());
568 self
569 }
570
571 pub fn value_content(
574 mut self,
575 render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
576 ) -> Self {
577 self.value_content = Some(std::sync::Arc::new(render));
578 self
579 }
580
581 pub fn value(mut self, v: f32) -> Self {
582 self.value = v;
583 self
584 }
585
586 pub fn min_value(mut self, v: f32) -> Self {
587 self.min_value = v;
588 self
589 }
590
591 pub fn max_value(mut self, v: f32) -> Self {
592 self.max_value = v;
593 self
594 }
595
596 pub fn is_indeterminate(mut self, v: bool) -> Self {
598 self.is_indeterminate = v;
599 self
600 }
601
602 pub fn color(mut self, c: Color) -> Self {
603 self.color = c;
604 self
605 }
606
607 pub fn size(mut self, s: Size) -> Self {
611 self.size_px = match s {
612 Size::Sm => px(20.),
613 Size::Md => px(28.),
614 Size::Lg => px(36.),
615 };
616 self
617 }
618
619 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
624 self.sx = Some(crate::util::capture_sx(style));
625 self
626 }
627
628 pub fn show_value_label(mut self, v: bool) -> Self {
629 self.show_value = v;
630 self
631 }
632
633 pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
635 self.format = Some(format);
636 self
637 }
638}
639
640impl Default for ProgressCircle {
641 fn default() -> Self {
642 Self::new()
643 }
644}
645
646impl RenderOnce for ProgressCircle {
647 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
648 let motion_id = self
654 .id
655 .clone()
656 .unwrap_or_else(|| gpui::ElementId::from("progress-circle"));
657 let reduce_motion = ActiveTheme::reduce_motion(cx);
658 let value = if self.min_value <= self.max_value {
662 self.value.clamp(self.min_value, self.max_value)
663 } else {
664 self.value
665 };
666 let fraction = if self.is_indeterminate {
667 0.25
669 } else {
670 fraction_of(self.value, self.min_value, self.max_value)
671 };
672 let mut fraction_motion =
673 crate::anim::Tween::keyed(&motion_id, "fill-fraction", fraction, window, cx);
674 if self.is_indeterminate {
675 fraction_motion.settle();
676 } else {
677 fraction_motion.snap_if_reduced(reduce_motion);
678 }
679 let animate_fraction = !self.is_indeterminate && fraction_motion.animates(reduce_motion);
680 let paint_fraction = fraction_motion.value();
681 let colors = cx.colors();
682 let arc_color = if self.color == Color::Default {
683 colors.default.foreground
684 } else {
685 cx.role(self.color).color
686 };
687 let stroke_w = self.size_px / 9.;
689 let spins = self.is_indeterminate && !reduce_motion;
690 let rotation = std::rc::Rc::new(std::cell::Cell::new(0.0f32));
691 let paint_rotation = rotation.clone();
692
693 let arc = gpui::canvas(
694 move |bounds, _, _| bounds,
695 move |bounds, _, window, _| {
696 let fraction = paint_fraction.get();
697 if fraction <= 0.0 {
698 return;
699 }
700 let mut builder = gpui::PathBuilder::stroke(stroke_w);
701 let center = bounds.center();
702 let radius = (bounds.size.width.min(bounds.size.height) / 2.) - stroke_w / 2.;
703 let start = std::f32::consts::FRAC_PI_2 - paint_rotation.get();
706 let sweep = std::f32::consts::TAU * fraction;
707 let steps = ((sweep / 0.05).ceil() as usize).max(2);
708 for i in 0..=steps {
709 let a = start - sweep * i as f32 / steps as f32;
710 let p = gpui::point(center.x + radius * a.cos(), center.y - radius * a.sin());
711 if i == 0 {
712 builder.move_to(p);
713 } else {
714 builder.line_to(p);
715 }
716 }
717 if let Ok(path) = builder.build() {
718 window.paint_path(path, arc_color);
719 }
720 let cap_radius = stroke_w / 2.;
724 for angle in [start, start - sweep] {
725 let cap_center = gpui::point(
726 center.x + radius * angle.cos(),
727 center.y - radius * angle.sin(),
728 );
729 crate::util::paint_disc(cap_center, cap_radius, arc_color, window);
730 }
731 },
732 )
733 .absolute()
734 .inset_0();
735
736 let spin_id = match &self.id {
737 Some(id) => element_id::scoped(id, "spin"),
738 None => gpui::ElementId::from("progress-circle-spin"),
739 };
740 let track = gpui::div()
741 .absolute()
742 .inset_0()
743 .rounded_full()
744 .border(stroke_w)
745 .border_color(colors.default.color);
746
747 let progress_visual = gpui::div().absolute().inset_0().child(track).child(arc);
752 let progress_visual = if spins {
753 progress_visual
754 .with_animation(
755 spin_id,
756 gpui::Animation::new(std::time::Duration::from_millis(
757 crate::anim::PROGRESS_CIRCLE_SPIN_MS,
758 ))
759 .repeat(),
760 move |visual, delta| {
761 rotation.set(crate::anim::progress_circle_spin_turn(delta));
762 visual
763 },
764 )
765 .into_any_element()
766 } else if animate_fraction {
767 let from = fraction_motion.from();
768 let to = fraction_motion.target();
769 let value = fraction_motion.value();
770 progress_visual
771 .with_animation(
772 element_id::indexed(&motion_id, "fill-fraction", fraction_motion.generation()),
773 gpui::Animation::new(std::time::Duration::from_millis(
774 crate::anim::PROGRESS_CIRCLE_FILL_MS,
775 ))
776 .with_easing(crate::anim::ease_out()),
777 move |visual, delta| {
778 value.set(from + (to - from) * delta);
779 visual
780 },
781 )
782 .into_any_element()
783 } else {
784 fraction_motion.settle();
785 progress_visual.into_any_element()
786 };
787
788 let el = gpui::div()
789 .relative()
790 .flex()
791 .items_center()
792 .justify_center()
793 .size(self.size_px)
794 .child(progress_visual)
798 .when(self.show_value, |el| {
799 let format = self
800 .format
801 .clone()
802 .unwrap_or_else(herogpui_core::NumberFormat::percent);
803 let (percentage, value_text) = if self.is_indeterminate {
808 (0.0, String::new())
809 } else {
810 let n = if format.style == herogpui_core::NumberStyle::Percent {
811 fraction as f64
812 } else {
813 value as f64
814 };
815 (fraction * 100., format.format(n))
816 };
817 match &self.value_content {
818 Some(render) => {
819 el.child(render(percentage, &value_text, self.is_indeterminate))
820 }
821 None => el.child(
822 gpui::div()
823 .text_size(px(12.))
824 .font_weight(gpui::FontWeight::SEMIBOLD)
825 .text_color(colors.foreground)
826 .child(value_text),
827 ),
828 }
829 });
830 let el = crate::util::apply_sx(el, &self.sx);
831 match self.id {
832 Some(id) => {
833 let range = if self.is_indeterminate {
834 crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
835 } else {
836 crate::a11y::Range::new(
837 self.min_value as f64,
838 self.max_value as f64,
839 self.value as f64,
840 )
841 };
842 el.id(id)
843 .a11y(crate::a11y::Role::ProgressIndicator)
844 .a11y_range(&range)
845 .into_any_element()
846 }
847 None => el.into_any_element(),
848 }
849 }
850}