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(|| {
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        // Three branches matching the module-doc paint paths:
203        //
204        // 1. Horizontal indeterminate (shader): the shader self-paints
205        //    track + sweep in one procedural quad; mount ONLY the
206        //    sweep leaf, skip the recipe frame to avoid double-painting
207        //    the track.
208        // 2. Vertical indeterminate (or reduced-motion fallback): the
209        //    recipe frame paints the track; the sweep leaf paints the
210        //    moving fill on top inside a `ZStack`.
211        // 3. Determinate (or reduced-motion non-indeterminate): the
212        //    recipe frame paints track + proportional fill; no leaf.
213        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            // Sub-perceptual epsilon + 15 Hz frame-interval cadence,
233            // per the module-doc rationale. Skipped under
234            // reduced-motion so the signal stays at 0.0.
235            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            // Determinate: register the bound value on the ProgressBar itself at
250            // AccessibilityOnly so a progress update re-walks the AT tree and
251            // re-announces `numeric_value` (WCAG 4.1.3). The value otherwise
252            // only drives RepaintOnly painting inside the recipe frame and
253            // never reaches assistive tech.
254            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        // Announce progress updates to assistive tech (WCAG 4.1.3) for BOTH
302        // states: the indeterminate "busy" state and each determinate value
303        // change. Previously only the indeterminate branch was live, so a
304        // determinate bar advancing 0% -> 100% was silent to screen readers.
305        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
319/// Internal leaf that paints the indeterminate sweep. Owns the only
320/// remaining `paint()` in the `ProgressBar` widget family (the
321/// motion-infrastructure call to `draw_animated_quad` or the
322/// signal-driven moving fill); the parent `ProgressBar` itself stays
323/// pure composition.
324enum IndeterminateSweepLeaf {
325    /// Horizontal shader path — one procedural quad per frame.
326    Shader(AnimatedQuadHandle),
327    /// Vertical / reduced-motion signal path — a rect placed at
328    /// `pos ∈ [0, 1]` along the long axis.
329    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        // Fills whatever bounds the parent ZStack / ProgressBar
372        // assigns.
373        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                // One quad per frame; the fragment shader self-paints
384                // track + sweep. Sweep extends slightly past the
385                // rounded corners on large radii — acceptable trade
386                // for one-draw-call animation.
387                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        // Presentational — the parent `ProgressBar` emits the
416        // `Role::ProgressIndicator` node.
417        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        // Inspect the raw AccessKit node for numeric value + live region.
495        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        // WCAG 4.1.3 (audit G4): a determinate progress bar is a polite live
505        // region so value advances are announced — previously only the
506        // indeterminate branch set this, leaving determinate progress silent.
507        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}