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::environment::LayoutDirection;
22use teksilo_core::focus::FocusOrigin;
23use teksilo_core::signal::Signal;
24use teksilo_core::styles::{
25    FocusRingStroke, SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
26};
27use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{CornerRadius, InputTokens};
30
31/// Minimum cross-axis size of the slider row, in dp. Sized to
32/// accommodate the thumb plus the focus-ring envelope.
33const MIN_CROSS_SIZE: f32 = 24.0;
34
35// IntUI design tokens for Slider. The recipe owns its own dimensions.
36pub const SLIDER_TRACK_HEIGHT: f32 = 4.0;
37pub const SLIDER_THUMB_DIAMETER: f32 = 14.0;
38pub const SLIDER_TICK_SIZE: f32 = 2.0;
39
40/// Dimension data for `RecipeSliderStyle`. All fields are in dp.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct SliderRecipe {
43    pub track_height: f32,
44    pub thumb_diameter: f32,
45    pub tick_size: f32,
46}
47
48impl SliderRecipe {
49    /// This recipe's dimensions resolved against a density's [`InputTokens`].
50    ///
51    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
52    /// ladder — so the shipped values below are the Compact column by
53    /// construction and cannot drift from it.
54    ///
55    /// Every dimension this recipe carries is a decoration (a corner radius, a
56    /// hairline, a glyph metric), so the parameter is unused: the density
57    /// ladder never moves any of them. It is taken all the same, so every
58    /// recipe is constructed the same way at its widget's build site.
59    pub fn for_tokens(_tokens: &InputTokens) -> Self {
60        Self {
61            track_height: SLIDER_TRACK_HEIGHT,
62            thumb_diameter: SLIDER_THUMB_DIAMETER,
63            tick_size: SLIDER_TICK_SIZE,
64        }
65    }
66}
67
68impl Default for SliderRecipe {
69    fn default() -> Self {
70        Self::for_tokens(&InputTokens::default())
71    }
72}
73
74/// Default `SliderStyle` shipped with Teksilo. Colors come from
75/// `theme.colors.{accent, surface_sunken, ...}`.
76#[derive(Debug, Default, Clone, Copy)]
77pub struct RecipeSliderStyle {
78    pub recipe: SliderRecipe,
79}
80
81impl RecipeSliderStyle {
82    pub fn new(recipe: SliderRecipe) -> Self {
83        Self { recipe }
84    }
85
86    /// This style with every dimension resolved against a density's
87    /// [`InputTokens`], as `RecipeSliderStyle::for_tokens(&ctx.theme().input)` at
88    /// the widget's own build site.
89    ///
90    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
91    /// tree gets exactly the values this module documents.
92    pub fn for_tokens(tokens: &InputTokens) -> Self {
93        Self {
94            recipe: SliderRecipe::for_tokens(tokens),
95        }
96    }
97}
98
99impl SliderStyle for RecipeSliderStyle {
100    fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
101        self.recipe.thumb_diameter
102    }
103
104    fn make_body(&self, cfg: &SliderStyleConfig, ctx: &mut BuildContext) -> WidgetId {
105        ctx.add(SliderBody {
106            recipe: self.recipe,
107            value_normalized: cfg.value_normalized.clone(),
108            is_hovered: cfg.is_hovered.clone(),
109            is_dragging: cfg.is_dragging.clone(),
110            is_disabled: cfg.is_disabled.clone(),
111            focus_origin: cfg.focus_origin.clone(),
112            orientation: cfg.orientation,
113            tick_count: cfg.tick_count,
114            variant: cfg.variant,
115        })
116    }
117}
118
119/// Internal leaf widget that paints the track + fill + thumb. Owned
120/// by `RecipeSliderStyle::make_body`; not exposed publicly because
121/// custom `SliderStyle` impls compose their own body instead.
122struct SliderBody {
123    recipe: SliderRecipe,
124    value_normalized: Signal<f32>,
125    is_hovered: Signal<bool>,
126    is_dragging: Signal<bool>,
127    is_disabled: Signal<bool>,
128    focus_origin: Signal<Option<FocusOrigin>>,
129    orientation: SliderOrientation,
130    tick_count: Option<u32>,
131    variant: SliderVariant,
132}
133
134impl std::fmt::Debug for SliderBody {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.debug_struct("SliderBody")
137            .field("orientation", &self.orientation)
138            .field("variant", &self.variant)
139            .finish()
140    }
141}
142
143impl Widget for SliderBody {
144    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
145        let id = ctx.self_id();
146        let registry = ctx.binding_registry();
147        self.value_normalized
148            .bind_to(id, registry, BindingLevel::RepaintOnly);
149        self.is_hovered
150            .bind_to(id, registry, BindingLevel::RepaintOnly);
151        self.is_dragging
152            .bind_to(id, registry, BindingLevel::RepaintOnly);
153        self.is_disabled
154            .bind_to(id, registry, BindingLevel::RepaintOnly);
155        self.focus_origin
156            .bind_to(id, registry, BindingLevel::RepaintOnly);
157        vec![]
158    }
159
160    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
161        let envelope = ctx.theme.shape.focus_ring_envelope();
162        let cross = (self.recipe.thumb_diameter + envelope * 2.0).max(MIN_CROSS_SIZE);
163        match self.orientation {
164            SliderOrientation::Horizontal => {
165                let width = proposal.width.unwrap_or(200.0);
166                Size::new(width, cross)
167            }
168            SliderOrientation::Vertical => {
169                let height = proposal.height.unwrap_or(200.0);
170                Size::new(cross, height)
171            }
172        }
173        .into()
174    }
175
176    fn place_children(
177        &self,
178        _bounds: Rect,
179        _proposal: SizeProposal,
180        _children: &mut [WidgetPlacement],
181        _ctx: &LayoutContext,
182    ) {
183    }
184
185    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
186        let colors = &ctx.theme.colors;
187        let shape = &ctx.theme.shape;
188        let track_height = self.recipe.track_height;
189        let thumb_diameter = self.recipe.thumb_diameter;
190        let thumb_radius = thumb_diameter * 0.5;
191        let enabled = !self.is_disabled.get();
192        let t = self.value_normalized.get().clamp(0.0, 1.0);
193
194        let rtl = ctx.layout_direction == LayoutDirection::RightToLeft;
195
196        let radius = CornerRadius::uniform(track_height * 0.5);
197        let track_color = if enabled {
198            colors.surface_sunken
199        } else {
200            colors.accent_disabled
201        };
202        let fill_color = if enabled {
203            colors.accent
204        } else {
205            colors.text_disabled
206        };
207
208        let (track_rect, fill_rect, thumb_cx, thumb_cy) = match self.orientation {
209            SliderOrientation::Horizontal => {
210                let ty = bounds.y + (bounds.height - track_height) * 0.5;
211                let track = Rect::new(
212                    bounds.x + thumb_radius,
213                    ty,
214                    (bounds.width - thumb_radius * 2.0).max(0.0),
215                    track_height,
216                );
217                let usable = track.width;
218                // A horizontal slider is a reading-order axis: its minimum sits
219                // at the leading edge, which is the RIGHT edge in an RTL UI.
220                // Mirroring here and in `Slider`'s own position→value map is
221                // one change made twice, and the two are pinned to each other
222                // by `Slider::target_regions`, which reports the thumb the same
223                // way. A vertical slider is unaffected — RTL is a horizontal
224                // convention.
225                let thumb_pos = if rtl {
226                    track.x + usable * (1.0 - t)
227                } else {
228                    track.x + usable * t
229                };
230                let fill = if rtl {
231                    let fill_w = (track.right() - thumb_pos).max(0.0);
232                    Rect::new(thumb_pos, ty, fill_w, track_height)
233                } else {
234                    let fill_w = (thumb_pos - track.x).max(0.0);
235                    Rect::new(track.x, ty, fill_w, track_height)
236                };
237                (track, fill, thumb_pos, bounds.y + bounds.height * 0.5)
238            }
239            SliderOrientation::Vertical => {
240                let tx = bounds.x + (bounds.width - track_height) * 0.5;
241                let track = Rect::new(
242                    tx,
243                    bounds.y + thumb_radius,
244                    track_height,
245                    (bounds.height - thumb_radius * 2.0).max(0.0),
246                );
247                let usable = track.height;
248                // A vertical slider's minimum is at the **bottom** and its
249                // maximum at the top — Qt's `QSlider`, GTK4's `GtkScale`, the
250                // Win32 trackbar and `<input type=range>` all agree, and it is
251                // what makes `ArrowUp` (which `range_nav` reports as an
252                // increase) move the thumb up. Growing the thumb position with
253                // `t` sent it *down* on every increase, so the keys and the
254                // pointer both drove the control backwards.
255                let thumb_pos = track.bottom() - usable * t;
256                let fill_h = (track.bottom() - thumb_pos).max(0.0);
257                let fill = Rect::new(tx, thumb_pos, track_height, fill_h);
258                (track, fill, bounds.x + bounds.width * 0.5, thumb_pos)
259            }
260        };
261
262        canvas.fill_rounded_rect(track_rect, radius, track_color);
263        if fill_rect.width > 0.0 && fill_rect.height > 0.0 {
264            canvas.fill_rounded_rect(fill_rect, radius, fill_color);
265        }
266
267        // Discrete-variant ticks. Painted above the track so they
268        // read as fixed reference points; the fill overlays them
269        // naturally because tick rendering happens before the thumb.
270        // Continuous variant skips this loop entirely.
271        if matches!(self.variant, SliderVariant::Discrete)
272            && let Some(n) = self.tick_count
273            && n >= 2
274        {
275            let tick_size = self.recipe.tick_size.max(2.0);
276            let tick_color = if enabled {
277                colors.text_secondary
278            } else {
279                colors.text_disabled
280            };
281            for i in 0..n {
282                let tt = i as f32 / (n - 1) as f32;
283                match self.orientation {
284                    SliderOrientation::Horizontal => {
285                        let tt = if rtl { 1.0 - tt } else { tt };
286                        let tx = track_rect.x + track_rect.width * tt;
287                        let ty = track_rect.y - tick_size - 2.0;
288                        let r = Rect::new(tx - tick_size * 0.5, ty, tick_size, tick_size);
289                        canvas.fill_rounded_rect(
290                            r,
291                            CornerRadius::uniform(tick_size * 0.5),
292                            tick_color,
293                        );
294                    }
295                    SliderOrientation::Vertical => {
296                        let ty = track_rect.y + track_rect.height * tt;
297                        let tx = track_rect.x - tick_size - 2.0;
298                        let r = Rect::new(tx, ty - tick_size * 0.5, tick_size, tick_size);
299                        canvas.fill_rounded_rect(
300                            r,
301                            CornerRadius::uniform(tick_size * 0.5),
302                            tick_color,
303                        );
304                    }
305                }
306            }
307        }
308
309        // Thumb
310        let thumb_color = if !enabled {
311            colors.text_disabled
312        } else if self.is_dragging.get() {
313            colors.accent_pressed
314        } else if self.is_hovered.get() {
315            colors.accent_hover
316        } else {
317            colors.accent
318        };
319        let thumb_rect = Rect::new(
320            thumb_cx - thumb_radius,
321            thumb_cy - thumb_radius,
322            thumb_diameter,
323            thumb_diameter,
324        );
325        canvas.fill_rounded_rect(thumb_rect, CornerRadius::uniform(thumb_radius), thumb_color);
326
327        // Focus ring — keyboard-only, drawn outside the thumb in the
328        // theme-defined gap.
329        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
330            let ring = FocusRingStroke::around(thumb_rect, thumb_radius, shape);
331            canvas.stroke_rounded_rect(
332                ring.rect,
333                CornerRadius::uniform(ring.corner_radius),
334                colors.focus_ring,
335                shape.focus_ring_width,
336            );
337        }
338    }
339
340    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
341        // Accessibility lives on the parent Slider widget, not the
342        // body. The body is presentational; mark it hidden so the
343        // walker prunes it rather than emitting a nameless Role::Unknown.
344        builder.set_hidden();
345    }
346}