1use std::rc::Rc;
40use std::time::Duration;
41
42use teksilo_canvas::{AnimatedQuadClass, Canvas, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::animated_quad::{AnimatedQuadHandle, AnimatedQuadKind};
45use teksilo_core::binding::BindingLevel;
46use teksilo_core::color_prop::ColorProp;
47use teksilo_core::signal::{Prop, Signal};
48use teksilo_core::styles::{ProgressBarStyleConfig, ProgressKind, SharedProgressBarStyle};
49use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
50use teksilo_core::widget_id::WidgetId;
51#[cfg(test)]
52use teksilo_tokens::Color;
53use teksilo_tokens::{CornerRadius, Orientation, SurfaceRole};
54
55use crate::primitives::ZStack;
56use crate::styles::recipe_progress_bar_style::PROGRESS_BAR_CORNER_RADIUS;
57use teksilo_i18n::LocalizedString;
58
59const DEFAULT_THICKNESS: f32 = 4.0;
60const INDETERMINATE_FRAME_INTERVAL: Duration = Duration::from_millis(66);
64const INDETERMINATE_SWEEP_RATIO: f32 = 0.42;
65
66pub struct ProgressBar {
68 value: Prop<f32>,
69 indeterminate: bool,
70 orientation: Orientation,
71 thickness: Option<f32>,
73 style_thickness: Option<f32>,
77 track_color: Option<ColorProp>,
78 fill_color: Option<ColorProp>,
79 label: Option<LocalizedString>,
80 style_override: Option<SharedProgressBarStyle>,
82 root_child_id: Option<WidgetId>,
83}
84
85impl ProgressBar {
86 pub fn new(value: f32) -> Self {
88 Self {
89 value: Prop::Static(value.clamp(0.0, 1.0)),
90 indeterminate: false,
91 orientation: Orientation::Horizontal,
92 thickness: None,
93 style_thickness: None,
94 track_color: None,
95 fill_color: None,
96 label: None,
97 style_override: None,
98 root_child_id: None,
99 }
100 }
101
102 pub fn indeterminate() -> Self {
104 Self {
105 value: Prop::Static(0.0),
106 indeterminate: true,
107 orientation: Orientation::Horizontal,
108 thickness: None,
109 style_thickness: None,
110 track_color: None,
111 fill_color: None,
112 label: None,
113 style_override: None,
114 root_child_id: None,
115 }
116 }
117
118 pub fn value(mut self, state: impl Into<Prop<f32>>) -> Self {
120 self.value = state.into();
121 self
122 }
123
124 pub fn orientation(mut self, orientation: Orientation) -> Self {
128 self.orientation = orientation;
129 self
130 }
131
132 pub fn thickness(mut self, thickness: f32) -> Self {
136 self.thickness = Some(thickness);
137 self
138 }
139
140 fn effective_thickness(&self) -> f32 {
143 self.thickness
144 .or(self.style_thickness)
145 .unwrap_or(DEFAULT_THICKNESS)
146 }
147
148 pub fn track_color(mut self, color: impl Into<ColorProp>) -> Self {
151 self.track_color = Some(color.into());
152 self
153 }
154
155 pub fn fill_color(mut self, color: impl Into<ColorProp>) -> Self {
158 self.fill_color = Some(color.into());
159 self
160 }
161
162 pub fn style(mut self, style: impl teksilo_core::styles::ProgressBarStyle) -> Self {
168 self.style_override = Some(Rc::new(style));
169 self
170 }
171
172 pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
174 let ls: LocalizedString = text.into();
175 self.label = Some(ls);
176 self
177 }
178}
179
180impl std::fmt::Debug for ProgressBar {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("ProgressBar")
183 .field("thickness", &self.effective_thickness())
184 .finish()
185 }
186}
187
188impl Widget for ProgressBar {
189 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
190 let reduced_motion = ctx.prefers_reduced_motion();
194 let sweep_period = ctx.theme().motion.duration_indeterminate_sweep;
195
196 let style: SharedProgressBarStyle = self
197 .style_override
198 .clone()
199 .or_else(|| ctx.theme().style_slots.progress_bar.clone())
200 .unwrap_or_else(|| {
201 Rc::new(crate::styles::RecipeProgressBarStyle::for_tokens(
202 &ctx.theme().input,
203 ))
204 });
205 self.style_thickness = style.thickness(&ctx.theme().input);
206 let style_owns_busy = self.indeterminate && style.paints_busy_indicator();
209 let animate = self.indeterminate && !reduced_motion && !style_owns_busy;
210 let use_shader_path = animate && matches!(self.orientation, Orientation::Horizontal);
211 let cfg = ProgressBarStyleConfig {
212 orientation: self.orientation,
213 progress: if self.indeterminate {
214 ProgressKind::Indeterminate
215 } else {
216 ProgressKind::Determinate(self.value.clone())
217 },
218 track_color_override: self.track_color.clone(),
219 fill_color_override: self.fill_color.clone(),
220 };
221
222 let root = if use_shader_path {
234 let track = self
235 .track_color
236 .clone()
237 .unwrap_or_else(|| SurfaceRole::Sunken.into());
238 let fill = self
239 .fill_color
240 .clone()
241 .unwrap_or_else(|| SurfaceRole::Accent.into());
242 let handle = ctx.animated_quad(AnimatedQuadKind::IndeterminateSweep {
243 period: sweep_period,
244 sweep_ratio: INDETERMINATE_SWEEP_RATIO,
245 track_color: track,
246 fill_color: fill,
247 });
248 ctx.add(IndeterminateSweepLeaf::shader(handle))
249 } else if style_owns_busy {
250 style.make_body(&cfg, ctx)
251 } else if self.indeterminate {
252 let frame_id = style.make_body(&cfg, ctx);
253 let pos = ctx.animated_signal(0.0);
254 if !reduced_motion {
258 ctx.animate()
259 .sweep()
260 .linear()
261 .frame_interval(INDETERMINATE_FRAME_INTERVAL)
262 .to(&pos, 1.0);
263 }
264 let fill = self
265 .fill_color
266 .clone()
267 .unwrap_or_else(|| SurfaceRole::Accent.into());
268 let leaf_id = ctx.add(IndeterminateSweepLeaf::signal(self.orientation, pos, fill));
269 ctx.add(ZStack::new().child(frame_id).child(leaf_id))
270 } else {
271 self.value.register_if_bound(
277 ctx.self_id(),
278 ctx.binding_registry(),
279 BindingLevel::AccessibilityOnly,
280 );
281 style.make_body(&cfg, ctx)
282 };
283 self.root_child_id = Some(root);
284 vec![root]
285 }
286
287 fn layout_response(
288 &self,
289 proposal: SizeProposal,
290 _ctx: &LayoutContext,
291 ) -> teksilo_core::widget::LayoutResponse {
292 match self.orientation {
293 Orientation::Horizontal => {
294 let width = proposal.width.unwrap_or(100.0);
295 Size::new(width, self.effective_thickness())
296 }
297 Orientation::Vertical => {
298 let height = proposal.height.unwrap_or(100.0);
299 Size::new(self.effective_thickness(), height)
300 }
301 }
302 .into()
303 }
304
305 fn place_children(
306 &self,
307 bounds: Rect,
308 _proposal: SizeProposal,
309 children: &mut [WidgetPlacement],
310 _ctx: &LayoutContext,
311 ) {
312 for child in children.iter_mut() {
313 child.origin = bounds.origin();
314 child.size = bounds.size();
315 }
316 }
317
318 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
319 builder.set_role(teksilo_core::accesskit::Role::ProgressIndicator);
320 if let Some(ref label) = self.label {
321 builder.set_name(label.clone());
322 }
323 builder.set_live(teksilo_core::accesskit::Live::Polite);
332 if !self.indeterminate {
333 let value = self.value.get();
334 builder.set_numeric_value(value as f64);
335 builder.set_min_numeric_value(0.0);
336 builder.set_max_numeric_value(1.0);
337 }
338 }
339
340 fn children(&self) -> Vec<WidgetId> {
341 self.root_child_id.into_iter().collect()
342 }
343}
344
345enum IndeterminateSweepLeaf {
351 Shader(AnimatedQuadHandle),
353 Signal {
356 orientation: Orientation,
357 pos: Signal<f32>,
358 fill: ColorProp,
359 },
360}
361
362impl IndeterminateSweepLeaf {
363 fn shader(handle: AnimatedQuadHandle) -> Self {
364 Self::Shader(handle)
365 }
366 fn signal(orientation: Orientation, pos: Signal<f32>, fill: ColorProp) -> Self {
367 Self::Signal {
368 orientation,
369 pos,
370 fill,
371 }
372 }
373}
374
375impl std::fmt::Debug for IndeterminateSweepLeaf {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 Self::Shader(_) => f.debug_struct("IndeterminateSweepLeaf::Shader").finish(),
379 Self::Signal { .. } => f.debug_struct("IndeterminateSweepLeaf::Signal").finish(),
380 }
381 }
382}
383
384impl Widget for IndeterminateSweepLeaf {
385 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
386 if let Self::Signal { pos, .. } = self {
387 let id = ctx.self_id();
388 pos.bind_to(id, ctx.binding_registry(), BindingLevel::RepaintOnly);
389 }
390 vec![]
391 }
392
393 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse
394 where
395 Self: Sized,
396 {
397 Size::new(
400 proposal.width.unwrap_or(0.0),
401 proposal.height.unwrap_or(0.0),
402 )
403 .into()
404 }
405
406 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
407 match self {
408 Self::Shader(handle) => {
409 canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural);
414 }
415 Self::Signal {
416 orientation,
417 pos,
418 fill,
419 } => {
420 let radius = CornerRadius::uniform(PROGRESS_BAR_CORNER_RADIUS);
421 let value = pos.get().clamp(0.0, 1.0);
422 let fill_color = fill.resolve(ctx.theme, ctx.effective_enabled);
423 let fill_rect = match orientation {
424 Orientation::Horizontal => {
425 let sweep_w = bounds.width * INDETERMINATE_SWEEP_RATIO;
426 let x = bounds.x - sweep_w + (bounds.width + sweep_w) * value;
427 Rect::new(x, bounds.y, sweep_w, bounds.height)
428 }
429 Orientation::Vertical => {
430 let sweep_h = bounds.height * INDETERMINATE_SWEEP_RATIO;
431 let y = bounds.y - sweep_h + (bounds.height + sweep_h) * value;
432 Rect::new(bounds.x, y, bounds.width, sweep_h)
433 }
434 };
435 canvas.fill_rounded_rect(fill_rect, radius, fill_color);
436 }
437 }
438 }
439
440 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
441 builder.set_hidden();
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use teksilo_core::styles::ProgressBarStyle;
451 use teksilo_core::widget_tree::WidgetTree;
452
453 #[test]
454 fn progress_bar_size() {
455 let mut tree = WidgetTree::new();
456 let pb = tree.add(ProgressBar::new(0.5));
457 tree.layout(SizeProposal {
458 width: Some(200.0),
459 height: None,
460 });
461 let b = tree.bounds(pb);
462 assert!((b.width - 200.0).abs() < 0.01);
463 assert!((b.height - 4.0).abs() < 0.01);
464 }
465
466 #[test]
467 fn progress_bar_paints_track_and_fill() {
468 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
469 tree.add(ProgressBar::new(0.5));
470 tree.layout(SizeProposal::exact(200.0, 100.0));
471 let frame = tree.render();
472 assert!(frame.shapes.len() >= 2, "should have track and fill shapes");
473 }
474
475 #[test]
476 fn progress_bar_fill_width_proportional() {
477 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
478 let _pb = tree.add(ProgressBar::new(0.5).fill_color(Color::RED));
479 tree.layout(SizeProposal::exact(200.0, 100.0));
480 let frame = tree.render();
481 let fill_shapes: Vec<_> = frame
482 .shapes
483 .iter()
484 .filter(|s| s.color == Color::RED.to_array())
485 .collect();
486 assert!(!fill_shapes.is_empty(), "should have a red fill shape");
487 let fill = &fill_shapes[0];
488 let fill_width = fill.screen[2];
489 assert!(
490 (fill_width - 100.0).abs() < 1.0,
491 "fill width should be ~100, got {}",
492 fill_width
493 );
494 }
495
496 #[test]
497 fn zero_value_no_fill() {
498 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
499 tree.add(ProgressBar::new(0.0).fill_color(Color::RED));
500 tree.layout(SizeProposal::exact(200.0, 100.0));
501 let frame = tree.render();
502 let fill_shapes: Vec<_> = frame
503 .shapes
504 .iter()
505 .filter(|s| s.color == Color::RED.to_array())
506 .collect();
507 assert!(fill_shapes.is_empty(), "zero progress should have no fill");
508 }
509
510 #[test]
511 fn accessibility_values() {
512 let mut tree = WidgetTree::new();
513 let pb = tree.add(ProgressBar::new(0.75));
514 tree.layout(SizeProposal::exact(200.0, 100.0));
515 let info = tree.accessibility_node(pb);
516 assert_eq!(
517 info.role(),
518 teksilo_core::accesskit::Role::ProgressIndicator
519 );
520
521 let update = tree.sync_accessibility();
523 let nid = teksilo_core::accessibility::widget_id_to_node_id(pb);
524 let node = update
525 .nodes
526 .iter()
527 .find(|(id, _)| *id == nid)
528 .map(|(_, n)| n)
529 .expect("progress bar node in tree");
530 assert_eq!(node.numeric_value(), Some(0.75));
531 assert_eq!(node.live(), Some(teksilo_core::accesskit::Live::Polite));
535 }
536
537 #[test]
538 fn indeterminate_progress_bar_emits_animated_quad() {
539 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
540 tree.add(ProgressBar::indeterminate());
541
542 tree.layout(SizeProposal::exact(200.0, 40.0));
543 let frame1 = tree.render();
544 assert_eq!(
545 frame1.animated_quads.len(),
546 1,
547 "horizontal indeterminate should emit exactly one AnimatedQuad"
548 );
549 assert_eq!(frame1.anim_params.len(), 1);
550 let phase1 = frame1.anim_params[frame1.animated_quads[0].slot as usize].phase;
551
552 std::thread::sleep(Duration::from_millis(250));
553 let frame2 = tree.render();
554 let phase2 = frame2.anim_params[frame2.animated_quads[0].slot as usize].phase;
555 assert_ne!(
556 phase1, phase2,
557 "animated-quad phase must advance between frames"
558 );
559 }
560
561 #[derive(Debug, Default, Clone, Copy)]
564 struct BusyStyle;
565
566 impl ProgressBarStyle for BusyStyle {
567 fn make_body(
568 &self,
569 cfg: &ProgressBarStyleConfig,
570 ctx: &mut teksilo_core::build_context::BuildContext,
571 ) -> WidgetId {
572 let color = match cfg.progress {
573 ProgressKind::Indeterminate => Color::RED,
574 ProgressKind::Determinate(_) => Color::BLUE,
575 };
576 ctx.add(crate::primitives::RectWidget::new().background(color))
577 }
578
579 fn thickness(&self, _tokens: &teksilo_tokens::InputTokens) -> Option<f32> {
580 Some(9.0)
581 }
582
583 fn paints_busy_indicator(&self) -> bool {
584 true
585 }
586 }
587
588 fn laid_out(bar: ProgressBar) -> (WidgetTree, WidgetId) {
590 let proposal = match bar.orientation {
591 Orientation::Horizontal => SizeProposal {
592 width: Some(200.0),
593 height: None,
594 },
595 Orientation::Vertical => SizeProposal {
596 width: None,
597 height: Some(200.0),
598 },
599 };
600 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
601 let id = tree.add(bar);
602 tree.layout(proposal);
603 (tree, id)
604 }
605
606 #[test]
609 fn the_styles_thickness_sizes_a_bar_the_app_did_not_size() {
610 let (tree, id) = laid_out(ProgressBar::new(0.5).style(BusyStyle));
611 assert_eq!(tree.bounds(id).height, 9.0);
612 let (tree, id) = laid_out(
613 ProgressBar::new(0.5)
614 .orientation(Orientation::Vertical)
615 .style(BusyStyle),
616 );
617 assert_eq!(tree.bounds(id).width, 9.0);
618 let (tree, id) = laid_out(ProgressBar::new(0.5).thickness(3.0).style(BusyStyle));
619 assert_eq!(tree.bounds(id).height, 3.0);
620 assert_eq!(
622 crate::styles::RecipeProgressBarStyle::default()
623 .thickness(&teksilo_tokens::InputTokens::default()),
624 None
625 );
626 let (tree, id) = laid_out(ProgressBar::new(0.5));
627 assert_eq!(tree.bounds(id).height, DEFAULT_THICKNESS);
628 }
629
630 #[test]
633 fn a_style_that_paints_the_busy_indicator_replaces_the_sweep() {
634 for orientation in [Orientation::Horizontal, Orientation::Vertical] {
635 let (mut tree, _) = laid_out(
636 ProgressBar::indeterminate()
637 .orientation(orientation)
638 .style(BusyStyle),
639 );
640 let frame = tree.render();
641 assert!(frame.animated_quads.is_empty(), "{orientation:?}");
642 let red: Vec<_> = frame
643 .shapes
644 .iter()
645 .filter(|s| s.color == Color::RED.to_array())
646 .collect();
647 assert_eq!(red.len(), 1, "{orientation:?}: the style's body");
648 assert_eq!(
649 frame.shapes.len(),
650 1,
651 "{orientation:?}: nothing painted over it"
652 );
653 }
654 assert!(!crate::styles::RecipeProgressBarStyle::default().paints_busy_indicator());
656 }
657}