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}