Skip to main content

teksilo_widgets/styles/
recipe_slider_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SliderStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeSliderStyle` ships the IntUI look out of the box. The visual
7//! body is a tiny private leaf widget (`SliderBody`) that paints
8//! track + fill + thumb + focus ring directly on the canvas. Same
9//! "leaf body" choice as `RecipeToggleStyle` and for the same reason —
10//! there's no general-purpose absolute-positioning primitive for the
11//! thumb on the track, and the compositional version would add several
12//! arena nodes per slider for no visual win.
13//!
14//! Custom `SliderStyle` impls are free to compose `RectWidget` layers
15//! and a positioned thumb if they prefer.
16
17use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
18use teksilo_core::accessibility::AccessNodeBuilder;
19use teksilo_core::binding::BindingLevel;
20use teksilo_core::build_context::BuildContext;
21use teksilo_core::focus::FocusOrigin;
22use teksilo_core::signal::Signal;
23use teksilo_core::styles::{SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant};
24use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::CornerRadius;
27
28/// Minimum cross-axis size of the slider row, in dp. Sized to
29/// accommodate the thumb plus the focus-ring envelope.
30const MIN_CROSS_SIZE: f32 = 24.0;
31
32// IntUI design tokens for Slider. The recipe owns its own dimensions.
33pub const SLIDER_TRACK_HEIGHT: f32 = 4.0;
34pub const SLIDER_THUMB_DIAMETER: f32 = 14.0;
35pub const SLIDER_TICK_SIZE: f32 = 2.0;
36
37/// Dimension data for `RecipeSliderStyle`. All fields are in dp.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct SliderRecipe {
40    pub track_height: f32,
41    pub thumb_diameter: f32,
42    pub tick_size: f32,
43}
44
45impl Default for SliderRecipe {
46    fn default() -> Self {
47        Self {
48            track_height: SLIDER_TRACK_HEIGHT,
49            thumb_diameter: SLIDER_THUMB_DIAMETER,
50            tick_size: SLIDER_TICK_SIZE,
51        }
52    }
53}
54
55/// Default `SliderStyle` shipped with Teksilo. Colors come from
56/// `theme.colors.{accent, surface_sunken, ...}`.
57#[derive(Debug, Default, Clone, Copy)]
58pub struct RecipeSliderStyle {
59    pub recipe: SliderRecipe,
60}
61
62impl RecipeSliderStyle {
63    pub fn new(recipe: SliderRecipe) -> Self {
64        Self { recipe }
65    }
66}
67
68impl SliderStyle for RecipeSliderStyle {
69    fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
70        self.recipe.thumb_diameter
71    }
72
73    fn make_body(&self, cfg: &SliderStyleConfig, ctx: &mut BuildContext) -> WidgetId {
74        ctx.add(SliderBody {
75            recipe: self.recipe,
76            value_normalized: cfg.value_normalized.clone(),
77            is_hovered: cfg.is_hovered.clone(),
78            is_dragging: cfg.is_dragging.clone(),
79            is_disabled: cfg.is_disabled.clone(),
80            focus_origin: cfg.focus_origin.clone(),
81            orientation: cfg.orientation,
82            tick_count: cfg.tick_count,
83            variant: cfg.variant,
84        })
85    }
86}
87
88/// Internal leaf widget that paints the track + fill + thumb. Owned
89/// by `RecipeSliderStyle::make_body`; not exposed publicly because
90/// custom `SliderStyle` impls compose their own body instead.
91struct SliderBody {
92    recipe: SliderRecipe,
93    value_normalized: Signal<f32>,
94    is_hovered: Signal<bool>,
95    is_dragging: Signal<bool>,
96    is_disabled: Signal<bool>,
97    focus_origin: Signal<Option<FocusOrigin>>,
98    orientation: SliderOrientation,
99    tick_count: Option<u32>,
100    variant: SliderVariant,
101}
102
103impl std::fmt::Debug for SliderBody {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("SliderBody")
106            .field("orientation", &self.orientation)
107            .field("variant", &self.variant)
108            .finish()
109    }
110}
111
112impl Widget for SliderBody {
113    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
114        let id = ctx.self_id();
115        let registry = ctx.binding_registry();
116        self.value_normalized
117            .bind_to(id, registry, BindingLevel::RepaintOnly);
118        self.is_hovered
119            .bind_to(id, registry, BindingLevel::RepaintOnly);
120        self.is_dragging
121            .bind_to(id, registry, BindingLevel::RepaintOnly);
122        self.is_disabled
123            .bind_to(id, registry, BindingLevel::RepaintOnly);
124        self.focus_origin
125            .bind_to(id, registry, BindingLevel::RepaintOnly);
126        vec![]
127    }
128
129    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
130        let envelope = ctx.theme.shape.focus_ring_offset + ctx.theme.shape.focus_ring_width;
131        let cross = (self.recipe.thumb_diameter + envelope * 2.0).max(MIN_CROSS_SIZE);
132        match self.orientation {
133            SliderOrientation::Horizontal => {
134                let width = proposal.width.unwrap_or(200.0);
135                Size::new(width, cross)
136            }
137            SliderOrientation::Vertical => {
138                let height = proposal.height.unwrap_or(200.0);
139                Size::new(cross, height)
140            }
141        }
142        .into()
143    }
144
145    fn place_children(
146        &self,
147        _bounds: Rect,
148        _proposal: SizeProposal,
149        _children: &mut [WidgetPlacement],
150        _ctx: &LayoutContext,
151    ) {
152    }
153
154    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
155        let colors = &ctx.theme.colors;
156        let shape = &ctx.theme.shape;
157        let track_height = self.recipe.track_height;
158        let thumb_diameter = self.recipe.thumb_diameter;
159        let thumb_radius = thumb_diameter * 0.5;
160        let enabled = !self.is_disabled.get();
161        let t = self.value_normalized.get().clamp(0.0, 1.0);
162
163        let radius = CornerRadius::uniform(track_height * 0.5);
164        let track_color = if enabled {
165            colors.surface_sunken
166        } else {
167            colors.accent_disabled
168        };
169        let fill_color = if enabled {
170            colors.accent
171        } else {
172            colors.text_disabled
173        };
174
175        let (track_rect, fill_rect, thumb_cx, thumb_cy) = match self.orientation {
176            SliderOrientation::Horizontal => {
177                let ty = bounds.y + (bounds.height - track_height) * 0.5;
178                let track = Rect::new(
179                    bounds.x + thumb_radius,
180                    ty,
181                    (bounds.width - thumb_radius * 2.0).max(0.0),
182                    track_height,
183                );
184                let usable = track.width;
185                let thumb_pos = track.x + usable * t;
186                let fill_w = (thumb_pos - track.x).max(0.0);
187                let fill = Rect::new(track.x, ty, fill_w, track_height);
188                (track, fill, thumb_pos, bounds.y + bounds.height * 0.5)
189            }
190            SliderOrientation::Vertical => {
191                let tx = bounds.x + (bounds.width - track_height) * 0.5;
192                let track = Rect::new(
193                    tx,
194                    bounds.y + thumb_radius,
195                    track_height,
196                    (bounds.height - thumb_radius * 2.0).max(0.0),
197                );
198                let usable = track.height;
199                let thumb_pos = track.y + usable * t;
200                let fill_h = (thumb_pos - track.y).max(0.0);
201                let fill = Rect::new(tx, track.y, track_height, fill_h);
202                (track, fill, bounds.x + bounds.width * 0.5, thumb_pos)
203            }
204        };
205
206        canvas.fill_rounded_rect(track_rect, radius, track_color);
207        if fill_rect.width > 0.0 && fill_rect.height > 0.0 {
208            canvas.fill_rounded_rect(fill_rect, radius, fill_color);
209        }
210
211        // Discrete-variant ticks. Painted above the track so they
212        // read as fixed reference points; the fill overlays them
213        // naturally because tick rendering happens before the thumb.
214        // Continuous variant skips this loop entirely.
215        if matches!(self.variant, SliderVariant::Discrete)
216            && let Some(n) = self.tick_count
217            && n >= 2
218        {
219            let tick_size = self.recipe.tick_size.max(2.0);
220            let tick_color = if enabled {
221                colors.text_secondary
222            } else {
223                colors.text_disabled
224            };
225            for i in 0..n {
226                let tt = i as f32 / (n - 1) as f32;
227                match self.orientation {
228                    SliderOrientation::Horizontal => {
229                        let tx = track_rect.x + track_rect.width * tt;
230                        let ty = track_rect.y - tick_size - 2.0;
231                        let r = Rect::new(tx - tick_size * 0.5, ty, tick_size, tick_size);
232                        canvas.fill_rounded_rect(
233                            r,
234                            CornerRadius::uniform(tick_size * 0.5),
235                            tick_color,
236                        );
237                    }
238                    SliderOrientation::Vertical => {
239                        let ty = track_rect.y + track_rect.height * tt;
240                        let tx = track_rect.x - tick_size - 2.0;
241                        let r = Rect::new(tx, ty - tick_size * 0.5, tick_size, tick_size);
242                        canvas.fill_rounded_rect(
243                            r,
244                            CornerRadius::uniform(tick_size * 0.5),
245                            tick_color,
246                        );
247                    }
248                }
249            }
250        }
251
252        // Thumb
253        let thumb_color = if !enabled {
254            colors.text_disabled
255        } else if self.is_dragging.get() {
256            colors.accent_pressed
257        } else if self.is_hovered.get() {
258            colors.accent_hover
259        } else {
260            colors.accent
261        };
262        let thumb_rect = Rect::new(
263            thumb_cx - thumb_radius,
264            thumb_cy - thumb_radius,
265            thumb_diameter,
266            thumb_diameter,
267        );
268        canvas.fill_rounded_rect(thumb_rect, CornerRadius::uniform(thumb_radius), thumb_color);
269
270        // Focus ring — keyboard-only, drawn outside the thumb in the
271        // theme-defined gap.
272        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
273            let offset = shape.focus_ring_offset;
274            let half_stroke = shape.focus_ring_width * 0.5;
275            let ring_inset = offset + half_stroke;
276            let ring_rect = Rect::new(
277                thumb_rect.x - ring_inset,
278                thumb_rect.y - ring_inset,
279                thumb_rect.width + ring_inset * 2.0,
280                thumb_rect.height + ring_inset * 2.0,
281            );
282            canvas.stroke_rounded_rect(
283                ring_rect,
284                CornerRadius::uniform(thumb_radius + ring_inset),
285                colors.focus_ring,
286                shape.focus_ring_width,
287            );
288        }
289    }
290
291    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
292        // Accessibility lives on the parent Slider widget, not the
293        // body. The body is presentational; mark it hidden so the
294        // walker prunes it rather than emitting a nameless Role::Unknown.
295        builder.set_hidden();
296    }
297}