Skip to main content

teksilo_widgets/styles/
recipe_progress_bar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ProgressBarStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeProgressBarStyle` ships the IntUI progress-bar stationary
7//! chrome: a `surface_sunken` track and (for determinate bars) an
8//! `accent`-colored proportional fill. The indeterminate sweep is
9//! deliberately *not* part of this recipe — it stays widget-owned in
10//! `ProgressBar::build`, which mounts an `IndeterminateSweepLeaf` on
11//! top for both the horizontal-shader path and the vertical /
12//! reduced-motion signal path (principle 6: motion infrastructure is
13//! not chrome).
14//!
15//! Apps that want a different progress look (segmented chunks, gradient
16//! fill, branded colour) write their own `impl ProgressBarStyle` block
17//! and install it per-call (`ProgressBar::style(...)`) or theme-wide
18//! (`theme.style_slots.progress_bar`).
19
20use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
21use teksilo_core::accessibility::AccessNodeBuilder;
22use teksilo_core::binding::BindingLevel;
23use teksilo_core::build_context::BuildContext;
24use teksilo_core::color_prop::ColorProp;
25use teksilo_core::signal::Prop;
26use teksilo_core::styles::{ProgressBarStyle, ProgressBarStyleConfig, ProgressKind};
27use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{CornerRadius, InputTokens, Orientation, SurfaceRole};
30
31// IntUI design tokens for ProgressBar. The recipe owns its own dimensions.
32pub const PROGRESS_BAR_CORNER_RADIUS: f32 = 2.0;
33
34/// Configurable dimensions for [`RecipeProgressBarStyle`].
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct ProgressBarRecipe {
37    pub corner_radius: f32,
38}
39
40impl ProgressBarRecipe {
41    /// This recipe's dimensions resolved against a density's [`InputTokens`].
42    ///
43    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
44    /// ladder — so the shipped values below are the Compact column by
45    /// construction and cannot drift from it.
46    ///
47    /// Every dimension this recipe carries is a decoration (a corner radius, a
48    /// hairline, a glyph metric), so the parameter is unused: the density
49    /// ladder never moves any of them. It is taken all the same, so every
50    /// recipe is constructed the same way at its widget's build site.
51    pub fn for_tokens(_tokens: &InputTokens) -> Self {
52        Self {
53            corner_radius: PROGRESS_BAR_CORNER_RADIUS,
54        }
55    }
56}
57
58impl Default for ProgressBarRecipe {
59    fn default() -> Self {
60        Self::for_tokens(&InputTokens::default())
61    }
62}
63
64/// Default `ProgressBarStyle` shipped with Teksilo. Track is
65/// `SurfaceRole::Sunken`, fill is `SurfaceRole::Accent`.
66#[derive(Debug, Default, Clone, Copy)]
67pub struct RecipeProgressBarStyle {
68    pub recipe: ProgressBarRecipe,
69}
70
71impl RecipeProgressBarStyle {
72    pub fn new(recipe: ProgressBarRecipe) -> Self {
73        Self { recipe }
74    }
75
76    /// This style with every dimension resolved against a density's
77    /// [`InputTokens`], as `RecipeProgressBarStyle::for_tokens(&ctx.theme().input)` at
78    /// the widget's own build site.
79    ///
80    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
81    /// tree gets exactly the values this module documents.
82    pub fn for_tokens(tokens: &InputTokens) -> Self {
83        Self {
84            recipe: ProgressBarRecipe::for_tokens(tokens),
85        }
86    }
87}
88
89impl ProgressBarStyle for RecipeProgressBarStyle {
90    fn make_body(&self, cfg: &ProgressBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
91        let track = cfg
92            .track_color_override
93            .clone()
94            .unwrap_or_else(|| SurfaceRole::Sunken.into());
95        let fill = cfg
96            .fill_color_override
97            .clone()
98            .unwrap_or_else(|| SurfaceRole::Accent.into());
99        let determinate_value = match &cfg.progress {
100            ProgressKind::Determinate(p) => Some(p.clone()),
101            ProgressKind::Indeterminate => None,
102        };
103        ctx.add(ProgressBarFrame {
104            orientation: cfg.orientation,
105            track,
106            fill,
107            determinate_value,
108            recipe: self.recipe,
109        })
110    }
111}
112
113/// Internal recipe widget that paints the progress bar's stationary
114/// chrome. For determinate bars it paints the track + a fill rect
115/// proportional to the bound value; for indeterminate bars it paints
116/// only the track (the moving sweep is composed on top by the
117/// `ProgressBar` widget's `build()` — see
118/// `IndeterminateSweepLeaf` in `progress_bar.rs`). The shader-quad
119/// horizontal path replaces the entire track-plus-sweep visual in one
120/// procedural draw; the `ProgressBar` widget skips mounting this
121/// frame in that case to avoid double-painting.
122struct ProgressBarFrame {
123    orientation: Orientation,
124    track: ColorProp,
125    fill: ColorProp,
126    /// `Some` for determinate bars; `None` for indeterminate (the
127    /// widget mounts the sweep leaf separately).
128    determinate_value: Option<Prop<f32>>,
129    recipe: ProgressBarRecipe,
130}
131
132impl std::fmt::Debug for ProgressBarFrame {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("ProgressBarFrame")
135            .field("orientation", &self.orientation)
136            .finish()
137    }
138}
139
140impl Widget for ProgressBarFrame {
141    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
142        let id = ctx.self_id();
143        let registry = ctx.binding_registry();
144        if let Some(p) = &self.determinate_value {
145            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
146        }
147        vec![]
148    }
149
150    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
151        // The frame fills whatever bounds its parent assigns — the
152        // `ProgressBar` widget owns the intrinsic-size policy.
153        Size::new(
154            proposal.width.unwrap_or(0.0),
155            proposal.height.unwrap_or(0.0),
156        )
157        .into()
158    }
159
160    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
161        let radius = CornerRadius::uniform(self.recipe.corner_radius);
162        let track_color = self.track.resolve(ctx.theme, ctx.effective_enabled);
163        canvas.fill_rounded_rect(bounds, radius, track_color);
164
165        if let Some(value_prop) = &self.determinate_value {
166            let value = value_prop.get().clamp(0.0, 1.0);
167            if value > 0.0 {
168                let fill_color = self.fill.resolve(ctx.theme, ctx.effective_enabled);
169                let fill_rect = match self.orientation {
170                    Orientation::Horizontal => {
171                        Rect::new(bounds.x, bounds.y, bounds.width * value, bounds.height)
172                    }
173                    Orientation::Vertical => {
174                        let fill_h = bounds.height * value;
175                        Rect::new(
176                            bounds.x,
177                            bounds.y + bounds.height - fill_h,
178                            bounds.width,
179                            fill_h,
180                        )
181                    }
182                };
183                canvas.fill_rounded_rect(fill_rect, radius, fill_color);
184            }
185        }
186    }
187
188    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
189        // Presentational — the parent `ProgressBar` emits the
190        // `Role::ProgressIndicator` node with the numeric value.
191        builder.set_hidden();
192    }
193}