1use 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
35pub const PROGRESS_PERIOD_MS: u32 = 1200;
39
40const INDETERMINATE_FILL_FRACTION: f32 = 0.3;
44
45#[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#[derive(Component, Clone, Debug)]
57pub struct ProgressBar {
58 pub value: Option<f32>,
61 pub max: f32,
63 pub period_ms: u32,
66}
67
68impl ProgressBar {
69 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#[derive(Component, Clone, Copy, Debug, Default)]
97#[require(StyleOverrides)]
98pub struct ProgressFill;
99
100impl StyleOwner for ProgressFill {
101 const RANK: u8 = 20;
102}
103
104pub struct ProgressPlugin;
112
113impl Plugin for ProgressPlugin {
114 fn build(self, app: &mut App) {
115 app.add_systems(
117 TickStage::Systems,
118 sync_progress_fill.after(apply_progress_bindings),
119 );
120 }
121}
122
123pub 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 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
173fn 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#[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 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 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 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 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 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 #[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}