Skip to main content

lumen_primitives/
progress.rs

1//! `<progress>` bar behavior: determinate fill sync + indeterminate
2//! sweep animation.
3//!
4//! The markup parser desugars `<progress>` into a track element (tag
5//! `progress`, carrying [`ProgressBar`]) with a single `.progress-fill`
6//! child (carrying [`ProgressFill`]). Both are real elements, so all
7//! styling - track / fill colors, height, radius, the indeterminate
8//! sweep period - is CSS-reachable through the skins:
9//!
10//! ```css
11//! progress       { bg: var(--lumen-track); height: 6; radius: 3; }
12//! .progress-fill { bg: var(--lumen-accent); radius: 3; }
13//! progress       { progress-duration: var(--lumen-progress-period); }
14//! ```
15//!
16//! - Determinate (`value=`, or a `bind-value=` signal once it holds a
17//!   number): the fill's width tracks `value / max` as a percent of the
18//!   track.
19//! - Indeterminate (no `value`, and no number yet in a `bind-value`
20//!   signal): a 30 %-wide chunk sweeps back and forth (GTK-style bounce; period from
21//!   `progress-duration`, token `--lumen-progress-period`). The sweep
22//!   keeps the frame loop awake via [`AnimationsActive`] only while an
23//!   indeterminate bar is actually visible.
24//! - Not focusable, no interaction - `<progress>` never carries
25//!   `TabIndex` and none of these systems consume input.
26
27use bevy_ecs::prelude::*;
28use lumen_core::components::{BindValue, Edges, Length, Style, Transform, Visible};
29use lumen_core::prelude::*;
30use lumen_core::property_store::{PropertyKey, PropertyStore, PropertyValue};
31use lumen_core::render_world::AnimationsActive;
32use lumen_core::style_overrides::{Live, StyleFields, StyleOverrides, StyleOwner, StyleValues};
33use lumen_core::time::Instant;
34
35/// Runtime fallback for the indeterminate sweep period. The single
36/// Rust-side value; skins route `progress-duration:
37/// var(--lumen-progress-period)` over it and markup `duration=` wins.
38pub const PROGRESS_PERIOD_MS: u32 = 1200;
39
40/// Fallback fraction of the track width the indeterminate chunk
41/// occupies, used when the track carries no [`ProgressChunk`] (e.g. no
42/// CSS `progress-chunk` authored).
43const INDETERMINATE_FILL_FRACTION: f32 = 0.3;
44
45/// Width of the indeterminate chunk as a fraction of the track.
46#[derive(Component, Clone, Copy, Debug, PartialEq)]
47pub struct ProgressChunk(pub f32);
48
49impl Default for ProgressChunk {
50    fn default() -> Self {
51        Self(INDETERMINATE_FILL_FRACTION)
52    }
53}
54
55/// State for one `<progress>` element (the track entity).
56#[derive(Component, Clone, Debug)]
57pub struct ProgressBar {
58    /// `Some(v)` = determinate at `v / max`; `None` = indeterminate.
59    /// `bind-value` writes flip an indeterminate bar to determinate.
60    pub value: Option<f32>,
61    /// Upper bound for `value`. Authored `max=`, default 1.0.
62    pub max: f32,
63    /// Indeterminate sweep period in milliseconds (one full
64    /// left-to-right-to-left bounce).
65    pub period_ms: u32,
66}
67
68impl ProgressBar {
69    /// `value` held to this bar's bounds.
70    ///
71    /// A progress bar starts at zero and runs to its [`Self::max`]; it has no
72    /// lower bound of its own, which is what separates it from a
73    /// [`lumen_core::components::SliderValue`]. A negative `max` would leave
74    /// no range at all, so the upper bound is held at zero too.
75    ///
76    /// Both the signal binding and the web emitter writing the page ahead of
77    /// it read the rule here, so a bar in a document and a bar in the app
78    /// that adopts it sit in the same place.
79    pub fn clamp(&self, value: f32) -> f32 {
80        value.clamp(0.0, self.max.max(0.0))
81    }
82}
83
84impl Default for ProgressBar {
85    fn default() -> Self {
86        Self {
87            value: None,
88            max: 1.0,
89            period_ms: PROGRESS_PERIOD_MS,
90        }
91    }
92}
93
94/// Marker on the fill child spawned inside every `<progress>`. Its width
95/// and left edge are [`sync_progress_fill`]'s, held in [`StyleOverrides`].
96#[derive(Component, Clone, Copy, Debug, Default)]
97#[require(StyleOverrides)]
98pub struct ProgressFill;
99
100impl StyleOwner for ProgressFill {
101    const RANK: u8 = 20;
102}
103
104/// Plugin: registers the fill sync system.
105///
106/// The binding pull, [`apply_progress_bindings`], is installed by whoever
107/// wires the app's signal bindings, beside the other pull readers, because
108/// it must run after every writer of the store on the tick (the script's
109/// command applier, the derivation pass, the control write-backs) and those
110/// are named only there.
111pub struct ProgressPlugin;
112
113impl Plugin for ProgressPlugin {
114    fn build(self, app: &mut App) {
115        // After the pull so the fill reflects this tick's value.
116        app.add_systems(
117            TickStage::Systems,
118            sync_progress_fill.after(apply_progress_bindings),
119        );
120    }
121}
122
123/// Pull `bind-value` signal writes into [`ProgressBar::value`]. Same
124/// dirty-gated shape as `lumen_core::signals::apply_value_bindings`
125/// (which targets sliders - progress deliberately does not carry a
126/// `SliderValue`, or it would inherit wheel / click / drag mutation),
127/// and installed with the same ordering, after every store writer, so a
128/// write's one-tick dirty flag is still set when this reads it.
129pub fn apply_progress_bindings(
130    store: Res<PropertyStore>,
131    mut q: Query<(
132        &BindValue,
133        &mut ProgressBar,
134        Option<&mut lumen_core::components::A11yValue>,
135    )>,
136    new_binds: Query<(), Added<BindValue>>,
137) {
138    if store.dirty_peek().is_empty() && new_binds.is_empty() {
139        return;
140    }
141    for (bind, mut bar, a11y) in &mut q {
142        let key = PropertyKey::Global(std::sync::Arc::<str>::from(bind.0.as_str()));
143        let Some(pv) = store.get(&key) else {
144            continue;
145        };
146        let parsed: Option<f32> = match pv {
147            PropertyValue::F64(n) => Some(*n as f32),
148            PropertyValue::I64(n) => Some(*n as f32),
149            PropertyValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
150            PropertyValue::Str(s) => s.as_ref().parse::<f32>().ok(),
151            _ => None,
152        };
153        let Some(parsed) = parsed else {
154            continue;
155        };
156        let clamped = bar.clamp(parsed);
157        if bar.value != Some(clamped) {
158            bar.value = Some(clamped);
159        }
160        // Mirror into the accessibility value so the published reading
161        // follows `bind-value` writes. Guarded so an unchanged value
162        // does not trip the a11y change detection.
163        if let Some(mut v) = a11y {
164            let (now, max) = (f64::from(clamped), f64::from(bar.max));
165            if v.now != now || v.max != max {
166                v.now = now;
167                v.max = max;
168            }
169        }
170    }
171}
172
173/// Triangle wave in `[0, 1]` for phase `t` in `[0, 1]`: `0 -> 1` over
174/// the first half, `1 -> 0` over the second (GTK-style bounce).
175fn bounce(t: f32) -> f32 {
176    let t = t.fract();
177    if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 }
178}
179
180/// Keep every `.progress-fill` child in step with its parent
181/// [`ProgressBar`].
182///
183/// - Determinate: `width = value / max` percent, pinned left.
184/// - Indeterminate: fixed 30 % width, `inset.left` bouncing across the
185///   remaining track width on [`ProgressBar::period_ms`]; requests an
186///   [`AnimationsActive`] follow-up tick while any such bar is visible
187///   so the sweep animates without input events. Hidden bars (closed
188///   tab, hidden dialog) stop requesting ticks - quiescence holds.
189#[allow(clippy::type_complexity, clippy::too_many_arguments)]
190pub fn sync_progress_fill(
191    mut commands: Commands,
192    bars: Query<(&ProgressBar, &Transform, Option<&ProgressChunk>)>,
193    mut fills: Query<(Entity, &ChildOf, &mut Style, &mut StyleOverrides), With<ProgressFill>>,
194    parents: Query<&ChildOf>,
195    visibles: Query<&Visible>,
196    // `Without<ProgressFill>` keeps this read disjoint from the `&mut
197    // Style` above (B0001); the hidden walk only ever inspects the
198    // track and its ancestors, never a fill child.
199    styles: Query<&Style, Without<ProgressFill>>,
200    anim: Option<Res<AnimationsActive>>,
201    mut epoch: Local<Option<Instant>>,
202) {
203    let epoch = *epoch.get_or_insert_with(Instant::now);
204    let now = Instant::now();
205    let mut any_indeterminate_visible = false;
206    for (fill_e, child_of, mut style, mut overrides) in &mut fills {
207        let parent = child_of.parent();
208        let Ok((bar, tr, chunk)) = bars.get(parent) else {
209            continue;
210        };
211        // The fill's width and left edge are the bar's to drive, held over
212        // whatever its styles say so a restyle keeps them; the other edges
213        // stay the styles'.
214        let mut drive = |style: &mut Mut<Style>, width: Length, left: f32| {
215            let live = Live {
216                style: Some(style),
217                ..Live::default()
218            };
219            let resting = overrides
220                .below::<ProgressFill>(StyleFields::INSET, &live)
221                .inset
222                .unwrap_or(style.inset);
223            let held = StyleValues {
224                width: Some(width),
225                inset: Some(Edges { left, ..resting }),
226                ..StyleValues::default()
227            };
228            overrides
229                .hold::<ProgressFill>(held, &live)
230                .write_style(style);
231            commands.entity(fill_e).insert(DirtyLayout);
232        };
233        match bar.value {
234            Some(v) => {
235                let denom = bar.max.max(f32::EPSILON);
236                let pct = (v / denom).clamp(0.0, 1.0) * 100.0;
237                let target = Length::Percent(pct);
238                if style.width != target || style.inset.left != 0.0 {
239                    drive(&mut style, target, 0.0);
240                }
241            }
242            None => {
243                // Shared section 17.4 walk over a `Without<ProgressFill>`-filtered
244                // Style query (inferred `F`) so it coexists with the
245                // fill-mutating `styles` view in this same system.
246                if hidden_via_ancestors(parent, &parents, &visibles, &styles) {
247                    continue;
248                }
249                any_indeterminate_visible = true;
250                if tr.size.x <= 0.0 {
251                    continue;
252                }
253                let period = bar.period_ms.max(1) as f32 / 1000.0;
254                let phase = (now - epoch).as_secs_f32() / period;
255                let fraction = chunk.copied().unwrap_or_default().0;
256                let fill_w = tr.size.x * fraction;
257                let left = bounce(phase) * (tr.size.x - fill_w).max(0.0);
258                let target_w = Length::Percent(fraction * 100.0);
259                // An `auto` (NaN) `left` has never been placed.
260                if style.width != target_w
261                    || style.inset.left.is_nan()
262                    || (style.inset.left - left).abs() > 0.25
263                {
264                    drive(&mut style, target_w, left);
265                }
266            }
267        }
268    }
269    if any_indeterminate_visible && let Some(anim) = anim {
270        anim.request();
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use bevy_ecs::system::RunSystemOnce;
278    use glam::Vec2;
279
280    fn spawn_bar(world: &mut World, bar: ProgressBar, width: f32) -> (Entity, Entity) {
281        let track = world
282            .spawn((bar, Transform::new(Vec2::ZERO, Vec2::new(width, 6.0))))
283            .id();
284        let fill = world
285            .spawn((
286                ProgressFill,
287                Style {
288                    position: lumen_core::components::Position::Absolute,
289                    ..Default::default()
290                },
291                ChildOf(track),
292            ))
293            .id();
294        (track, fill)
295    }
296
297    #[test]
298    fn determinate_fill_width_tracks_value_over_max() {
299        let mut world = World::new();
300        let (_, fill) = spawn_bar(
301            &mut world,
302            ProgressBar {
303                value: Some(30.0),
304                max: 100.0,
305                period_ms: PROGRESS_PERIOD_MS,
306            },
307            200.0,
308        );
309        world.run_system_once(sync_progress_fill).unwrap();
310        let style = world.get::<Style>(fill).unwrap();
311        let Length::Percent(p) = style.width else {
312            panic!("fill width must be percent, got {:?}", style.width);
313        };
314        assert!((p - 30.0).abs() < 0.01, "expected ~30%, got {p}");
315        assert_eq!(style.inset.left, 0.0);
316    }
317
318    #[test]
319    fn determinate_value_clamps_to_max() {
320        let mut world = World::new();
321        let (track, fill) = spawn_bar(&mut world, ProgressBar::default(), 200.0);
322        world.get_mut::<ProgressBar>(track).unwrap().value = Some(250.0);
323        world.get_mut::<ProgressBar>(track).unwrap().max = 100.0;
324        world.run_system_once(sync_progress_fill).unwrap();
325        let style = world.get::<Style>(fill).unwrap();
326        assert_eq!(
327            style.width,
328            Length::Percent(100.0),
329            "value > max pins at 100%"
330        );
331    }
332
333    #[test]
334    fn indeterminate_bar_animates_and_requests_ticks() {
335        let mut world = World::new();
336        world.insert_resource(AnimationsActive::default());
337        // No `ProgressChunk` on the track - the no-CSS-authored path must
338        // reproduce today's hardcoded 30% chunk exactly.
339        let (_, fill) = spawn_bar(&mut world, ProgressBar::default(), 200.0);
340        world.run_system_once(sync_progress_fill).unwrap();
341        let style = world.get::<Style>(fill).unwrap();
342        let Length::Percent(p) = style.width else {
343            panic!("fill width must be percent, got {:?}", style.width);
344        };
345        assert!(
346            (p - 30.0).abs() < 0.01,
347            "indeterminate chunk is ~30%, got {p}"
348        );
349        assert!(
350            world.resource::<AnimationsActive>().get(),
351            "visible indeterminate bar keeps the frame loop awake"
352        );
353    }
354
355    /// A CSS-supplied [`ProgressChunk`] on the track changes the
356    /// indeterminate sweep's chunk width.
357    #[test]
358    fn custom_progress_chunk_changes_the_fill_width() {
359        let mut world = World::new();
360        world.insert_resource(AnimationsActive::default());
361        let (track, fill) = spawn_bar(&mut world, ProgressBar::default(), 200.0);
362        world.entity_mut(track).insert(ProgressChunk(0.6));
363        world.run_system_once(sync_progress_fill).unwrap();
364        let style = world.get::<Style>(fill).unwrap();
365        let Length::Percent(p) = style.width else {
366            panic!("fill width must be percent, got {:?}", style.width);
367        };
368        assert!(
369            (p - 60.0).abs() < 0.01,
370            "a CSS-supplied progress-chunk of 0.6 renders as ~60%, got {p}"
371        );
372    }
373
374    #[test]
375    fn hidden_indeterminate_bar_stays_quiescent() {
376        let mut world = World::new();
377        world.insert_resource(AnimationsActive::default());
378        let (track, _) = spawn_bar(&mut world, ProgressBar::default(), 200.0);
379        world.entity_mut(track).insert(Visible(false));
380        world.run_system_once(sync_progress_fill).unwrap();
381        assert!(
382            !world.resource::<AnimationsActive>().get(),
383            "hidden bar must not request ticks"
384        );
385    }
386
387    #[test]
388    fn binding_write_flips_indeterminate_to_determinate() {
389        let mut world = World::new();
390        let mut store = PropertyStore::default();
391        store.set_global_str("pct", "0.4");
392        world.insert_resource(store);
393        let (track, _) = spawn_bar(&mut world, ProgressBar::default(), 200.0);
394        world.entity_mut(track).insert(BindValue("pct".into()));
395        world.run_system_once(apply_progress_bindings).unwrap();
396        let bar = world.get::<ProgressBar>(track).unwrap();
397        assert_eq!(bar.value, Some(0.4));
398    }
399
400    #[test]
401    fn bounce_sweeps_out_and_back() {
402        assert_eq!(bounce(0.0), 0.0);
403        assert_eq!(bounce(0.25), 0.5);
404        assert_eq!(bounce(0.5), 1.0);
405        assert_eq!(bounce(0.75), 0.5);
406        assert!(bounce(0.999) < 0.01);
407    }
408}