Skip to main content

teksilo_widgets/styles/
recipe_scroll_bar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ScrollBarStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeScrollBarStyle` ships three visual variants (parallel to the
7//! widget's historical `ScrollBarVisual`):
8//!
9//! - `Permanent` — full track + thumb, always visible. Single
10//!   `FullBarPainter` child sized to the parent's slot.
11//! - `Overlay` — thin idle indicator that cross-fades to the full bar
12//!   on hover/drag. Two `Fade`-wrapped painters share the slot; the
13//!   `active` signal flips when either `is_hovered` or `is_dragging`
14//!   goes true.
15//! - `Thin` — thin idle indicator only. One `ThinIndicatorPainter`
16//!   child.
17//!
18//! The parent `ScrollBar` widget owns input handling (drag, track click,
19//! keyboard, hover) and bounds caching; this style only paints.
20
21use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::binding::BindingLevel;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::color_prop::ColorProp;
26use teksilo_core::signal::Signal;
27use teksilo_core::styles::{
28    ScrollBarOrientation, ScrollBarStyle, ScrollBarStyleConfig, ScrollBarVariant,
29};
30use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
31use teksilo_core::widget_id::WidgetId;
32use teksilo_tokens::{Color, CornerRadius};
33
34use crate::primitives::ZStack;
35
36// IntUI design tokens for ScrollBar. The recipe owns its own dimensions.
37pub const SCROLLBAR_THICKNESS_IDLE: f32 = 4.0;
38pub const SCROLLBAR_THICKNESS_HOVER: f32 = 8.0;
39pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
40pub const SCROLLBAR_CORNER_RADIUS: f32 = 2.0;
41
42// Opacity ramp applied to a `ScrollBarStyleConfig::thumb_color` override
43// (idle / hover / pressed), giving state feedback while keeping the caller's
44// tint. Mirrors the implicit alpha ramp of the default `scrollbar_thumb*`
45// tokens, but scaled up so a single opaque tint (e.g. `TextRole::TooltipText`)
46// reads clearly on whatever surface it sits over.
47const OVERRIDE_THUMB_ALPHA_IDLE: f32 = 0.50;
48const OVERRIDE_THUMB_ALPHA_HOVER: f32 = 0.72;
49const OVERRIDE_THUMB_ALPHA_PRESSED: f32 = 0.92;
50const OVERRIDE_TRACK_ALPHA: f32 = 0.12;
51
52/// Resolve the thumb colour for the given interaction state: the optional
53/// `thumb_color` override (tinted by the state alpha ramp) wins; otherwise the
54/// theme's `scrollbar_thumb*` tokens. Shared by both painters so the override
55/// path is identical for the thin indicator and the full bar.
56fn resolve_thumb_color(
57    override_color: Option<&ColorProp>,
58    ctx: &PaintContext,
59    hovered: bool,
60    pressed: bool,
61) -> Color {
62    match override_color {
63        Some(prop) => {
64            let alpha = if pressed {
65                OVERRIDE_THUMB_ALPHA_PRESSED
66            } else if hovered {
67                OVERRIDE_THUMB_ALPHA_HOVER
68            } else {
69                OVERRIDE_THUMB_ALPHA_IDLE
70            };
71            prop.resolve(ctx.theme, true).with_alpha(alpha)
72        }
73        None => {
74            let c = &ctx.theme.colors;
75            if pressed {
76                c.scrollbar_thumb_pressed
77            } else if hovered {
78                c.scrollbar_thumb_hover
79            } else {
80                c.scrollbar_thumb
81            }
82        }
83    }
84}
85
86/// Configurable dimensions for [`RecipeScrollBarStyle`].
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct ScrollBarRecipe {
89    pub thickness_idle: f32,
90    pub thickness_hover: f32,
91    pub min_thumb_length: f32,
92    pub corner_radius: f32,
93}
94
95impl Default for ScrollBarRecipe {
96    fn default() -> Self {
97        Self {
98            thickness_idle: SCROLLBAR_THICKNESS_IDLE,
99            thickness_hover: SCROLLBAR_THICKNESS_HOVER,
100            min_thumb_length: SCROLLBAR_MIN_THUMB_LENGTH,
101            corner_radius: SCROLLBAR_CORNER_RADIUS,
102        }
103    }
104}
105
106/// Default `ScrollBarStyle` shipped with Teksilo. Colors come from
107/// `theme.colors.scrollbar_*`.
108#[derive(Debug, Default, Clone, Copy)]
109pub struct RecipeScrollBarStyle {
110    pub recipe: ScrollBarRecipe,
111}
112
113impl RecipeScrollBarStyle {
114    pub fn new(recipe: ScrollBarRecipe) -> Self {
115        Self { recipe }
116    }
117}
118
119impl ScrollBarStyle for RecipeScrollBarStyle {
120    fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
121        // Thickness values come from the recipe's own design tokens.
122        // The widget's per-instance override (`ScrollBar::thickness`)
123        // is already reflected in the bounds we receive, so these
124        // values only affect the painters' fallback `layout_response`.
125        let thickness = self.recipe.thickness_hover;
126        let resting_thickness = self.recipe.thickness_idle;
127
128        match cfg.variant {
129            ScrollBarVariant::Permanent => ctx.add(FullBarPainter {
130                orientation: cfg.orientation,
131                thickness,
132                min_thumb_length: cfg.min_thumb_length,
133                scroll_ratio: cfg.scroll_ratio.clone(),
134                viewport_ratio: cfg.viewport_ratio.clone(),
135                is_hovered: cfg.is_hovered.clone(),
136                is_dragging: cfg.is_dragging.clone(),
137                is_idle: cfg.is_idle.clone(),
138                show_track: true,
139                thumb_color: cfg.thumb_color.clone(),
140            }),
141            ScrollBarVariant::Thin => ctx.add(ThinIndicatorPainter {
142                orientation: cfg.orientation,
143                thickness,
144                resting_thickness,
145                min_thumb_length: cfg.min_thumb_length,
146                scroll_ratio: cfg.scroll_ratio.clone(),
147                viewport_ratio: cfg.viewport_ratio.clone(),
148                is_idle: cfg.is_idle.clone(),
149                thumb_color: cfg.thumb_color.clone(),
150            }),
151            ScrollBarVariant::Overlay => {
152                // The overlay variant cross-fades two painters that
153                // share the slot. Avoid `Fade` here because its tween
154                // effect can only observe *mutable* signals; the
155                // hover/drag-derived "active" signal is multi-source
156                // and lives on the derived side of the Signal split.
157                //
158                // Instead, run two scoped effects (one per source
159                // signal — both are mutable on the widget side) and
160                // drive a single animated `revealed` opacity. The
161                // inverse opacity for the idle indicator is a derived
162                // signal, fine to bind through `set_opacity`.
163                let initial = if cfg.is_hovered.get() || cfg.is_dragging.get() {
164                    1.0
165                } else {
166                    0.0
167                };
168                let revealed = ctx.animated_signal(initial);
169                let anim = ctx.animate().fast().standard();
170                {
171                    let dragging = cfg.is_dragging.clone();
172                    let revealed = revealed.clone();
173                    let anim = anim.clone();
174                    ctx.effect(&cfg.is_hovered, move |hovered| {
175                        let target = if *hovered || dragging.get() { 1.0 } else { 0.0 };
176                        anim.to_or_snap(&revealed, target);
177                    });
178                }
179                {
180                    let hovered = cfg.is_hovered.clone();
181                    let revealed = revealed.clone();
182                    let anim = anim.clone();
183                    ctx.effect(&cfg.is_dragging, move |dragging| {
184                        let target = if hovered.get() || *dragging { 1.0 } else { 0.0 };
185                        anim.to_or_snap(&revealed, target);
186                    });
187                }
188                let inactive_opacity = revealed.map(|r| 1.0 - *r);
189
190                let thin = ThinIndicatorPainter {
191                    orientation: cfg.orientation,
192                    thickness,
193                    resting_thickness,
194                    min_thumb_length: cfg.min_thumb_length,
195                    scroll_ratio: cfg.scroll_ratio.clone(),
196                    viewport_ratio: cfg.viewport_ratio.clone(),
197                    is_idle: cfg.is_idle.clone(),
198                    thumb_color: cfg.thumb_color.clone(),
199                };
200                let full = FullBarPainter {
201                    orientation: cfg.orientation,
202                    thickness,
203                    min_thumb_length: cfg.min_thumb_length,
204                    scroll_ratio: cfg.scroll_ratio.clone(),
205                    viewport_ratio: cfg.viewport_ratio.clone(),
206                    is_hovered: cfg.is_hovered.clone(),
207                    is_dragging: cfg.is_dragging.clone(),
208                    is_idle: cfg.is_idle.clone(),
209                    show_track: false,
210                    thumb_color: cfg.thumb_color.clone(),
211                };
212
213                let thin_id = ctx.add(thin);
214                let full_id = ctx.add(full);
215                ctx.set_opacity(thin_id, inactive_opacity);
216                ctx.set_opacity(full_id, revealed);
217                ctx.add(ZStack::new().add_child(thin_id).add_child(full_id))
218            }
219        }
220    }
221}
222
223/// Paints the thin resting indicator. Used as the only body in `Thin`
224/// variant; layered under the full bar in `Overlay` variant.
225struct ThinIndicatorPainter {
226    orientation: ScrollBarOrientation,
227    /// Slot thickness — needed so `layout_response` returns a sensible
228    /// fallback size matching the parent slot.
229    thickness: f32,
230    resting_thickness: f32,
231    min_thumb_length: f32,
232    scroll_ratio: Signal<f32>,
233    viewport_ratio: Signal<f32>,
234    is_idle: Signal<bool>,
235    thumb_color: Option<ColorProp>,
236}
237
238impl std::fmt::Debug for ThinIndicatorPainter {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_struct("ThinIndicatorPainter")
241            .field("orientation", &self.orientation)
242            .finish()
243    }
244}
245
246impl Widget for ThinIndicatorPainter {
247    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
248        let id = ctx.self_id();
249        let registry = ctx.binding_registry();
250        self.scroll_ratio
251            .bind_to(id, registry, BindingLevel::RepaintOnly);
252        self.viewport_ratio
253            .bind_to(id, registry, BindingLevel::RepaintOnly);
254        self.is_idle
255            .bind_to(id, registry, BindingLevel::RepaintOnly);
256        Vec::new()
257    }
258
259    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
260        match self.orientation {
261            ScrollBarOrientation::Vertical => {
262                Size::new(self.thickness, proposal.height.unwrap_or(0.0))
263            }
264            ScrollBarOrientation::Horizontal => {
265                Size::new(proposal.width.unwrap_or(0.0), self.thickness)
266            }
267        }
268        .into()
269    }
270
271    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
272        if self.is_idle.get() {
273            return;
274        }
275        let thin = self.resting_thickness;
276        let thin_bounds = match self.orientation {
277            ScrollBarOrientation::Vertical => {
278                Rect::new(bounds.right() - thin, bounds.y, thin, bounds.height)
279            }
280            ScrollBarOrientation::Horizontal => {
281                Rect::new(bounds.x, bounds.bottom() - thin, bounds.width, thin)
282            }
283        };
284        let track_len = match self.orientation {
285            ScrollBarOrientation::Vertical => bounds.height,
286            ScrollBarOrientation::Horizontal => bounds.width,
287        };
288        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
289        let thumb_len = (track_len * ratio)
290            .max(self.min_thumb_length)
291            .min(track_len);
292        let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
293        let offset = scroll_ratio * (track_len - thumb_len);
294
295        let radius = CornerRadius::uniform(thin / 2.0);
296        let thumb_rect = match self.orientation {
297            ScrollBarOrientation::Vertical => {
298                Rect::new(thin_bounds.x, thin_bounds.y + offset, thin, thumb_len)
299            }
300            ScrollBarOrientation::Horizontal => {
301                Rect::new(thin_bounds.x + offset, thin_bounds.y, thumb_len, thin)
302            }
303        };
304        // Thin indicator has no hover/drag state — it's the resting strip.
305        let thumb_color = resolve_thumb_color(self.thumb_color.as_ref(), ctx, false, false);
306        canvas.fill_rounded_rect(thumb_rect, radius, thumb_color);
307    }
308
309    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
310        builder.set_hidden();
311    }
312}
313
314/// Paints the full track + thumb. Used as the only body in `Permanent`
315/// variant; layered over the thin indicator in `Overlay` variant (where
316/// `show_track` is false because the track only paints when the bar is
317/// active).
318struct FullBarPainter {
319    orientation: ScrollBarOrientation,
320    thickness: f32,
321    min_thumb_length: f32,
322    scroll_ratio: Signal<f32>,
323    viewport_ratio: Signal<f32>,
324    is_hovered: Signal<bool>,
325    is_dragging: Signal<bool>,
326    is_idle: Signal<bool>,
327    /// `true` in `Permanent` variant — paints a track behind the thumb.
328    /// `false` in `Overlay` variant — track only paints alongside the
329    /// thumb when the overlay reveals itself.
330    show_track: bool,
331    thumb_color: Option<ColorProp>,
332}
333
334impl std::fmt::Debug for FullBarPainter {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("FullBarPainter")
337            .field("orientation", &self.orientation)
338            .field("show_track", &self.show_track)
339            .finish()
340    }
341}
342
343impl Widget for FullBarPainter {
344    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
345        let id = ctx.self_id();
346        let registry = ctx.binding_registry();
347        self.scroll_ratio
348            .bind_to(id, registry, BindingLevel::RepaintOnly);
349        self.viewport_ratio
350            .bind_to(id, registry, BindingLevel::RepaintOnly);
351        self.is_hovered
352            .bind_to(id, registry, BindingLevel::RepaintOnly);
353        self.is_dragging
354            .bind_to(id, registry, BindingLevel::RepaintOnly);
355        self.is_idle
356            .bind_to(id, registry, BindingLevel::RepaintOnly);
357        Vec::new()
358    }
359
360    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
361        match self.orientation {
362            ScrollBarOrientation::Vertical => {
363                Size::new(self.thickness, proposal.height.unwrap_or(0.0))
364            }
365            ScrollBarOrientation::Horizontal => {
366                Size::new(proposal.width.unwrap_or(0.0), self.thickness)
367            }
368        }
369        .into()
370    }
371
372    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
373        if self.is_idle.get() {
374            return;
375        }
376        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
377        let track_len = match self.orientation {
378            ScrollBarOrientation::Vertical => bounds.height,
379            ScrollBarOrientation::Horizontal => bounds.width,
380        };
381        let thumb_len = (track_len * ratio)
382            .max(self.min_thumb_length)
383            .min(track_len);
384        let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
385        let offset = scroll_ratio * (track_len - thumb_len);
386
387        let radius = CornerRadius::uniform(self.thickness / 2.0);
388
389        if self.show_track {
390            let track = match &self.thumb_color {
391                Some(prop) => prop
392                    .resolve(ctx.theme, true)
393                    .with_alpha(OVERRIDE_TRACK_ALPHA),
394                None => ctx.theme.colors.scrollbar_track_hover,
395            };
396            canvas.fill_rounded_rect(bounds, radius, track);
397        }
398
399        let thumb = match self.orientation {
400            ScrollBarOrientation::Vertical => {
401                Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
402            }
403            ScrollBarOrientation::Horizontal => {
404                Rect::new(bounds.x + offset, bounds.y, thumb_len, bounds.height)
405            }
406        };
407        let thumb_color = resolve_thumb_color(
408            self.thumb_color.as_ref(),
409            ctx,
410            self.is_hovered.get(),
411            self.is_dragging.get(),
412        );
413        canvas.fill_rounded_rect(thumb, radius, thumb_color);
414    }
415
416    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
417        builder.set_hidden();
418    }
419}