Skip to main content

teksilo_widgets/
progress_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ProgressBar — a bar showing progress from 0.0 to 1.0.
5//!
6//! Supports determinate (fixed or reactive value), indeterminate (animated
7//! sweep), horizontal, and vertical orientations. The stationary chrome (track
8//! and determinate fill) is delegated to `ProgressBarStyle`; the indeterminate
9//! sweep is widget-owned (motion infrastructure is not chrome). Three paint
10//! paths exist internally:
11//!
12//! - **Horizontal indeterminate** uses the shader-driven animated-quad
13//!   pipeline. `ProgressBar::build` registers an `AnimatedQuadHandle`
14//!   and mounts a single `IndeterminateSweepLeaf` whose `paint()`
15//!   issues one `draw_animated_quad` per frame; the shader composes
16//!   the track + moving fill in a procedural draw. The recipe frame
17//!   is NOT mounted in this case (the shader self-paints both).
18//! - **Vertical indeterminate** keeps the signal-based path. The
19//!   recipe frame paints the track; an `IndeterminateSweepLeaf` in
20//!   signal mode paints a moving fill rect on top driven by a
21//!   `Signal<f32>::animate_looping`.
22//! - **Determinate** mounts the recipe frame only; the frame paints
23//!   the track plus a proportional fill rect.
24//!
25//! ```rust
26//! # use teksilo_widgets::ProgressBar;
27//! # use teksilo_core::signal::Signal;
28//! // Static determinate bar at 70 %:
29//! let _bar = ProgressBar::new(0.7).thickness(6.0);
30//!
31//! // Reactive determinate bar:
32//! let progress = Signal::new(0.0_f32);
33//! let _bar = ProgressBar::new(0.0).value(progress);
34//!
35//! // Indeterminate (animated sweep):
36//! let _spinner_bar = ProgressBar::indeterminate();
37//! ```
38
39use 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;
60/// ~15 Hz cadence — see module-level note in the original. The eye
61/// doesn't resolve >15 fps for the wide slow sweep, and every doubled
62/// frame is a full wgpu submit.
63const INDETERMINATE_FRAME_INTERVAL: Duration = Duration::from_millis(66);
64const INDETERMINATE_SWEEP_RATIO: f32 = 0.42;
65
66/// A progress bar — determinate or indeterminate, horizontal or vertical.
67pub 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    /// Per-call override for the stationary chrome (track + determinate fill).
76    style_override: Option<SharedProgressBarStyle>,
77    root_child_id: Option<WidgetId>,
78}
79
80impl ProgressBar {
81    /// Create a determinate progress bar with a static value (0.0–1.0).
82    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    /// Create an indeterminate progress bar (animated sweep).
97    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    /// Bind the progress value to a reactive state.
112    pub fn value(mut self, state: impl Into<Prop<f32>>) -> Self {
113        self.value = state.into();
114        self
115    }
116
117    /// Set the bar's orientation. Default is `Orientation::Horizontal`.
118    /// Vertical bars use the shader-driven animation path only for horizontal;
119    /// vertical indeterminate bars use the signal-driven path instead.
120    pub fn orientation(mut self, orientation: Orientation) -> Self {
121        self.orientation = orientation;
122        self
123    }
124
125    /// Set the bar's narrow dimension in logical pixels. For horizontal bars
126    /// this is the height; for vertical bars this is the width. Default is 4.0.
127    pub fn thickness(mut self, thickness: f32) -> Self {
128        self.thickness = thickness;
129        self
130    }
131
132    /// Override the track background. Default (unset) is `SurfaceRole::Sunken`.
133    /// Accepts `Color`, roles, or `Signal<Color>`.
134    pub fn track_color(mut self, color: impl Into<ColorProp>) -> Self {
135        self.track_color = Some(color.into());
136        self
137    }
138
139    /// Override the fill / sweep color. Default (unset) is `SurfaceRole::Accent`.
140    /// Accepts `Color`, roles, or `Signal<Color>`.
141    pub fn fill_color(mut self, color: impl Into<ColorProp>) -> Self {
142        self.fill_color = Some(color.into());
143        self
144    }
145
146    /// Per-call style override for the stationary chrome (track +
147    /// determinate fill). The indeterminate sweep is widget-owned and
148    /// always uses the shader-quad / signal-driven path described in
149    /// the module doc; the style supplies the sweep's *colour*
150    /// recipe via `fill_color_override` / `track_color_override`.
151    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    /// Accessible name for the progress bar.
157    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        // Reduced-motion gate: an indeterminate sweep is decorative;
175        // when reduced-motion is on, fall through to a static
176        // signal-driven path that never animates (pos stays at 0).
177        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        // Three branches matching the module-doc paint paths:
199        //
200        // 1. Horizontal indeterminate (shader): the shader self-paints
201        //    track + sweep in one procedural quad; mount ONLY the
202        //    sweep leaf, skip the recipe frame to avoid double-painting
203        //    the track.
204        // 2. Vertical indeterminate (or reduced-motion fallback): the
205        //    recipe frame paints the track; the sweep leaf paints the
206        //    moving fill on top inside a `ZStack`.
207        // 3. Determinate (or reduced-motion non-indeterminate): the
208        //    recipe frame paints track + proportional fill; no leaf.
209        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            // Sub-perceptual epsilon + 15 Hz frame-interval cadence,
229            // per the module-doc rationale. Skipped under
230            // reduced-motion so the signal stays at 0.0.
231            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            // Determinate: register the bound value on the ProgressBar itself at
246            // AccessibilityOnly so a progress update re-walks the AT tree and
247            // re-announces `numeric_value` (WCAG 4.1.3). The value otherwise
248            // only drives RepaintOnly painting inside the recipe frame and
249            // never reaches assistive tech.
250            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        // Announce progress updates to assistive tech (WCAG 4.1.3) for BOTH
298        // states: the indeterminate "busy" state and each determinate value
299        // change. Previously only the indeterminate branch was live, so a
300        // determinate bar advancing 0% -> 100% was silent to screen readers.
301        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
315/// Internal leaf that paints the indeterminate sweep. Owns the only
316/// remaining `paint()` in the `ProgressBar` widget family (the
317/// motion-infrastructure call to `draw_animated_quad` or the
318/// signal-driven moving fill); the parent `ProgressBar` itself stays
319/// pure composition.
320enum IndeterminateSweepLeaf {
321    /// Horizontal shader path — one procedural quad per frame.
322    Shader(AnimatedQuadHandle),
323    /// Vertical / reduced-motion signal path — a rect placed at
324    /// `pos ∈ [0, 1]` along the long axis.
325    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        // Fills whatever bounds the parent ZStack / ProgressBar
368        // assigns.
369        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                // One quad per frame; the fragment shader self-paints
380                // track + sweep. Sweep extends slightly past the
381                // rounded corners on large radii — acceptable trade
382                // for one-draw-call animation.
383                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        // Presentational — the parent `ProgressBar` emits the
412        // `Role::ProgressIndicator` node.
413        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        // Inspect the raw AccessKit node for numeric value + live region.
491        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        // WCAG 4.1.3 (audit G4): a determinate progress bar is a polite live
501        // region so value advances are announced — previously only the
502        // indeterminate branch set this, leaving determinate progress silent.
503        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}