Skip to main content

tessera_components/
glass_progress.rs

1//! A progress bar with a glassmorphic visual style.
2//!
3//! ## Usage
4//!
5//! Use to indicate the completion of a task or a specific value in a range.
6use tessera_ui::{
7    Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
8    layout::{MeasureScope, layout},
9    tessera,
10};
11
12use crate::{
13    fluid_glass::{GlassBorder, fluid_glass},
14    modifier::ModifierExt as _,
15    shape_def::{RoundedCorner, Shape},
16};
17
18fn default_progress_modifier() -> Modifier {
19    Modifier::new().width(Dp(200.0))
20}
21
22/// Produce a capsule-shaped RoundedRectangle shape for the given height (px).
23fn capsule_shape_for_height(height: Dp) -> Shape {
24    let radius = Dp(height.0 / 2.0);
25    Shape::RoundedRectangle {
26        top_left: RoundedCorner::manual(radius, 2.0),
27        top_right: RoundedCorner::manual(radius, 2.0),
28        bottom_right: RoundedCorner::manual(radius, 2.0),
29        bottom_left: RoundedCorner::manual(radius, 2.0),
30    }
31}
32
33#[tessera]
34pub(crate) fn glass_progress_fill(
35    value: Option<f32>,
36    tint_color: Option<Color>,
37    blur_radius: Option<Dp>,
38    shape: Option<Shape>,
39) {
40    let value = value.unwrap_or(0.0);
41    let tint_color = tint_color.unwrap_or(Color::TRANSPARENT);
42    let blur_radius = blur_radius.unwrap_or(Dp(0.0));
43    let shape = shape.unwrap_or(Shape::CAPSULE);
44    let value = value.clamp(0.0, 1.0);
45    layout()
46        .policy(GlassProgressFillLayout { value })
47        .child(move || {
48            fluid_glass()
49                .tint_color(tint_color)
50                .blur_radius(blur_radius)
51                .shape(shape)
52                .child(|| {});
53        });
54}
55
56#[derive(Clone, PartialEq)]
57struct GlassProgressFillLayout {
58    value: f32,
59}
60
61impl tessera_ui::LayoutModifierNode for GlassProgressFillLayout {
62    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
63        let mut result = LayoutResult::default();
64        let available_width = input
65            .parent_constraint()
66            .width()
67            .resolve_max()
68            .unwrap_or(Px(0));
69        let available_height = input
70            .parent_constraint()
71            .height()
72            .resolve_max()
73            .unwrap_or(Px(0));
74
75        let width_px = Px((available_width.to_f32() * self.value).round() as i32);
76        let child = input
77            .children()
78            .first()
79            .copied()
80            .expect("progress fill child should exist");
81
82        let child_constraint = Constraint::exact(width_px, available_height);
83        child.measure(input, &child_constraint)?;
84        result.place_child(child, PxPosition::new(Px(0), Px(0)));
85
86        Ok(result.with_size(ComputedData {
87            width: width_px,
88            height: available_height,
89        }))
90    }
91}
92
93/// # glass_progress
94///
95/// Renders a progress bar with a customizable glass effect.
96///
97/// ## Usage
98///
99/// Display a value in a continuous range (0.0 to 1.0) with a modern, glass-like
100/// appearance.
101///
102/// ## Parameters
103///
104/// - `value` — progress value in the range `0.0..=1.0`.
105/// - `modifier` — optional modifier chain for width and layout.
106/// - `height` — optional progress bar height.
107/// - `track_tint_color` — optional glass tint color for the track background.
108/// - `progress_tint_color` — optional glass tint color for the progress fill.
109/// - `blur_radius` — optional blur radius for the glass effect.
110/// - `track_border_width` — optional border width for the track.
111///
112/// ## Examples
113///
114/// ```
115/// use tessera_components::glass_progress::glass_progress;
116///
117/// # use tessera_ui::tessera;
118/// # #[tessera]
119/// # fn component() {
120/// // Render a progress bar at 75% completion.
121/// glass_progress().value(0.75);
122/// # }
123/// # tessera_ui::testing::layout_test(component).run();
124/// ```
125#[tessera]
126pub fn glass_progress(
127    value: Option<f32>,
128    modifier: Option<Modifier>,
129    height: Option<Dp>,
130    track_tint_color: Option<Color>,
131    progress_tint_color: Option<Color>,
132    blur_radius: Option<Dp>,
133    track_border_width: Option<Dp>,
134) {
135    let value = value.unwrap_or(0.0);
136    let modifier = modifier.unwrap_or_else(default_progress_modifier);
137    let height = height.unwrap_or(Dp(12.0));
138    let track_tint_color = track_tint_color.unwrap_or(Color::new(0.3, 0.3, 0.3, 0.15));
139    let progress_tint_color = progress_tint_color.unwrap_or(Color::new(0.5, 0.7, 1.0, 0.25));
140    let blur_radius = blur_radius.unwrap_or(Dp(8.0));
141    let track_border_width = track_border_width.unwrap_or(Dp(1.0));
142    let height_px = height.to_px();
143    layout()
144        .modifier(modifier)
145        .policy(GlassProgressLayout { height: height_px })
146        .child(move || {
147            let effective_height = Dp((height.0 - (track_border_width.0 * 2.0)).max(0.0));
148            let fill_shape = capsule_shape_for_height(effective_height);
149
150            fluid_glass()
151                .tint_color(track_tint_color)
152                .blur_radius(blur_radius)
153                .shape(capsule_shape_for_height(height))
154                .border(GlassBorder::new(track_border_width.into()))
155                .padding(track_border_width)
156                .child(move || {
157                    glass_progress_fill()
158                        .value(value)
159                        .tint_color(progress_tint_color)
160                        .blur_radius(blur_radius)
161                        .shape(fill_shape);
162                });
163        });
164}
165
166#[derive(Clone, Copy, PartialEq)]
167struct GlassProgressLayout {
168    height: Px,
169}
170
171impl tessera_ui::LayoutModifierNode for GlassProgressLayout {
172    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
173        let mut result = LayoutResult::default();
174        let track = input
175            .children()
176            .first()
177            .copied()
178            .expect("track should exist");
179        let constraint = Constraint::new(input.parent_constraint().width(), self.height);
180        let track_measurement = track.measure(input, &constraint)?;
181        result.place_child(track, PxPosition::new(Px(0), Px(0)));
182        Ok(result.with_size(track_measurement.size()))
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use tessera_ui::testing::{layout_test, pump_layout_test_session, start_layout_test_session};
189
190    use crate::{column::column, glass_slider::glass_slider};
191
192    use super::*;
193
194    #[tessera]
195    fn controls(value: f32) {
196        column().children(move || {
197            glass_slider()
198                .modifier(Modifier::new().width(Dp(200.0)))
199                .track_height(Dp(20.0))
200                .track_border_width(Dp::ZERO)
201                .value(value);
202            glass_progress()
203                .modifier(Modifier::new().width(Dp(200.0)))
204                .height(Dp(20.0))
205                .track_border_width(Dp::ZERO)
206                .value(value);
207        });
208    }
209
210    #[test]
211    fn glass_controls_measure_their_painted_fill_from_clamped_progress() {
212        for (value, width) in [(-0.5, 0), (0.25, 50), (0.75, 150), (1.5, 200)] {
213            let mut session = start_layout_test_session(
214                layout_test(move || {
215                    controls(value);
216                })
217                .viewport_px(220, 60),
218            );
219            pump_layout_test_session(&mut session, 0);
220            let snapshot = session.inspect_components();
221            let sizes: Vec<_> = snapshot
222                .nodes()
223                .iter()
224                .filter(|node| {
225                    std::iter::successors(snapshot.parent(node), |parent| snapshot.parent(parent))
226                        .take_while(|parent| parent.layout_coordinates().is_none())
227                        .any(|parent| parent.name() == "fluid_glass_inner")
228                })
229                .filter_map(|node| {
230                    node.layout_coordinates()
231                        .map(|coordinates| coordinates.local_size)
232                })
233                .collect();
234            assert_eq!(
235                sizes,
236                [
237                    tessera_ui::PxSize::new(Px(200), Px(20)),
238                    tessera_ui::PxSize::new(Px(width), Px(20)),
239                    tessera_ui::PxSize::new(Px(200), Px(20)),
240                    tessera_ui::PxSize::new(Px(width), Px(20)),
241                ]
242            );
243        }
244    }
245}