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: f32,
72 track_color: Option<ColorProp>,
73 fill_color: Option<ColorProp>,
74 label: Option<LocalizedString>,
75 style_override: Option<SharedProgressBarStyle>,
77 root_child_id: Option<WidgetId>,
78}
79
80impl ProgressBar {
81 pub fn new(value: f32) -> Self {
83 Self {
84 value: Prop::Static(value.clamp(0.0, 1.0)),
85 indeterminate: false,
86 orientation: Orientation::Horizontal,
87 thickness: DEFAULT_THICKNESS,
88 track_color: None,
89 fill_color: None,
90 label: None,
91 style_override: None,
92 root_child_id: None,
93 }
94 }
95
96 pub fn indeterminate() -> Self {
98 Self {
99 value: Prop::Static(0.0),
100 indeterminate: true,
101 orientation: Orientation::Horizontal,
102 thickness: DEFAULT_THICKNESS,
103 track_color: None,
104 fill_color: None,
105 label: None,
106 style_override: None,
107 root_child_id: None,
108 }
109 }
110
111 pub fn value(mut self, state: impl Into<Prop<f32>>) -> Self {
113 self.value = state.into();
114 self
115 }
116
117 pub fn orientation(mut self, orientation: Orientation) -> Self {
121 self.orientation = orientation;
122 self
123 }
124
125 pub fn thickness(mut self, thickness: f32) -> Self {
128 self.thickness = thickness;
129 self
130 }
131
132 pub fn track_color(mut self, color: impl Into<ColorProp>) -> Self {
135 self.track_color = Some(color.into());
136 self
137 }
138
139 pub fn fill_color(mut self, color: impl Into<ColorProp>) -> Self {
142 self.fill_color = Some(color.into());
143 self
144 }
145
146 pub fn style(mut self, style: impl teksilo_core::styles::ProgressBarStyle) -> Self {
152 self.style_override = Some(Rc::new(style));
153 self
154 }
155
156 pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
158 let ls: LocalizedString = text.into();
159 self.label = Some(ls);
160 self
161 }
162}
163
164impl std::fmt::Debug for ProgressBar {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("ProgressBar")
167 .field("thickness", &self.thickness)
168 .finish()
169 }
170}
171
172impl Widget for ProgressBar {
173 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
174 let reduced_motion = ctx.prefers_reduced_motion();
178 let animate = self.indeterminate && !reduced_motion;
179 let use_shader_path = animate && matches!(self.orientation, Orientation::Horizontal);
180 let sweep_period = ctx.theme().motion.duration_indeterminate_sweep;
181
182 let style: SharedProgressBarStyle = self
183 .style_override
184 .clone()
185 .or_else(|| ctx.theme().style_slots.progress_bar.clone())
186 .unwrap_or_else(|| Rc::new(crate::styles::RecipeProgressBarStyle::default()));
187 let cfg = ProgressBarStyleConfig {
188 orientation: self.orientation,
189 progress: if self.indeterminate {
190 ProgressKind::Indeterminate
191 } else {
192 ProgressKind::Determinate(self.value.clone())
193 },
194 track_color_override: self.track_color.clone(),
195 fill_color_override: self.fill_color.clone(),
196 };
197
198 let root = if use_shader_path {
210 let track = self
211 .track_color
212 .clone()
213 .unwrap_or_else(|| SurfaceRole::Sunken.into());
214 let fill = self
215 .fill_color
216 .clone()
217 .unwrap_or_else(|| SurfaceRole::Accent.into());
218 let handle = ctx.animated_quad(AnimatedQuadKind::IndeterminateSweep {
219 period: sweep_period,
220 sweep_ratio: INDETERMINATE_SWEEP_RATIO,
221 track_color: track,
222 fill_color: fill,
223 });
224 ctx.add(IndeterminateSweepLeaf::shader(handle))
225 } else if self.indeterminate {
226 let frame_id = style.make_body(&cfg, ctx);
227 let pos = ctx.animated_signal(0.0);
228 if !reduced_motion {
232 ctx.animate()
233 .sweep()
234 .linear()
235 .frame_interval(INDETERMINATE_FRAME_INTERVAL)
236 .to(&pos, 1.0);
237 }
238 let fill = self
239 .fill_color
240 .clone()
241 .unwrap_or_else(|| SurfaceRole::Accent.into());
242 let leaf_id = ctx.add(IndeterminateSweepLeaf::signal(self.orientation, pos, fill));
243 ctx.add(ZStack::new().add_child(frame_id).add_child(leaf_id))
244 } else {
245 self.value.register_if_bound(
251 ctx.self_id(),
252 ctx.binding_registry(),
253 BindingLevel::AccessibilityOnly,
254 );
255 style.make_body(&cfg, ctx)
256 };
257 self.root_child_id = Some(root);
258 vec![root]
259 }
260
261 fn layout_response(
262 &self,
263 proposal: SizeProposal,
264 _ctx: &LayoutContext,
265 ) -> teksilo_core::widget::LayoutResponse {
266 match self.orientation {
267 Orientation::Horizontal => {
268 let width = proposal.width.unwrap_or(100.0);
269 Size::new(width, self.thickness)
270 }
271 Orientation::Vertical => {
272 let height = proposal.height.unwrap_or(100.0);
273 Size::new(self.thickness, height)
274 }
275 }
276 .into()
277 }
278
279 fn place_children(
280 &self,
281 bounds: Rect,
282 _proposal: SizeProposal,
283 children: &mut [WidgetPlacement],
284 _ctx: &LayoutContext,
285 ) {
286 for child in children.iter_mut() {
287 child.origin = bounds.origin();
288 child.size = bounds.size();
289 }
290 }
291
292 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
293 builder.set_role(teksilo_core::accesskit::Role::ProgressIndicator);
294 if let Some(ref label) = self.label {
295 builder.set_name(label.clone());
296 }
297 builder.set_live(teksilo_core::accesskit::Live::Polite);
302 if !self.indeterminate {
303 let value = self.value.get();
304 builder.set_numeric_value(value as f64);
305 builder.set_min_numeric_value(0.0);
306 builder.set_max_numeric_value(1.0);
307 }
308 }
309
310 fn children(&self) -> Vec<WidgetId> {
311 self.root_child_id.into_iter().collect()
312 }
313}
314
315enum IndeterminateSweepLeaf {
321 Shader(AnimatedQuadHandle),
323 Signal {
326 orientation: Orientation,
327 pos: Signal<f32>,
328 fill: ColorProp,
329 },
330}
331
332impl IndeterminateSweepLeaf {
333 fn shader(handle: AnimatedQuadHandle) -> Self {
334 Self::Shader(handle)
335 }
336 fn signal(orientation: Orientation, pos: Signal<f32>, fill: ColorProp) -> Self {
337 Self::Signal {
338 orientation,
339 pos,
340 fill,
341 }
342 }
343}
344
345impl std::fmt::Debug for IndeterminateSweepLeaf {
346 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347 match self {
348 Self::Shader(_) => f.debug_struct("IndeterminateSweepLeaf::Shader").finish(),
349 Self::Signal { .. } => f.debug_struct("IndeterminateSweepLeaf::Signal").finish(),
350 }
351 }
352}
353
354impl Widget for IndeterminateSweepLeaf {
355 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
356 if let Self::Signal { pos, .. } = self {
357 let id = ctx.self_id();
358 pos.bind_to(id, ctx.binding_registry(), BindingLevel::RepaintOnly);
359 }
360 vec![]
361 }
362
363 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse
364 where
365 Self: Sized,
366 {
367 Size::new(
370 proposal.width.unwrap_or(0.0),
371 proposal.height.unwrap_or(0.0),
372 )
373 .into()
374 }
375
376 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
377 match self {
378 Self::Shader(handle) => {
379 canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural);
384 }
385 Self::Signal {
386 orientation,
387 pos,
388 fill,
389 } => {
390 let radius = CornerRadius::uniform(PROGRESS_BAR_CORNER_RADIUS);
391 let value = pos.get().clamp(0.0, 1.0);
392 let fill_color = fill.resolve(ctx.theme, ctx.effective_enabled);
393 let fill_rect = match orientation {
394 Orientation::Horizontal => {
395 let sweep_w = bounds.width * INDETERMINATE_SWEEP_RATIO;
396 let x = bounds.x - sweep_w + (bounds.width + sweep_w) * value;
397 Rect::new(x, bounds.y, sweep_w, bounds.height)
398 }
399 Orientation::Vertical => {
400 let sweep_h = bounds.height * INDETERMINATE_SWEEP_RATIO;
401 let y = bounds.y - sweep_h + (bounds.height + sweep_h) * value;
402 Rect::new(bounds.x, y, bounds.width, sweep_h)
403 }
404 };
405 canvas.fill_rounded_rect(fill_rect, radius, fill_color);
406 }
407 }
408 }
409
410 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
411 builder.set_hidden();
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use teksilo_core::widget_tree::WidgetTree;
421
422 #[test]
423 fn progress_bar_size() {
424 let mut tree = WidgetTree::new();
425 let pb = tree.add(ProgressBar::new(0.5));
426 tree.layout(SizeProposal {
427 width: Some(200.0),
428 height: None,
429 });
430 let b = tree.bounds(pb);
431 assert!((b.width - 200.0).abs() < 0.01);
432 assert!((b.height - 4.0).abs() < 0.01);
433 }
434
435 #[test]
436 fn progress_bar_paints_track_and_fill() {
437 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
438 tree.add(ProgressBar::new(0.5));
439 tree.layout(SizeProposal::exact(200.0, 100.0));
440 let frame = tree.render();
441 assert!(frame.shapes.len() >= 2, "should have track and fill shapes");
442 }
443
444 #[test]
445 fn progress_bar_fill_width_proportional() {
446 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
447 let _pb = tree.add(ProgressBar::new(0.5).fill_color(Color::RED));
448 tree.layout(SizeProposal::exact(200.0, 100.0));
449 let frame = tree.render();
450 let fill_shapes: Vec<_> = frame
451 .shapes
452 .iter()
453 .filter(|s| s.color == Color::RED.to_array())
454 .collect();
455 assert!(!fill_shapes.is_empty(), "should have a red fill shape");
456 let fill = &fill_shapes[0];
457 let fill_width = fill.screen[2];
458 assert!(
459 (fill_width - 100.0).abs() < 1.0,
460 "fill width should be ~100, got {}",
461 fill_width
462 );
463 }
464
465 #[test]
466 fn zero_value_no_fill() {
467 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
468 tree.add(ProgressBar::new(0.0).fill_color(Color::RED));
469 tree.layout(SizeProposal::exact(200.0, 100.0));
470 let frame = tree.render();
471 let fill_shapes: Vec<_> = frame
472 .shapes
473 .iter()
474 .filter(|s| s.color == Color::RED.to_array())
475 .collect();
476 assert!(fill_shapes.is_empty(), "zero progress should have no fill");
477 }
478
479 #[test]
480 fn accessibility_values() {
481 let mut tree = WidgetTree::new();
482 let pb = tree.add(ProgressBar::new(0.75));
483 tree.layout(SizeProposal::exact(200.0, 100.0));
484 let info = tree.accessibility_node(pb);
485 assert_eq!(
486 info.role(),
487 teksilo_core::accesskit::Role::ProgressIndicator
488 );
489
490 let update = tree.sync_accessibility();
492 let nid = teksilo_core::accessibility::widget_id_to_node_id(pb);
493 let node = update
494 .nodes
495 .iter()
496 .find(|(id, _)| *id == nid)
497 .map(|(_, n)| n)
498 .expect("progress bar node in tree");
499 assert_eq!(node.numeric_value(), Some(0.75));
500 assert_eq!(node.live(), Some(teksilo_core::accesskit::Live::Polite));
504 }
505
506 #[test]
507 fn indeterminate_progress_bar_emits_animated_quad() {
508 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
509 tree.add(ProgressBar::indeterminate());
510
511 tree.layout(SizeProposal::exact(200.0, 40.0));
512 let frame1 = tree.render();
513 assert_eq!(
514 frame1.animated_quads.len(),
515 1,
516 "horizontal indeterminate should emit exactly one AnimatedQuad"
517 );
518 assert_eq!(frame1.anim_params.len(), 1);
519 let phase1 = frame1.anim_params[frame1.animated_quads[0].slot as usize].phase;
520
521 std::thread::sleep(Duration::from_millis(250));
522 let frame2 = tree.render();
523 let phase2 = frame2.anim_params[frame2.animated_quads[0].slot as usize].phase;
524 assert_ne!(
525 phase1, phase2,
526 "animated-quad phase must advance between frames"
527 );
528 }
529}