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::environment::LayoutDirection;
27use teksilo_core::signal::Signal;
28use teksilo_core::styles::{
29    ScrollBarOrientation, ScrollBarStyle, ScrollBarStyleConfig, ScrollBarVariant,
30};
31use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget};
32use teksilo_core::widget_id::WidgetId;
33use teksilo_tokens::{Color, CornerRadius, InputTokens, TargetRole};
34
35use crate::primitives::ZStack;
36use teksilo_core::styles::density::dp;
37
38// IntUI design tokens for ScrollBar. The recipe owns its own dimensions.
39pub const SCROLLBAR_THICKNESS_IDLE: f32 = 4.0;
40pub const SCROLLBAR_THICKNESS_HOVER: f32 = 8.0;
41pub const SCROLLBAR_MIN_THUMB_LENGTH: f32 = 24.0;
42pub const SCROLLBAR_CORNER_RADIUS: f32 = 2.0;
43
44// Opacity ramp applied to a `ScrollBarStyleConfig::thumb_color` override
45// (idle / hover / pressed), giving state feedback while keeping the caller's
46// tint. Mirrors the implicit alpha ramp of the default `scrollbar_thumb*`
47// tokens, but scaled up so a single opaque tint (e.g. `TextRole::TooltipText`)
48// reads clearly on whatever surface it sits over.
49const OVERRIDE_THUMB_ALPHA_IDLE: f32 = 0.50;
50const OVERRIDE_THUMB_ALPHA_HOVER: f32 = 0.72;
51const OVERRIDE_THUMB_ALPHA_PRESSED: f32 = 0.92;
52const OVERRIDE_TRACK_ALPHA: f32 = 0.12;
53
54/// Resolve the thumb colour for the given interaction state: the optional
55/// `thumb_color` override (tinted by the state alpha ramp) wins; otherwise the
56/// theme's `scrollbar_thumb*` tokens. Shared by both painters so the override
57/// path is identical for the thin indicator and the full bar.
58fn resolve_thumb_color(
59    override_color: Option<&ColorProp>,
60    ctx: &PaintContext,
61    hovered: bool,
62    pressed: bool,
63) -> Color {
64    match override_color {
65        Some(prop) => {
66            let alpha = if pressed {
67                OVERRIDE_THUMB_ALPHA_PRESSED
68            } else if hovered {
69                OVERRIDE_THUMB_ALPHA_HOVER
70            } else {
71                OVERRIDE_THUMB_ALPHA_IDLE
72            };
73            prop.resolve(ctx.theme, true).with_alpha(alpha)
74        }
75        None => {
76            let c = &ctx.theme.colors;
77            if pressed {
78                c.scrollbar_thumb_pressed
79            } else if hovered {
80                c.scrollbar_thumb_hover
81            } else {
82                c.scrollbar_thumb
83            }
84        }
85    }
86}
87
88/// Configurable dimensions for [`RecipeScrollBarStyle`].
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub struct ScrollBarRecipe {
91    pub thickness_idle: f32,
92    pub thickness_hover: f32,
93    /// The density-resolved floor a thumb may not be shorter than. Read
94    /// through [`ScrollBarStyle::min_thumb_length`].
95    ///
96    /// The **widget** decides the number the painters actually use: it asks
97    /// the active style for this floor unless the caller named one of their
98    /// own, sizes its thumb geometry from the answer, and passes the same
99    /// answer down as `ScrollBarStyleConfig::min_thumb_length`.
100    pub min_thumb_length: f32,
101    pub corner_radius: f32,
102}
103
104impl ScrollBarRecipe {
105    /// This recipe's dimensions resolved against a density's [`InputTokens`].
106    ///
107    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
108    /// ladder — so the shipped values below are the Compact column by
109    /// construction and cannot drift from it.
110    pub fn for_tokens(tokens: &InputTokens) -> Self {
111        Self {
112            thickness_idle: SCROLLBAR_THICKNESS_IDLE,
113            thickness_hover: SCROLLBAR_THICKNESS_HOVER,
114            min_thumb_length: dp(SCROLLBAR_MIN_THUMB_LENGTH, TargetRole::Target, tokens),
115            corner_radius: SCROLLBAR_CORNER_RADIUS,
116        }
117    }
118}
119
120impl Default for ScrollBarRecipe {
121    fn default() -> Self {
122        Self::for_tokens(&InputTokens::default())
123    }
124}
125
126/// Default `ScrollBarStyle` shipped with Teksilo. Colors come from
127/// `theme.colors.scrollbar_*`.
128#[derive(Debug, Default, Clone, Copy)]
129pub struct RecipeScrollBarStyle {
130    pub recipe: ScrollBarRecipe,
131}
132
133impl RecipeScrollBarStyle {
134    pub fn new(recipe: ScrollBarRecipe) -> Self {
135        Self { recipe }
136    }
137
138    /// This style with every dimension resolved against a density's
139    /// [`InputTokens`], as `RecipeScrollBarStyle::for_tokens(&ctx.theme().input)` at
140    /// the widget's own build site.
141    ///
142    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
143    /// tree gets exactly the values this module documents.
144    pub fn for_tokens(tokens: &InputTokens) -> Self {
145        Self {
146            recipe: ScrollBarRecipe::for_tokens(tokens),
147        }
148    }
149}
150
151impl ScrollBarStyle for RecipeScrollBarStyle {
152    fn make_body(&self, cfg: &ScrollBarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
153        // Thickness values come from the recipe's own design tokens.
154        // The widget's per-instance override (`ScrollBar::thickness`)
155        // is already reflected in the bounds we receive, so these
156        // values only affect the painters' fallback `layout_response`.
157        let thickness = self.recipe.thickness_hover;
158        let resting_thickness = self.recipe.thickness_idle;
159
160        match cfg.variant {
161            ScrollBarVariant::Permanent => ctx.add(FullBarPainter {
162                orientation: cfg.orientation,
163                thickness,
164                min_thumb_length: cfg.min_thumb_length,
165                scroll_ratio: cfg.scroll_ratio.clone(),
166                viewport_ratio: cfg.viewport_ratio.clone(),
167                is_hovered: cfg.is_hovered.clone(),
168                is_dragging: cfg.is_dragging.clone(),
169                is_idle: cfg.is_idle.clone(),
170                show_track: true,
171                thumb_color: cfg.thumb_color.clone(),
172            }),
173            ScrollBarVariant::Thin => ctx.add(ThinIndicatorPainter {
174                orientation: cfg.orientation,
175                thickness,
176                resting_thickness,
177                min_thumb_length: cfg.min_thumb_length,
178                scroll_ratio: cfg.scroll_ratio.clone(),
179                viewport_ratio: cfg.viewport_ratio.clone(),
180                is_idle: cfg.is_idle.clone(),
181                thumb_color: cfg.thumb_color.clone(),
182            }),
183            ScrollBarVariant::Overlay => {
184                // The overlay variant cross-fades two painters that
185                // share the slot. Avoid `Fade` here because its tween
186                // effect can only observe *mutable* signals; the
187                // hover/drag-derived "active" signal is multi-source
188                // and lives on the derived side of the Signal split.
189                //
190                // Instead, run two scoped effects (one per source
191                // signal — both are mutable on the widget side) and
192                // drive a single animated `revealed` opacity. The
193                // inverse opacity for the idle indicator is a derived
194                // signal, fine to bind through `set_opacity`.
195                let initial = if cfg.is_hovered.get() || cfg.is_dragging.get() {
196                    1.0
197                } else {
198                    0.0
199                };
200                let revealed = ctx.animated_signal(initial);
201                let anim = ctx.animate().fast().standard();
202                {
203                    let dragging = cfg.is_dragging.clone();
204                    let revealed = revealed.clone();
205                    let anim = anim.clone();
206                    ctx.effect(&cfg.is_hovered, move |hovered| {
207                        let target = if *hovered || dragging.get() { 1.0 } else { 0.0 };
208                        anim.to_or_snap(&revealed, target);
209                    });
210                }
211                {
212                    let hovered = cfg.is_hovered.clone();
213                    let revealed = revealed.clone();
214                    let anim = anim.clone();
215                    ctx.effect(&cfg.is_dragging, move |dragging| {
216                        let target = if hovered.get() || *dragging { 1.0 } else { 0.0 };
217                        anim.to_or_snap(&revealed, target);
218                    });
219                }
220                let inactive_opacity = revealed.map(|r| 1.0 - *r);
221
222                let thin = ThinIndicatorPainter {
223                    orientation: cfg.orientation,
224                    thickness,
225                    resting_thickness,
226                    min_thumb_length: cfg.min_thumb_length,
227                    scroll_ratio: cfg.scroll_ratio.clone(),
228                    viewport_ratio: cfg.viewport_ratio.clone(),
229                    is_idle: cfg.is_idle.clone(),
230                    thumb_color: cfg.thumb_color.clone(),
231                };
232                let full = FullBarPainter {
233                    orientation: cfg.orientation,
234                    thickness,
235                    min_thumb_length: cfg.min_thumb_length,
236                    scroll_ratio: cfg.scroll_ratio.clone(),
237                    viewport_ratio: cfg.viewport_ratio.clone(),
238                    is_hovered: cfg.is_hovered.clone(),
239                    is_dragging: cfg.is_dragging.clone(),
240                    is_idle: cfg.is_idle.clone(),
241                    show_track: false,
242                    thumb_color: cfg.thumb_color.clone(),
243                };
244
245                let thin_id = ctx.add(thin);
246                let full_id = ctx.add(full);
247                ctx.set_opacity(thin_id, inactive_opacity);
248                ctx.set_opacity(full_id, revealed);
249                ctx.add(ZStack::new().child(thin_id).child(full_id))
250            }
251        }
252    }
253
254    /// This style's own thumb floor, so a preset's (macOS's 28 dp, Fluent's
255    /// 30 dp `ScrollBarHorizontalThumbMinWidth`) is the one a bar lays out.
256    /// `tokens` is unused: the recipe was already resolved against a density
257    /// when the style was made (`for_tokens`, or a preset's own factory), and
258    /// a second projection would apply the ladder twice.
259    fn min_thumb_length(&self, _tokens: &InputTokens) -> f32 {
260        self.recipe.min_thumb_length
261    }
262}
263
264/// Paints the thin resting indicator. Used as the only body in `Thin`
265/// variant; layered under the full bar in `Overlay` variant.
266struct ThinIndicatorPainter {
267    orientation: ScrollBarOrientation,
268    /// Slot thickness — needed so `layout_response` returns a sensible
269    /// fallback size matching the parent slot.
270    thickness: f32,
271    resting_thickness: f32,
272    min_thumb_length: f32,
273    scroll_ratio: Signal<f32>,
274    viewport_ratio: Signal<f32>,
275    is_idle: Signal<bool>,
276    thumb_color: Option<ColorProp>,
277}
278
279impl std::fmt::Debug for ThinIndicatorPainter {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("ThinIndicatorPainter")
282            .field("orientation", &self.orientation)
283            .finish()
284    }
285}
286
287/// The thumb's leading offset turned into an `x`, mirrored for a right-to-left
288/// window.
289///
290/// A horizontal bar's zero is the *start* of the content, which is the
291/// right-hand edge in a right-to-left window — `ScrollArea` places its content
292/// that way, and the widget's own hit-test and drag already mirror
293/// (`scroll_bar.rs`'s widget-local `thumb_rect`). A painter that pinned the
294/// thumb to the geometric left therefore drew it at the far end of the track
295/// while the content showed its beginning, and dragging it jumped.
296///
297/// The direction is read at **paint** time, not at build time, because a locale
298/// change repaints without rebuilding — the same obligation
299/// [`SliderStyle`](teksilo_core::styles::SliderStyle) documents.
300fn horizontal_thumb_x(
301    track_x: f32,
302    track_width: f32,
303    offset: f32,
304    thumb_len: f32,
305    ctx: &PaintContext,
306) -> f32 {
307    if ctx.layout_direction == LayoutDirection::RightToLeft {
308        track_x + track_width - offset - thumb_len
309    } else {
310        track_x + offset
311    }
312}
313
314impl Widget for ThinIndicatorPainter {
315    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
316        let id = ctx.self_id();
317        let registry = ctx.binding_registry();
318        self.scroll_ratio
319            .bind_to(id, registry, BindingLevel::RepaintOnly);
320        self.viewport_ratio
321            .bind_to(id, registry, BindingLevel::RepaintOnly);
322        self.is_idle
323            .bind_to(id, registry, BindingLevel::RepaintOnly);
324        Vec::new()
325    }
326
327    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
328        match self.orientation {
329            ScrollBarOrientation::Vertical => {
330                Size::new(self.thickness, proposal.height.unwrap_or(0.0))
331            }
332            ScrollBarOrientation::Horizontal => {
333                Size::new(proposal.width.unwrap_or(0.0), self.thickness)
334            }
335        }
336        .into()
337    }
338
339    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
340        if self.is_idle.get() {
341            return;
342        }
343        let thin = self.resting_thickness;
344        let thin_bounds = match self.orientation {
345            ScrollBarOrientation::Vertical => {
346                Rect::new(bounds.right() - thin, bounds.y, thin, bounds.height)
347            }
348            ScrollBarOrientation::Horizontal => {
349                Rect::new(bounds.x, bounds.bottom() - thin, bounds.width, thin)
350            }
351        };
352        let track_len = match self.orientation {
353            ScrollBarOrientation::Vertical => bounds.height,
354            ScrollBarOrientation::Horizontal => bounds.width,
355        };
356        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
357        let thumb_len = (track_len * ratio)
358            .max(self.min_thumb_length)
359            .min(track_len);
360        let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
361        let offset = scroll_ratio * (track_len - thumb_len);
362
363        let radius = CornerRadius::uniform(thin / 2.0);
364        let thumb_rect = match self.orientation {
365            ScrollBarOrientation::Vertical => {
366                Rect::new(thin_bounds.x, thin_bounds.y + offset, thin, thumb_len)
367            }
368            ScrollBarOrientation::Horizontal => Rect::new(
369                horizontal_thumb_x(thin_bounds.x, thin_bounds.width, offset, thumb_len, ctx),
370                thin_bounds.y,
371                thumb_len,
372                thin,
373            ),
374        };
375        // Thin indicator has no hover/drag state — it's the resting strip.
376        let thumb_color = resolve_thumb_color(self.thumb_color.as_ref(), ctx, false, false);
377        canvas.fill_rounded_rect(thumb_rect, radius, thumb_color);
378    }
379
380    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
381        builder.set_hidden();
382    }
383}
384
385/// Paints the full track + thumb. Used as the only body in `Permanent`
386/// variant; layered over the thin indicator in `Overlay` variant (where
387/// `show_track` is false because the track only paints when the bar is
388/// active).
389struct FullBarPainter {
390    orientation: ScrollBarOrientation,
391    thickness: f32,
392    min_thumb_length: f32,
393    scroll_ratio: Signal<f32>,
394    viewport_ratio: Signal<f32>,
395    is_hovered: Signal<bool>,
396    is_dragging: Signal<bool>,
397    is_idle: Signal<bool>,
398    /// `true` in `Permanent` variant — paints a track behind the thumb.
399    /// `false` in `Overlay` variant — track only paints alongside the
400    /// thumb when the overlay reveals itself.
401    show_track: bool,
402    thumb_color: Option<ColorProp>,
403}
404
405impl std::fmt::Debug for FullBarPainter {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        f.debug_struct("FullBarPainter")
408            .field("orientation", &self.orientation)
409            .field("show_track", &self.show_track)
410            .finish()
411    }
412}
413
414impl Widget for FullBarPainter {
415    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
416        let id = ctx.self_id();
417        let registry = ctx.binding_registry();
418        self.scroll_ratio
419            .bind_to(id, registry, BindingLevel::RepaintOnly);
420        self.viewport_ratio
421            .bind_to(id, registry, BindingLevel::RepaintOnly);
422        self.is_hovered
423            .bind_to(id, registry, BindingLevel::RepaintOnly);
424        self.is_dragging
425            .bind_to(id, registry, BindingLevel::RepaintOnly);
426        self.is_idle
427            .bind_to(id, registry, BindingLevel::RepaintOnly);
428        Vec::new()
429    }
430
431    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
432        match self.orientation {
433            ScrollBarOrientation::Vertical => {
434                Size::new(self.thickness, proposal.height.unwrap_or(0.0))
435            }
436            ScrollBarOrientation::Horizontal => {
437                Size::new(proposal.width.unwrap_or(0.0), self.thickness)
438            }
439        }
440        .into()
441    }
442
443    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
444        if self.is_idle.get() {
445            return;
446        }
447        let ratio = self.viewport_ratio.get().clamp(0.0, 1.0);
448        let track_len = match self.orientation {
449            ScrollBarOrientation::Vertical => bounds.height,
450            ScrollBarOrientation::Horizontal => bounds.width,
451        };
452        let thumb_len = (track_len * ratio)
453            .max(self.min_thumb_length)
454            .min(track_len);
455        let scroll_ratio = self.scroll_ratio.get().clamp(0.0, 1.0);
456        let offset = scroll_ratio * (track_len - thumb_len);
457
458        let radius = CornerRadius::uniform(self.thickness / 2.0);
459
460        if self.show_track {
461            let track = match &self.thumb_color {
462                Some(prop) => prop
463                    .resolve(ctx.theme, true)
464                    .with_alpha(OVERRIDE_TRACK_ALPHA),
465                None => ctx.theme.colors.scrollbar_track_hover,
466            };
467            canvas.fill_rounded_rect(bounds, radius, track);
468        }
469
470        let thumb = match self.orientation {
471            ScrollBarOrientation::Vertical => {
472                Rect::new(bounds.x, bounds.y + offset, bounds.width, thumb_len)
473            }
474            ScrollBarOrientation::Horizontal => Rect::new(
475                horizontal_thumb_x(bounds.x, bounds.width, offset, thumb_len, ctx),
476                bounds.y,
477                thumb_len,
478                bounds.height,
479            ),
480        };
481        let thumb_color = resolve_thumb_color(
482            self.thumb_color.as_ref(),
483            ctx,
484            self.is_hovered.get(),
485            self.is_dragging.get(),
486        );
487        canvas.fill_rounded_rect(thumb, radius, thumb_color);
488    }
489
490    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
491        builder.set_hidden();
492    }
493}