Skip to main content

damascene_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 Damascene 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 damascene_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
32// Lock in full per-item documentation for this module (issue #73).
33#![warn(missing_docs)]
34
35use std::panic::Location;
36
37use crate::layout::LayoutCtx;
38use crate::metrics::MetricsRole;
39use crate::shader::{ShaderBinding, StockShader, UniformValue};
40use crate::tokens;
41use crate::tree::*;
42
43/// Default bar height in logical pixels.
44pub const DEFAULT_HEIGHT: f32 = 8.0;
45
46/// A horizontal progress bar. `value` is clamped to `0.0..=1.0`; the
47/// returned `El` defaults to filling its container's width and a
48/// fixed [`DEFAULT_HEIGHT`]. Override with `.height(...)` /
49/// `.width(...)` like any El.
50///
51/// The visible portion fills with [`tokens::PRIMARY`]; use
52/// [`progress_with_color`] to vary it (e.g. `tokens::SUCCESS`, or
53/// `tokens::DESTRUCTIVE` when the value crosses a "near full"
54/// threshold).
55#[track_caller]
56pub fn progress(value: f32) -> El {
57    progress_with_color(value, tokens::PRIMARY)
58}
59
60/// A [`progress`] bar whose visible portion uses `fill_color` instead
61/// of the default [`tokens::PRIMARY`].
62#[track_caller]
63pub fn progress_with_color(value: f32, fill_color: Color) -> El {
64    let value = value.clamp(0.0, 1.0);
65    let layout = move |ctx: LayoutCtx| {
66        let r = ctx.container;
67        vec![
68            // Track spans the full container.
69            Rect::new(r.x, r.y, r.w, r.h),
70            // Fill spans the portion proportional to value.
71            Rect::new(r.x, r.y, r.w * value, r.h),
72        ]
73    };
74
75    El::new(Kind::Custom("progress"))
76        .axis(Axis::Overlay)
77        .children([
78            El::new(Kind::Custom("progress-track"))
79                .fill(tokens::MUTED)
80                .radius(tokens::RADIUS_PILL),
81            El::new(Kind::Custom("progress-fill"))
82                .fill(fill_color)
83                .radius(tokens::RADIUS_PILL),
84        ])
85        .at_loc(Location::caller())
86        .metrics_role(MetricsRole::Progress)
87        .layout(layout)
88        .width(Size::Fill(1.0))
89        .default_height(Size::Fixed(DEFAULT_HEIGHT))
90}
91
92/// Indeterminate horizontal loader — same dimensions as
93/// [`progress`], but with a small [`tokens::PRIMARY`] bar sliding
94/// back and forth across a muted track on a continuous loop. Use this
95/// in progress slots where no completion ratio is available
96/// (uploading to a server that doesn't report bytes-sent, parsing a
97/// stream of unknown length, etc.). The runtime keeps the host loop
98/// ticking automatically while one is in the tree. Use
99/// [`progress_indeterminate_with_color`] for a different bar color.
100///
101/// ```ignore
102/// use damascene_core::prelude::*;
103///
104/// row([
105///     text("Uploading…").label(),
106///     spacer(),
107///     progress_indeterminate().width(Size::Fixed(120.0)),
108/// ])
109/// ```
110#[track_caller]
111pub fn progress_indeterminate() -> El {
112    progress_indeterminate_with_color(tokens::PRIMARY)
113}
114
115/// A [`progress_indeterminate`] loader whose sliding bar uses
116/// `bar_color` instead of the default [`tokens::PRIMARY`].
117#[track_caller]
118pub fn progress_indeterminate_with_color(bar_color: Color) -> El {
119    let binding = ShaderBinding::stock(StockShader::ProgressIndeterminate)
120        .with("vec_a", UniformValue::Color(bar_color))
121        .with("vec_b", UniformValue::Color(tokens::MUTED))
122        // vec_c.x = radius (0 = default 4px; for a pill at 8px height we want PILL)
123        // vec_c.y = period seconds (0 = default 1.6)
124        // vec_c.z = bar width as fraction of track (0 = default 0.35)
125        // vec_c.w unused
126        .with(
127            "vec_c",
128            UniformValue::Vec4([tokens::RADIUS_PILL, 0.0, 0.0, 0.0]),
129        );
130
131    El::new(Kind::Custom("progress-indeterminate"))
132        .at_loc(Location::caller())
133        .shader(binding)
134        .metrics_role(MetricsRole::Progress)
135        .width(Size::Fill(1.0))
136        .default_height(Size::Fixed(DEFAULT_HEIGHT))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn track_and_fill_use_expected_tokens() {
145        let p = progress(0.5);
146        assert_eq!(p.children.len(), 2);
147        assert_eq!(p.children[0].fill, Some(tokens::MUTED), "track is muted");
148        assert_eq!(
149            p.children[1].fill,
150            Some(tokens::PRIMARY),
151            "fill uses caller's color"
152        );
153        // Both rounded pills so the bar reads as one piece.
154        assert_eq!(
155            p.children[0].radius,
156            crate::tree::Corners::all(tokens::RADIUS_PILL)
157        );
158        assert_eq!(
159            p.children[1].radius,
160            crate::tree::Corners::all(tokens::RADIUS_PILL)
161        );
162    }
163
164    #[test]
165    fn layout_clamps_value_below_zero() {
166        // The visible result of a clamped value is the fill rect's
167        // width, so verify the layout closure end-to-end.
168        use crate::layout::layout;
169        use crate::state::UiState;
170
171        let mut tree = progress(-0.5);
172        let mut state = UiState::new();
173        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
174        layout(&mut tree, &mut state, viewport);
175        let fill_rect = tree.children[1].computed_rect;
176        assert_eq!(fill_rect.w, 0.0, "negative values clamp to empty fill");
177    }
178
179    #[test]
180    fn layout_clamps_value_above_one() {
181        use crate::layout::layout;
182        use crate::state::UiState;
183
184        let mut tree = progress(1.5);
185        let mut state = UiState::new();
186        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
187        layout(&mut tree, &mut state, viewport);
188        let fill_rect = tree.children[1].computed_rect;
189        assert_eq!(fill_rect.w, 200.0, "values above 1.0 clamp to full track");
190    }
191
192    #[test]
193    fn indeterminate_binds_stock_shader() {
194        use crate::shader::ShaderHandle;
195        let p = progress_indeterminate();
196        let binding = p.shader_override.as_ref().expect("shader binding");
197        assert_eq!(
198            binding.handle,
199            ShaderHandle::Stock(StockShader::ProgressIndeterminate),
200            "progress_indeterminate must paint through stock::progress_indeterminate",
201        );
202        match binding.uniforms.get("vec_a") {
203            Some(UniformValue::Color(c)) => assert_eq!(*c, tokens::PRIMARY),
204            other => panic!("expected vec_a=PRIMARY, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn indeterminate_inherits_progress_dimensions() {
210        let p = progress_indeterminate();
211        assert_eq!(p.width, Size::Fill(1.0));
212        assert_eq!(p.height, Size::Fixed(DEFAULT_HEIGHT));
213    }
214
215    #[test]
216    fn layout_fills_proportionally_to_value() {
217        use crate::layout::layout;
218        use crate::state::UiState;
219
220        let mut tree = progress(0.25);
221        let mut state = UiState::new();
222        let viewport = Rect::new(0.0, 0.0, 200.0, DEFAULT_HEIGHT);
223        layout(&mut tree, &mut state, viewport);
224        let fill_rect = tree.children[1].computed_rect;
225        assert!(
226            (fill_rect.w - 50.0).abs() < 1e-3,
227            "0.25 * 200 = 50; got {}",
228            fill_rect.w
229        );
230    }
231}