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    /// The app's thickness, if it set one.
72    thickness: Option<f32>,
73    /// The thickness the style resolved at the last build declares
74    /// ([`ProgressBarStyle::thickness`](teksilo_core::styles::ProgressBarStyle::thickness)),
75    /// used when the app set none.
76    style_thickness: Option<f32>,
77    track_color: Option<ColorProp>,
78    fill_color: Option<ColorProp>,
79    label: Option<LocalizedString>,
80    /// Per-call override for the stationary chrome (track + determinate fill).
81    style_override: Option<SharedProgressBarStyle>,
82    root_child_id: Option<WidgetId>,
83}
84
85impl ProgressBar {
86    /// Create a determinate progress bar with a static value (0.0–1.0).
87    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    /// Create an indeterminate progress bar (animated sweep).
103    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    /// Bind the progress value to a reactive state.
119    pub fn value(mut self, state: impl Into<Prop<f32>>) -> Self {
120        self.value = state.into();
121        self
122    }
123
124    /// Set the bar's orientation. Default is `Orientation::Horizontal`.
125    /// Vertical bars use the shader-driven animation path only for horizontal;
126    /// vertical indeterminate bars use the signal-driven path instead.
127    pub fn orientation(mut self, orientation: Orientation) -> Self {
128        self.orientation = orientation;
129        self
130    }
131
132    /// Set the bar's narrow dimension in logical pixels. For horizontal bars
133    /// this is the height; for vertical bars this is the width. Default is the
134    /// active style's thickness, 4.0 for the shipped style.
135    pub fn thickness(mut self, thickness: f32) -> Self {
136        self.thickness = Some(thickness);
137        self
138    }
139
140    /// The narrow dimension laid out: the app's, else the style's, else the
141    /// shipped 4.0.
142    fn effective_thickness(&self) -> f32 {
143        self.thickness
144            .or(self.style_thickness)
145            .unwrap_or(DEFAULT_THICKNESS)
146    }
147
148    /// Override the track background. Default (unset) is `SurfaceRole::Sunken`.
149    /// Accepts `Color`, roles, or `Signal<Color>`.
150    pub fn track_color(mut self, color: impl Into<ColorProp>) -> Self {
151        self.track_color = Some(color.into());
152        self
153    }
154
155    /// Override the fill / sweep color. Default (unset) is `SurfaceRole::Accent`.
156    /// Accepts `Color`, roles, or `Signal<Color>`.
157    pub fn fill_color(mut self, color: impl Into<ColorProp>) -> Self {
158        self.fill_color = Some(color.into());
159        self
160    }
161
162    /// Per-call style override for the stationary chrome (track +
163    /// determinate fill). The indeterminate sweep is widget-owned and
164    /// always uses the shader-quad / signal-driven path described in
165    /// the module doc; the style supplies the sweep's *colour*
166    /// recipe via `fill_color_override` / `track_color_override`.
167    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    /// Accessible name for the progress bar.
173    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        // Reduced-motion gate: an indeterminate sweep is decorative;
191        // when reduced-motion is on, fall through to a static
192        // signal-driven path that never animates (pos stays at 0).
193        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        // A style that paints its own busy indicator takes the indeterminate
207        // bar whole, in either orientation: no sweep is drawn over it.
208        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        // Three branches matching the module-doc paint paths:
223        //
224        // 1. Horizontal indeterminate (shader): the shader self-paints
225        //    track + sweep in one procedural quad; mount ONLY the
226        //    sweep leaf, skip the recipe frame to avoid double-painting
227        //    the track.
228        // 2. Vertical indeterminate (or reduced-motion fallback): the
229        //    recipe frame paints the track; the sweep leaf paints the
230        //    moving fill on top inside a `ZStack`.
231        // 3. Determinate (or reduced-motion non-indeterminate): the
232        //    recipe frame paints track + proportional fill; no leaf.
233        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            // Sub-perceptual epsilon + 15 Hz frame-interval cadence,
255            // per the module-doc rationale. Skipped under
256            // reduced-motion so the signal stays at 0.0.
257            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            // Determinate: register the bound value on the ProgressBar itself at
272            // AccessibilityOnly so a progress update re-walks the AT tree and
273            // re-announces `numeric_value` (WCAG 4.1.3). The value otherwise
274            // only drives RepaintOnly painting inside the recipe frame and
275            // never reaches assistive tech.
276            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        // Polite in both states, so the bar's label is announced as it
324        // appears. Every adapter announces a live node's name, as it enters
325        // the tree and when the name changes, and never a change of its
326        // value: a determinate advance reaches a screen reader as the
327        // value-changed event every numeric value raises, which Orca 46.1
328        // speaks for a progress bar through its own progress-bar updates
329        // (`onValueChanged`, `isProgressBarUpdate`) whether or not it is
330        // live.
331        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
345/// Internal leaf that paints the indeterminate sweep. Owns the only
346/// remaining `paint()` in the `ProgressBar` widget family (the
347/// motion-infrastructure call to `draw_animated_quad` or the
348/// signal-driven moving fill); the parent `ProgressBar` itself stays
349/// pure composition.
350enum IndeterminateSweepLeaf {
351    /// Horizontal shader path — one procedural quad per frame.
352    Shader(AnimatedQuadHandle),
353    /// Vertical / reduced-motion signal path — a rect placed at
354    /// `pos ∈ [0, 1]` along the long axis.
355    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        // Fills whatever bounds the parent ZStack / ProgressBar
398        // assigns.
399        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                // One quad per frame; the fragment shader self-paints
410                // track + sweep. Sweep extends slightly past the
411                // rounded corners on large radii — acceptable trade
412                // for one-draw-call animation.
413                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        // Presentational — the parent `ProgressBar` emits the
442        // `Role::ProgressIndicator` node.
443        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        // Inspect the raw AccessKit node for numeric value + live region.
522        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        // Audit G4: a determinate progress bar is a polite live region as
532        // well, so its label is announced as it appears; its advance is the
533        // numeric value above, which no adapter announces as a live change.
534        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    /// A style that sizes its bar and paints its own busy indicator: a red
562    /// rectangle for an indeterminate bar, so the test can find it.
563    #[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    /// `bar` laid out 200 long with its narrow dimension left to it.
589    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    /// The style's thickness is the bar's when the app names none, across
607    /// either orientation, and the app's own thickness wins over it.
608    #[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        // The shipped style declares none, which keeps the widget's 4.
621        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    /// A style that paints its own busy indicator is mounted alone for an
631    /// indeterminate bar in both orientations: no shader quad, no sweep.
632    #[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        // The shipped style keeps the widget's sweep.
655        assert!(!crate::styles::RecipeProgressBarStyle::default().paints_busy_indicator());
656    }
657}