Skip to main content

aetna_core/widgets/
progress.rs

1//! Progress — a non-interactive horizontal bar showing how full a
2//! `0.0..=1.0` value is. Shaped like the shadcn / Radix Progress
3//! primitive, scaled down to a single `progress(value)` builder
4//! because Aetna progress bars don't need to advertise their
5//! indeterminate or label-bearing state — apps compose those around
6//! the bar.
7//!
8//! ```ignore
9//! use aetna_core::prelude::*;
10//!
11//! struct Storage { used_pct: u32 }
12//!
13//! impl App for Storage {
14//!     fn build(&self, _cx: &BuildCx) -> El {
15//!         column([
16//!             row([
17//!                 text("Storage").label(),
18//!                 spacer(),
19//!                 text(format!("{}%", self.used_pct)).muted(),
20//!             ]),
21//!             progress(self.used_pct as f32 / 100.0),
22//!         ])
23//!     }
24//! }
25//! ```
26//!
27//! Progress paints the same way as the slider track + fill, minus the
28//! thumb. There's no `apply_event` because the widget is read-only —
29//! apps update the underlying value through whatever channel they
30//! own (timer tick, async snapshot, computed metric, ...).
31
32use std::panic::Location;
33
34use crate::layout::LayoutCtx;
35use crate::metrics::MetricsRole;
36use crate::shader::{ShaderBinding, StockShader, UniformValue};
37use crate::tokens;
38use crate::tree::*;
39
40/// Default bar height in logical pixels.
41pub const DEFAULT_HEIGHT: f32 = 8.0;
42
43/// A horizontal progress bar. `value` is clamped to `0.0..=1.0`; the
44/// returned `El` defaults to filling its container's width and a
45/// fixed [`DEFAULT_HEIGHT`]. Override with `.height(...)` /
46/// `.width(...)` like any El.
47///
48/// Pass `tokens::PRIMARY`, `tokens::SUCCESS`, etc. via `fill_color`
49/// to vary the visible portion's color (e.g. switch to
50/// `tokens::DESTRUCTIVE` when the value crosses a "near full"
51/// threshold).
52#[track_caller]
53pub fn progress(value: f32, fill_color: Color) -> El {
54    let value = value.clamp(0.0, 1.0);
55    let layout = move |ctx: LayoutCtx| {
56        let r = ctx.container;
57        vec![
58            // Track spans the full container.
59            Rect::new(r.x, r.y, r.w, r.h),
60            // Fill spans the portion proportional to value.
61            Rect::new(r.x, r.y, r.w * value, r.h),
62        ]
63    };
64
65    stack([
66        El::new(Kind::Custom("progress-track"))
67            .fill(tokens::MUTED)
68            .radius(tokens::RADIUS_PILL),
69        El::new(Kind::Custom("progress-fill"))
70            .fill(fill_color)
71            .radius(tokens::RADIUS_PILL),
72    ])
73    .at_loc(Location::caller())
74    .metrics_role(MetricsRole::Progress)
75    .layout(layout)
76    .width(Size::Fill(1.0))
77    .default_height(Size::Fixed(DEFAULT_HEIGHT))
78}
79
80/// Indeterminate horizontal loader — same dimensions as
81/// [`progress`], but with a small bar of `bar_color` sliding back
82/// and forth across a muted track on a continuous loop. Use this in
83/// progress slots where no completion ratio is available (uploading
84/// to a server that doesn't report bytes-sent, parsing a stream of
85/// unknown length, etc.). The runtime keeps the host loop ticking
86/// automatically while one is in the tree.
87///
88/// ```ignore
89/// use aetna_core::prelude::*;
90///
91/// row([
92///     text("Uploading…").label(),
93///     spacer(),
94///     progress_indeterminate(tokens::PRIMARY)
95///         .width(Size::Fixed(120.0)),
96/// ])
97/// ```
98#[track_caller]
99pub fn progress_indeterminate(bar_color: Color) -> El {
100    let binding = ShaderBinding::stock(StockShader::ProgressIndeterminate)
101        .with("vec_a", UniformValue::Color(bar_color))
102        .with("vec_b", UniformValue::Color(tokens::MUTED))
103        // vec_c.x = radius (0 = default 4px; for a pill at 8px height we want PILL)
104        // vec_c.y = period seconds (0 = default 1.6)
105        // vec_c.z = bar width as fraction of track (0 = default 0.35)
106        // vec_c.w unused
107        .with(
108            "vec_c",
109            UniformValue::Vec4([tokens::RADIUS_PILL, 0.0, 0.0, 0.0]),
110        );
111
112    El::new(Kind::Custom("progress-indeterminate"))
113        .at_loc(Location::caller())
114        .shader(binding)
115        .metrics_role(MetricsRole::Progress)
116        .width(Size::Fill(1.0))
117        .default_height(Size::Fixed(DEFAULT_HEIGHT))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn track_and_fill_use_expected_tokens() {
126        let p = progress(0.5, tokens::PRIMARY);
127        assert_eq!(p.children.len(), 2);
128        assert_eq!(p.children[0].fill, Some(tokens::MUTED), "track is muted");
129        assert_eq!(
130            p.children[1].fill,
131            Some(tokens::PRIMARY),
132            "fill uses caller's color"
133        );
134        // Both rounded pills so the bar reads as one piece.
135        assert_eq!(
136            p.children[0].radius,
137            crate::tree::Corners::all(tokens::RADIUS_PILL)
138        );
139        assert_eq!(
140            p.children[1].radius,
141            crate::tree::Corners::all(tokens::RADIUS_PILL)
142        );
143    }
144
145    #[test]
146    fn layout_clamps_value_below_zero() {
147        // The visible result of a clamped value is the fill rect's
148        // width, so verify the layout closure end-to-end.
149        use crate::layout::layout;
150        use crate::state::UiState;
151
152        let mut tree = progress(-0.5, tokens::PRIMARY);
153        let mut state = UiState::new();
154        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
155        layout(&mut tree, &mut state, viewport);
156        let fill_rect = state.rect(&tree.children[1].computed_id);
157        assert_eq!(fill_rect.w, 0.0, "negative values clamp to empty fill");
158    }
159
160    #[test]
161    fn layout_clamps_value_above_one() {
162        use crate::layout::layout;
163        use crate::state::UiState;
164
165        let mut tree = progress(1.5, tokens::PRIMARY);
166        let mut state = UiState::new();
167        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
168        layout(&mut tree, &mut state, viewport);
169        let fill_rect = state.rect(&tree.children[1].computed_id);
170        assert_eq!(fill_rect.w, 200.0, "values above 1.0 clamp to full track");
171    }
172
173    #[test]
174    fn indeterminate_binds_stock_shader() {
175        use crate::shader::ShaderHandle;
176        let p = progress_indeterminate(tokens::PRIMARY);
177        let binding = p.shader_override.as_ref().expect("shader binding");
178        assert_eq!(
179            binding.handle,
180            ShaderHandle::Stock(StockShader::ProgressIndeterminate),
181            "progress_indeterminate must paint through stock::progress_indeterminate",
182        );
183        match binding.uniforms.get("vec_a") {
184            Some(UniformValue::Color(c)) => assert_eq!(*c, tokens::PRIMARY),
185            other => panic!("expected vec_a=PRIMARY, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn indeterminate_inherits_progress_dimensions() {
191        let p = progress_indeterminate(tokens::PRIMARY);
192        assert_eq!(p.width, Size::Fill(1.0));
193        assert_eq!(p.height, Size::Fixed(DEFAULT_HEIGHT));
194    }
195
196    #[test]
197    fn layout_fills_proportionally_to_value() {
198        use crate::layout::layout;
199        use crate::state::UiState;
200
201        let mut tree = progress(0.25, tokens::PRIMARY);
202        let mut state = UiState::new();
203        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
204        layout(&mut tree, &mut state, viewport);
205        let fill_rect = state.rect(&tree.children[1].computed_id);
206        assert!(
207            (fill_rect.w - 50.0).abs() < 1e-3,
208            "0.25 * 200 = 50; got {}",
209            fill_rect.w
210        );
211    }
212}