1use presentar_core::{
4 widget::{AccessibleRole, LayoutResult},
5 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Rect,
6 Size, TypeId, Widget,
7};
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::time::Duration;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14pub enum ProgressMode {
15 #[default]
17 Determinate,
18 Indeterminate,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProgressBar {
25 value: f32,
27 mode: ProgressMode,
29 min_width: f32,
31 height: f32,
33 corner_radius: f32,
35 track_color: Color,
37 fill_color: Color,
39 show_label: bool,
41 label_color: Color,
43 accessible_name_value: Option<String>,
45 test_id_value: Option<String>,
47 #[serde(skip)]
49 bounds: Rect,
50}
51
52impl Default for ProgressBar {
53 fn default() -> Self {
54 Self {
55 value: 0.0,
56 mode: ProgressMode::Determinate,
57 min_width: 100.0,
58 height: 8.0,
59 corner_radius: 4.0,
60 track_color: Color::new(0.88, 0.88, 0.88, 1.0), fill_color: Color::new(0.13, 0.59, 0.95, 1.0), show_label: false,
63 label_color: Color::BLACK,
64 accessible_name_value: None,
65 test_id_value: None,
66 bounds: Rect::default(),
67 }
68 }
69}
70
71impl ProgressBar {
72 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[must_use]
80 pub fn with_value(value: f32) -> Self {
81 Self::default().value(value)
82 }
83
84 #[must_use]
86 pub fn value(mut self, value: f32) -> Self {
87 self.value = value.clamp(0.0, 1.0);
88 self
89 }
90
91 #[must_use]
93 pub const fn mode(mut self, mode: ProgressMode) -> Self {
94 self.mode = mode;
95 self
96 }
97
98 #[must_use]
100 pub const fn indeterminate(self) -> Self {
101 self.mode(ProgressMode::Indeterminate)
102 }
103
104 #[must_use]
106 pub fn min_width(mut self, width: f32) -> Self {
107 self.min_width = width.max(20.0);
108 self
109 }
110
111 #[must_use]
113 pub fn height(mut self, height: f32) -> Self {
114 self.height = height.max(4.0);
115 self
116 }
117
118 #[must_use]
120 pub fn corner_radius(mut self, radius: f32) -> Self {
121 self.corner_radius = radius.max(0.0);
122 self
123 }
124
125 #[must_use]
127 pub const fn track_color(mut self, color: Color) -> Self {
128 self.track_color = color;
129 self
130 }
131
132 #[must_use]
134 pub const fn fill_color(mut self, color: Color) -> Self {
135 self.fill_color = color;
136 self
137 }
138
139 #[must_use]
141 pub const fn with_label(mut self) -> Self {
142 self.show_label = true;
143 self
144 }
145
146 #[must_use]
148 pub const fn show_label(mut self, show: bool) -> Self {
149 self.show_label = show;
150 self
151 }
152
153 #[must_use]
155 pub const fn label_color(mut self, color: Color) -> Self {
156 self.label_color = color;
157 self
158 }
159
160 #[must_use]
162 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
163 self.accessible_name_value = Some(name.into());
164 self
165 }
166
167 #[must_use]
169 pub fn test_id(mut self, id: impl Into<String>) -> Self {
170 self.test_id_value = Some(id.into());
171 self
172 }
173
174 #[must_use]
176 pub const fn get_value(&self) -> f32 {
177 self.value
178 }
179
180 #[must_use]
182 pub const fn get_mode(&self) -> ProgressMode {
183 self.mode
184 }
185
186 #[must_use]
188 pub fn percentage(&self) -> u8 {
189 (self.value * 100.0).round() as u8
190 }
191
192 #[must_use]
194 pub fn is_complete(&self) -> bool {
195 self.mode == ProgressMode::Determinate && self.value >= 1.0
196 }
197
198 #[must_use]
200 pub fn is_indeterminate(&self) -> bool {
201 self.mode == ProgressMode::Indeterminate
202 }
203
204 pub fn set_value(&mut self, value: f32) {
206 self.value = value.clamp(0.0, 1.0);
207 }
208
209 pub fn increment(&mut self, delta: f32) {
211 self.value = (self.value + delta).clamp(0.0, 1.0);
212 }
213
214 fn fill_width(&self, total_width: f32) -> f32 {
216 total_width * self.value
217 }
218
219 #[must_use]
221 pub const fn get_track_color(&self) -> Color {
222 self.track_color
223 }
224
225 #[must_use]
227 pub const fn get_fill_color(&self) -> Color {
228 self.fill_color
229 }
230
231 #[must_use]
233 pub const fn get_label_color(&self) -> Color {
234 self.label_color
235 }
236
237 #[must_use]
239 pub const fn is_label_shown(&self) -> bool {
240 self.show_label
241 }
242
243 #[must_use]
245 pub const fn get_min_width(&self) -> f32 {
246 self.min_width
247 }
248
249 #[must_use]
251 pub const fn get_height(&self) -> f32 {
252 self.height
253 }
254
255 #[must_use]
257 pub const fn get_corner_radius(&self) -> f32 {
258 self.corner_radius
259 }
260}
261
262impl Widget for ProgressBar {
263 fn type_id(&self) -> TypeId {
264 TypeId::of::<Self>()
265 }
266
267 fn measure(&self, constraints: Constraints) -> Size {
268 let preferred_height = if self.show_label {
269 self.height + 20.0
270 } else {
271 self.height
272 };
273 let preferred = Size::new(self.min_width, preferred_height);
274 constraints.constrain(preferred)
275 }
276
277 fn layout(&mut self, bounds: Rect) -> LayoutResult {
278 self.bounds = bounds;
279 LayoutResult {
280 size: bounds.size(),
281 }
282 }
283
284 fn paint(&self, canvas: &mut dyn Canvas) {
285 let track_rect = Rect::new(self.bounds.x, self.bounds.y, self.bounds.width, self.height);
287 canvas.fill_rect(track_rect, self.track_color);
288
289 if self.mode == ProgressMode::Determinate && self.value > 0.0 {
291 let fill_width = self.fill_width(track_rect.width);
292 let fill_rect = Rect::new(track_rect.x, track_rect.y, fill_width, self.height);
293 canvas.fill_rect(fill_rect, self.fill_color);
294 }
295 }
296
297 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
298 None
300 }
301
302 fn children(&self) -> &[Box<dyn Widget>] {
303 &[]
304 }
305
306 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
307 &mut []
308 }
309
310 fn is_interactive(&self) -> bool {
311 false
312 }
313
314 fn is_focusable(&self) -> bool {
315 false
316 }
317
318 fn accessible_name(&self) -> Option<&str> {
319 self.accessible_name_value.as_deref()
320 }
321
322 fn accessible_role(&self) -> AccessibleRole {
323 AccessibleRole::ProgressBar
324 }
325
326 fn test_id(&self) -> Option<&str> {
327 self.test_id_value.as_deref()
328 }
329}
330
331impl Brick for ProgressBar {
333 fn brick_name(&self) -> &'static str {
334 "ProgressBar"
335 }
336
337 fn assertions(&self) -> &[BrickAssertion] {
338 &[BrickAssertion::MaxLatencyMs(16)]
339 }
340
341 fn budget(&self) -> BrickBudget {
342 BrickBudget::uniform(16)
343 }
344
345 fn verify(&self) -> BrickVerification {
346 BrickVerification {
347 passed: self.assertions().to_vec(),
348 failed: vec![],
349 verification_time: Duration::from_micros(10),
350 }
351 }
352
353 fn to_html(&self) -> String {
354 r#"<div class="brick-progressbar"></div>"#.to_string()
355 }
356
357 fn to_css(&self) -> String {
358 ".brick-progressbar { display: block; }".to_string()
359 }
360
361 fn test_id(&self) -> Option<&str> {
362 self.test_id_value.as_deref()
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
373 fn test_progress_mode_default() {
374 assert_eq!(ProgressMode::default(), ProgressMode::Determinate);
375 }
376
377 #[test]
378 fn test_progress_mode_equality() {
379 assert_eq!(ProgressMode::Determinate, ProgressMode::Determinate);
380 assert_eq!(ProgressMode::Indeterminate, ProgressMode::Indeterminate);
381 assert_ne!(ProgressMode::Determinate, ProgressMode::Indeterminate);
382 }
383
384 #[test]
387 fn test_progress_bar_new() {
388 let pb = ProgressBar::new();
389 assert_eq!(pb.get_value(), 0.0);
390 assert_eq!(pb.get_mode(), ProgressMode::Determinate);
391 }
392
393 #[test]
394 fn test_progress_bar_with_value() {
395 let pb = ProgressBar::with_value(0.5);
396 assert_eq!(pb.get_value(), 0.5);
397 }
398
399 #[test]
400 fn test_progress_bar_default() {
401 let pb = ProgressBar::default();
402 assert_eq!(pb.get_value(), 0.0);
403 assert_eq!(pb.get_mode(), ProgressMode::Determinate);
404 assert!(!pb.is_label_shown());
405 }
406
407 #[test]
408 fn test_progress_bar_builder() {
409 let pb = ProgressBar::new()
410 .value(0.75)
411 .min_width(200.0)
412 .height(12.0)
413 .corner_radius(6.0)
414 .track_color(Color::WHITE)
415 .fill_color(Color::new(0.0, 1.0, 0.0, 1.0))
416 .with_label()
417 .label_color(Color::BLACK)
418 .accessible_name("Loading progress")
419 .test_id("main-progress");
420
421 assert_eq!(pb.get_value(), 0.75);
422 assert_eq!(pb.get_min_width(), 200.0);
423 assert_eq!(pb.get_height(), 12.0);
424 assert_eq!(pb.get_corner_radius(), 6.0);
425 assert_eq!(pb.get_track_color(), Color::WHITE);
426 assert_eq!(pb.get_fill_color(), Color::new(0.0, 1.0, 0.0, 1.0));
427 assert!(pb.is_label_shown());
428 assert_eq!(pb.get_label_color(), Color::BLACK);
429 assert_eq!(Widget::accessible_name(&pb), Some("Loading progress"));
430 assert_eq!(Widget::test_id(&pb), Some("main-progress"));
431 }
432
433 #[test]
436 fn test_progress_bar_value_clamped_min() {
437 let pb = ProgressBar::new().value(-0.5);
438 assert_eq!(pb.get_value(), 0.0);
439 }
440
441 #[test]
442 fn test_progress_bar_value_clamped_max() {
443 let pb = ProgressBar::new().value(1.5);
444 assert_eq!(pb.get_value(), 1.0);
445 }
446
447 #[test]
448 fn test_progress_bar_set_value() {
449 let mut pb = ProgressBar::new();
450 pb.set_value(0.6);
451 assert_eq!(pb.get_value(), 0.6);
452 }
453
454 #[test]
455 fn test_progress_bar_set_value_clamped() {
456 let mut pb = ProgressBar::new();
457 pb.set_value(2.0);
458 assert_eq!(pb.get_value(), 1.0);
459 pb.set_value(-1.0);
460 assert_eq!(pb.get_value(), 0.0);
461 }
462
463 #[test]
464 fn test_progress_bar_increment() {
465 let mut pb = ProgressBar::with_value(0.3);
466 pb.increment(0.2);
467 assert!((pb.get_value() - 0.5).abs() < 0.001);
468 }
469
470 #[test]
471 fn test_progress_bar_increment_clamped() {
472 let mut pb = ProgressBar::with_value(0.9);
473 pb.increment(0.5);
474 assert_eq!(pb.get_value(), 1.0);
475 }
476
477 #[test]
478 fn test_progress_bar_percentage() {
479 let pb = ProgressBar::with_value(0.0);
480 assert_eq!(pb.percentage(), 0);
481
482 let pb = ProgressBar::with_value(0.5);
483 assert_eq!(pb.percentage(), 50);
484
485 let pb = ProgressBar::with_value(1.0);
486 assert_eq!(pb.percentage(), 100);
487
488 let pb = ProgressBar::with_value(0.333);
489 assert_eq!(pb.percentage(), 33);
490 }
491
492 #[test]
495 fn test_progress_bar_mode() {
496 let pb = ProgressBar::new().mode(ProgressMode::Indeterminate);
497 assert_eq!(pb.get_mode(), ProgressMode::Indeterminate);
498 }
499
500 #[test]
501 fn test_progress_bar_indeterminate() {
502 let pb = ProgressBar::new().indeterminate();
503 assert!(pb.is_indeterminate());
504 }
505
506 #[test]
507 fn test_progress_bar_is_complete() {
508 let pb = ProgressBar::with_value(1.0);
509 assert!(pb.is_complete());
510
511 let pb = ProgressBar::with_value(0.99);
512 assert!(!pb.is_complete());
513
514 let pb = ProgressBar::with_value(1.0).indeterminate();
515 assert!(!pb.is_complete());
516 }
517
518 #[test]
521 fn test_progress_bar_min_width_min() {
522 let pb = ProgressBar::new().min_width(5.0);
523 assert_eq!(pb.get_min_width(), 20.0);
524 }
525
526 #[test]
527 fn test_progress_bar_height_min() {
528 let pb = ProgressBar::new().height(1.0);
529 assert_eq!(pb.get_height(), 4.0);
530 }
531
532 #[test]
533 fn test_progress_bar_corner_radius() {
534 let pb = ProgressBar::new().corner_radius(10.0);
535 assert_eq!(pb.get_corner_radius(), 10.0);
536 }
537
538 #[test]
539 fn test_progress_bar_corner_radius_min() {
540 let pb = ProgressBar::new().corner_radius(-5.0);
541 assert_eq!(pb.get_corner_radius(), 0.0);
542 }
543
544 #[test]
547 fn test_progress_bar_colors() {
548 let track = Color::new(0.78, 0.78, 0.78, 1.0);
549 let fill = Color::new(0.0, 0.5, 1.0, 1.0);
550 let label = Color::new(0.2, 0.2, 0.2, 1.0);
551
552 let pb = ProgressBar::new()
553 .track_color(track)
554 .fill_color(fill)
555 .label_color(label);
556
557 assert_eq!(pb.get_track_color(), track);
558 assert_eq!(pb.get_fill_color(), fill);
559 assert_eq!(pb.get_label_color(), label);
560 }
561
562 #[test]
565 fn test_progress_bar_show_label() {
566 let pb = ProgressBar::new().show_label(true);
567 assert!(pb.is_label_shown());
568
569 let pb = ProgressBar::new().show_label(false);
570 assert!(!pb.is_label_shown());
571 }
572
573 #[test]
574 fn test_progress_bar_with_label() {
575 let pb = ProgressBar::new().with_label();
576 assert!(pb.is_label_shown());
577 }
578
579 #[test]
582 fn test_progress_bar_fill_width() {
583 let pb = ProgressBar::with_value(0.5);
584 assert_eq!(pb.fill_width(100.0), 50.0);
585
586 let pb = ProgressBar::with_value(0.0);
587 assert_eq!(pb.fill_width(100.0), 0.0);
588
589 let pb = ProgressBar::with_value(1.0);
590 assert_eq!(pb.fill_width(100.0), 100.0);
591 }
592
593 #[test]
596 fn test_progress_bar_type_id() {
597 let pb = ProgressBar::new();
598 assert_eq!(Widget::type_id(&pb), TypeId::of::<ProgressBar>());
599 }
600
601 #[test]
602 fn test_progress_bar_measure() {
603 let pb = ProgressBar::new().min_width(150.0).height(10.0);
604 let size = pb.measure(Constraints::loose(Size::new(300.0, 100.0)));
605 assert_eq!(size.width, 150.0);
606 assert_eq!(size.height, 10.0);
607 }
608
609 #[test]
610 fn test_progress_bar_measure_with_label() {
611 let pb = ProgressBar::new()
612 .min_width(150.0)
613 .height(10.0)
614 .with_label();
615 let size = pb.measure(Constraints::loose(Size::new(300.0, 100.0)));
616 assert_eq!(size.width, 150.0);
617 assert_eq!(size.height, 30.0); }
619
620 #[test]
621 fn test_progress_bar_layout() {
622 let mut pb = ProgressBar::new();
623 let bounds = Rect::new(10.0, 20.0, 200.0, 8.0);
624 let result = pb.layout(bounds);
625 assert_eq!(result.size, Size::new(200.0, 8.0));
626 assert_eq!(pb.bounds, bounds);
627 }
628
629 #[test]
630 fn test_progress_bar_children() {
631 let pb = ProgressBar::new();
632 assert!(pb.children().is_empty());
633 }
634
635 #[test]
636 fn test_progress_bar_is_interactive() {
637 let pb = ProgressBar::new();
638 assert!(!pb.is_interactive());
639 }
640
641 #[test]
642 fn test_progress_bar_is_focusable() {
643 let pb = ProgressBar::new();
644 assert!(!pb.is_focusable());
645 }
646
647 #[test]
648 fn test_progress_bar_accessible_role() {
649 let pb = ProgressBar::new();
650 assert_eq!(pb.accessible_role(), AccessibleRole::ProgressBar);
651 }
652
653 #[test]
654 fn test_progress_bar_accessible_name() {
655 let pb = ProgressBar::new().accessible_name("Download progress");
656 assert_eq!(Widget::accessible_name(&pb), Some("Download progress"));
657 }
658
659 #[test]
660 fn test_progress_bar_accessible_name_none() {
661 let pb = ProgressBar::new();
662 assert_eq!(Widget::accessible_name(&pb), None);
663 }
664
665 #[test]
666 fn test_progress_bar_test_id() {
667 let pb = ProgressBar::new().test_id("upload-progress");
668 assert_eq!(Widget::test_id(&pb), Some("upload-progress"));
669 }
670
671 use presentar_core::draw::DrawCommand;
674 use presentar_core::RecordingCanvas;
675
676 #[test]
677 fn test_progress_bar_paint_draws_track() {
678 let mut pb = ProgressBar::new();
679 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
680
681 let mut canvas = RecordingCanvas::new();
682 pb.paint(&mut canvas);
683
684 assert!(canvas.command_count() >= 1);
686
687 match &canvas.commands()[0] {
688 DrawCommand::Rect { bounds, style, .. } => {
689 assert_eq!(bounds.width, 200.0);
690 assert_eq!(bounds.height, 8.0);
691 assert!(style.fill.is_some());
692 }
693 _ => panic!("Expected Rect command for track"),
694 }
695 }
696
697 #[test]
698 fn test_progress_bar_paint_zero_percent() {
699 let mut pb = ProgressBar::with_value(0.0);
700 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
701
702 let mut canvas = RecordingCanvas::new();
703 pb.paint(&mut canvas);
704
705 assert_eq!(canvas.command_count(), 1);
707 }
708
709 #[test]
710 fn test_progress_bar_paint_50_percent() {
711 let mut pb = ProgressBar::with_value(0.5);
712 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
713
714 let mut canvas = RecordingCanvas::new();
715 pb.paint(&mut canvas);
716
717 assert_eq!(canvas.command_count(), 2);
719
720 match &canvas.commands()[1] {
722 DrawCommand::Rect { bounds, .. } => {
723 assert_eq!(bounds.width, 100.0);
724 }
725 _ => panic!("Expected Rect command for fill"),
726 }
727 }
728
729 #[test]
730 fn test_progress_bar_paint_100_percent() {
731 let mut pb = ProgressBar::with_value(1.0);
732 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
733
734 let mut canvas = RecordingCanvas::new();
735 pb.paint(&mut canvas);
736
737 assert_eq!(canvas.command_count(), 2);
739
740 match &canvas.commands()[1] {
742 DrawCommand::Rect { bounds, .. } => {
743 assert_eq!(bounds.width, 200.0);
744 }
745 _ => panic!("Expected Rect command for fill"),
746 }
747 }
748
749 #[test]
750 fn test_progress_bar_paint_25_percent() {
751 let mut pb = ProgressBar::with_value(0.25);
752 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
753
754 let mut canvas = RecordingCanvas::new();
755 pb.paint(&mut canvas);
756
757 match &canvas.commands()[1] {
758 DrawCommand::Rect { bounds, .. } => {
759 assert_eq!(bounds.width, 50.0);
760 }
761 _ => panic!("Expected Rect command for fill"),
762 }
763 }
764
765 #[test]
766 fn test_progress_bar_paint_indeterminate_no_fill() {
767 let mut pb = ProgressBar::with_value(0.5).indeterminate();
768 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
769
770 let mut canvas = RecordingCanvas::new();
771 pb.paint(&mut canvas);
772
773 assert_eq!(canvas.command_count(), 1);
775 }
776
777 #[test]
778 fn test_progress_bar_paint_uses_track_color() {
779 let track_color = Color::new(0.9, 0.9, 0.9, 1.0);
780 let mut pb = ProgressBar::new().track_color(track_color);
781 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
782
783 let mut canvas = RecordingCanvas::new();
784 pb.paint(&mut canvas);
785
786 match &canvas.commands()[0] {
787 DrawCommand::Rect { style, .. } => {
788 assert_eq!(style.fill, Some(track_color));
789 }
790 _ => panic!("Expected Rect command"),
791 }
792 }
793
794 #[test]
795 fn test_progress_bar_paint_uses_fill_color() {
796 let fill_color = Color::new(0.0, 0.8, 0.0, 1.0);
797 let mut pb = ProgressBar::with_value(0.5).fill_color(fill_color);
798 pb.layout(Rect::new(0.0, 0.0, 200.0, 8.0));
799
800 let mut canvas = RecordingCanvas::new();
801 pb.paint(&mut canvas);
802
803 match &canvas.commands()[1] {
804 DrawCommand::Rect { style, .. } => {
805 assert_eq!(style.fill, Some(fill_color));
806 }
807 _ => panic!("Expected Rect command"),
808 }
809 }
810
811 #[test]
812 fn test_progress_bar_paint_position_from_layout() {
813 let mut pb = ProgressBar::with_value(0.5);
814 pb.layout(Rect::new(50.0, 100.0, 200.0, 8.0));
815
816 let mut canvas = RecordingCanvas::new();
817 pb.paint(&mut canvas);
818
819 match &canvas.commands()[0] {
821 DrawCommand::Rect { bounds, .. } => {
822 assert_eq!(bounds.x, 50.0);
823 assert_eq!(bounds.y, 100.0);
824 }
825 _ => panic!("Expected Rect command"),
826 }
827
828 match &canvas.commands()[1] {
830 DrawCommand::Rect { bounds, .. } => {
831 assert_eq!(bounds.x, 50.0);
832 assert_eq!(bounds.y, 100.0);
833 }
834 _ => panic!("Expected Rect command"),
835 }
836 }
837
838 #[test]
839 fn test_progress_bar_paint_uses_height() {
840 let mut pb = ProgressBar::new().height(16.0);
841 pb.layout(Rect::new(0.0, 0.0, 200.0, 16.0));
842
843 let mut canvas = RecordingCanvas::new();
844 pb.paint(&mut canvas);
845
846 match &canvas.commands()[0] {
847 DrawCommand::Rect { bounds, .. } => {
848 assert_eq!(bounds.height, 16.0);
849 }
850 _ => panic!("Expected Rect command"),
851 }
852 }
853}