Skip to main content

teksilo_widgets/
slider.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Slider — a draggable value selector bound to a `Signal<f32>`.
5//!
6//! The widget owns all input handling: pointer drag (click-to-jump and
7//! thumb-drag), keyboard arrows (`ArrowRight`/`ArrowLeft`/`Up`/`Down`,
8//! `Home`, `End`), and `Increment`/`Decrement` accessibility actions.
9//! All visual chrome is delegated to a
10//! [`SliderStyle`] implementation; the
11//! IntUI default ships out of the box and is also the theme-wide slot
12//! override target (`theme.style_slots.slider`).
13//!
14//! ## Accessibility
15//!
16//! Exposes `Role::Slider` with numeric value, min, max, step, and
17//! orientation. Screen readers announce the current value on every
18//! change. The focus ring follows the `:focus-visible` heuristic —
19//! visible after keyboard interaction, invisible after a pointer tap.
20//!
21//! ```rust
22//! # use teksilo_core::signal::Signal;
23//! # use teksilo_widgets::Slider;
24//! let volume = Signal::new(0.5_f32);
25//! let _w = Slider::new(volume, 0.0, 1.0).step(0.05);
26//! ```
27
28use std::cell::Cell;
29use std::rc::Rc;
30
31use teksilo_canvas::{Rect, SizeProposal};
32use teksilo_core::accessibility::AccessNodeBuilder;
33use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
34use teksilo_core::focus::FocusOrigin;
35use teksilo_core::gesture::DragPhase;
36use teksilo_core::signal::{Prop, Signal};
37use teksilo_core::styles::{
38    SharedSliderStyle, SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
39};
40use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
41use teksilo_core::widget_builder::HandlerSet;
42use teksilo_core::widget_id::WidgetId;
43use teksilo_tokens::Orientation;
44
45// Re-export the variant enum at module top so callers can write
46// `Slider::new(...).variant(SliderVariant::Discrete)` without a deeper
47// import path.
48pub use teksilo_core::styles::SliderVariant as SliderVariantExport;
49use teksilo_i18n::LocalizedString;
50
51/// A draggable value selector bound to a `Signal<f32>` in a continuous
52/// or discrete range. Visual chrome is fully delegated to a
53/// [`SliderStyle`] implementation.
54pub struct Slider {
55    value: Signal<f32>,
56    min: f32,
57    max: f32,
58    step: Option<f32>,
59    orientation: Orientation,
60    /// Enabled state, static or reactive; forwarded to the arena at
61    /// build time.
62    enabled: Prop<bool>,
63    /// Accessible name, announced by screen readers as the control's label.
64    label: Option<LocalizedString>,
65    variant: SliderVariant,
66    tick_count: Option<u32>,
67    style_override: Option<SharedSliderStyle>,
68    hovered: Signal<bool>,
69    dragging: Signal<bool>,
70    /// Raw keyboard/pointer focus (any modality). The keyboard-only focus
71    /// ring is derived live from this × the input-modality signal in
72    /// `build()` (`:focus-visible`).
73    focused: Signal<bool>,
74    cached_bounds: Rc<Cell<Rect>>,
75    body_id: Option<WidgetId>,
76    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
77    /// with the rich / composite slots — every setter clears the other two so
78    /// the last call wins.
79    tooltip_text: Option<LocalizedString>,
80    /// Optional rich tooltip source (registry key or inline content).
81    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
82    /// Optional composite tooltip body (arbitrary widget tree).
83    composite_tooltip_content: Option<Box<dyn Widget>>,
84}
85
86impl Slider {
87    /// Create a horizontal slider bound to `value` with the given inclusive
88    /// range. Use [`orientation`](Self::orientation) to switch to vertical.
89    pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self {
90        Self {
91            value,
92            min,
93            max,
94            step: None,
95            orientation: Orientation::Horizontal,
96            enabled: Prop::Static(true),
97            label: None,
98            variant: SliderVariant::default(),
99            tick_count: None,
100            style_override: None,
101            hovered: Signal::new(false),
102            dragging: Signal::new(false),
103            focused: Signal::new(false),
104            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
105            body_id: None,
106            tooltip_text: None,
107            rich_tooltip_source: None,
108            composite_tooltip_content: None,
109        }
110    }
111
112    /// Set the discrete step size for keyboard arrows and accessibility
113    /// Increment/Decrement actions. When unset, defaults to 1 % of the
114    /// range.
115    pub fn step(mut self, step: f32) -> Self {
116        self.step = Some(step);
117        self
118    }
119
120    /// Set the slider orientation (`Horizontal` by default). Vertical
121    /// sliders map Up/Down arrow keys to increase/decrease.
122    pub fn orientation(mut self, orientation: Orientation) -> Self {
123        self.orientation = orientation;
124        self
125    }
126
127    /// Set the enabled state, statically or reactively. Forwarded to
128    /// the arena at build time via
129    /// `ctx.enabled_when(slider_id, self.enabled.clone())`.
130    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
131        self.enabled = enabled.into();
132        self
133    }
134
135    /// Pick a Tier-1 design-language variant
136    /// ([`SliderVariant::Continuous`] / `Discrete` / `Range`). The
137    /// active [`SliderStyle`] decides what to do with the hint —
138    /// IntUI's default impl paints ticks for `Discrete` and ignores
139    /// `Range` (the widget itself doesn't yet wire dual-thumb
140    /// behaviour).
141    pub fn variant(mut self, variant: SliderVariant) -> Self {
142        self.variant = variant;
143        self
144    }
145
146    /// Configure the tick count for a `Discrete` slider. The
147    /// IntUI default paints `n` evenly spaced tick marks above the
148    /// track (or to the leading side for vertical orientation).
149    pub fn tick_count(mut self, count: u32) -> Self {
150        self.tick_count = Some(count);
151        self
152    }
153
154    /// Override the active [`SliderStyle`] for this widget instance
155    /// only.
156    pub fn style(mut self, style: impl SliderStyle) -> Self {
157        self.style_override = Some(Rc::new(style));
158        self
159    }
160
161    /// Set an accessible name for the slider, announced by screen readers.
162    /// ARIA requires sliders to have a label; when none is set here the
163    /// caller is responsible for labelling via a wrapping element.
164    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
165        let ls: LocalizedString = label.into();
166        self.label = Some(ls);
167        self
168    }
169
170    /// Attach a plain single-line tooltip shown after a hover delay.
171    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
172    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
173    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
174    /// wins and clears the others.
175    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
176        self.tooltip_text = Some(text.into());
177        self.rich_tooltip_source = None;
178        self.composite_tooltip_content = None;
179        self
180    }
181
182    /// Attach a rich tooltip driven by a registry key. The registry
183    /// entry supplies title, body markup, optional shortcut chip and
184    /// cascade links. Mutually exclusive with the other tooltip setters.
185    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
186        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
187        self.tooltip_text = None;
188        self.composite_tooltip_content = None;
189        self
190    }
191
192    /// Attach a rich tooltip from an inline [`TooltipContent`](crate::tooltip::TooltipContent)
193    /// value, bypassing the registry lookup. Mutually exclusive with the
194    /// other tooltip setters.
195    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
196        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
197        self.tooltip_text = None;
198        self.composite_tooltip_content = None;
199        self
200    }
201
202    /// Attach a composite tooltip whose body is an arbitrary widget tree.
203    /// Uses the heavier `tooltip_delay_heavy` delay. Mutually exclusive
204    /// with the other tooltip setters.
205    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
206        self.composite_tooltip_content = Some(Box::new(content));
207        self.tooltip_text = None;
208        self.rich_tooltip_source = None;
209        self
210    }
211}
212
213impl std::fmt::Debug for Slider {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("Slider")
216            .field("min", &self.min)
217            .field("max", &self.max)
218            .field("enabled", &self.enabled.get())
219            .field("variant", &self.variant)
220            .finish()
221    }
222}
223
224impl Widget for Slider {
225    fn build(
226        &mut self,
227        ctx: &mut teksilo_core::build_context::BuildContext,
228    ) -> Vec<teksilo_core::widget_id::WidgetId> {
229        let self_id = ctx.self_id();
230        // Forward the enabled state into the arena; see IconButton.
231        ctx.enabled_when(self_id, self.enabled.clone());
232        let effective_enabled = ctx.effective_enabled_signal(self_id);
233
234        // Resolve the active style: per-call override > theme slot >
235        // built-in `RecipeSliderStyle` default.
236        let style: SharedSliderStyle = self
237            .style_override
238            .clone()
239            .or_else(|| ctx.theme().style_slots.slider.clone())
240            .unwrap_or_else(|| Rc::new(crate::styles::RecipeSliderStyle::default()));
241
242        // Derived `value_normalized` signal — re-renders the body
243        // whenever the user-visible value changes.
244        let min = self.min;
245        let max = self.max;
246        let value_normalized = self.value.map(move |v| {
247            let range = max - min;
248            if range <= 0.0 {
249                0.0
250            } else {
251                ((*v - min) / range).clamp(0.0, 1.0)
252            }
253        });
254
255        let orientation = match self.orientation {
256            Orientation::Horizontal => SliderOrientation::Horizontal,
257            Orientation::Vertical => SliderOrientation::Vertical,
258        };
259
260        let cfg = SliderStyleConfig {
261            value_normalized,
262            is_hovered: self.hovered.clone(),
263            is_dragging: self.dragging.clone(),
264            is_disabled: effective_enabled.map(|on| !*on),
265            // `:focus-visible`: derive the keyboard/pointer origin live from
266            // the input-modality signal (true after a key event, false after
267            // pointer-down) rather than snapshotting hover at focus time, so
268            // the focus ring follows the *current* modality.
269            focus_origin: self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
270                if !*f {
271                    None
272                } else if *v {
273                    Some(FocusOrigin::Keyboard)
274                } else {
275                    Some(FocusOrigin::Pointer)
276                }
277            }),
278            orientation,
279            tick_count: self.tick_count,
280            variant: self.variant,
281        };
282        let body_id = style.make_body(&cfg, ctx);
283        self.body_id = Some(body_id);
284
285        // Capture the thumb radius at build time. The event handlers
286        // need it for value computation, but they only receive
287        // `EventContext` and can't reach the theme at event time.
288        // Query the *resolved* style so a custom `SliderStyle` with a
289        // different thumb size keeps drag hit-testing aligned, instead of
290        // baking in the recipe's design constant.
291        let thumb_radius = style.thumb_diameter(&cfg) * 0.5;
292
293        let value = self.value.clone();
294        let step = self.step;
295        let orientation = self.orientation;
296        let hovered = self.hovered.clone();
297        let dragging = self.dragging.clone();
298        let focused = self.focused.clone();
299        let cached_bounds = self.cached_bounds.clone();
300
301        let adjust_by_step = {
302            let value = value.clone();
303            move |positive: bool| {
304                let s = step.unwrap_or((max - min) * 0.01);
305                let current = value.get();
306                let new_val = if positive { current + s } else { current - s };
307                value.set(new_val.clamp(min, max));
308            }
309        };
310
311        let set_value_from_position = {
312            let value = value.clone();
313            let cached_bounds = cached_bounds.clone();
314            move |x: f32, y: f32| {
315                let bounds = cached_bounds.get();
316                let pos = match orientation {
317                    Orientation::Horizontal => x,
318                    Orientation::Vertical => y,
319                };
320                let usable = match orientation {
321                    Orientation::Horizontal => bounds.width,
322                    Orientation::Vertical => bounds.height,
323                } - thumb_radius * 2.0;
324                if usable <= 0.0 {
325                    return;
326                }
327                // `pos` arrives widget-local (origin at the slider's own
328                // top-left), so the track starts at `thumb_radius`, not at
329                // `bounds.x` / `bounds.y`.
330                let t = ((pos - thumb_radius) / usable).clamp(0.0, 1.0);
331                let mut val = min + t * (max - min);
332                if let Some(s) = step
333                    && s > 0.0
334                {
335                    val = ((val - min) / s).round() * s + min;
336                }
337                value.set(val.clamp(min, max));
338            }
339        };
340
341        // Framework gates events on `arena.is_enabled(self_id)`, so
342        // no per-handler enabled snapshot guards anymore.
343        let mut handlers = HandlerSet::new()
344            .focusable(true)
345            .cursor(CursorIcon::Pointer);
346
347        // Thumb drag — routed through the typed gesture API.
348        {
349            let dragging = dragging.clone();
350            let set_value = set_value_from_position.clone();
351            handlers = handlers.on_drag(move |phase, _ctx| match phase {
352                DragPhase::Started {
353                    position,
354                    button: PointerButton::Primary,
355                } => {
356                    dragging.set(true);
357                    set_value(position.x, position.y);
358                }
359                DragPhase::Moved { position, .. } if dragging.get() => {
360                    set_value(position.x, position.y);
361                }
362                DragPhase::Ended { .. } => {
363                    dragging.set(false);
364                }
365                _ => {}
366            });
367        }
368
369        // Track click — jump the value to the click position.
370        {
371            let set_value = set_value_from_position.clone();
372            handlers = handlers.on_tap(move |event, _ctx| {
373                set_value(event.position.x, event.position.y);
374            });
375        }
376
377        // Hover handler
378        {
379            let hovered = hovered.clone();
380            handlers = handlers.on_hover(move |entered, _ctx| {
381                hovered.set(entered);
382            });
383        }
384
385        // Key handler
386        {
387            let adjust = adjust_by_step.clone();
388            let value = value.clone();
389            handlers = handlers.on_key(move |event, _ctx| match event {
390                WidgetEvent::KeyDown { key, .. } => match key {
391                    Key::ArrowRight | Key::ArrowUp => {
392                        adjust(true);
393                        EventResponse::Handled
394                    }
395                    Key::ArrowLeft | Key::ArrowDown => {
396                        adjust(false);
397                        EventResponse::Handled
398                    }
399                    Key::Home => {
400                        value.set(min);
401                        EventResponse::Handled
402                    }
403                    Key::End => {
404                        value.set(max);
405                        EventResponse::Handled
406                    }
407                    _ => EventResponse::Ignored,
408                },
409                _ => EventResponse::Ignored,
410            });
411        }
412
413        // Focus handler. Track raw focus only; the keyboard/pointer
414        // distinction is derived live from the input-modality signal in
415        // `build()` (`:focus-visible`), so clicking to focus then pressing a
416        // key reveals the ring.
417        {
418            let focused = focused.clone();
419            handlers = handlers.on_focus(move |gained, _ctx| {
420                focused.set(gained);
421            });
422        }
423
424        // Access action handler
425        {
426            let adjust = adjust_by_step.clone();
427            handlers = handlers.on_access_action(move |action, _ctx| match action {
428                teksilo_core::accesskit::Action::Increment => {
429                    adjust(true);
430                    EventResponse::Handled
431                }
432                teksilo_core::accesskit::Action::Decrement => {
433                    adjust(false);
434                    EventResponse::Handled
435                }
436                _ => EventResponse::Ignored,
437            });
438        }
439
440        ctx.apply_self_handlers(handlers);
441
442        // Tooltip attachment — at most one branch fires (the setters are
443        // mutually exclusive). Anchor on `body_id`, the primary visible root.
444        if let Some(content) = self.composite_tooltip_content.take() {
445            let delay = ctx.theme().motion.tooltip_delay_heavy;
446            crate::tooltip::attach_composite_tooltip_boxed(ctx, body_id, content, delay);
447        } else if let Some(source) = self.rich_tooltip_source.clone() {
448            let delay = ctx.theme().motion.tooltip_delay;
449            crate::tooltip::attach_rich_tooltip_source(ctx, body_id, source, delay);
450        } else if let Some(text) = self.tooltip_text.clone() {
451            let delay = ctx.theme().motion.tooltip_delay;
452            crate::tooltip::attach_plain_tooltip(ctx, body_id, text, delay);
453        }
454
455        vec![body_id]
456    }
457
458    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
459        self.body_id
460            .and_then(|id| ctx.child_size(id, proposal))
461            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
462            .into()
463    }
464
465    fn place_children(
466        &self,
467        bounds: Rect,
468        _proposal: SizeProposal,
469        children: &mut [WidgetPlacement],
470        _ctx: &LayoutContext,
471    ) {
472        // Cache bounds for event handling (needed before paint).
473        self.cached_bounds.set(bounds);
474        if let Some(child) = children.first_mut() {
475            child.origin = bounds.origin();
476            child.size = bounds.size();
477        }
478    }
479
480    fn children(&self) -> Vec<WidgetId> {
481        self.body_id.into_iter().collect()
482    }
483
484    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
485        builder.set_role(teksilo_core::accesskit::Role::Slider);
486        if let Some(ref label) = self.label {
487            builder.set_name(label.resolve_now());
488        }
489        builder.set_numeric_value(self.value.get() as f64);
490        builder.set_min_numeric_value(self.min as f64);
491        builder.set_max_numeric_value(self.max as f64);
492        // Publish the keyboard step so Orca / VoiceOver can announce
493        // "step by N" when the user holds an arrow key. If the caller
494        // didn't configure an explicit step, fall back to 1% of the
495        // range — same heuristic the keyboard handler uses.
496        let step = self.step.unwrap_or((self.max - self.min) * 0.01);
497        builder.set_numeric_value_step(step as f64);
498        let orientation = match self.orientation {
499            Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
500            Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
501        };
502        builder.set_orientation(orientation);
503        // Framework a11y walker sets `set_disabled` from arena state.
504        builder.add_action(teksilo_core::accesskit::Action::Increment);
505        builder.add_action(teksilo_core::accesskit::Action::Decrement);
506        builder.add_action(teksilo_core::accesskit::Action::Focus);
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use teksilo_canvas::Point;
514    use teksilo_core::event::Modifiers;
515    use teksilo_core::widget_tree::WidgetTree;
516
517    #[test]
518    fn focus_ring_only_under_focus_visible() {
519        // `:focus-visible`: the keyboard-only focus ring (now derived live
520        // from the input-modality signal, not a hover-at-focus snapshot).
521        // Programmatic focus leaves `focus_visible` false → no ring; a key
522        // press reveals it.
523        let theme = teksilo_core::presets::intui::light();
524        let ring = theme.colors.focus_ring.to_array();
525        let mut tree = WidgetTree::new().with_theme(theme);
526        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
527        tree.layout(SizeProposal::exact(200.0, 60.0));
528
529        tree.focus(s);
530        assert!(
531            !frame_has_ring(&tree.render(), ring),
532            "no focus ring while focus-visible is false (pointer modality)",
533        );
534
535        tree.press_key(Key::ArrowDown, Modifiers::NONE);
536        assert!(
537            frame_has_ring(&tree.render(), ring),
538            "focus ring shows under keyboard modality",
539        );
540    }
541
542    /// Whether the focus-ring *stroke* (ring color + non-zero stroke width) is
543    /// present. A plain color match is ambiguous: in IntUI `focus_ring` shares
544    /// the `accent` RGBA, and the slider paints accent *fills* (track + thumb)
545    /// — the ring is the only *stroked* shape in that color.
546    fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
547        frame
548            .shapes
549            .iter()
550            .any(|s| s.color == color && s.stroke_width > 0.0)
551            || frame.cosmetic_lines.iter().any(|l| l.color == color)
552    }
553
554    #[test]
555    fn keyboard_adjusts_value() {
556        let value = Signal::new(50.0_f32);
557        let mut tree = WidgetTree::new();
558        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
559        tree.layout(SizeProposal::exact(200.0, 60.0));
560
561        tree.focus(s);
562        tree.press_key(Key::ArrowRight, Modifiers::NONE);
563        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
564
565        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
566        assert!((value.get() - 50.0).abs() < 0.01);
567    }
568
569    #[test]
570    fn home_end_jump_to_bounds() {
571        let value = Signal::new(50.0_f32);
572        let mut tree = WidgetTree::new();
573        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
574        tree.layout(SizeProposal::exact(200.0, 60.0));
575
576        tree.focus(s);
577        tree.press_key(Key::Home, Modifiers::NONE);
578        assert!((value.get() - 0.0).abs() < 0.01);
579
580        tree.press_key(Key::End, Modifiers::NONE);
581        assert!((value.get() - 100.0).abs() < 0.01);
582    }
583
584    #[test]
585    fn track_click_sets_value() {
586        let value = Signal::new(0.0_f32);
587        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
588        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
589        tree.layout(SizeProposal::exact(200.0, 60.0));
590        // Render to trigger paint() which caches bounds for event handling
591        tree.render();
592
593        // Click at the widget center
594        tree.click(s);
595
596        // Value should be approximately 50 (midpoint of 0..100)
597        let val = value.get();
598        assert!(
599            (val - 50.0).abs() < 15.0,
600            "track click at center should set value near 50, got {}",
601            val
602        );
603    }
604
605    #[test]
606    fn track_click_sets_value_at_nonzero_origin() {
607        // Regression for the widget-local coordinate migration: a slider
608        // offset from the window origin must still map a click correctly.
609        use crate::primitives::{FixedSize, HStack};
610        use teksilo_canvas::Point;
611        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
612
613        let value = Signal::new(0.0_f32);
614        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
615        let sid = tree.add(Slider::new(value.clone(), 0.0, 100.0));
616        let _row = tree.add(
617            HStack::new()
618                .child(FixedSize::new().width(40.0).height(60.0))
619                .add_child(sid),
620        );
621        tree.layout(SizeProposal::exact(240.0, 60.0));
622        tree.render();
623
624        // The slider sits at window x ∈ [40, 240] (width 200). Its local
625        // centre (x = 100) is window x = 140 → value 50, independent of
626        // the thumb radius.
627        let b = tree.bounds(sid);
628        assert!(
629            (b.x - 40.0).abs() < 0.5,
630            "slider should be offset, x={}",
631            b.x
632        );
633        for ev in [
634            WidgetEvent::PointerDown {
635                position: Point::new(140.0, 30.0),
636                button: PointerButton::Primary,
637                modifiers: Modifiers::NONE,
638            },
639            WidgetEvent::PointerUp {
640                position: Point::new(140.0, 30.0),
641                button: PointerButton::Primary,
642                modifiers: Modifiers::NONE,
643            },
644        ] {
645            tree.dispatch_event(ev);
646        }
647        assert!(
648            (value.get() - 50.0).abs() < 1.0,
649            "click at the offset slider's centre should set ~50, got {}",
650            value.get()
651        );
652    }
653
654    #[test]
655    fn accessibility() {
656        let value = Signal::new(25.0_f32);
657        let mut tree = WidgetTree::new();
658        let s = tree.add(Slider::new(value, 0.0, 100.0));
659        tree.layout(SizeProposal::exact(200.0, 60.0));
660        let info = tree.accessibility_node(s);
661        assert_eq!(info.role(), teksilo_core::accesskit::Role::Slider);
662    }
663
664    #[test]
665    fn step_snaps_value() {
666        let value = Signal::new(0.0_f32);
667        let mut tree = WidgetTree::new();
668        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(25.0));
669        tree.layout(SizeProposal::exact(200.0, 60.0));
670
671        tree.focus(s);
672        tree.press_key(Key::ArrowRight, Modifiers::NONE);
673        assert!((value.get() - 25.0).abs() < 0.01);
674        tree.press_key(Key::ArrowRight, Modifiers::NONE);
675        assert!((value.get() - 50.0).abs() < 0.01);
676    }
677
678    #[test]
679    fn thumb_drag_updates_value() {
680        let theme = teksilo_core::presets::intui::light();
681        let thumb_radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
682        let value = Signal::new(50.0_f32);
683        let mut tree = WidgetTree::new().with_theme(theme);
684        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
685        tree.layout(SizeProposal::exact(200.0, 60.0));
686        tree.render(); // cache bounds for event handling
687
688        let bounds = tree.bounds(s);
689        // Thumb center for value=50: bounds.x + r + (width - 2r) * 0.5
690        let thumb_cx = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.5;
691        let center_y = bounds.y + bounds.height / 2.0;
692
693        // Pointer down on thumb
694        tree.pointer_down_button(Point::new(thumb_cx, center_y), PointerButton::Primary);
695
696        // Drag to 75% position. DragRecognizer needs one move past its
697        // 5 px threshold to emit `DragStarted` (which carries the *down*
698        // position, leaving value at 50%), and a second move to emit
699        // `DragMoved` — the latter is what actually drives the value.
700        let target_x = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.75;
701        tree.pointer_move(Point::new(thumb_cx + 10.0, center_y));
702        tree.pointer_move(Point::new(target_x, center_y));
703
704        let val = value.get();
705        assert!(
706            (val - 75.0).abs() < 5.0,
707            "dragging to 75% should set value near 75, got {}",
708            val
709        );
710
711        // Release
712        tree.pointer_up_button(Point::new(target_x, center_y), PointerButton::Primary);
713    }
714
715    #[test]
716    fn accessibility_has_actions() {
717        let value = Signal::new(25.0_f32);
718        let mut tree = WidgetTree::new();
719        let s = tree.add(Slider::new(value, 0.0, 100.0));
720        tree.layout(SizeProposal::exact(200.0, 60.0));
721        let info = tree.accessibility_node(s);
722        assert!(
723            info.actions()
724                .contains(&teksilo_core::accesskit::Action::Increment)
725        );
726        assert!(
727            info.actions()
728                .contains(&teksilo_core::accesskit::Action::Decrement)
729        );
730    }
731}