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(|| {
187 Rc::new(crate::styles::RecipeProgressBarStyle::for_tokens(
188 &ctx.theme().input,
189 ))
190 });
191 let cfg = ProgressBarStyleConfig {
192 orientation: self.orientation,
193 progress: if self.indeterminate {
194 ProgressKind::Indeterminate
195 } else {
196 ProgressKind::Determinate(self.value.clone())
197 },
198 track_color_override: self.track_color.clone(),
199 fill_color_override: self.fill_color.clone(),
200 };
201
202 let root = if use_shader_path {
214 let track = self
215 .track_color
216 .clone()
217 .unwrap_or_else(|| SurfaceRole::Sunken.into());
218 let fill = self
219 .fill_color
220 .clone()
221 .unwrap_or_else(|| SurfaceRole::Accent.into());
222 let handle = ctx.animated_quad(AnimatedQuadKind::IndeterminateSweep {
223 period: sweep_period,
224 sweep_ratio: INDETERMINATE_SWEEP_RATIO,
225 track_color: track,
226 fill_color: fill,
227 });
228 ctx.add(IndeterminateSweepLeaf::shader(handle))
229 } else if self.indeterminate {
230 let frame_id = style.make_body(&cfg, ctx);
231 let pos = ctx.animated_signal(0.0);
232 if !reduced_motion {
236 ctx.animate()
237 .sweep()
238 .linear()
239 .frame_interval(INDETERMINATE_FRAME_INTERVAL)
240 .to(&pos, 1.0);
241 }
242 let fill = self
243 .fill_color
244 .clone()
245 .unwrap_or_else(|| SurfaceRole::Accent.into());
246 let leaf_id = ctx.add(IndeterminateSweepLeaf::signal(self.orientation, pos, fill));
247 ctx.add(ZStack::new().child(frame_id).child(leaf_id))
248 } else {
249 self.value.register_if_bound(
255 ctx.self_id(),
256 ctx.binding_registry(),
257 BindingLevel::AccessibilityOnly,
258 );
259 style.make_body(&cfg, ctx)
260 };
261 self.root_child_id = Some(root);
262 vec![root]
263 }
264
265 fn layout_response(
266 &self,
267 proposal: SizeProposal,
268 _ctx: &LayoutContext,
269 ) -> teksilo_core::widget::LayoutResponse {
270 match self.orientation {
271 Orientation::Horizontal => {
272 let width = proposal.width.unwrap_or(100.0);
273 Size::new(width, self.thickness)
274 }
275 Orientation::Vertical => {
276 let height = proposal.height.unwrap_or(100.0);
277 Size::new(self.thickness, height)
278 }
279 }
280 .into()
281 }
282
283 fn place_children(
284 &self,
285 bounds: Rect,
286 _proposal: SizeProposal,
287 children: &mut [WidgetPlacement],
288 _ctx: &LayoutContext,
289 ) {
290 for child in children.iter_mut() {
291 child.origin = bounds.origin();
292 child.size = bounds.size();
293 }
294 }
295
296 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
297 builder.set_role(teksilo_core::accesskit::Role::ProgressIndicator);
298 if let Some(ref label) = self.label {
299 builder.set_name(label.clone());
300 }
301 builder.set_live(teksilo_core::accesskit::Live::Polite);
306 if !self.indeterminate {
307 let value = self.value.get();
308 builder.set_numeric_value(value as f64);
309 builder.set_min_numeric_value(0.0);
310 builder.set_max_numeric_value(1.0);
311 }
312 }
313
314 fn children(&self) -> Vec<WidgetId> {
315 self.root_child_id.into_iter().collect()
316 }
317}
318
319enum IndeterminateSweepLeaf {
325 Shader(AnimatedQuadHandle),
327 Signal {
330 orientation: Orientation,
331 pos: Signal<f32>,
332 fill: ColorProp,
333 },
334}
335
336impl IndeterminateSweepLeaf {
337 fn shader(handle: AnimatedQuadHandle) -> Self {
338 Self::Shader(handle)
339 }
340 fn signal(orientation: Orientation, pos: Signal<f32>, fill: ColorProp) -> Self {
341 Self::Signal {
342 orientation,
343 pos,
344 fill,
345 }
346 }
347}
348
349impl std::fmt::Debug for IndeterminateSweepLeaf {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 match self {
352 Self::Shader(_) => f.debug_struct("IndeterminateSweepLeaf::Shader").finish(),
353 Self::Signal { .. } => f.debug_struct("IndeterminateSweepLeaf::Signal").finish(),
354 }
355 }
356}
357
358impl Widget for IndeterminateSweepLeaf {
359 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
360 if let Self::Signal { pos, .. } = self {
361 let id = ctx.self_id();
362 pos.bind_to(id, ctx.binding_registry(), BindingLevel::RepaintOnly);
363 }
364 vec![]
365 }
366
367 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse
368 where
369 Self: Sized,
370 {
371 Size::new(
374 proposal.width.unwrap_or(0.0),
375 proposal.height.unwrap_or(0.0),
376 )
377 .into()
378 }
379
380 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
381 match self {
382 Self::Shader(handle) => {
383 canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural);
388 }
389 Self::Signal {
390 orientation,
391 pos,
392 fill,
393 } => {
394 let radius = CornerRadius::uniform(PROGRESS_BAR_CORNER_RADIUS);
395 let value = pos.get().clamp(0.0, 1.0);
396 let fill_color = fill.resolve(ctx.theme, ctx.effective_enabled);
397 let fill_rect = match orientation {
398 Orientation::Horizontal => {
399 let sweep_w = bounds.width * INDETERMINATE_SWEEP_RATIO;
400 let x = bounds.x - sweep_w + (bounds.width + sweep_w) * value;
401 Rect::new(x, bounds.y, sweep_w, bounds.height)
402 }
403 Orientation::Vertical => {
404 let sweep_h = bounds.height * INDETERMINATE_SWEEP_RATIO;
405 let y = bounds.y - sweep_h + (bounds.height + sweep_h) * value;
406 Rect::new(bounds.x, y, bounds.width, sweep_h)
407 }
408 };
409 canvas.fill_rounded_rect(fill_rect, radius, fill_color);
410 }
411 }
412 }
413
414 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
415 builder.set_hidden();
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use teksilo_core::widget_tree::WidgetTree;
425
426 #[test]
427 fn progress_bar_size() {
428 let mut tree = WidgetTree::new();
429 let pb = tree.add(ProgressBar::new(0.5));
430 tree.layout(SizeProposal {
431 width: Some(200.0),
432 height: None,
433 });
434 let b = tree.bounds(pb);
435 assert!((b.width - 200.0).abs() < 0.01);
436 assert!((b.height - 4.0).abs() < 0.01);
437 }
438
439 #[test]
440 fn progress_bar_paints_track_and_fill() {
441 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
442 tree.add(ProgressBar::new(0.5));
443 tree.layout(SizeProposal::exact(200.0, 100.0));
444 let frame = tree.render();
445 assert!(frame.shapes.len() >= 2, "should have track and fill shapes");
446 }
447
448 #[test]
449 fn progress_bar_fill_width_proportional() {
450 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
451 let _pb = tree.add(ProgressBar::new(0.5).fill_color(Color::RED));
452 tree.layout(SizeProposal::exact(200.0, 100.0));
453 let frame = tree.render();
454 let fill_shapes: Vec<_> = frame
455 .shapes
456 .iter()
457 .filter(|s| s.color == Color::RED.to_array())
458 .collect();
459 assert!(!fill_shapes.is_empty(), "should have a red fill shape");
460 let fill = &fill_shapes[0];
461 let fill_width = fill.screen[2];
462 assert!(
463 (fill_width - 100.0).abs() < 1.0,
464 "fill width should be ~100, got {}",
465 fill_width
466 );
467 }
468
469 #[test]
470 fn zero_value_no_fill() {
471 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
472 tree.add(ProgressBar::new(0.0).fill_color(Color::RED));
473 tree.layout(SizeProposal::exact(200.0, 100.0));
474 let frame = tree.render();
475 let fill_shapes: Vec<_> = frame
476 .shapes
477 .iter()
478 .filter(|s| s.color == Color::RED.to_array())
479 .collect();
480 assert!(fill_shapes.is_empty(), "zero progress should have no fill");
481 }
482
483 #[test]
484 fn accessibility_values() {
485 let mut tree = WidgetTree::new();
486 let pb = tree.add(ProgressBar::new(0.75));
487 tree.layout(SizeProposal::exact(200.0, 100.0));
488 let info = tree.accessibility_node(pb);
489 assert_eq!(
490 info.role(),
491 teksilo_core::accesskit::Role::ProgressIndicator
492 );
493
494 let update = tree.sync_accessibility();
496 let nid = teksilo_core::accessibility::widget_id_to_node_id(pb);
497 let node = update
498 .nodes
499 .iter()
500 .find(|(id, _)| *id == nid)
501 .map(|(_, n)| n)
502 .expect("progress bar node in tree");
503 assert_eq!(node.numeric_value(), Some(0.75));
504 assert_eq!(node.live(), Some(teksilo_core::accesskit::Live::Polite));
508 }
509
510 #[test]
511 fn indeterminate_progress_bar_emits_animated_quad() {
512 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
513 tree.add(ProgressBar::indeterminate());
514
515 tree.layout(SizeProposal::exact(200.0, 40.0));
516 let frame1 = tree.render();
517 assert_eq!(
518 frame1.animated_quads.len(),
519 1,
520 "horizontal indeterminate should emit exactly one AnimatedQuad"
521 );
522 assert_eq!(frame1.anim_params.len(), 1);
523 let phase1 = frame1.anim_params[frame1.animated_quads[0].slot as usize].phase;
524
525 std::thread::sleep(Duration::from_millis(250));
526 let frame2 = tree.render();
527 let phase2 = frame2.anim_params[frame2.animated_quads[0].slot as usize].phase;
528 assert_ne!(
529 phase1, phase2,
530 "animated-quad phase must advance between frames"
531 );
532 }
533}