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#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
107#[derive(IntoElement)]
108pub struct ProgressBar {
109 id: gpui::ElementId,
110 value: f32,
111 min_value: f32,
112 max_value: f32,
113 size: Size,
114 color: Color,
115 is_indeterminate: bool,
116 label: Option<String>,
117 show_value: bool,
118 value_label: Option<SharedString>,
119 value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
122 format: Option<herogpui_core::NumberFormat>,
124 sx: Option<Box<gpui::StyleRefinement>>,
126 radius: Option<Pixels>,
129 a11y_role: crate::a11y::Role,
133}
134
135impl ProgressBar {
136 pub(crate) fn as_meter(mut self) -> Self {
143 self.a11y_role = crate::a11y::Role::Meter;
144 self
145 }
146
147 pub fn new(id: impl Into<gpui::ElementId>) -> Self {
149 Self {
150 id: id.into(),
151 value: 0.0,
152 min_value: 0.0,
153 max_value: 100.0,
154 size: Size::Md,
155 color: Color::Accent,
156 is_indeterminate: false,
157 label: None,
158 show_value: false,
159 value_label: None,
160 value_content: None,
161 format: None,
162 sx: None,
163 radius: None,
164 a11y_role: crate::a11y::Role::ProgressIndicator,
165 }
166 }
167
168 pub(crate) fn sx_refinement(mut self, sx: Box<gpui::StyleRefinement>) -> Self {
172 self.sx = Some(sx);
173 self
174 }
175
176 pub fn value(mut self, v: f32) -> Self {
178 self.value = v;
179 self
180 }
181
182 pub fn min_value(mut self, v: f32) -> Self {
184 self.min_value = v;
185 self
186 }
187
188 pub fn max_value(mut self, v: f32) -> Self {
190 self.max_value = v;
191 self
192 }
193
194 pub fn is_indeterminate(mut self, v: bool) -> Self {
197 self.is_indeterminate = v;
198 self
199 }
200
201 pub fn value_content(
204 mut self,
205 render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
206 ) -> Self {
207 self.value_content = Some(std::sync::Arc::new(render));
208 self
209 }
210
211 pub fn value_label(mut self, text: impl Into<SharedString>) -> Self {
213 self.value_label = Some(text.into());
214 self
215 }
216
217 pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
219 self.format = Some(format);
220 self
221 }
222
223 pub fn size(mut self, s: Size) -> Self {
225 self.size = s;
226 self
227 }
228
229 pub fn color(mut self, c: Color) -> Self {
231 self.color = c;
232 self
233 }
234
235 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
240 self.radius = Some(radius.into());
241 self
242 }
243
244 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
249 crate::util::refine_sx(&mut self.sx, style);
250 self
251 }
252
253 pub fn label(mut self, l: impl Into<String>) -> Self {
255 self.label = Some(l.into());
256 self
257 }
258
259 pub fn show_value_label(mut self, v: bool) -> Self {
261 self.show_value = v;
262 self
263 }
264}
265
266impl RenderOnce for ProgressBar {
267 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
268 let colors = cx.colors();
269 let progress_fill_color = if self.color == Color::Default {
270 colors.default.foreground
271 } else {
272 cx.role(self.color).color
273 };
274 let progress_track_color = colors.default.color;
275 let text_color = colors.foreground;
276 let (h, radius) = match self.size {
277 Size::Sm => (px(4.), crate::util::micro_radius(cx)),
278 Size::Md => (px(8.), crate::util::hairline_radius(cx)),
279 Size::Lg => (px(12.), crate::util::mark_radius(cx)),
280 };
281 let radius = self.radius.unwrap_or(radius);
284 let value = if self.min_value <= self.max_value {
289 self.value.clamp(self.min_value, self.max_value)
290 } else {
291 self.value
292 };
293 let fraction = fraction_of(self.value, self.min_value, self.max_value);
294 let announced_value_text: Option<SharedString> = (!self.is_indeterminate).then(|| {
298 self.value_label.clone().unwrap_or_else(|| {
299 let format = self
300 .format
301 .clone()
302 .unwrap_or_else(herogpui_core::NumberFormat::percent);
303 let n = if format.style == herogpui_core::NumberStyle::Percent {
304 fraction as f64
305 } else {
306 value as f64
307 };
308 SharedString::from(format.format(n))
309 })
310 });
311 let fill_motion = progress_bar_motion(
312 &self.id,
313 if self.is_indeterminate { 0.4 } else { fraction },
314 !self.is_indeterminate && !ActiveTheme::reduce_motion(cx),
315 window,
316 cx,
317 );
318
319 let range = if self.is_indeterminate {
322 crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
323 } else {
324 crate::a11y::Range::new(
325 self.min_value as f64,
326 self.max_value as f64,
327 self.value as f64,
328 )
329 .text(announced_value_text.clone())
330 };
331 let mut el = gpui::div()
335 .flex()
336 .flex_col()
337 .gap(px(4.))
338 .w_full()
339 .id(self.id.clone())
340 .a11y_named(
341 self.a11y_role,
342 &crate::a11y::Name::maybe(self.label.clone().map(SharedString::from)),
343 )
344 .a11y_range(&range);
345
346 if self.label.is_some() || self.show_value {
349 let value_text = announced_value_text.unwrap_or_default();
352 let percentage = if self.is_indeterminate {
353 0.0
354 } else {
355 fraction * 100.
356 };
357 el = el.child(
358 gpui::div()
359 .flex()
360 .justify_between()
361 .text_size(px(14.))
362 .line_height(px(20.))
363 .font_weight(gpui::FontWeight::MEDIUM)
364 .text_color(text_color)
365 .child(self.label.clone().unwrap_or_default())
366 .when(self.show_value, |l| {
367 let output = match &self.value_content {
372 Some(render) => render(percentage, &value_text, self.is_indeterminate),
373 None => value_text.to_string().into_any_element(),
374 };
375 l.child(
376 gpui::div()
377 .font_features(crate::util::tabular_font_features())
378 .child(output),
379 )
380 }),
381 );
382 }
383
384 let track = gpui::div()
387 .w_full()
388 .h(h)
389 .overflow_hidden()
390 .rounded(radius)
391 .bg(progress_track_color);
392
393 let track = if self.is_indeterminate && !ActiveTheme::reduce_motion(cx) {
396 let indeterminate_id = element_id::scoped(&self.id, "indeterminate");
397 track
398 .child(
399 gpui::div()
400 .relative()
401 .h_full()
402 .w(gpui::relative(0.4))
403 .rounded(radius)
404 .bg(progress_fill_color)
405 .with_animation(
406 indeterminate_id,
407 gpui::Animation::new(std::time::Duration::from_millis(
408 crate::anim::PROGRESS_BAR_INDETERMINATE_MS,
409 ))
410 .with_easing(crate::anim::progress_bar_indeterminate_ease())
411 .repeat(),
412 |el, delta| el.left(gpui::relative(delta * 1.8 - 0.4)),
413 ),
414 )
415 .into_any_element()
416 } else if self.is_indeterminate {
417 track
418 .child(
419 gpui::div()
420 .h_full()
421 .rounded(radius)
422 .bg(progress_fill_color)
423 .w(gpui::relative(0.4)),
424 )
425 .into_any_element()
426 } else {
427 let indicator = gpui::div().h_full().rounded(radius).bg(progress_fill_color);
428 track
429 .child(fill_motion.render(indicator))
430 .into_any_element()
431 };
432
433 el = el.child(track);
434 crate::util::apply_sx(el, &self.sx)
435 }
436}
437
438fn fraction_of(value: f32, min: f32, max: f32) -> f32 {
441 let span = max - min;
442 if span.abs() < f32::EPSILON {
443 return 0.0;
444 }
445 ((value - min) / span).clamp(0.0, 1.0)
446}
447
448#[cfg(test)]
449mod tests {
450 use super::ProgressBarMotion;
451 use std::cell::Cell;
452 use std::rc::Rc;
453
454 fn close(left: f32, right: f32) -> bool {
455 (left - right).abs() < f32::EPSILON
456 }
457
458 #[test]
459 fn progress_bar_motion_reverses_from_current_width_and_snaps_without_motion() {
460 let width = Rc::new(Cell::new(0.25));
461 let mut motion = ProgressBarMotion {
462 target: 0.25,
463 generation: 0,
464 from: 0.25,
465 width: width.clone(),
466 };
467
468 assert!(motion.retarget(0.75, true));
469 assert_eq!(motion.generation, 1);
470 assert!(close(motion.from, 0.25));
471 assert!(close(width.get(), 0.25));
472
473 width.set(0.5);
474 assert!(motion.retarget(0.1, true));
475 assert_eq!(motion.generation, 2);
476 assert!(close(motion.from, 0.5));
477 assert!(close(width.get(), 0.5));
478
479 assert!(motion.retarget(0.9, false));
480 assert_eq!(motion.generation, 3);
481 assert!(close(motion.from, 0.9));
482 assert!(close(width.get(), 0.9));
483 }
484
485 #[test]
486 fn indeterminate_circle_rotates_track_and_fill_as_one_surface() {
487 let source = include_str!("progress.rs")
488 .rsplit("/// Circular progress ring")
489 .next()
490 .expect("the ProgressCircle implementation is always present");
491 assert!(source.contains("let progress_visual = gpui::div()"));
492 assert!(source.contains(".child(track).child(arc)"));
493 assert!(source.contains("progress_visual\n .with_animation("));
494 assert!(source.contains("rotation.set(crate::anim::progress_circle_spin_turn(delta))"));
495 }
496
497 #[test]
498 fn determinate_circle_interpolates_the_retained_arc_fraction() {
499 let source = include_str!("progress.rs")
500 .rsplit("/// Circular progress ring")
501 .next()
502 .expect("the ProgressCircle implementation is always present");
503 assert!(source.contains("PROGRESS_CIRCLE_FILL_MS"));
504 assert!(source.contains("\"fill-fraction\""));
505 assert!(source.contains("let paint_fraction = fraction_motion.value()"));
506 assert!(source.contains("let fraction = paint_fraction.get()"));
507 assert!(source.contains("fraction_motion.animates(reduce_motion)"));
508 }
509
510 #[test]
511 fn progress_output_uses_shared_tabular_numeric_features() {
512 let source = include_str!("progress.rs")
513 .split("/// Circular progress ring")
514 .next()
515 .expect("the ProgressBar implementation is always present");
516 assert!(source.contains("tabular_font_features"));
517 assert!(source.contains("font_features(crate::util::tabular_font_features())"));
518 }
519
520 #[test]
521 fn indeterminate_bar_animation_is_instance_scoped() {
522 let source = include_str!("progress.rs");
523 assert!(
524 source.contains("element_id::scoped(&self.id, \"indeterminate\")"),
525 "each indeterminate bar needs an animation id under its own component id"
526 );
527 assert!(
528 !source.contains("\"progress-bar-indeterminate\""),
529 "a shared literal animation id lets sibling bars collide"
530 );
531 }
532}
533
534#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
536#[derive(IntoElement)]
537pub struct ProgressCircle {
538 value: f32,
539 min_value: f32,
540 max_value: f32,
541 color: Color,
542 size_px: Pixels,
543 is_indeterminate: bool,
544 show_value: bool,
545 value_content: Option<std::sync::Arc<dyn Fn(f32, &str, bool) -> gpui::AnyElement + 'static>>,
548 format: Option<herogpui_core::NumberFormat>,
550 id: Option<gpui::ElementId>,
551 sx: Option<Box<gpui::StyleRefinement>>,
553}
554
555impl ProgressCircle {
556 pub fn new() -> Self {
558 Self {
559 value: 0.0,
560 min_value: 0.0,
561 max_value: 100.0,
562 color: Color::Accent,
563 size_px: px(28.),
564 is_indeterminate: false,
565 show_value: false,
566 value_content: None,
567 format: None,
568 id: None,
569 sx: None,
570 }
571 }
572
573 pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
577 self.id = Some(id.into());
578 self
579 }
580
581 pub fn value_content(
584 mut self,
585 render: impl Fn(f32, &str, bool) -> gpui::AnyElement + 'static,
586 ) -> Self {
587 self.value_content = Some(std::sync::Arc::new(render));
588 self
589 }
590
591 pub fn value(mut self, v: f32) -> Self {
593 self.value = v;
594 self
595 }
596
597 pub fn min_value(mut self, v: f32) -> Self {
599 self.min_value = v;
600 self
601 }
602
603 pub fn max_value(mut self, v: f32) -> Self {
605 self.max_value = v;
606 self
607 }
608
609 pub fn is_indeterminate(mut self, v: bool) -> Self {
611 self.is_indeterminate = v;
612 self
613 }
614
615 pub fn color(mut self, c: Color) -> Self {
617 self.color = c;
618 self
619 }
620
621 pub fn size(mut self, s: Size) -> Self {
625 self.size_px = match s {
626 Size::Sm => px(20.),
627 Size::Md => px(28.),
628 Size::Lg => px(36.),
629 };
630 self
631 }
632
633 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
638 crate::util::refine_sx(&mut self.sx, style);
639 self
640 }
641
642 pub fn show_value_label(mut self, v: bool) -> Self {
644 self.show_value = v;
645 self
646 }
647
648 pub fn format_options(mut self, format: herogpui_core::NumberFormat) -> Self {
650 self.format = Some(format);
651 self
652 }
653}
654
655impl Default for ProgressCircle {
656 fn default() -> Self {
657 Self::new()
658 }
659}
660
661impl RenderOnce for ProgressCircle {
662 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
663 let motion_id = self
669 .id
670 .clone()
671 .unwrap_or_else(|| gpui::ElementId::from("progress-circle"));
672 let reduce_motion = ActiveTheme::reduce_motion(cx);
673 let value = if self.min_value <= self.max_value {
677 self.value.clamp(self.min_value, self.max_value)
678 } else {
679 self.value
680 };
681 let fraction = if self.is_indeterminate {
682 0.25
684 } else {
685 fraction_of(self.value, self.min_value, self.max_value)
686 };
687 let mut fraction_motion =
688 crate::anim::Tween::keyed(&motion_id, "fill-fraction", fraction, window, cx);
689 if self.is_indeterminate {
690 fraction_motion.settle();
691 } else {
692 fraction_motion.snap_if_reduced(reduce_motion);
693 }
694 let animate_fraction = !self.is_indeterminate && fraction_motion.animates(reduce_motion);
695 let paint_fraction = fraction_motion.value();
696 let colors = cx.colors();
697 let arc_color = if self.color == Color::Default {
698 colors.default.foreground
699 } else {
700 cx.role(self.color).color
701 };
702 let stroke_w = self.size_px / 9.;
704 let spins = self.is_indeterminate && !reduce_motion;
705 let rotation = std::rc::Rc::new(std::cell::Cell::new(0.0f32));
706 let paint_rotation = rotation.clone();
707
708 let arc = gpui::canvas(
709 move |bounds, _, _| bounds,
710 move |bounds, _, window, _| {
711 let fraction = paint_fraction.get();
712 if fraction <= 0.0 {
713 return;
714 }
715 let mut builder = gpui::PathBuilder::stroke(stroke_w);
716 let center = bounds.center();
717 let radius = (bounds.size.width.min(bounds.size.height) / 2.) - stroke_w / 2.;
718 let start = std::f32::consts::FRAC_PI_2 - paint_rotation.get();
721 let sweep = std::f32::consts::TAU * fraction;
722 let steps = ((sweep / 0.05).ceil() as usize).max(2);
723 for i in 0..=steps {
724 let a = start - sweep * i as f32 / steps as f32;
725 let p = gpui::point(center.x + radius * a.cos(), center.y - radius * a.sin());
726 if i == 0 {
727 builder.move_to(p);
728 } else {
729 builder.line_to(p);
730 }
731 }
732 if let Ok(path) = builder.build() {
733 window.paint_path(path, arc_color);
734 }
735 let cap_radius = stroke_w / 2.;
739 for angle in [start, start - sweep] {
740 let cap_center = gpui::point(
741 center.x + radius * angle.cos(),
742 center.y - radius * angle.sin(),
743 );
744 crate::util::paint_disc(cap_center, cap_radius, arc_color, window);
745 }
746 },
747 )
748 .absolute()
749 .inset_0();
750
751 let spin_id = match &self.id {
752 Some(id) => element_id::scoped(id, "spin"),
753 None => gpui::ElementId::from("progress-circle-spin"),
754 };
755 let track = gpui::div()
756 .absolute()
757 .inset_0()
758 .rounded_full()
759 .border(stroke_w)
760 .border_color(colors.default.color);
761
762 let progress_visual = gpui::div().absolute().inset_0().child(track).child(arc);
767 let progress_visual = if spins {
768 progress_visual
769 .with_animation(
770 spin_id,
771 gpui::Animation::new(std::time::Duration::from_millis(
772 crate::anim::PROGRESS_CIRCLE_SPIN_MS,
773 ))
774 .repeat(),
775 move |visual, delta| {
776 rotation.set(crate::anim::progress_circle_spin_turn(delta));
777 visual
778 },
779 )
780 .into_any_element()
781 } else if animate_fraction {
782 let from = fraction_motion.from();
783 let to = fraction_motion.target();
784 let value = fraction_motion.value();
785 progress_visual
786 .with_animation(
787 element_id::indexed(&motion_id, "fill-fraction", fraction_motion.generation()),
788 gpui::Animation::new(std::time::Duration::from_millis(
789 crate::anim::PROGRESS_CIRCLE_FILL_MS,
790 ))
791 .with_easing(crate::anim::ease_out()),
792 move |visual, delta| {
793 value.set(from + (to - from) * delta);
794 visual
795 },
796 )
797 .into_any_element()
798 } else {
799 fraction_motion.settle();
800 progress_visual.into_any_element()
801 };
802
803 let el = gpui::div()
804 .relative()
805 .flex()
806 .items_center()
807 .justify_center()
808 .size(self.size_px)
809 .child(progress_visual)
813 .when(self.show_value, |el| {
814 let format = self
815 .format
816 .clone()
817 .unwrap_or_else(herogpui_core::NumberFormat::percent);
818 let (percentage, value_text) = if self.is_indeterminate {
823 (0.0, String::new())
824 } else {
825 let n = if format.style == herogpui_core::NumberStyle::Percent {
826 fraction as f64
827 } else {
828 value as f64
829 };
830 (fraction * 100., format.format(n))
831 };
832 match &self.value_content {
833 Some(render) => {
834 el.child(render(percentage, &value_text, self.is_indeterminate))
835 }
836 None => el.child(
837 gpui::div()
838 .text_size(px(12.))
839 .font_weight(gpui::FontWeight::SEMIBOLD)
840 .text_color(colors.foreground)
841 .child(value_text),
842 ),
843 }
844 });
845 let el = crate::util::apply_sx(el, &self.sx);
846 match self.id {
847 Some(id) => {
848 let range = if self.is_indeterminate {
849 crate::a11y::Range::indeterminate(self.min_value as f64, self.max_value as f64)
850 } else {
851 crate::a11y::Range::new(
852 self.min_value as f64,
853 self.max_value as f64,
854 self.value as f64,
855 )
856 };
857 el.id(id)
858 .a11y(crate::a11y::Role::ProgressIndicator)
859 .a11y_range(&range)
860 .into_any_element()
861 }
862 None => el.into_any_element(),
863 }
864 }
865}
866
867crate::util::impl_component_styled!(ProgressBar, ProgressCircle);