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), the keyboard, and the `Increment` / `Decrement` /
8//! `SetValue` accessibility actions. All visual chrome is delegated to a
9//! [`SliderStyle`] implementation; the
10//! IntUI default ships out of the box and is also the theme-wide slot
11//! override target (`theme.style_slots.slider`).
12//!
13//! ## Keyboard
14//!
15//! - `ArrowRight` / `ArrowUp` and `ArrowLeft` / `ArrowDown` — one
16//!   [`step`](Slider::step), defaulting to 1 % of the range.
17//! - `PageUp` / `PageDown` — one [`page_step`](Slider::page_step),
18//!   defaulting to ten times the step and so to 10 % of the range. That
19//!   is `QAbstractSlider::pageStep`, `GtkScale`'s page increment, and
20//!   what `<input type=range>` gives in both WebKit and Blink.
21//! - `Home` / `End` — the minimum and the maximum.
22//! - A chord holding `Ctrl`, `Alt` or `Super` is not the slider's and
23//!   falls through to the application; `Shift` does not change the step.
24//!
25//! The chord table is shared with every other bounded-scalar control; see
26//! `docs/range-keyboard.md`.
27//!
28//! ## Accessibility
29//!
30//! Exposes `Role::Slider` with numeric value, min, max, step, the page
31//! distance as `numeric_value_jump`, and orientation. Screen readers
32//! announce the current value on every change. `SetValue` accepts a
33//! number or a numeric string and snaps it to `step`, so an assistive
34//! technology's write lands on the same grid a drag does — AT-SPI's
35//! `Value.SetCurrentValue` is how Orca sets a slider, and macOS gates
36//! `setAccessibilityValue:` settability on the action being advertised.
37//! The focus ring follows the `:focus-visible` heuristic — visible after
38//! keyboard interaction, invisible after a pointer tap.
39//!
40//! ```rust
41//! # use teksilo_core::signal::Signal;
42//! # use teksilo_widgets::Slider;
43//! let volume = Signal::new(0.5_f32);
44//! let _w = Slider::new(volume, 0.0, 1.0).step(0.05);
45//! ```
46//!
47//! ## Value readout and reset
48//!
49//! [`value_tooltip`](Slider::value_tooltip) shows the value as text beside the
50//! thumb — "-3.0 dB" on an equalizer band — while the pointer is over the
51//! slider, while a drag is moving it, and while it has keyboard focus. It is up
52//! at once rather than after a hover delay, follows the thumb wherever the
53//! slider goes, and changes as the value does. It takes no input: a press or a
54//! hover on it belongs to what is under it, and Escape takes it down without
55//! keeping the key from the field the user pressed it for. The same text
56//! becomes the slider's accessible value, beside the number, so a reader that
57//! speaks a value's text says "-3.0 dB" rather than "-3".
58//!
59//! [`value_tooltip_placement`](Slider::value_tooltip_placement) moves the
60//! readout: [`Above`](SliderValueTooltipPlacement::Above) or
61//! [`Below`](SliderValueTooltipPlacement::Below) the thumb, or
62//! [`FollowPointer`](SliderValueTooltipPlacement::FollowPointer), which rides
63//! the pointer rather than the thumb while a drag moves the value — useful on
64//! a stepped slider, whose thumb snaps away from the hand. The default,
65//! [`Auto`](SliderValueTooltipPlacement::Auto), is the placement described
66//! above. [`value_tooltip_style`](Slider::value_tooltip_style) paints this
67//! slider's readout with a [`TooltipStyle`] of its own instead of the theme's
68//! tooltip chrome.
69//!
70//! [`default_value`](Slider::default_value) adds a way back: a double-click on
71//! the slider, or the "Reset to default" accessibility action, puts the value
72//! back to the default, as a user change, so [`on_change`](Slider::on_change)
73//! reports it.
74//!
75//! ```rust
76//! # use teksilo_core::signal::Signal;
77//! # use teksilo_i18n::lit;
78//! # use teksilo_widgets::{Slider, SliderValueTooltipPlacement};
79//! let gain = Signal::new(0.0_f32);
80//! let _band = Slider::new(gain, -12.0, 12.0)
81//!     .step(0.5)
82//!     .label(lit!("Low shelf"))
83//!     .value_tooltip(|db| lit!(format!("{db:+.1} dB")))
84//!     .value_tooltip_placement(SliderValueTooltipPlacement::Below)
85//!     .default_value(0.0);
86//! ```
87//!
88//! ## Clicks, presses and buttons
89//!
90//! A click on the track jumps the value to it, on release; a drag moves it
91//! from the moment the pointer has travelled far enough to be a drag. Both
92//! answer to the primary button only. An application that wants a click of
93//! its own on the slider — a secondary-click reset, say — attaches its own
94//! `on_tap` and widens that handler's mask; the slider's track click stays on
95//! the primary button, and the secondary click is the application's alone:
96//!
97//! ```rust
98//! # use teksilo_core::event::ButtonMask;
99//! # use teksilo_core::signal::Signal;
100//! # use teksilo_core::widget_builder::WidgetBuilder;
101//! # use teksilo_widgets::Slider;
102//! let gain = Signal::new(0.0_f32);
103//! let reset = gain.clone();
104//! let _w = Slider::new(gain, -12.0, 12.0)
105//!     .on_tap(move |_, _| reset.set(0.0))
106//!     .accept_tap_buttons(ButtonMask::SECONDARY);
107//! ```
108//!
109//! [`jump_on_press`](Slider::jump_on_press) moves the value as soon as a mouse
110//! button goes down rather than on release, and a drag carries on from there.
111//! A finger's or a pen's press still writes nothing until it is a tap or a
112//! drag: such a press may yet become a scroll.
113//!
114//! ## Touch and pen
115//!
116//! A slider is a **continuous manipulator**: the value it produces *is* the
117//! press position, so a finger that lands on it adjusts it — even inside a
118//! scrolling form, and from the first movement rather than after a long-press
119//! timer. Two separate things deliver that. The press *capture* the drag takes
120//! makes the slider the innermost member of the pointer's sequence, which is
121//! what stops an enclosing scroller winning the gesture. `touch_action(NONE)`
122//! is the declaration on top: it forbids every default touch behaviour on the
123//! hit path, which in practice means a **two-contact pinch** started on the
124//! slider never reaches the surface under it. `docs/touch-and-pen.md` §7.3.
125//!
126//! [`teksilo_core::widget::Widget::target_regions`]
127//! reports what the style painted inside the slider's one node: the whole node
128//! as the press surface, and the knob as the grab affordance, sized through the
129//! resolved style's density-aware `thumb_diameter_for`. Nothing else in the
130//! tree can see the knob — it is drawn on the same canvas as the track — so
131//! this is the only way a conformance audit or a coarse-press router learns it
132//! is there.
133//!
134//! **Right-to-left.** A horizontal slider's minimum sits at the *leading* edge,
135//! which is the right-hand one in an RTL UI, so both the painted fill and the
136//! position→value map mirror. They were previously mirrored in neither, so an
137//! RTL slider's knob moved away from the finger dragging it.
138
139use std::cell::Cell;
140use std::rc::Rc;
141
142use teksilo_canvas::{Point, Rect, SizeProposal};
143use teksilo_core::accessibility::AccessNodeBuilder;
144use teksilo_core::binding::BindingLevel;
145use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
146use teksilo_core::focus::FocusOrigin;
147use teksilo_core::gesture::{DragPhase, TapStreak};
148use teksilo_core::pointer::PointerId;
149use teksilo_core::pointer::touch_action::TouchAction;
150use teksilo_core::signal::{Prop, Signal};
151use teksilo_core::styles::{
152    SharedSliderStyle, SharedTooltipStyle, SliderOrientation, SliderStyle, SliderStyleConfig,
153    SliderVariant, TooltipStyle,
154};
155use teksilo_core::widget::{CursorIcon, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
156use teksilo_core::widget_builder::HandlerSet;
157use teksilo_core::widget_id::WidgetId;
158use teksilo_tokens::Orientation;
159
160use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
161
162mod readout;
163
164use readout::{Readout, ReadoutInputs, ValueFormat};
165
166// Re-export the variant enum at module top so callers can write
167// `Slider::new(...).variant(SliderVariant::Discrete)` without a deeper
168// import path.
169pub use teksilo_core::styles::SliderVariant as SliderVariantExport;
170use teksilo_i18n::LocalizedString;
171
172/// [`Widget::target_regions`] part id for the whole press surface — the track
173/// plus everything either side of it, which is what a tap or a drag acts on.
174pub const SLIDER_PART_BODY: u16 = 0;
175/// [`Widget::target_regions`] part id for the knob.
176pub const SLIDER_PART_THUMB: u16 = 1;
177
178/// AccessKit custom-action id of "Reset to default", advertised when a
179/// [`default_value`](Slider::default_value) is set.
180///
181/// Fixed, so an assistive client's recorded id keeps meaning the same thing,
182/// and away from the low numbers: an application's own
183/// `.access_custom_action(..)` entries are numbered from 0 by position, and the
184/// dispatcher hands one `CustomAction(id)` to the widget and to those entries
185/// alike.
186const RESET_ACTION_ID: i32 = 1000;
187
188/// Where [`Slider::value_tooltip`]'s readout sits, set with
189/// [`Slider::value_tooltip_placement`].
190///
191/// Every placement keeps the readout clear of the strip the thumb occupies
192/// across the whole slider, so it covers neither the knob nor what the style
193/// draws beside the track, and flips to the other side when the window has no
194/// room on the one asked for.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum SliderValueTooltipPlacement {
198    /// Above a horizontal slider's thumb, or below it near the top of the
199    /// window; beside a vertical slider's thumb, on the inline-start side when
200    /// there is room. The default.
201    #[default]
202    Auto,
203    /// Above the thumb, centred on it, below when the window has no room
204    /// above. On a vertical slider this sits over the track above the thumb.
205    Above,
206    /// Below the thumb, centred on it, above when the window has no room
207    /// below. On a vertical slider this sits over the track below the thumb.
208    Below,
209    /// Where [`Auto`](Self::Auto) puts it, but at the pointer rather than the
210    /// thumb while a drag is moving the value: along the slider's axis it
211    /// tracks the pointer, past either end of the track too, where the thumb
212    /// has stopped. Hovered or focused from the keyboard, it sits by the thumb
213    /// as `Auto` does.
214    FollowPointer,
215}
216
217/// A draggable value selector bound to a `Signal<f32>` in a continuous
218/// or discrete range. Visual chrome is fully delegated to a
219/// [`SliderStyle`] implementation.
220pub struct Slider {
221    value: Signal<f32>,
222    min: f32,
223    max: f32,
224    step: Option<f32>,
225    page_step: Option<f32>,
226    orientation: Orientation,
227    /// Enabled state, static or reactive; forwarded to the arena at
228    /// build time.
229    enabled: Prop<bool>,
230    /// Accessible name, announced by screen readers as the control's label.
231    label: Option<LocalizedString>,
232    variant: SliderVariant,
233    tick_count: Option<u32>,
234    style_override: Option<SharedSliderStyle>,
235    on_change: Option<Rc<dyn Fn(f32, &mut teksilo_core::widget::EventContext)>>,
236    hovered: Signal<bool>,
237    dragging: Signal<bool>,
238    /// Raw keyboard/pointer focus (any modality). The keyboard-only focus
239    /// ring is derived live from this × the input-modality signal in
240    /// `build()` (`:focus-visible`).
241    focused: Signal<bool>,
242    cached_bounds: Rc<Cell<Rect>>,
243    /// Reading direction, captured in `place_children` (the one hook with a
244    /// `LayoutContext`) so both the position→value map, which only ever sees an
245    /// `EventContext`, and `target_regions`, which sees neither, agree with
246    /// what the style painted.
247    cached_rtl: Rc<Cell<bool>>,
248    /// The knob diameter the resolved style reported at build time, kept so
249    /// `target_regions` can place the thumb exactly where the body painted it.
250    thumb_diameter: Cell<f32>,
251    body_id: Option<WidgetId>,
252    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
253    /// with the rich / composite slots — every setter clears the other two so
254    /// the last call wins.
255    tooltip_text: Option<LocalizedString>,
256    /// Optional rich tooltip source (registry key or inline content).
257    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
258    /// Optional composite tooltip body (arbitrary widget tree).
259    composite_tooltip_content: Option<Box<dyn Widget>>,
260    /// Formats the value for the live readout and the accessible value text.
261    value_format: Option<ValueFormat>,
262    /// Where the readout sits.
263    value_tooltip_placement: SliderValueTooltipPlacement,
264    /// The readout's chrome, when it is not the theme's tooltip chrome.
265    value_tooltip_style: Option<SharedTooltipStyle>,
266    /// The value the reset gesture and the reset action put back.
267    default_value: Option<f32>,
268    /// Whether a mouse press moves the value at once.
269    jump_on_press: bool,
270    /// The arena's effective enabled state, captured at build so
271    /// `accessibility()` can withdraw the reset action from a disabled slider.
272    effective_enabled: Option<Signal<bool>>,
273}
274
275impl Slider {
276    /// Create a horizontal slider bound to `value` with the given inclusive
277    /// range. Use [`orientation`](Self::orientation) to switch to vertical.
278    pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self {
279        Self {
280            value,
281            min,
282            max,
283            step: None,
284            page_step: None,
285            orientation: Orientation::Horizontal,
286            enabled: Prop::Static(true),
287            label: None,
288            variant: SliderVariant::default(),
289            tick_count: None,
290            style_override: None,
291            hovered: Signal::new(false),
292            on_change: None,
293            dragging: Signal::new(false),
294            focused: Signal::new(false),
295            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
296            cached_rtl: Rc::new(Cell::new(false)),
297            thumb_diameter: Cell::new(0.0),
298            body_id: None,
299            tooltip_text: None,
300            rich_tooltip_source: None,
301            composite_tooltip_content: None,
302            value_format: None,
303            value_tooltip_placement: SliderValueTooltipPlacement::Auto,
304            value_tooltip_style: None,
305            default_value: None,
306            jump_on_press: false,
307            effective_enabled: None,
308        }
309    }
310
311    /// Run `f` for every value this control produces under the **user's**
312    /// hand, with an `EventContext`, so it can do what a bare `Signal` write
313    /// cannot (`ctx.send_intent(...)`, opening a window). Fires for a track
314    /// click, for each step of a drag, for the arrows, for an assistive
315    /// technology's `Increment` / `Decrement` / `SetValue`, and for a reset to
316    /// the [`default_value`](Self::default_value).
317    ///
318    /// **A drag fires this repeatedly** — once per value it actually produces,
319    /// not once per pointer sample, since a write that changes nothing reports
320    /// nothing. It is still the wrong place for work that should happen once
321    /// per interaction: persisting to disk, a network call, an undo entry.
322    /// There is no commit-on-release callback yet; observe the signal and do
323    /// that work when the value settles.
324    ///
325    /// Does **not** fire for programmatic writes to the bound signal — there is
326    /// no event in flight to carry. Observe the signal for that.
327    pub fn on_change(
328        mut self,
329        f: impl Fn(f32, &mut teksilo_core::widget::EventContext) + 'static,
330    ) -> Self {
331        self.on_change = Some(Rc::new(f));
332        self
333    }
334
335    /// Set the discrete step size for keyboard arrows and accessibility
336    /// Increment/Decrement actions. When unset, defaults to 1 % of the
337    /// range.
338    pub fn step(mut self, step: f32) -> Self {
339        self.step = Some(step);
340        self
341    }
342
343    /// Set the step size for `PageUp` / `PageDown`. When unset, ten times the
344    /// effective [`step`](Self::step) — so with the default step of 1 % of the
345    /// range a page is 10 % of it, which is what `QAbstractSlider::pageStep`,
346    /// `GtkScale`'s page increment and `<input type=range>` in both WebKit and
347    /// Blink all give, and the same `10 x` rule
348    /// [`SpinBox::page_step`](crate::SpinBox::page_step) uses.
349    pub fn page_step(mut self, page_step: f32) -> Self {
350        self.page_step = Some(page_step);
351        self
352    }
353
354    /// The effective arrow step: the configured one, else 1 % of the range.
355    ///
356    /// One definition for the key handler, the AccessKit
357    /// `Increment`/`Decrement` path and the published `numeric_value_step`,
358    /// which had drifted into two copies of the same fallback.
359    fn effective_step(&self) -> f32 {
360        self.step.unwrap_or((self.max - self.min) * 0.01)
361    }
362
363    /// The effective `PageUp` / `PageDown` distance.
364    fn effective_page_step(&self) -> f32 {
365        self.page_step.unwrap_or(self.effective_step() * 10.0)
366    }
367
368    /// Set the slider orientation (`Horizontal` by default). Vertical
369    /// sliders map Up/Down arrow keys to increase/decrease.
370    pub fn orientation(mut self, orientation: Orientation) -> Self {
371        self.orientation = orientation;
372        self
373    }
374
375    /// Set the enabled state, statically or reactively. Forwarded to
376    /// the arena at build time via
377    /// `ctx.enabled_when(slider_id, self.enabled.clone())`.
378    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
379        self.enabled = enabled.into();
380        self
381    }
382
383    /// Pick a Tier-1 design-language variant
384    /// ([`SliderVariant::Continuous`] / `Discrete` / `Range`). The
385    /// active [`SliderStyle`] decides what to do with the hint —
386    /// IntUI's default impl paints ticks for `Discrete` and ignores
387    /// `Range` (the widget itself doesn't yet wire dual-thumb
388    /// behaviour).
389    pub fn variant(mut self, variant: SliderVariant) -> Self {
390        self.variant = variant;
391        self
392    }
393
394    /// Configure the tick count for a `Discrete` slider. The
395    /// IntUI default paints `n` evenly spaced tick marks above the
396    /// track (or to the leading side for vertical orientation).
397    pub fn tick_count(mut self, count: u32) -> Self {
398        self.tick_count = Some(count);
399        self
400    }
401
402    /// Override the active [`SliderStyle`] for this widget instance
403    /// only.
404    pub fn style(mut self, style: impl SliderStyle) -> Self {
405        self.style_override = Some(Rc::new(style));
406        self
407    }
408
409    /// Set an accessible name for the slider, announced by screen readers.
410    /// ARIA requires sliders to have a label; when none is set here the
411    /// caller is responsible for labelling via a wrapping element.
412    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
413        let ls: LocalizedString = label.into();
414        self.label = Some(ls);
415        self
416    }
417
418    /// Attach a plain single-line tooltip shown after a hover delay.
419    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
420    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
421    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
422    /// wins and clears the others. Independent of
423    /// [`value_tooltip`](Self::value_tooltip), which neither replaces nor is
424    /// replaced by any of them.
425    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
426        self.tooltip_text = Some(text.into());
427        self.rich_tooltip_source = None;
428        self.composite_tooltip_content = None;
429        self
430    }
431
432    /// Attach a rich tooltip driven by a registry key. The registry
433    /// entry supplies title, body markup, optional shortcut chip and
434    /// cascade links. Mutually exclusive with the other tooltip setters.
435    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
436        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
437        self.tooltip_text = None;
438        self.composite_tooltip_content = None;
439        self
440    }
441
442    /// Attach a rich tooltip from an inline [`TooltipContent`](crate::tooltip::TooltipContent)
443    /// value, bypassing the registry lookup. Mutually exclusive with the
444    /// other tooltip setters.
445    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
446        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
447        self.tooltip_text = None;
448        self.composite_tooltip_content = None;
449        self
450    }
451
452    /// Attach a composite tooltip whose body is an arbitrary widget tree.
453    /// Uses the heavier `tooltip_delay_heavy` delay. Mutually exclusive
454    /// with the other tooltip setters.
455    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
456        self.composite_tooltip_content = Some(Box::new(content));
457        self.tooltip_text = None;
458        self.rich_tooltip_source = None;
459        self
460    }
461
462    /// Show the value as text by the thumb, formatted by `format`, while the
463    /// slider is hovered, dragged, or focused from the keyboard.
464    ///
465    /// Unlike a [`tooltip`](Self::tooltip), the readout is up at once, with no
466    /// hover delay; a press does not close it, so it stays up through a drag;
467    /// and it moves with the thumb and changes as the value does, without a
468    /// rebuild. By default it sits above a horizontal slider's thumb, or below
469    /// it near the top of the window, and beside a vertical slider, on the
470    /// inline-start side when there is room, so it covers neither the thumb
471    /// nor the track; [`value_tooltip_placement`](Self::value_tooltip_placement)
472    /// picks another side. It is re-placed on every layout pass from where the
473    /// slider is then, so it follows a value the application writes, a scroll
474    /// that moves the slider and a resize that stretches it. Its chrome is the
475    /// theme's tooltip chrome unless
476    /// [`value_tooltip_style`](Self::value_tooltip_style) gives it another.
477    ///
478    /// It takes no input of its own. A press or a hover on it reaches what is
479    /// under it, and a press there closes a popover it is outside of. Escape
480    /// hides it until the next hover, drag or key, and goes on to whatever
481    /// else wanted the key, the focused field included, as a plain tooltip's
482    /// Escape does. Focus leaving the slider takes it down only when nothing
483    /// else holds it up: the pointer still on the slider keeps it.
484    ///
485    /// The text is also the slider's accessible value, published beside the
486    /// number: UI Automation and macOS carry it, so a reader there says
487    /// "-3.0 dB" rather than "-3". AT-SPI carries the number alone, so Orca
488    /// keeps reading the number.
489    ///
490    /// `format` receives the value the bound signal holds, on every change to
491    /// it. It returns a [`LocalizedString`], so a `tr!(..)` message
492    /// re-resolves on a locale change and `lit!(..)` serves text that is not
493    /// translated.
494    ///
495    /// A [`tooltip`](Self::tooltip), rich or composite tooltip set beside it
496    /// keeps its hover delay, its place under the slider and the accessible
497    /// description it gives, and both show; to name the control in the
498    /// readout instead, say so in `format`.
499    pub fn value_tooltip(mut self, format: impl Fn(f32) -> LocalizedString + 'static) -> Self {
500        self.value_format = Some(Rc::new(format));
501        self
502    }
503
504    /// Choose where the [`value_tooltip`](Self::value_tooltip) readout sits:
505    /// above or below the thumb, or at the pointer while a drag moves the
506    /// value. [`SliderValueTooltipPlacement::Auto`] by default, which is where
507    /// the readout has always gone. Has no effect without a `value_tooltip`.
508    pub fn value_tooltip_placement(mut self, placement: SliderValueTooltipPlacement) -> Self {
509        self.value_tooltip_placement = placement;
510        self
511    }
512
513    /// Paint the [`value_tooltip`](Self::value_tooltip) readout with `style`
514    /// rather than the active theme's tooltip chrome
515    /// (`theme.style_slots.tooltip`, else the built-in recipe). Only this
516    /// slider's readout changes; its plain, rich or composite
517    /// [`tooltip`](Self::tooltip) keeps the theme's. Has no effect without a
518    /// `value_tooltip`.
519    pub fn value_tooltip_style(mut self, style: impl TooltipStyle) -> Self {
520        self.value_tooltip_style = Some(Rc::new(style));
521        self
522    }
523
524    /// Move the value to the pointer as soon as a mouse button goes down on
525    /// the slider, rather than when the click is released or a drag starts.
526    /// Off by default.
527    ///
528    /// With it off, a press writes nothing: the value moves when the pointer
529    /// has travelled far enough to start a drag, or to the click's position
530    /// when it is released without one. With it on, a primary press of a
531    /// mouse moves the value to the pointer at once, a drag that follows
532    /// carries on from there without a jump, and the release of a click adds
533    /// nothing. [`on_change`](Self::on_change) reports the press's value as a
534    /// user change.
535    ///
536    /// Touch and pen keep the default whatever this says: a contact landing
537    /// on a slider may be the start of a scroll or of something an ancestor
538    /// claims, so it writes nothing until it is a drag or a tap. The second
539    /// press of a double-click does not jump either when a
540    /// [`default_value`](Self::default_value) is set, since that click resets.
541    /// The keyboard and the accessibility actions are unaffected.
542    pub fn jump_on_press(mut self, on: bool) -> Self {
543        self.jump_on_press = on;
544        self
545    }
546
547    /// Let the user put the value back to `value`: with a double-click on the
548    /// slider, or with the "Reset to default" accessibility action, which this
549    /// also advertises.
550    ///
551    /// The reset is a user change, written through the same path as a drag or
552    /// an arrow key, so [`on_change`](Self::on_change) reports it unless the
553    /// value was already there. It is clamped to the range and not snapped to
554    /// the [`step`](Self::step): a default between two steps lands where it
555    /// was asked to. A double-click's first click jumps the value to where it
556    /// landed, as every click on the track does, so `on_change` reports that
557    /// value and then the default; further clicks in the same burst leave the
558    /// default where it is. A disabled slider ignores the double-click, and
559    /// neither offers nor takes the action.
560    ///
561    /// An application's own `.access_custom_action(..)` on the slider is
562    /// offered beside the reset, not instead of it.
563    pub fn default_value(mut self, value: f32) -> Self {
564        self.default_value = Some(value);
565        self
566    }
567}
568
569impl std::fmt::Debug for Slider {
570    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571        f.debug_struct("Slider")
572            .field("min", &self.min)
573            .field("max", &self.max)
574            .field("enabled", &self.enabled.get())
575            .field("variant", &self.variant)
576            .finish()
577    }
578}
579
580impl Widget for Slider {
581    fn build(
582        &mut self,
583        ctx: &mut teksilo_core::build_context::BuildContext,
584    ) -> Vec<teksilo_core::widget_id::WidgetId> {
585        let self_id = ctx.self_id();
586        // Forward the enabled state into the arena; see IconButton.
587        ctx.enabled_when(self_id, self.enabled.clone());
588        let effective_enabled = ctx.effective_enabled_signal(self_id);
589        // The reset action is offered only while it would do something.
590        effective_enabled.bind_to(
591            self_id,
592            ctx.binding_registry(),
593            BindingLevel::AccessibilityOnly,
594        );
595        self.effective_enabled = Some(effective_enabled.clone());
596        // `accessibility()` reads the value only when the tree is walked, and
597        // the style's repaint does not walk it; see `Checkbox::build`.
598        self.value.bind_to(
599            self_id,
600            ctx.binding_registry(),
601            BindingLevel::AccessibilityOnly,
602        );
603
604        // Resolve the active style: per-call override > theme slot >
605        // built-in `RecipeSliderStyle` default.
606        let style: SharedSliderStyle = self
607            .style_override
608            .clone()
609            .or_else(|| ctx.theme().style_slots.slider.clone())
610            .unwrap_or_else(|| {
611                Rc::new(crate::styles::RecipeSliderStyle::for_tokens(
612                    &ctx.theme().input,
613                ))
614            });
615
616        // Derived `value_normalized` signal — re-renders the body
617        // whenever the user-visible value changes.
618        let min = self.min;
619        let max = self.max;
620        let value_normalized = self.value.map(move |v| {
621            let range = max - min;
622            if range <= 0.0 {
623                0.0
624            } else {
625                ((*v - min) / range).clamp(0.0, 1.0)
626            }
627        });
628
629        let orientation = match self.orientation {
630            Orientation::Horizontal => SliderOrientation::Horizontal,
631            Orientation::Vertical => SliderOrientation::Vertical,
632        };
633
634        let cfg = SliderStyleConfig {
635            value_normalized,
636            is_hovered: self.hovered.clone(),
637            is_dragging: self.dragging.clone(),
638            is_disabled: effective_enabled.map(|on| !*on),
639            // `:focus-visible`: derive the keyboard/pointer origin live from
640            // the input-modality signal (true after a key event, false after
641            // pointer-down) rather than snapshotting hover at focus time, so
642            // the focus ring follows the *current* modality.
643            focus_origin: self.focused.zip(&ctx.focus_visible()).map(|(f, v)| {
644                if !*f {
645                    None
646                } else if *v {
647                    Some(FocusOrigin::Keyboard)
648                } else {
649                    Some(FocusOrigin::POINTER)
650                }
651            }),
652            orientation,
653            tick_count: self.tick_count,
654            variant: self.variant,
655        };
656        let body_id = style.make_body(&cfg, ctx);
657        self.body_id = Some(body_id);
658
659        // Capture the thumb radius at build time. The event handlers
660        // need it for value computation, but they only receive
661        // `EventContext` and can't reach the theme at event time.
662        // Query the *resolved* style so a custom `SliderStyle` with a
663        // different thumb size keeps drag hit-testing aligned, instead of
664        // baking in the recipe's design constant. Through the density-aware
665        // overload, so a style that does size its knob by density is asked the
666        // question that lets it answer; the default forwards to the plain
667        // `thumb_diameter` and the painted 14 dp knob is unchanged.
668        let thumb_radius = style.thumb_diameter_for(&cfg, &ctx.theme().input) * 0.5;
669        self.thumb_diameter.set(thumb_radius * 2.0);
670
671        let value = self.value.clone();
672        let single_step = self.effective_step();
673        let page_step = self.effective_page_step();
674        let snap_step = self.effective_step();
675        let orientation = self.orientation;
676        let hovered = self.hovered.clone();
677        let dragging = self.dragging.clone();
678        let focused = self.focused.clone();
679        let cached_bounds = self.cached_bounds.clone();
680
681        // One reporter for every user-driven write. It compares against the
682        // value before the write, so a write that changes nothing — a drag
683        // past the end, a snap landing on the grid point it was already on —
684        // reports nothing, and a drag reports once per value it actually
685        // produces rather than once per pointer sample.
686        let report = {
687            let value = value.clone();
688            let on_change = self.on_change.clone();
689            move |before: f32, ctx: &mut teksilo_core::widget::EventContext| {
690                let now = value.get();
691                if now != before
692                    && let Some(ref f) = on_change
693                {
694                    f(now, ctx);
695                }
696            }
697        };
698
699        // The value readout, when one is configured, and the one call every
700        // handler below ends with: it raises, moves or takes down the readout
701        // after whatever the handler changed. A no-op on a slider without one.
702        // Where a drag's pointer is, widget-local, for a readout that follows
703        // it. Written only for `FollowPointer`, and then bound at `Relayout`:
704        // a pointer move that changes no value would otherwise run no layout
705        // pass, and the overlay is re-placed only on one.
706        let follows_pointer = self.value_format.is_some()
707            && self.value_tooltip_placement == SliderValueTooltipPlacement::FollowPointer;
708        let drag_at: Signal<Option<Point>> = Signal::new(None);
709        if follows_pointer {
710            drag_at.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
711        }
712        let readout = self.value_format.clone().map(|format| {
713            Readout::mount(
714                ctx,
715                ReadoutInputs {
716                    value: value.clone(),
717                    min,
718                    max,
719                    orientation,
720                    thumb_diameter: thumb_radius * 2.0,
721                    bounds: cached_bounds.clone(),
722                    hovered: hovered.clone(),
723                    dragging: dragging.clone(),
724                    focused: focused.clone(),
725                    placement: self.value_tooltip_placement,
726                    drag_at: drag_at.clone(),
727                    style: self.value_tooltip_style.clone(),
728                },
729                format,
730                effective_enabled.clone(),
731            )
732        });
733        let sync_readout = move |ctx: &mut teksilo_core::widget::EventContext| {
734            if let Some(readout) = &readout {
735                readout.sync(ctx);
736            }
737        };
738
739        // The reset `default_value` asks for, written and reported like any
740        // other user change. `None` when no default is set, and then neither a
741        // double-click nor the reset action does anything of its own.
742        let reset_to_default = self.default_value.map(|default| {
743            let value = value.clone();
744            let report = report.clone();
745            let sync_readout = sync_readout.clone();
746            move |ctx: &mut teksilo_core::widget::EventContext| {
747                let before = value.get();
748                value.set(default.clamp(min, max));
749                report(before, ctx);
750                sync_readout(ctx);
751            }
752        });
753        // The clicks in a row the track has taken, for the double-click.
754        let taps = Rc::new(Cell::new(TapStreak::EMPTY));
755
756        let adjust_by_step = {
757            let value = value.clone();
758            move |positive: bool, page: bool| {
759                let s = if page { page_step } else { single_step };
760                let current = value.get();
761                let new_val = if positive { current + s } else { current - s };
762                value.set(new_val.clamp(min, max));
763            }
764        };
765
766        // Snap-and-clamp writer. `set_value_from_position` decides *where* the
767        // pointer is; this decides what a value means once you have one — so an
768        // assistive technology's `SetValue` lands on the same grid a drag does
769        // instead of between two ticks.
770        //
771        // The grid is [`effective_step`](Self::effective_step), not the
772        // configured `step`: that is the distance the arrows move, the distance
773        // `Increment` / `Decrement` move, and the one published as
774        // `numeric_value_step`, so snapping to anything else would let a write
775        // land between two values every other path can reach. A slider that
776        // configures no step gets the same 1 %-of-range grid its arrows already
777        // walk — a hundred positions, which is what `<input type=range>` gives
778        // a stepless range too.
779        let set_value_snapped = {
780            let value = value.clone();
781            move |v: f32| {
782                let mut val = v;
783                if snap_step > 0.0 {
784                    val = ((val - min) / snap_step).round() * snap_step + min;
785                }
786                value.set(val.clamp(min, max));
787            }
788        };
789
790        let set_value_from_position = {
791            let set_value_snapped = set_value_snapped.clone();
792            let cached_bounds = cached_bounds.clone();
793            move |x: f32, y: f32, rtl: bool| {
794                let bounds = cached_bounds.get();
795                let pos = match orientation {
796                    Orientation::Horizontal => x,
797                    Orientation::Vertical => y,
798                };
799                let usable = match orientation {
800                    Orientation::Horizontal => bounds.width,
801                    Orientation::Vertical => bounds.height,
802                } - thumb_radius * 2.0;
803                if usable <= 0.0 {
804                    return;
805                }
806                // `pos` arrives widget-local (origin at the slider's own
807                // top-left), so the track starts at `thumb_radius`, not at
808                // `bounds.x` / `bounds.y`.
809                let mut t = ((pos - thumb_radius) / usable).clamp(0.0, 1.0);
810                // The minimum sits at the leading edge, which is the right one
811                // in a right-to-left window, so a horizontal slider reads its
812                // pointer position from the other end.
813                //
814                // A vertical slider mirrors unconditionally instead: its
815                // minimum is at the **bottom** and its maximum at the top —
816                // Qt, GTK4, the Win32 trackbar and `<input type=range>` all
817                // agree, and it is what makes `ArrowUp` raise the thumb rather
818                // than lower it. `y` grows downward, so the raw ratio is the
819                // value's complement. The layout direction does not reach this
820                // axis: there is no leading/trailing on the vertical.
821                match orientation {
822                    Orientation::Horizontal => {
823                        if rtl {
824                            t = 1.0 - t;
825                        }
826                    }
827                    Orientation::Vertical => t = 1.0 - t,
828                }
829                set_value_snapped(min + t * (max - min));
830            }
831        };
832
833        // Framework gates events on `arena.is_enabled(self_id)`, so
834        // no per-handler enabled snapshot guards anymore.
835        let mut handlers = HandlerSet::new()
836            .focusable(true)
837            .cursor(CursorIcon::Pointer)
838            // A continuous manipulator: the value it produces IS the press
839            // position, so no default touch behaviour may be run on it. `NONE`
840            // freezes the whole hit path's touch action at the press
841            // (`docs/touch-and-pen.md` §7.3, A6). What that observably forbids
842            // — and what `two_contacts_on_a_slider_pinch_nothing_underneath`
843            // holds it to — is a **two-contact pinch** started on the slider
844            // reaching the surface underneath. Keeping an enclosing scroller
845            // off the press is the drag's capture, not this.
846            .touch_action(TouchAction::NONE);
847
848        // The pointer driving the drag, so a cancel can be told apart from one
849        // aimed at some other contact.
850        let drag_pointer: Rc<Cell<Option<PointerId>>> = Rc::new(Cell::new(None));
851
852        // Thumb drag — routed through the typed gesture API.
853        {
854            let dragging = dragging.clone();
855            let drag_pointer = drag_pointer.clone();
856            let set_value = set_value_from_position.clone();
857            let value_before = value.clone();
858            let report = report.clone();
859            let sync_readout = sync_readout.clone();
860            let taps = taps.clone();
861            let drag_at = drag_at.clone();
862            handlers = handlers.on_drag(move |phase, ctx| {
863                match phase {
864                    DragPhase::Started {
865                        position,
866                        button: PointerButton::Primary,
867                        pointer,
868                    } => {
869                        // A drag between two clicks makes them two clicks, not
870                        // a double-click: the tap streak's own rule.
871                        taps.set(TapStreak::EMPTY);
872                        dragging.set(true);
873                        drag_pointer.set(Some(pointer.id));
874                        if follows_pointer {
875                            // `Started` carries the press position; the
876                            // pointer is already past the slop from it.
877                            let now = ctx
878                                .pointer_position()
879                                .and_then(|p| ctx.to_local(p))
880                                .unwrap_or(position);
881                            drag_at.set(Some(now));
882                        }
883                        let before = value_before.get();
884                        set_value(position.x, position.y, ctx.is_rtl());
885                        report(before, ctx);
886                    }
887                    DragPhase::Moved { position, .. } if dragging.get() => {
888                        if follows_pointer {
889                            drag_at.set(Some(position));
890                        }
891                        let before = value_before.get();
892                        set_value(position.x, position.y, ctx.is_rtl());
893                        report(before, ctx);
894                    }
895                    // `Cancelled` keeps the gesture contract (one of the two
896                    // follows every `Started`), though the tree today revokes a
897                    // press as a `PointerCancel`, handled below.
898                    DragPhase::Ended { .. } | DragPhase::Cancelled { .. } => {
899                        dragging.set(false);
900                        drag_pointer.set(None);
901                        if drag_at.get().is_some() {
902                            drag_at.set(None);
903                        }
904                    }
905                    _ => return,
906                }
907                sync_readout(ctx);
908            });
909        }
910
911        // A cancelled press is over too, with no `Ended` to say so (a modal
912        // opening, the window losing focus). Left set, `dragging` kept the
913        // pressed look, and now a readout, on for good.
914        //
915        // Only the dragging pointer's cancel ends the drag. The node hears
916        // cancels for other contacts too — a second finger that landed on it
917        // and was then taken away — and clearing the drag for one of those
918        // froze the thumb under the finger still moving it.
919        {
920            let dragging = dragging.clone();
921            let sync_readout = sync_readout.clone();
922            let drag_at = drag_at.clone();
923            handlers = handlers.on_pointer_cancel(move |pointer, _reason, ctx| {
924                if drag_pointer.get() != Some(pointer.id) {
925                    return;
926                }
927                drag_pointer.set(None);
928                dragging.set(false);
929                if drag_at.get().is_some() {
930                    drag_at.set(None);
931                }
932                sync_readout(ctx);
933            });
934        }
935
936        // Whether the press that a tap now completes has already moved the
937        // value (`jump_on_press`), so the tap does not move it a second time.
938        let press_jumped = Rc::new(Cell::new(false));
939
940        // `jump_on_press`: a mouse press writes the value at once. A precise,
941        // indirect pointer only; a direct one's press is not yet a choice
942        // (`docs/touch-and-pen.md`, "Common operations") and keeps writing on
943        // the drag or the tap, as without the option.
944        if self.jump_on_press {
945            let set_value = set_value_from_position.clone();
946            let value_before = value.clone();
947            let report = report.clone();
948            let sync_readout = sync_readout.clone();
949            let press_jumped = press_jumped.clone();
950            let taps = taps.clone();
951            let resets = reset_to_default.is_some();
952            let input = ctx.theme().input;
953            handlers = handlers.on_pointer_event(move |event, ctx| {
954                let WidgetEvent::PointerDown {
955                    position,
956                    button: PointerButton::Primary,
957                    pointer,
958                    ..
959                } = event
960                else {
961                    return EventResponse::Ignored;
962                };
963                press_jumped.set(false);
964                if pointer.kind.is_direct() {
965                    return EventResponse::Ignored;
966                }
967                // The second press of a double-click is the reset's, which the
968                // tap applies on its release; jumping first would report a
969                // value the reset then takes back.
970                if resets {
971                    let mut streak = taps.get();
972                    let profile = input.profile(pointer.kind);
973                    if streak.advance(pointer.time, profile, *position, PointerButton::Primary) >= 2
974                    {
975                        return EventResponse::Ignored;
976                    }
977                }
978                let before = value_before.get();
979                set_value(position.x, position.y, ctx.is_rtl());
980                report(before, ctx);
981                press_jumped.set(true);
982                sync_readout(ctx);
983                // Not consumed: the same press still feeds the tap and the
984                // drag recognizers.
985                EventResponse::Ignored
986            });
987        }
988
989        // Track click — jump the value to the click position — and, with a
990        // `default_value`, the double-click that resets it.
991        //
992        // The double-click is counted here, on the node's own tap streak,
993        // rather than with `on_double_tap`: a multi-tap recognizer on a node
994        // takes the single-tap one off it (`WidgetTree::ensure_gesture_arena`),
995        // and the click that jumps the value with it. `TapStreak` is the
996        // framework's own continuation rule (same button, within the profile's
997        // multi-tap interval and slop), so the two cannot disagree on what a
998        // double-click is. Its second click resets instead of jumping; the
999        // first has already jumped, as any click does. Every click after the
1000        // second that the streak still continues is part of the same gesture,
1001        // and resets too (to where the value already is) rather than jumping.
1002        {
1003            let set_value = set_value_from_position.clone();
1004            let value_before = value.clone();
1005            let report = report.clone();
1006            let sync_readout = sync_readout.clone();
1007            let reset = reset_to_default.clone();
1008            let taps = taps.clone();
1009            // The profile the recognizers would read, as the theme stands at
1010            // build time; the thumb radius above is read the same way.
1011            let input = ctx.theme().input;
1012            let press_jumped = press_jumped.clone();
1013            handlers = handlers.on_tap(move |event, ctx| {
1014                // The track click is the primary button's. The arena already
1015                // keeps an application's widened tap mask from reaching this
1016                // handler; the guard keeps it so if a mask ever does.
1017                if event.button != PointerButton::Primary {
1018                    return;
1019                }
1020                if let Some(ref reset) = reset {
1021                    // The streak measures from where each click was pressed;
1022                    // a tap's release is within its slop of that.
1023                    let profile = input.profile(event.pointer.kind);
1024                    let mut streak = taps.get();
1025                    let count =
1026                        streak.advance(event.pointer.time, profile, event.position, event.button);
1027                    if count >= 2 {
1028                        // Kept at one, as if this click had started the
1029                        // streak, so the next click that continues it counts
1030                        // two again. `TapStreak` counts to three and then starts
1031                        // over at one, which made the third click of a burst a
1032                        // jump and the fourth a fresh first click.
1033                        let mut rebased = TapStreak::EMPTY;
1034                        rebased.advance(event.pointer.time, profile, event.position, event.button);
1035                        taps.set(rebased);
1036                        reset(ctx);
1037                        return;
1038                    }
1039                    taps.set(streak);
1040                }
1041                // `jump_on_press` already put the value where this click
1042                // pressed; its release, a few dp away, adds nothing.
1043                if press_jumped.replace(false) {
1044                    sync_readout(ctx);
1045                    return;
1046                }
1047                let before = value_before.get();
1048                set_value(event.position.x, event.position.y, ctx.is_rtl());
1049                report(before, ctx);
1050                sync_readout(ctx);
1051            });
1052        }
1053
1054        // Hover. Two writers for one flag, because neither is enough alone.
1055        // The tree's `hover_within` follows the pointer whatever the slider can
1056        // be told: a disabled slider hears no `PointerLeave`, and a parked one
1057        // has its hover cleared with no event at all, and either way a flag
1058        // kept by the handler alone stayed `true` and held the readout up for
1059        // whatever came next to raise it. The pointer is always over a strict
1060        // descendant here, the body the slider lays out over its whole bounds.
1061        // The handler is the other writer, because it is the one that runs with
1062        // an `EventContext` to raise the readout, and it runs before the tree
1063        // updates `hover_within` for the same move.
1064        handlers = handlers.hover_within(hovered.clone());
1065        {
1066            let hovered = hovered.clone();
1067            let sync_readout = sync_readout.clone();
1068            handlers = handlers.on_hover(move |entered, ctx| {
1069                hovered.set(entered);
1070                sync_readout(ctx);
1071            });
1072        }
1073
1074        // Key handler. The chord table is shared with every other bounded
1075        // scalar (`common::range_nav`); what stays here is the arithmetic.
1076        {
1077            let adjust = adjust_by_step.clone();
1078            let value = value.clone();
1079            let report = report.clone();
1080            let sync_readout = sync_readout.clone();
1081            handlers = handlers.on_key(move |event, ctx| {
1082                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1083                    return EventResponse::Ignored;
1084                };
1085                // The paint, the pointer mapping and the arrows all mirror
1086                // against this same answer, read at event time so a locale
1087                // flip needs no rebuild. Mirroring any one of the three alone
1088                // would leave the `Left` key and a leftward drag moving the
1089                // thumb in opposite directions.
1090                let response = match range_nav::range_move(
1091                    *key,
1092                    *modifiers,
1093                    RangeKind::Scalar,
1094                    RangeAxis::Both,
1095                    ctx.is_rtl(),
1096                ) {
1097                    Some(mv) => {
1098                        let before = value.get();
1099                        match mv {
1100                            RangeMove::Step { increase } => adjust(increase, false),
1101                            RangeMove::Page { increase } => adjust(increase, true),
1102                            RangeMove::ToMin => value.set(min),
1103                            RangeMove::ToMax => value.set(max),
1104                        }
1105                        report(before, ctx);
1106                        EventResponse::Handled
1107                    }
1108                    None => EventResponse::Ignored,
1109                };
1110                // After a key the slider does not take as well: any key makes
1111                // the focus keyboard focus, which the readout follows as the
1112                // focus ring does. Except Escape, which is how the readout is
1113                // taken down: the tree has already retired it by the time the
1114                // key gets here, and syncing would raise it straight back.
1115                if *key != Key::Escape {
1116                    sync_readout(ctx);
1117                }
1118                response
1119            });
1120        }
1121
1122        // Focus handler. Track raw focus only; the keyboard/pointer
1123        // distinction is derived live from the input-modality signal in
1124        // `build()` (`:focus-visible`), so clicking to focus then pressing a
1125        // key reveals the ring.
1126        {
1127            let focused = focused.clone();
1128            let sync_readout = sync_readout.clone();
1129            handlers = handlers.on_focus(move |gained, ctx| {
1130                focused.set(gained);
1131                sync_readout(ctx);
1132            });
1133        }
1134
1135        // Access action handler. `SetValue` is the assistive technology's
1136        // direct value write — AT-SPI's `Value.SetCurrentValue`, which is
1137        // Orca's slider value entry, and macOS's `setAccessibilityValue:` on
1138        // the AXSlider. AT-SPI publishes the `Value` interface off
1139        // `numeric_value` alone, so that call already reached this widget and
1140        // was dropped on the floor; macOS instead gates AXValue settability on
1141        // `supports_action(SetValue, ..)`, which is why `accessibility()` has
1142        // to advertise it. It is the payload handler because `ActionData` is
1143        // where the value rides, and Increment and Decrement sit beside it so
1144        // the whole action set reads as one match rather than being split
1145        // across two slots.
1146        {
1147            let adjust = adjust_by_step.clone();
1148            let set_snapped = set_value_snapped.clone();
1149            let value_before = value.clone();
1150            let report = report.clone();
1151            let reset = reset_to_default.clone();
1152            let sync_readout = sync_readout.clone();
1153            handlers = handlers.on_access_action_request(move |action, _node, data, ctx| {
1154                use teksilo_core::accesskit::{Action, ActionData};
1155                // "Reset to default", which reports for itself.
1156                if action == Action::CustomAction
1157                    && matches!(data, Some(ActionData::CustomAction(RESET_ACTION_ID)))
1158                    && let Some(ref reset) = reset
1159                {
1160                    reset(ctx);
1161                    return EventResponse::Handled;
1162                }
1163                let before = value_before.get();
1164                let outcome = match (action, data) {
1165                    (Action::Increment, _) => {
1166                        adjust(true, false);
1167                        EventResponse::Handled
1168                    }
1169                    (Action::Decrement, _) => {
1170                        adjust(false, false);
1171                        EventResponse::Handled
1172                    }
1173                    // A non-finite value would clamp to a bound rather than be
1174                    // refused, so it is declined outright: writing a number the
1175                    // caller did not ask for is worse than reporting failure.
1176                    (Action::SetValue, Some(ActionData::NumericValue(v))) if v.is_finite() => {
1177                        set_snapped(v as f32);
1178                        EventResponse::Handled
1179                    }
1180                    // The string shape arrives from an `NSString` write and
1181                    // from Teksilo's own automation `set_value` tool, which
1182                    // sends only strings. A number only: the `value_tooltip`
1183                    // text the node publishes ("-3.0 dB") has no inverse, so a
1184                    // write of it is declined rather than guessed at.
1185                    (Action::SetValue, Some(ActionData::Value(s))) => {
1186                        match s.trim().parse::<f32>() {
1187                            Ok(v) if v.is_finite() => {
1188                                set_snapped(v);
1189                                EventResponse::Handled
1190                            }
1191                            _ => EventResponse::Ignored,
1192                        }
1193                    }
1194                    _ => EventResponse::Ignored,
1195                };
1196                if outcome == EventResponse::Handled {
1197                    report(before, ctx);
1198                    sync_readout(ctx);
1199                }
1200                outcome
1201            });
1202        }
1203
1204        ctx.apply_self_handlers(handlers);
1205
1206        // Tooltip attachment — at most one branch fires (the setters are
1207        // mutually exclusive). Anchor on `body_id`, the primary visible root.
1208        if let Some(content) = self.composite_tooltip_content.take() {
1209            let delay = ctx.theme().motion.tooltip_delay_heavy;
1210            crate::tooltip::attach_composite_tooltip_boxed(ctx, body_id, content, delay);
1211        } else if let Some(source) = self.rich_tooltip_source.clone() {
1212            let delay = ctx.theme().motion.tooltip_delay;
1213            crate::tooltip::attach_rich_tooltip_source(ctx, body_id, source, delay);
1214        } else if let Some(text) = self.tooltip_text.clone() {
1215            let delay = ctx.theme().motion.tooltip_delay;
1216            crate::tooltip::attach_plain_tooltip(ctx, body_id, text, delay);
1217        }
1218
1219        vec![body_id]
1220    }
1221
1222    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1223        self.body_id
1224            .and_then(|id| ctx.child_size(id, proposal))
1225            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1226            .into()
1227    }
1228
1229    fn place_children(
1230        &self,
1231        bounds: Rect,
1232        _proposal: SizeProposal,
1233        children: &mut [WidgetPlacement],
1234        ctx: &LayoutContext,
1235    ) {
1236        // Cache bounds for event handling (needed before paint).
1237        self.cached_bounds.set(bounds);
1238        self.cached_rtl.set(ctx.is_rtl());
1239        if let Some(child) = children.first_mut() {
1240            child.origin = bounds.origin();
1241            child.size = bounds.size();
1242        }
1243    }
1244
1245    fn children(&self) -> Vec<WidgetId> {
1246        self.body_id.into_iter().collect()
1247    }
1248
1249    /// What a slider paints inside its one node: a track and a knob.
1250    ///
1251    /// The node itself is the *target* — a press anywhere on it jumps the value
1252    /// and a drag from anywhere moves it — so it is reported whole, and it is
1253    /// what the conformance audit measures (the shipped body is at least 24 dp
1254    /// across, `MIN_CROSS_SIZE` in `styles/recipe_slider_style.rs`). The knob is
1255    /// reported as a **grab** because that is the affordance a user aims at,
1256    /// and because nothing else in the tree can see that it exists: it is drawn
1257    /// on the same canvas as the track, and its diameter comes from the
1258    /// resolved [`SliderStyle`], not from any layout.
1259    ///
1260    /// The geometry is derived exactly as the body paints it, mirrored under
1261    /// RTL, so the report and the picture cannot drift.
1262    fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
1263        use teksilo_core::partition::TargetRegion;
1264
1265        let mut regions = vec![TargetRegion::target(bounds, SLIDER_PART_BODY)];
1266        let diameter = self.thumb_diameter.get();
1267        if diameter <= 0.0 || !diameter.is_finite() {
1268            return regions;
1269        }
1270        regions.push(TargetRegion::grab(
1271            thumb_rect(
1272                bounds,
1273                fraction(self.value.get(), self.min, self.max),
1274                diameter,
1275                self.orientation,
1276                self.cached_rtl.get(),
1277            ),
1278            SLIDER_PART_THUMB,
1279        ));
1280        regions
1281    }
1282
1283    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1284        builder.set_role(teksilo_core::accesskit::Role::Slider);
1285        if let Some(ref label) = self.label {
1286            builder.set_name(label.resolve_now());
1287        }
1288        let step = self.effective_step();
1289        builder.set_numeric_value(on_grid(self.value.get(), self.min, step));
1290        builder.set_min_numeric_value(as_written(self.min));
1291        builder.set_max_numeric_value(as_written(self.max));
1292        // Publish both keyboard distances so Orca / VoiceOver can announce
1293        // "step by N" for an arrow and the coarser figure for a page key.
1294        // Both come from the same helpers the handlers use, so the announced
1295        // number and the applied one cannot drift.
1296        builder.set_numeric_value_step(on_grid(step, self.min, step));
1297        builder.set_numeric_value_jump(on_grid(self.effective_page_step(), self.min, step));
1298        let orientation = match self.orientation {
1299            Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
1300            Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
1301        };
1302        builder.set_orientation(orientation);
1303        // Framework a11y walker sets `set_disabled` from arena state.
1304        builder.add_action(teksilo_core::accesskit::Action::Increment);
1305        builder.add_action(teksilo_core::accesskit::Action::Decrement);
1306        // macOS gates `setAccessibilityValue:` settability on the node
1307        // supporting this action, so the advertisement is what makes
1308        // VoiceOver's value entry reachable at all.
1309        builder.add_action(teksilo_core::accesskit::Action::SetValue);
1310        builder.add_action(teksilo_core::accesskit::Action::Focus);
1311        // The readout's text, beside the number: the string a reader that
1312        // speaks a value's text says, and the one the sighted user sees.
1313        if let Some(ref format) = self.value_format {
1314            builder.set_value(format(self.value.get()).resolve_now());
1315        }
1316        // Not on a disabled slider, where the action would be refused: an
1317        // assistive client offering it would offer a menu entry that does
1318        // nothing.
1319        let enabled = self.effective_enabled.as_ref().is_none_or(|on| on.get());
1320        if self.default_value.is_some() && enabled {
1321            builder.add_action(teksilo_core::accesskit::Action::CustomAction);
1322            builder.set_custom_actions(vec![teksilo_core::accesskit::CustomAction {
1323                id: RESET_ACTION_ID,
1324                description: teksilo_i18n::tr_widget!(slider_reset_to_default()).resolve_now(),
1325            }]);
1326        }
1327    }
1328}
1329
1330/// Where `value` sits in `min..=max`, from 0 at `min` to 1 at `max`, clamped.
1331/// 0 for a range too narrow to divide by.
1332fn fraction(value: f32, min: f32, max: f32) -> f32 {
1333    let range = max - min;
1334    if range.abs() < f32::EPSILON {
1335        0.0
1336    } else {
1337        ((value - min) / range).clamp(0.0, 1.0)
1338    }
1339}
1340
1341/// The knob's square at fraction `t` of the range, inside `bounds`, as the
1342/// default style paints it: the centre travels the length less one diameter,
1343/// a horizontal slider's minimum is at the leading edge (the right one under
1344/// RTL), and a vertical slider's is at the bottom.
1345///
1346/// One answer for [`Slider::target_regions`] and the value readout, so the
1347/// grab region reported and the thumb the readout clears are the thumb that
1348/// was painted.
1349fn thumb_rect(bounds: Rect, t: f32, diameter: f32, orientation: Orientation, rtl: bool) -> Rect {
1350    let radius = diameter * 0.5;
1351    let (cx, cy) = match orientation {
1352        Orientation::Horizontal => {
1353            let usable = (bounds.width - diameter).max(0.0);
1354            let t = if rtl { 1.0 - t } else { t };
1355            (
1356                bounds.x + radius + usable * t,
1357                bounds.y + bounds.height * 0.5,
1358            )
1359        }
1360        Orientation::Vertical => {
1361            let usable = (bounds.height - diameter).max(0.0);
1362            (
1363                bounds.x + bounds.width * 0.5,
1364                bounds.y + radius + usable * (1.0 - t),
1365            )
1366        }
1367    };
1368    Rect::new(cx - radius, cy - radius, diameter, diameter)
1369}
1370
1371/// The `f64` a platform is handed for one of the slider's `f32`s: the one
1372/// nearest the shortest decimal that reads back as that `f32`, rather than the
1373/// `f32`'s exact binary value. `0.3_f32 as f64` is 0.30000001192092896, and a
1374/// screen reader speaks the number it is given: Orca reads every digit of a
1375/// value below 1 (`orca/ax_value.py`, `get_current_value_text`), and AT-SPI
1376/// carries no value text to say it otherwise.
1377fn as_written(value: f32) -> f64 {
1378    value.to_string().parse().unwrap_or(f64::from(value))
1379}
1380
1381/// [`as_written`] for a figure on the grid the arrows walk from `min` by
1382/// `step`, put back on that grid when `f32` arithmetic has only drifted off
1383/// it. An arrow adds an `f32` step to an `f32` value and rounds the sum to an
1384/// `f32`, so 0.65 + 0.01 is 0.65999997, the third step up from 0.3 is
1385/// 0.32999998, and ten steps of 0.01 are 0.099999994. Every point of the grid
1386/// is written with no more decimal places than `min` and `step` take, so a
1387/// figure within a thousandth of a step of such a decimal is published as
1388/// that decimal. Any other figure, such as a value the application set
1389/// between two steps, is published as it is.
1390fn on_grid(figure: f32, min: f32, step: f32) -> f64 {
1391    fn places(value: f32) -> usize {
1392        // `Display` never writes an `f32` with an exponent.
1393        value
1394            .to_string()
1395            .split_once('.')
1396            .map_or(0, |(_, fraction)| fraction.len())
1397    }
1398    let written = as_written(figure);
1399    let places = places(min).max(places(step));
1400    let rounded: f64 = format!("{written:.places$}").parse().unwrap_or(written);
1401    if (rounded - written).abs() <= as_written(step).abs() * 1e-3 {
1402        rounded
1403    } else {
1404        written
1405    }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411    use teksilo_canvas::Point;
1412    use teksilo_core::event::{Key, Modifiers};
1413    use teksilo_core::widget_tree::WidgetTree;
1414
1415    #[test]
1416    fn focus_ring_only_under_focus_visible() {
1417        // `:focus-visible`: the keyboard-only focus ring (now derived live
1418        // from the input-modality signal, not a hover-at-focus snapshot).
1419        // Programmatic focus leaves `focus_visible` false → no ring; a key
1420        // press reveals it.
1421        let theme = teksilo_core::presets::intui::light();
1422        let ring = theme.colors.focus_ring.to_array();
1423        let mut tree = WidgetTree::new().with_theme(theme);
1424        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1425        tree.layout(SizeProposal::exact(200.0, 60.0));
1426
1427        tree.focus(s);
1428        assert!(
1429            !frame_has_ring(&tree.render(), ring),
1430            "no focus ring while focus-visible is false (pointer modality)",
1431        );
1432
1433        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1434        assert!(
1435            frame_has_ring(&tree.render(), ring),
1436            "focus ring shows under keyboard modality",
1437        );
1438    }
1439
1440    /// Whether the focus-ring *stroke* (ring color + non-zero stroke width) is
1441    /// present. A plain color match is ambiguous: in IntUI `focus_ring` shares
1442    /// the `accent` RGBA, and the slider paints accent *fills* (track + thumb)
1443    /// — the ring is the only *stroked* shape in that color.
1444    fn frame_has_ring(frame: &teksilo_canvas::RenderFrame, color: [f32; 4]) -> bool {
1445        frame
1446            .shapes
1447            .iter()
1448            .any(|s| s.color == color && s.stroke_width > 0.0)
1449            || frame.cosmetic_lines.iter().any(|l| l.color == color)
1450    }
1451
1452    /// The thumb's keyboard ring under a theme whose `focus_ring_offset` is
1453    /// `offset` (width 2), as `[x, y, width, height]`.
1454    fn thumb_ring(offset: f32) -> [f32; 4] {
1455        let mut theme = teksilo_core::presets::intui::light();
1456        theme.shape.focus_ring_offset = offset;
1457        theme.shape.focus_ring_width = 2.0;
1458        let ring = theme.colors.focus_ring.to_array();
1459        let mut tree = WidgetTree::new().with_theme(theme);
1460        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1461        tree.layout(SizeProposal::exact(200.0, 60.0));
1462        tree.focus(s);
1463        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1464        tree.layout(SizeProposal::exact(200.0, 60.0));
1465        let frame = tree.render();
1466        frame
1467            .shapes
1468            .iter()
1469            .find(|s| s.color == ring && s.stroke_width > 0.0)
1470            .expect("the keyboard ring is stroked")
1471            .screen
1472    }
1473
1474    #[test]
1475    fn a_negative_focus_ring_offset_shrinks_the_thumb_ring_without_inverting() {
1476        let outer = thumb_ring(2.0);
1477        let inner = thumb_ring(-1.0);
1478        assert!(inner[2] < outer[2] && inner[3] < outer[3]);
1479        assert!(inner[2] >= 0.0 && inner[3] >= 0.0);
1480
1481        let deep = thumb_ring(-40.0);
1482        assert!(deep[2] >= 0.0 && deep[3] >= 0.0, "ring inverted: {deep:?}");
1483    }
1484
1485    #[test]
1486    fn keyboard_adjusts_value() {
1487        let value = Signal::new(50.0_f32);
1488        let mut tree = WidgetTree::new();
1489        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1490        tree.layout(SizeProposal::exact(200.0, 60.0));
1491
1492        tree.focus(s);
1493        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1494        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1495
1496        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1497        assert!((value.get() - 50.0).abs() < 0.01);
1498    }
1499
1500    #[test]
1501    fn on_change_reports_each_value_the_user_produces_and_nothing_else() {
1502        use std::cell::RefCell;
1503        use std::rc::Rc;
1504
1505        let value = Signal::new(50.0_f32);
1506        let seen: Rc<RefCell<Vec<f32>>> = Rc::new(RefCell::new(Vec::new()));
1507        let sink = seen.clone();
1508        let mut tree = WidgetTree::new();
1509        let s = tree.add(
1510            Slider::new(value.clone(), 0.0, 100.0)
1511                .step(10.0)
1512                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
1513        );
1514        tree.layout(SizeProposal::exact(200.0, 60.0));
1515        tree.focus(s);
1516
1517        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1518        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1519        tree.press_key(Key::End, Modifiers::NONE);
1520        // Already at the maximum: the write clamps to the value it already
1521        // holds, which is not a change and is not reported.
1522        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1523
1524        assert_eq!(*seen.borrow(), vec![60.0, 50.0, 100.0]);
1525    }
1526
1527    #[test]
1528    fn on_change_is_silent_for_a_programmatic_write() {
1529        use std::cell::Cell;
1530        use std::rc::Rc;
1531
1532        let value = Signal::new(50.0_f32);
1533        let fired = Rc::new(Cell::new(false));
1534        let sink = fired.clone();
1535        let mut tree = WidgetTree::new();
1536        let _s = tree.add(
1537            Slider::new(value.clone(), 0.0, 100.0)
1538                .step(10.0)
1539                .on_change(move |_now, _ctx| sink.set(true)),
1540        );
1541        tree.layout(SizeProposal::exact(200.0, 60.0));
1542
1543        value.set(70.0);
1544        assert!(!fired.get());
1545    }
1546
1547    #[test]
1548    fn home_end_jump_to_bounds() {
1549        let value = Signal::new(50.0_f32);
1550        let mut tree = WidgetTree::new();
1551        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1552        tree.layout(SizeProposal::exact(200.0, 60.0));
1553
1554        tree.focus(s);
1555        tree.press_key(Key::Home, Modifiers::NONE);
1556        assert!((value.get() - 0.0).abs() < 0.01);
1557
1558        tree.press_key(Key::End, Modifiers::NONE);
1559        assert!((value.get() - 100.0).abs() < 0.01);
1560    }
1561
1562    /// The published `(numeric_value_step, numeric_value_jump)` pair.
1563    ///
1564    /// Read off the raw AccessKit node rather than `AccessibilityInfo`, which
1565    /// carries only role / name / actions. `accesskit::Node` is not `Clone`, so
1566    /// the values come back rather than the node.
1567    fn a11y_steps(
1568        tree: &mut WidgetTree,
1569        id: teksilo_core::widget_id::WidgetId,
1570    ) -> (Option<f64>, Option<f64>) {
1571        let update = tree.sync_accessibility();
1572        let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1573        update
1574            .nodes
1575            .iter()
1576            .find(|(node_id, _)| *node_id == nid)
1577            .map(|(_, n)| (n.numeric_value_step(), n.numeric_value_jump()))
1578            .expect("the slider publishes an AT node")
1579    }
1580
1581    fn access(
1582        tree: &mut WidgetTree,
1583        id: teksilo_core::widget_id::WidgetId,
1584        action: teksilo_core::accesskit::Action,
1585        data: Option<teksilo_core::accesskit::ActionData>,
1586    ) -> bool {
1587        let mut ops = teksilo_core::window::NoopWindowOps;
1588        tree.dispatch_access_action(
1589            teksilo_core::accessibility::widget_id_to_node_id(id),
1590            action,
1591            data,
1592            &mut ops,
1593        )
1594    }
1595
1596    #[test]
1597    fn page_keys_move_ten_arrow_steps() {
1598        // The default step is 1 % of the range and the default page is ten of
1599        // them, so a page is 10 % — `QAbstractSlider::pageStep`, `GtkScale`'s
1600        // page increment and `<input type=range>` in WebKit and Blink all land
1601        // on the same figure.
1602        let value = Signal::new(50.0_f32);
1603        let mut tree = WidgetTree::new();
1604        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1605        tree.layout(SizeProposal::exact(200.0, 60.0));
1606        tree.focus(s);
1607
1608        tree.press_key(Key::PageUp, Modifiers::NONE);
1609        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1610        tree.press_key(Key::PageDown, Modifiers::NONE);
1611        assert!((value.get() - 50.0).abs() < 0.01, "value={}", value.get());
1612    }
1613
1614    #[test]
1615    fn page_step_overrides_the_default() {
1616        let value = Signal::new(50.0_f32);
1617        let mut tree = WidgetTree::new();
1618        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).page_step(25.0));
1619        tree.layout(SizeProposal::exact(200.0, 60.0));
1620        tree.focus(s);
1621
1622        tree.press_key(Key::PageUp, Modifiers::NONE);
1623        assert!((value.get() - 75.0).abs() < 0.01, "value={}", value.get());
1624    }
1625
1626    #[test]
1627    fn page_step_follows_an_explicit_step() {
1628        // Ten times *the effective step*, not ten times 1 % of the range.
1629        let value = Signal::new(50.0_f32);
1630        let mut tree = WidgetTree::new();
1631        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(2.0));
1632        tree.layout(SizeProposal::exact(200.0, 60.0));
1633        tree.focus(s);
1634
1635        tree.press_key(Key::PageUp, Modifiers::NONE);
1636        assert!((value.get() - 70.0).abs() < 0.01, "value={}", value.get());
1637    }
1638
1639    #[test]
1640    fn paging_clamps_at_the_bounds() {
1641        let value = Signal::new(95.0_f32);
1642        let mut tree = WidgetTree::new();
1643        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1644        tree.layout(SizeProposal::exact(200.0, 60.0));
1645        tree.focus(s);
1646
1647        tree.press_key(Key::PageUp, Modifiers::NONE);
1648        assert!((value.get() - 100.0).abs() < 0.01, "value={}", value.get());
1649    }
1650
1651    #[test]
1652    fn an_accelerator_chord_leaves_the_value_alone() {
1653        // Behaviour change: `Ctrl+Home` used to drive the slider to its minimum
1654        // and report the key handled, so the chord never reached the
1655        // application's own Shortcut/Action pipeline.
1656        let value = Signal::new(50.0_f32);
1657        let mut tree = WidgetTree::new();
1658        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1659        tree.layout(SizeProposal::exact(200.0, 60.0));
1660        tree.focus(s);
1661
1662        for (key, mods) in [
1663            (Key::Home, Modifiers::CTRL),
1664            (Key::End, Modifiers::ALT),
1665            (Key::ArrowRight, Modifiers::CTRL),
1666            (Key::PageDown, Modifiers::SUPER),
1667        ] {
1668            tree.press_key(key, mods);
1669            assert!(
1670                (value.get() - 50.0).abs() < 0.01,
1671                "{key:?} with {mods:?} moved the value to {}",
1672                value.get()
1673            );
1674        }
1675    }
1676
1677    #[test]
1678    fn a_shifted_arrow_still_steps() {
1679        // `Shift` is not a distinct chord on a slider, so it must not disable
1680        // the one the user pressed.
1681        let value = Signal::new(50.0_f32);
1682        let mut tree = WidgetTree::new();
1683        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1684        tree.layout(SizeProposal::exact(200.0, 60.0));
1685        tree.focus(s);
1686
1687        tree.press_key(Key::ArrowRight, Modifiers::SHIFT);
1688        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1689    }
1690
1691    #[test]
1692    fn accessibility_publishes_the_page_step_as_the_value_jump() {
1693        // Orca and VoiceOver announce the coarse distance from this property,
1694        // and it must be the one the page keys actually apply.
1695        let mut tree = WidgetTree::new();
1696        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
1697        tree.layout(SizeProposal::exact(200.0, 60.0));
1698        assert_eq!(a11y_steps(&mut tree, s), (Some(1.0), Some(10.0)));
1699
1700        let mut tree = WidgetTree::new();
1701        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0).page_step(25.0));
1702        tree.layout(SizeProposal::exact(200.0, 60.0));
1703        assert_eq!(a11y_steps(&mut tree, s).1, Some(25.0));
1704    }
1705
1706    #[test]
1707    fn a_reader_is_told_each_new_value_as_it_happens() {
1708        // An arrow moved the value and told no platform: the new figure came
1709        // out only with the next unrelated walk of the tree, most often the
1710        // next focus move, where Orca's "52" was cut by the new focus.
1711        use crate::common::heard_test::{Heard, Listener};
1712
1713        let value = Signal::new(50.0_f32);
1714        let mut tree = WidgetTree::new();
1715        let s =
1716            tree.add(Slider::new(value.clone(), 0.0, 100.0).label(teksilo_i18n::lit!("Volume")));
1717        let p = SizeProposal::exact(200.0, 60.0);
1718        tree.layout(p);
1719        tree.focus(s);
1720        tree.layout(p);
1721        let mut listener = Listener::attach(&mut tree);
1722
1723        for heard in ["51", "52"] {
1724            tree.press_key(Key::ArrowRight, Modifiers::NONE);
1725            tree.layout(p);
1726            assert_eq!(
1727                listener.heard(&mut tree),
1728                vec![Heard::FocusNumber(heard.to_string())],
1729                "Right"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn a_reader_hears_the_figure_the_app_holds_not_its_binary_noise() {
1736        // An `f32` widened as it is reads 0.3 as 0.30000001192092896, and Orca
1737        // speaks every digit of a value under 1 (`ax_value.py`,
1738        // `get_current_value_text`).
1739        use crate::common::heard_test::{Heard, Listener};
1740
1741        let value = Signal::new(0.3_f32);
1742        let mut tree = WidgetTree::new();
1743        let s = tree.add(
1744            Slider::new(value.clone(), 0.0, 1.0)
1745                .step(0.01)
1746                .orientation(Orientation::Vertical)
1747                .label(teksilo_i18n::lit!("Opacity")),
1748        );
1749        let p = SizeProposal::exact(60.0, 200.0);
1750        tree.layout(p);
1751
1752        let update = tree.sync_accessibility();
1753        let nid = teksilo_core::accessibility::widget_id_to_node_id(s);
1754        let node = &update
1755            .nodes
1756            .iter()
1757            .find(|(id, _)| *id == nid)
1758            .expect("the slider publishes an AT node")
1759            .1;
1760        assert_eq!(
1761            (node.numeric_value(), node.numeric_value_step()),
1762            (Some(0.3), Some(0.01)),
1763        );
1764
1765        tree.focus(s);
1766        tree.layout(p);
1767        let mut listener = Listener::attach(&mut tree);
1768        tree.press_key(Key::ArrowUp, Modifiers::NONE);
1769        tree.layout(p);
1770        assert_eq!(
1771            listener.heard(&mut tree),
1772            vec![Heard::FocusNumber("0.31".to_string())],
1773        );
1774    }
1775
1776    #[test]
1777    fn a_reader_hears_each_step_on_its_grid_not_the_drift_of_f32_sums() {
1778        // Each arrow adds an `f32` step to an `f32` value and rounds the sum
1779        // to an `f32`, so the value drifts off the step's grid: 0.65 + 0.01 is
1780        // 0.65999997, the third step up from 0.3 is 0.32999998, and ten steps
1781        // of 0.01 are 0.099999994. Orca speaks every digit it is given below 1.
1782        use crate::common::heard_test::{Heard, Listener};
1783
1784        // No step: the arrows walk 1 % of the range, as the menus example's
1785        // Opacity slider does from 0.65.
1786        let value = Signal::new(0.65_f32);
1787        let mut tree = WidgetTree::new();
1788        let s = tree.add(Slider::new(value.clone(), 0.0, 1.0).label(teksilo_i18n::lit!("Opacity")));
1789        let p = SizeProposal::exact(200.0, 60.0);
1790        tree.layout(p);
1791        assert_eq!(
1792            a11y_steps(&mut tree, s),
1793            (Some(0.01), Some(0.1)),
1794            "the arrow and page distances"
1795        );
1796        tree.focus(s);
1797        tree.layout(p);
1798        let mut listener = Listener::attach(&mut tree);
1799        for heard in ["0.66", "0.67", "0.68"] {
1800            tree.press_key(Key::ArrowRight, Modifiers::NONE);
1801            tree.layout(p);
1802            assert_eq!(
1803                listener.heard(&mut tree),
1804                vec![Heard::FocusNumber(heard.to_string())],
1805                "Right"
1806            );
1807        }
1808
1809        value.set(0.3);
1810        tree.layout(p);
1811        let _ = listener.heard(&mut tree);
1812        for heard in ["0.31", "0.32", "0.33"] {
1813            tree.press_key(Key::ArrowRight, Modifiers::NONE);
1814            tree.layout(p);
1815            assert_eq!(
1816                listener.heard(&mut tree),
1817                vec![Heard::FocusNumber(heard.to_string())],
1818                "Right from 0.3"
1819            );
1820        }
1821
1822        // A value the app set between two steps is not its neighbour on the
1823        // grid, and is published as it is.
1824        value.set(0.655);
1825        tree.layout(p);
1826        assert_eq!(
1827            listener.heard(&mut tree),
1828            vec![Heard::FocusNumber("0.655".to_string())],
1829            "a value off the grid"
1830        );
1831    }
1832
1833    #[test]
1834    fn access_set_value_writes_and_snaps() {
1835        // AT-SPI publishes the `Value` interface off `numeric_value` alone, so
1836        // `Value.SetCurrentValue` — Orca's slider value entry — reached this
1837        // widget long before the action was advertised, and did nothing.
1838        use teksilo_core::accesskit::{Action, ActionData};
1839        let value = Signal::new(50.0_f32);
1840        let mut tree = WidgetTree::new();
1841        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1842        tree.layout(SizeProposal::exact(200.0, 60.0));
1843
1844        assert!(
1845            tree.accessibility_node(s)
1846                .actions()
1847                .contains(&Action::SetValue),
1848            "macOS gates AXValue settability on the advertisement"
1849        );
1850
1851        assert!(access(
1852            &mut tree,
1853            s,
1854            Action::SetValue,
1855            Some(ActionData::NumericValue(73.0))
1856        ));
1857        assert!(
1858            (value.get() - 70.0).abs() < 0.01,
1859            "an AT write snaps to `step`, like a drag: {}",
1860            value.get()
1861        );
1862
1863        // The string shape is what the automation `set_value` tool sends.
1864        assert!(access(
1865            &mut tree,
1866            s,
1867            Action::SetValue,
1868            Some(ActionData::Value("1000".into()))
1869        ));
1870        assert!((value.get() - 100.0).abs() < 0.01, "out of range clamps");
1871
1872        // A value that is not a number is declined rather than silently
1873        // becoming one.
1874        assert!(!access(
1875            &mut tree,
1876            s,
1877            Action::SetValue,
1878            Some(ActionData::Value("seventy".into()))
1879        ));
1880        assert!((value.get() - 100.0).abs() < 0.01);
1881    }
1882
1883    #[test]
1884    fn access_increment_survives_the_payload_handler() {
1885        // Increment and Decrement moved into the payload handler with
1886        // SetValue, because that is where `ActionData` rides. They used to have
1887        // to: the dispatcher called `on_access_action_request` *instead of*
1888        // `on_access_action`. It now fires both, but the pair still lives here
1889        // — one handler, one match, one place to read.
1890        use teksilo_core::accesskit::Action;
1891        let value = Signal::new(50.0_f32);
1892        let mut tree = WidgetTree::new();
1893        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1894        tree.layout(SizeProposal::exact(200.0, 60.0));
1895
1896        assert!(access(&mut tree, s, Action::Increment, None));
1897        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
1898        assert!(access(&mut tree, s, Action::Decrement, None));
1899        assert!((value.get() - 50.0).abs() < 0.01, "value={}", value.get());
1900    }
1901
1902    #[test]
1903    fn an_app_installed_access_action_still_fires_on_a_payload_widget() {
1904        // `.on_access_action(..)` is the app's hook; `on_access_action_request`
1905        // is what a widget reaches for when it needs `target_node` or `data`.
1906        // The dispatcher used to prefer the payload slot when it was set, so
1907        // installing an app handler on a `Slider`, `SpinBox`, `TextInput`,
1908        // `CodeEditor` or `TabBar` produced a handler that never ran and no
1909        // diagnostic anywhere.
1910        use std::cell::Cell;
1911        use std::rc::Rc;
1912        use teksilo_core::accesskit::Action;
1913        use teksilo_core::widget_builder::WidgetBuilder;
1914
1915        let seen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
1916        let seen_h = seen.clone();
1917        let value = Signal::new(50.0_f32);
1918        let mut tree = WidgetTree::new();
1919        let s = tree.add(
1920            Slider::new(value.clone(), 0.0, 100.0)
1921                .step(10.0)
1922                .on_access_action(move |action, _ctx| {
1923                    if action == Action::Increment {
1924                        seen_h.set(seen_h.get() + 1);
1925                    }
1926                    EventResponse::Ignored
1927                }),
1928        );
1929        tree.layout(SizeProposal::exact(200.0, 60.0));
1930
1931        assert!(access(&mut tree, s, Action::Increment, None));
1932        assert_eq!(seen.get(), 1, "the app's handler runs");
1933        assert!(
1934            (value.get() - 60.0).abs() < 0.01,
1935            "and the widget's own still steps: {}",
1936            value.get()
1937        );
1938    }
1939
1940    #[test]
1941    fn rtl_mirrors_the_arrows_the_pointer_and_the_fill_together() {
1942        // A horizontal slider's minimum sits at the *leading* edge, which is
1943        // the right one in a right-to-left window. All three readings of that
1944        // — the arrows, the click-to-jump and the painted fill — mirror off
1945        // the same answer; mirroring any one alone would leave the `Left` key
1946        // and a leftward drag moving the thumb in opposite directions.
1947        let value = Signal::new(50.0_f32);
1948        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1949        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1950        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
1951        tree.layout(SizeProposal::exact(200.0, 60.0));
1952        tree.render();
1953        tree.focus(s);
1954
1955        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
1956        assert!(
1957            (value.get() - 60.0).abs() < 0.01,
1958            "under RTL the leftward arrow increases, got {}",
1959            value.get()
1960        );
1961        tree.press_key(Key::ArrowRight, Modifiers::NONE);
1962        assert!((value.get() - 50.0).abs() < 0.01);
1963
1964        // The pointer reads from the other end too: a click near the *right*
1965        // edge is near the minimum under RTL, where it would be the maximum
1966        // left-to-right.
1967        let p = Point::new(190.0, 30.0);
1968        tree.pointer_move(p);
1969        tree.dispatch_event(WidgetEvent::pointer_down(
1970            p,
1971            PointerButton::Primary,
1972            Modifiers::NONE,
1973        ));
1974        tree.dispatch_event(WidgetEvent::pointer_up(
1975            p,
1976            PointerButton::Primary,
1977            Modifiers::NONE,
1978        ));
1979        assert!(
1980            value.get() < 20.0,
1981            "a click near the right edge is near the minimum under RTL, got {}",
1982            value.get()
1983        );
1984    }
1985
1986    #[test]
1987    fn an_at_write_lands_on_the_grid_the_arrows_walk() {
1988        // The advertised `numeric_value_step`, the arrows and `Increment` all
1989        // move by `effective_step` — 1 % of the range when no step is
1990        // configured. The write snapped to the *configured* step instead, so on
1991        // a stepless slider it landed between two values every other path could
1992        // reach, and Orca then announced a number its own Up arrow could never
1993        // produce.
1994        use teksilo_core::accesskit::{Action, ActionData};
1995        let value = Signal::new(50.0_f32);
1996        let mut tree = WidgetTree::new();
1997        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
1998        tree.layout(SizeProposal::exact(200.0, 60.0));
1999
2000        assert!(access(
2001            &mut tree,
2002            s,
2003            Action::SetValue,
2004            Some(ActionData::NumericValue(37.3))
2005        ));
2006        assert!(
2007            (value.get() - 37.0).abs() < 0.01,
2008            "a stepless 0..100 slider steps by 1, so the write snaps there: {}",
2009            value.get()
2010        );
2011
2012        // …and the arrow lands on the same grid from there.
2013        tree.focus(s);
2014        tree.press_key(Key::ArrowRight, Modifiers::NONE);
2015        assert!((value.get() - 38.0).abs() < 0.01, "value={}", value.get());
2016    }
2017
2018    #[test]
2019    fn a_vertical_slider_puts_its_maximum_at_the_top() {
2020        // Qt's `QSlider`, GTK4's `GtkScale`, the Win32 trackbar and
2021        // `<input type=range>` all put a vertical slider's minimum at the
2022        // bottom, and it is what makes `ArrowUp` — which `range_nav` reports as
2023        // an increase — raise the thumb. Growing the thumb position with the
2024        // value sent it *down*, so the keys and the pointer both drove the
2025        // control backwards.
2026        let value = Signal::new(50.0_f32);
2027        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2028        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).orientation(Orientation::Vertical));
2029        tree.layout(SizeProposal::exact(60.0, 200.0));
2030        tree.render();
2031
2032        // A click near the top is near the maximum.
2033        let top = Point::new(30.0, 12.0);
2034        tree.pointer_move(top);
2035        for ev in [
2036            WidgetEvent::pointer_down(top, PointerButton::Primary, Modifiers::NONE),
2037            WidgetEvent::pointer_up(top, PointerButton::Primary, Modifiers::NONE),
2038        ] {
2039            tree.dispatch_event(ev);
2040        }
2041        assert!(
2042            value.get() > 90.0,
2043            "a click near the top is near the maximum, got {}",
2044            value.get()
2045        );
2046
2047        // …and the arrow that increases the value moves towards it.
2048        value.set(50.0);
2049        tree.focus(s);
2050        tree.press_key(Key::ArrowUp, Modifiers::NONE);
2051        let raised = value.get();
2052        assert!(raised > 50.0, "ArrowUp increases, got {raised}");
2053
2054        // The painted thumb agrees: higher value, smaller y.
2055        let thumb_y = |tree: &mut WidgetTree| -> f32 {
2056            let radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER;
2057            tree.render()
2058                .shapes
2059                .iter()
2060                .filter(|q| (q.screen[3] - radius).abs() < 1.0)
2061                .map(|q| q.screen[1])
2062                .fold(f32::MAX, f32::min)
2063        };
2064        value.set(10.0);
2065        let low = thumb_y(&mut tree);
2066        value.set(90.0);
2067        let high = thumb_y(&mut tree);
2068        assert!(
2069            high < low,
2070            "the thumb rises as the value grows: y({}) at 90 vs y({}) at 10",
2071            high,
2072            low
2073        );
2074    }
2075
2076    #[test]
2077    fn a_vertical_slider_ignores_the_layout_direction() {
2078        // `Up`/`Down` name points in value space; only the horizontal pair
2079        // mirrors.
2080        let value = Signal::new(50.0_f32);
2081        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2082        tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
2083        let s = tree.add(
2084            Slider::new(value.clone(), 0.0, 100.0)
2085                .step(10.0)
2086                .orientation(Orientation::Vertical),
2087        );
2088        tree.layout(SizeProposal::exact(60.0, 200.0));
2089        tree.focus(s);
2090
2091        tree.press_key(Key::ArrowUp, Modifiers::NONE);
2092        assert!((value.get() - 60.0).abs() < 0.01, "value={}", value.get());
2093    }
2094
2095    #[test]
2096    fn track_click_sets_value() {
2097        let value = Signal::new(0.0_f32);
2098        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2099        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2100        tree.layout(SizeProposal::exact(200.0, 60.0));
2101        // `place_children` cached the bounds for event handling during the
2102        // layout above; the render is what paints the body.
2103        tree.render();
2104
2105        // Click at the widget center
2106        tree.click(s);
2107
2108        // Value should be approximately 50 (midpoint of 0..100)
2109        let val = value.get();
2110        assert!(
2111            (val - 50.0).abs() < 15.0,
2112            "track click at center should set value near 50, got {}",
2113            val
2114        );
2115    }
2116
2117    #[test]
2118    fn track_click_sets_value_at_nonzero_origin() {
2119        // Regression for the widget-local coordinate migration: a slider
2120        // offset from the window origin must still map a click correctly.
2121        use crate::primitives::{FixedSize, HStack};
2122        use teksilo_canvas::Point;
2123        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2124
2125        let value = Signal::new(0.0_f32);
2126        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2127        let sid = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2128        let _row = tree.add(
2129            HStack::new()
2130                .child(FixedSize::new().width(40.0).height(60.0))
2131                .child(sid),
2132        );
2133        tree.layout(SizeProposal::exact(240.0, 60.0));
2134        tree.render();
2135
2136        // The slider sits at window x ∈ [40, 240] (width 200). Its local
2137        // centre (x = 100) is window x = 140 → value 50, independent of
2138        // the thumb radius.
2139        let b = tree.bounds(sid);
2140        assert!(
2141            (b.x - 40.0).abs() < 0.5,
2142            "slider should be offset, x={}",
2143            b.x
2144        );
2145        for ev in [
2146            WidgetEvent::pointer_down(
2147                Point::new(140.0, 30.0),
2148                PointerButton::Primary,
2149                Modifiers::NONE,
2150            ),
2151            WidgetEvent::pointer_up(
2152                Point::new(140.0, 30.0),
2153                PointerButton::Primary,
2154                Modifiers::NONE,
2155            ),
2156        ] {
2157            tree.dispatch_event(ev);
2158        }
2159        assert!(
2160            (value.get() - 50.0).abs() < 1.0,
2161            "click at the offset slider's centre should set ~50, got {}",
2162            value.get()
2163        );
2164    }
2165
2166    #[test]
2167    fn accessibility() {
2168        let value = Signal::new(25.0_f32);
2169        let mut tree = WidgetTree::new();
2170        let s = tree.add(Slider::new(value, 0.0, 100.0));
2171        tree.layout(SizeProposal::exact(200.0, 60.0));
2172        let info = tree.accessibility_node(s);
2173        assert_eq!(info.role(), teksilo_core::accesskit::Role::Slider);
2174    }
2175
2176    #[test]
2177    fn step_snaps_value() {
2178        let value = Signal::new(0.0_f32);
2179        let mut tree = WidgetTree::new();
2180        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(25.0));
2181        tree.layout(SizeProposal::exact(200.0, 60.0));
2182
2183        tree.focus(s);
2184        tree.press_key(Key::ArrowRight, Modifiers::NONE);
2185        assert!((value.get() - 25.0).abs() < 0.01);
2186        tree.press_key(Key::ArrowRight, Modifiers::NONE);
2187        assert!((value.get() - 50.0).abs() < 0.01);
2188    }
2189
2190    #[test]
2191    fn thumb_drag_updates_value() {
2192        let theme = teksilo_core::presets::intui::light();
2193        let thumb_radius = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
2194        let value = Signal::new(50.0_f32);
2195        let mut tree = WidgetTree::new().with_theme(theme);
2196        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2197        tree.layout(SizeProposal::exact(200.0, 60.0));
2198        tree.render(); // cache bounds for event handling
2199
2200        let bounds = tree.bounds(s);
2201        // Thumb center for value=50: bounds.x + r + (width - 2r) * 0.5
2202        let thumb_cx = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.5;
2203        let center_y = bounds.y + bounds.height / 2.0;
2204
2205        // Pointer down on thumb
2206        tree.pointer_down_button(Point::new(thumb_cx, center_y), PointerButton::Primary);
2207
2208        // Drag to 75% position. DragRecognizer needs one move past its
2209        // 5 px threshold to emit `DragStarted` (which carries the *down*
2210        // position, leaving value at 50%), and a second move to emit
2211        // `DragMoved` — the latter is what actually drives the value.
2212        let target_x = bounds.x + thumb_radius + (bounds.width - thumb_radius * 2.0) * 0.75;
2213        tree.pointer_move(Point::new(thumb_cx + 10.0, center_y));
2214        tree.pointer_move(Point::new(target_x, center_y));
2215
2216        let val = value.get();
2217        assert!(
2218            (val - 75.0).abs() < 5.0,
2219            "dragging to 75% should set value near 75, got {}",
2220            val
2221        );
2222
2223        // Release
2224        tree.pointer_up_button(Point::new(target_x, center_y), PointerButton::Primary);
2225    }
2226
2227    #[test]
2228    fn accessibility_has_actions() {
2229        let value = Signal::new(25.0_f32);
2230        let mut tree = WidgetTree::new();
2231        let s = tree.add(Slider::new(value, 0.0, 100.0));
2232        tree.layout(SizeProposal::exact(200.0, 60.0));
2233        let info = tree.accessibility_node(s);
2234        assert!(
2235            info.actions()
2236                .contains(&teksilo_core::accesskit::Action::Increment)
2237        );
2238        assert!(
2239            info.actions()
2240                .contains(&teksilo_core::accesskit::Action::Decrement)
2241        );
2242    }
2243
2244    // -----------------------------------------------------------------
2245    // Touch: the manipulator contract
2246    // -----------------------------------------------------------------
2247
2248    /// A finger drag that starts on a slider inside a scroller adjusts the
2249    /// slider, from the **first move sample**, and scrolls nothing.
2250    ///
2251    /// The end-to-end behaviour the touch programme promises for a continuous
2252    /// manipulator. What delivers it is the press capture the slider's drag
2253    /// takes: it makes the slider the innermost member of the pointer's
2254    /// sequence, which both stops the enclosing claimant winning the gesture
2255    /// and means no long-press timer is ever interposed. The drag is deliberately
2256    /// **vertical-dominant** — straight down the axis the scroller claims and
2257    /// far past the 36 dp pan slop — because a drag along the slider's own axis
2258    /// is not contested by a vertical claimant and would prove nothing.
2259    #[test]
2260    fn a_finger_drag_on_a_slider_inside_a_scroller_adjusts_the_slider() {
2261        use crate::button::press_test_support::{finger, touch};
2262        use teksilo_core::event::EventResponse;
2263        use teksilo_core::pointer::PointerPhase;
2264        use teksilo_core::pointer::touch_action::PanClaim;
2265        use teksilo_core::widget_builder::WidgetBuilder;
2266
2267        let scrolled = Rc::new(Cell::new(0_u32));
2268        let count = scrolled.clone();
2269        let value = Signal::new(0.0_f32);
2270        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2271        let slider = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2272        let _list = tree.add(
2273            crate::primitives::VStack::new()
2274                .child(slider)
2275                .scroll_container(teksilo_core::pointer::touch_action::PanAxes::BOTH)
2276                .pan_claim(PanClaim::vertical())
2277                .on_scroll(move |_e, _c| {
2278                    count.set(count.get() + 1);
2279                    EventResponse::Handled
2280                }),
2281        );
2282        tree.layout(SizeProposal::exact(200.0, 400.0));
2283        let b = tree.bounds(slider);
2284        let start = Point::new(b.x + 20.0, b.center().y);
2285        let id = finger();
2286        tree.dispatch_pointer(touch(id, PointerPhase::Down, start, 0));
2287
2288        // One move, already past the coarse 18 dp drag slop. The value must
2289        // have moved on *this* sample: a drag deferred to the 500 ms long-press
2290        // timer would leave it at zero here.
2291        tree.dispatch_pointer(touch(
2292            id,
2293            PointerPhase::Move,
2294            Point::new(start.x + 30.0, start.y + 45.0),
2295            20,
2296        ));
2297        assert!(
2298            value.get() > 0.0,
2299            "the drag must arm on the first move, not after a long press (value {})",
2300            value.get(),
2301        );
2302
2303        // …and keep going, vertically far past the 36 dp pan slop.
2304        for (i, (dx, dy)) in [(60.0_f32, 90.0_f32), (90.0, 140.0)]
2305            .into_iter()
2306            .enumerate()
2307        {
2308            let at = Point::new(start.x + dx, start.y + dy);
2309            tree.dispatch_pointer(touch(id, PointerPhase::Move, at, 40 + i as u64 * 20));
2310        }
2311        tree.dispatch_pointer(touch(
2312            id,
2313            PointerPhase::Up,
2314            Point::new(start.x + 90.0, start.y + 140.0),
2315            100,
2316        ));
2317        assert!(
2318            value.get() > 40.0,
2319            "the finger drag did not reach the slider (value {})",
2320            value.get(),
2321        );
2322        assert_eq!(scrolled.get(), 0, "and it must not have scrolled the list");
2323    }
2324
2325    /// **`touch_action(NONE)`'s own test.** Two contacts landing on a
2326    /// manipulator start no pinch on the surface under it.
2327    ///
2328    /// This is the one consumer of the declaration that the slider's press
2329    /// capture does not already cover: `feed_pinch` asks
2330    /// `effective_touch_action(hit target).allows_pinch()` on the contact
2331    /// itself, before any capture or arbitration exists. Relax the slider to
2332    /// `AUTO` and the pinch starts.
2333    #[test]
2334    fn two_contacts_on_a_slider_pinch_nothing_underneath() {
2335        use crate::button::press_test_support::{finger, touch};
2336        use teksilo_core::gesture::PinchPhase;
2337        use teksilo_core::pointer::PointerPhase;
2338        use teksilo_core::widget_builder::WidgetBuilder;
2339
2340        let started = Rc::new(Cell::new(0_u32));
2341        let n = started.clone();
2342        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2343        let slider = tree.add(Slider::new(Signal::new(0.0_f32), 0.0, 100.0));
2344        let _surface = tree.add(crate::primitives::ZStack::new().child(slider).on_pinch(
2345            move |phase, _c| {
2346                if matches!(phase, PinchPhase::Started { .. }) {
2347                    n.set(n.get() + 1);
2348                }
2349            },
2350        ));
2351        tree.layout(SizeProposal::exact(200.0, 200.0));
2352        let b = tree.bounds(slider);
2353        let a = finger();
2354        let c = finger();
2355        tree.dispatch_pointer(touch(
2356            a,
2357            PointerPhase::Down,
2358            Point::new(b.x + 40.0, b.center().y),
2359            0,
2360        ));
2361        tree.dispatch_pointer(touch(
2362            c,
2363            PointerPhase::Down,
2364            Point::new(b.x + 150.0, b.center().y),
2365            5,
2366        ));
2367
2368        assert_eq!(
2369            started.get(),
2370            0,
2371            "a pinch started on a manipulator must not reach the surface under it",
2372        );
2373        assert!(
2374            !tree.touch_pinch_active(),
2375            "…and no pinch may be running at all",
2376        );
2377    }
2378
2379    /// The mirroring reaches the mounted widget, off the same layout direction
2380    /// the pointer path reads at event time — which is what makes the keys and
2381    /// the drag agree by construction rather than by coincidence. The chord
2382    /// table itself is `common::range_nav`, shared with every other bounded
2383    /// scalar and tested there.
2384    #[test]
2385    fn an_rtl_slider_raises_its_value_on_the_leftward_arrow() {
2386        use teksilo_core::environment::LayoutDirection;
2387
2388        for (direction, raising, lowering) in [
2389            (
2390                LayoutDirection::LeftToRight,
2391                Key::ArrowRight,
2392                Key::ArrowLeft,
2393            ),
2394            (
2395                LayoutDirection::RightToLeft,
2396                Key::ArrowLeft,
2397                Key::ArrowRight,
2398            ),
2399        ] {
2400            let value = Signal::new(50.0_f32);
2401            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2402            tree.set_layout_direction(direction);
2403            let s = tree.add(Slider::new(value.clone(), 0.0, 100.0).step(10.0));
2404            tree.layout(SizeProposal::exact(200.0, 40.0));
2405            tree.focus(s);
2406            tree.press_key(raising, Modifiers::NONE);
2407            assert_eq!(value.get(), 60.0, "{direction:?}: {raising:?} must raise");
2408            tree.press_key(lowering, Modifiers::NONE);
2409            tree.press_key(lowering, Modifiers::NONE);
2410            assert_eq!(value.get(), 40.0, "{direction:?}: {lowering:?} must lower");
2411        }
2412    }
2413
2414    /// A horizontal slider's minimum sits at the **leading** edge, which is the
2415    /// right-hand one under RTL. The painted knob and the value the press maps
2416    /// to must agree; before this they ran opposite ways.
2417    #[test]
2418    fn the_value_axis_mirrors_under_rtl() {
2419        use teksilo_core::environment::LayoutDirection;
2420
2421        let value = Signal::new(0.0_f32);
2422        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2423        tree.set_layout_direction(LayoutDirection::RightToLeft);
2424        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2425        tree.layout(SizeProposal::exact(200.0, 40.0));
2426        let b = tree.bounds(s);
2427
2428        // A tap near the RIGHT edge is the minimum under RTL.
2429        tree.pointer_down_button(
2430            Point::new(b.right() - 4.0, b.center().y),
2431            PointerButton::Primary,
2432        );
2433        tree.pointer_up_button(
2434            Point::new(b.right() - 4.0, b.center().y),
2435            PointerButton::Primary,
2436        );
2437        assert!(
2438            value.get() < 10.0,
2439            "RTL minimum is the right edge, got {}",
2440            value.get()
2441        );
2442
2443        // …and the LEFT edge is the maximum.
2444        tree.pointer_down_button(Point::new(b.x + 4.0, b.center().y), PointerButton::Primary);
2445        tree.pointer_up_button(Point::new(b.x + 4.0, b.center().y), PointerButton::Primary);
2446        assert!(
2447            value.get() > 90.0,
2448            "RTL maximum is the left edge, got {}",
2449            value.get()
2450        );
2451    }
2452
2453    /// What the body actually painted, read out of the render frame.
2454    ///
2455    /// The default `SliderStyle` draws track, fill and knob as three rounded
2456    /// rects on one canvas, so nothing in the widget tree can be measured to
2457    /// find them; the frame is the only place the picture exists. They are told
2458    /// apart by the two things the recipe fixes: the track carries
2459    /// `surface_sunken`, and of the two accent-coloured rects the knob is the
2460    /// square one (`thumb_diameter` on both axes) while the fill is
2461    /// `track_height` tall.
2462    struct PaintedSlider {
2463        track: teksilo_canvas::Rect,
2464        fill: Option<teksilo_canvas::Rect>,
2465        thumb: teksilo_canvas::Rect,
2466    }
2467
2468    fn painted_slider(
2469        direction: teksilo_core::environment::LayoutDirection,
2470        value: f32,
2471    ) -> PaintedSlider {
2472        use crate::styles::recipe_slider_style::{SLIDER_THUMB_DIAMETER, SLIDER_TRACK_HEIGHT};
2473
2474        let theme = teksilo_core::presets::intui::light();
2475        let accent = theme.colors.accent.to_array();
2476        let sunken = theme.colors.surface_sunken.to_array();
2477        let mut tree = WidgetTree::new().with_theme(theme);
2478        tree.set_layout_direction(direction);
2479        let _s = tree.add(Slider::new(Signal::new(value), 0.0, 100.0));
2480        tree.layout(SizeProposal::exact(200.0, 40.0));
2481        let frame = tree.render();
2482        let rect = |q: &teksilo_canvas::ShapeQuad| {
2483            teksilo_canvas::Rect::new(q.screen[0], q.screen[1], q.screen[2], q.screen[3])
2484        };
2485        let track = frame
2486            .shapes
2487            .iter()
2488            .find(|q| q.color == sunken)
2489            .map(rect)
2490            .expect("the body paints a track");
2491        let thumb = frame
2492            .shapes
2493            .iter()
2494            .find(|q| {
2495                q.color == accent
2496                    && (q.screen[2] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2497                    && (q.screen[3] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2498            })
2499            .map(rect)
2500            .expect("the body paints a knob");
2501        let fill = frame
2502            .shapes
2503            .iter()
2504            .find(|q| {
2505                q.color == accent
2506                    && (q.screen[3] - SLIDER_TRACK_HEIGHT).abs() < 0.01
2507                    && q.screen[2] > 0.0
2508            })
2509            .map(rect);
2510        PaintedSlider { track, fill, thumb }
2511    }
2512
2513    /// The reported knob is where the body painted it, in both directions.
2514    ///
2515    /// Two halves, and the second is what makes the first mean anything: the
2516    /// report is derived in `Slider::target_regions` from a cached rtl flag and
2517    /// the picture is derived in `SliderBody::paint` from the paint context's
2518    /// own, so "the report and the picture cannot drift" is a claim about two
2519    /// separate pieces of arithmetic. Reading only the report would leave the
2520    /// paint free to run the other way.
2521    #[test]
2522    fn the_reported_thumb_mirrors_with_the_paint() {
2523        use teksilo_core::environment::LayoutDirection;
2524
2525        fn thumb_centre(direction: LayoutDirection) -> f32 {
2526            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2527            tree.set_layout_direction(direction);
2528            let s = tree.add(Slider::new(Signal::new(0.0_f32), 0.0, 100.0));
2529            tree.layout(SizeProposal::exact(200.0, 40.0));
2530            let bounds = tree.bounds(s);
2531            let regions = tree.widget_target_regions(s);
2532            let thumb = regions
2533                .iter()
2534                .find(|r| r.part == SLIDER_PART_THUMB)
2535                .expect("the slider reports its knob");
2536            thumb.rect.center().x - bounds.x
2537        }
2538
2539        let ltr = thumb_centre(LayoutDirection::LeftToRight);
2540        let rtl = thumb_centre(LayoutDirection::RightToLeft);
2541        assert!(
2542            ltr < 20.0,
2543            "at the minimum, LTR puts the knob at the left ({ltr})"
2544        );
2545        assert!(rtl > 180.0, "and RTL puts it at the right ({rtl})");
2546
2547        // …and the paint agrees, at a value where being off by the mirror is
2548        // three quarters of the track rather than a rounding error.
2549        for direction in [LayoutDirection::LeftToRight, LayoutDirection::RightToLeft] {
2550            let painted = painted_slider(direction, 25.0);
2551            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2552            tree.set_layout_direction(direction);
2553            let s = tree.add(Slider::new(Signal::new(25.0_f32), 0.0, 100.0));
2554            tree.layout(SizeProposal::exact(200.0, 40.0));
2555            let reported = tree
2556                .widget_target_regions(s)
2557                .into_iter()
2558                .find(|r| r.part == SLIDER_PART_THUMB)
2559                .expect("the slider reports its knob");
2560            assert!(
2561                (reported.rect.center().x - painted.thumb.center().x).abs() < 0.51,
2562                "{direction:?}: the slider reports its knob at {} and paints it at {}",
2563                reported.rect.center().x,
2564                painted.thumb.center().x,
2565            );
2566        }
2567    }
2568
2569    /// Under RTL a horizontal slider's minimum is the **right** edge, so the
2570    /// filled part of the track is the stretch between the knob and that edge.
2571    ///
2572    /// The knob and the fill are mirrored by two separate expressions in
2573    /// `SliderBody::paint`, and reverting either one on its own leaves a
2574    /// picture that is merely wrong rather than crashing: a knob a quarter of
2575    /// the way along an RTL track, or a fill running from the wrong edge and
2576    /// three times too long. Both are pinned here, against a value far from the
2577    /// midpoint so neither can hide behind symmetry.
2578    #[test]
2579    fn the_rtl_fill_runs_from_the_knob_to_the_leading_edge() {
2580        use teksilo_core::environment::LayoutDirection;
2581
2582        let ltr = painted_slider(LayoutDirection::LeftToRight, 25.0);
2583        let ltr_fill = ltr.fill.expect("a quarter-full slider paints a fill");
2584        assert!(
2585            (ltr_fill.x - ltr.track.x).abs() < 0.51,
2586            "LTR fills from the left edge of the track, not from {}",
2587            ltr_fill.x,
2588        );
2589        assert!(
2590            (ltr_fill.width - ltr.track.width * 0.25).abs() < 0.51,
2591            "LTR fills a quarter of the track, not {} of {}",
2592            ltr_fill.width,
2593            ltr.track.width,
2594        );
2595        assert!(
2596            (ltr_fill.right() - ltr.thumb.center().x).abs() < 0.51,
2597            "LTR: the fill ends at the knob ({} vs {})",
2598            ltr_fill.right(),
2599            ltr.thumb.center().x,
2600        );
2601
2602        let rtl = painted_slider(LayoutDirection::RightToLeft, 25.0);
2603        let rtl_fill = rtl.fill.expect("a quarter-full slider paints a fill");
2604        assert!(
2605            (rtl.thumb.center().x - (rtl.track.x + rtl.track.width * 0.75)).abs() < 0.51,
2606            "RTL puts a quarter-value knob three quarters along the track, not at {}",
2607            rtl.thumb.center().x,
2608        );
2609        assert!(
2610            (rtl_fill.right() - rtl.track.right()).abs() < 0.51,
2611            "RTL fills to the right edge of the track, not to {}",
2612            rtl_fill.right(),
2613        );
2614        assert!(
2615            (rtl_fill.width - rtl.track.width * 0.25).abs() < 0.51,
2616            "RTL fills a quarter of the track, not {} of {}",
2617            rtl_fill.width,
2618            rtl.track.width,
2619        );
2620        assert!(
2621            (rtl_fill.x - rtl.thumb.center().x).abs() < 0.51,
2622            "RTL: the fill starts at the knob ({} vs {})",
2623            rtl_fill.x,
2624            rtl.thumb.center().x,
2625        );
2626    }
2627
2628    /// The reported knob is the size the **style** says it painted.
2629    ///
2630    /// `Slider` cannot see the knob: it is three rounded rects on one canvas,
2631    /// and the only place its diameter exists is the resolved `SliderStyle`.
2632    /// So the widget asks — `thumb_diameter_for`, at build time, while the
2633    /// theme is still in reach — and the source comment says why it asks
2634    /// rather than baking in the recipe's design constant: "so a custom
2635    /// `SliderStyle` with a different thumb size keeps drag hit-testing
2636    /// aligned".
2637    ///
2638    /// The expectation therefore has to come from the style too. A test that
2639    /// re-derives it from the widget's own cached field asserts only that the
2640    /// field equals itself, and a knob frozen at any plausible-looking constant
2641    /// — the 24 dp conformance floor, say, against the shipped 14 dp — passes
2642    /// it while every custom style's drag lands on the wrong pixel.
2643    #[test]
2644    fn the_reported_knob_is_the_size_the_style_reports() {
2645        use teksilo_core::styles::{
2646            SliderOrientation, SliderStyle, SliderStyleConfig, SliderVariant,
2647        };
2648
2649        fn config() -> SliderStyleConfig {
2650            SliderStyleConfig {
2651                value_normalized: Signal::new(0.5),
2652                is_hovered: Signal::new(false),
2653                is_dragging: Signal::new(false),
2654                is_disabled: Signal::new(false),
2655                focus_origin: Signal::new(None),
2656                orientation: SliderOrientation::Horizontal,
2657                tick_count: None,
2658                variant: SliderVariant::Continuous,
2659            }
2660        }
2661
2662        /// The knob region a slider reports, built with the default style or
2663        /// with one that declares `diameter`.
2664        fn reported_knob(diameter: Option<f32>) -> Rect {
2665            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2666            let mut slider = Slider::new(Signal::new(50.0_f32), 0.0, 100.0);
2667            if let Some(diameter) = diameter {
2668                slider = slider.style(FatKnob(diameter));
2669            }
2670            let s = tree.add(slider);
2671            tree.layout(SizeProposal::exact(200.0, 60.0));
2672            tree.widget_target_regions(s)
2673                .into_iter()
2674                .find(|r| r.part == SLIDER_PART_THUMB)
2675                .expect("the slider reports its knob")
2676                .rect
2677        }
2678
2679        /// A style that paints the stock body but declares a knob of its own.
2680        /// The whole point of the `thumb_diameter` hook.
2681        struct FatKnob(f32);
2682
2683        impl SliderStyle for FatKnob {
2684            fn make_body(
2685                &self,
2686                cfg: &SliderStyleConfig,
2687                ctx: &mut teksilo_core::build_context::BuildContext,
2688            ) -> WidgetId {
2689                crate::styles::RecipeSliderStyle::default().make_body(cfg, ctx)
2690            }
2691
2692            fn thumb_diameter(&self, _cfg: &SliderStyleConfig) -> f32 {
2693                self.0
2694            }
2695        }
2696
2697        // The shipped default: the number in the report is the number the
2698        // default style answers with, not a constant that happens to look
2699        // right today.
2700        let shipped = crate::styles::RecipeSliderStyle::default().thumb_diameter(&config());
2701        let knob = reported_knob(None);
2702        assert_eq!(
2703            (knob.width, knob.height),
2704            (shipped, shipped),
2705            "the default style reports a {shipped} dp knob and the region is {}x{}",
2706            knob.width,
2707            knob.height,
2708        );
2709
2710        // And a style that disagrees is obeyed. 30 dp is nothing the widget
2711        // could arrive at on its own: not the recipe's 14, not the 24 dp
2712        // conformance floor, not the 6 dp grab floor the audit checks against.
2713        let fat = reported_knob(Some(30.0));
2714        assert_eq!(
2715            (fat.width, fat.height),
2716            (30.0, 30.0),
2717            "a custom style's 30 dp knob reported as {}x{}",
2718            fat.width,
2719            fat.height,
2720        );
2721        assert_ne!(
2722            fat.width, shipped,
2723            "the probe only discriminates while the two styles disagree",
2724        );
2725    }
2726
2727    /// The slider reports its **whole node** as the press surface, beside the
2728    /// knob.
2729    ///
2730    /// It is the first of the two regions and it is claimed twice in prose —
2731    /// the module header's "the whole node as the press surface" and
2732    /// `target_regions`' "it is reported whole, and it is what the conformance
2733    /// audit measures". Both are load-bearing: a press anywhere on the body
2734    /// jumps the value, so the body is the target a coarse-press router aims
2735    /// at, and it is the rect the audit measures the 24 dp floor against. A
2736    /// slider that reported only its knob would advertise a 14 dp control.
2737    ///
2738    /// The tests either side of this one do not cover it: the floor sweep
2739    /// iterates whatever comes back, so it passes vacuously on an empty-but-
2740    /// for-the-knob report, and the other two search for the thumb part alone.
2741    /// The colour-picker strips have exactly this test
2742    /// (`the_hue_strip_reports_its_body_and_its_thumb`).
2743    #[test]
2744    fn the_reported_body_is_the_whole_node() {
2745        use teksilo_core::partition::TargetRegion;
2746
2747        fn body_of(regions: &[TargetRegion]) -> &TargetRegion {
2748            regions
2749                .iter()
2750                .find(|r| r.part == SLIDER_PART_BODY)
2751                .expect("the slider reports its body as a press surface")
2752        }
2753
2754        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2755        let value = Signal::new(50.0_f32);
2756        let s = tree.add(Slider::new(value.clone(), 0.0, 100.0));
2757        tree.layout(SizeProposal::exact(200.0, 40.0));
2758
2759        let bounds = tree.bounds(s);
2760        let regions = tree.widget_target_regions(s);
2761        assert_eq!(regions.len(), 2, "body and knob, got {regions:?}");
2762
2763        let body = body_of(&regions);
2764        assert_eq!(
2765            body.rect, bounds,
2766            "the body region is the node's own rect, not a slice of it",
2767        );
2768        assert_eq!(
2769            body.role,
2770            teksilo_tokens::TargetRole::Target,
2771            "a press surface is a Target — a Grab would be audited against the \
2772             6 dp floor instead of the 24 dp one",
2773        );
2774
2775        // The body is the node, so it does not follow the value the way the
2776        // knob does — a report that mixed the two up is caught here.
2777        let knob = regions
2778            .iter()
2779            .find(|r| r.part == SLIDER_PART_THUMB)
2780            .expect("the slider reports its knob")
2781            .rect;
2782        assert!(
2783            body.rect.width > knob.width,
2784            "the body ({:?}) should span the whole node the knob ({knob:?}) slides along",
2785            body.rect,
2786        );
2787        value.set(90.0);
2788        assert_eq!(
2789            body_of(&tree.widget_target_regions(s)).rect,
2790            bounds,
2791            "the body stays the whole node as the value moves",
2792        );
2793    }
2794
2795    /// Every region the slider reports clears the floor its role is audited
2796    /// against, at the density CI runs at.
2797    #[test]
2798    fn the_reported_regions_clear_their_floors_at_compact() {
2799        let theme = teksilo_core::presets::intui::light();
2800        let tokens = theme.input;
2801        let mut tree = WidgetTree::new().with_theme(theme);
2802        let s = tree.add(Slider::new(Signal::new(50.0_f32), 0.0, 100.0));
2803        tree.layout(SizeProposal::exact(200.0, 40.0));
2804        for region in tree.widget_target_regions(s) {
2805            let floor = match region.role {
2806                teksilo_tokens::TargetRole::Target => tokens.min_target_conformance,
2807                teksilo_tokens::TargetRole::Grab => tokens.grab_size,
2808                teksilo_tokens::TargetRole::Decoration => continue,
2809            };
2810            let min = region.rect.width.min(region.rect.height);
2811            assert!(
2812                min >= floor,
2813                "part {} ({:?}) measured {min}, under its {floor} dp floor",
2814                region.part,
2815                region.role,
2816            );
2817        }
2818    }
2819
2820    /// A vertical slider reports its knob where the body paints it: its
2821    /// minimum is at the **bottom**.
2822    ///
2823    /// The report ran the other way, from the top, while the paint and the
2824    /// pointer mapping both put the maximum at the top, so a quarter-value
2825    /// knob was reported three quarters of the way down.
2826    #[test]
2827    fn a_vertical_slider_reports_its_knob_where_it_paints_it() {
2828        use crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER;
2829
2830        let theme = teksilo_core::presets::intui::light();
2831        let accent = theme.colors.accent.to_array();
2832        let mut tree = WidgetTree::new().with_theme(theme);
2833        let s = tree
2834            .add(Slider::new(Signal::new(25.0_f32), 0.0, 100.0).orientation(Orientation::Vertical));
2835        tree.layout(SizeProposal::exact(60.0, 200.0));
2836        let painted = tree
2837            .render()
2838            .shapes
2839            .iter()
2840            .find(|q| {
2841                q.color == accent
2842                    && (q.screen[2] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2843                    && (q.screen[3] - SLIDER_THUMB_DIAMETER).abs() < 0.01
2844            })
2845            .map(|q| q.screen[1] + q.screen[3] * 0.5)
2846            .expect("the body paints a knob");
2847        let reported = tree
2848            .widget_target_regions(s)
2849            .into_iter()
2850            .find(|r| r.part == SLIDER_PART_THUMB)
2851            .expect("the slider reports its knob")
2852            .rect
2853            .center()
2854            .y;
2855        let bounds = tree.bounds(s);
2856        assert!(
2857            painted > bounds.center().y,
2858            "a quarter-value knob is painted in the lower half ({painted})"
2859        );
2860        assert!(
2861            (reported - painted).abs() < 0.51,
2862            "the knob is reported at y {reported} and painted at y {painted}",
2863        );
2864    }
2865
2866    // -----------------------------------------------------------------
2867    // The value readout and the reset to a default
2868    // -----------------------------------------------------------------
2869
2870    use crate::primitives::{FixedSize, HStack, VStack};
2871    use std::cell::RefCell;
2872    use teksilo_i18n::lit;
2873
2874    /// The readout an equalizer band shows: the gain in decibels, signed.
2875    fn db(gain: f32) -> LocalizedString {
2876        lit!(format!("{gain:+.1} dB"))
2877    }
2878
2879    /// The window every readout test runs in.
2880    fn window() -> SizeProposal {
2881        SizeProposal::exact(400.0, 300.0)
2882    }
2883
2884    /// A tree with real-looking text metrics, so a readout has a size to
2885    /// place.
2886    fn readout_tree() -> WidgetTree {
2887        WidgetTree::new()
2888            .with_theme(teksilo_core::presets::intui::light())
2889            .with_text_backend(Rc::new(
2890                RefCell::new(teksilo_canvas::MockTextBackend::new()),
2891            ))
2892    }
2893
2894    /// `slider`, full width, 100 dp below the top of the window, with room
2895    /// above it for the readout and below it to take the pointer to.
2896    fn horizontal_scene(slider: Slider) -> (WidgetTree, WidgetId) {
2897        let mut tree = readout_tree();
2898        let s = tree.add(slider);
2899        let _column = tree.add(
2900            VStack::new()
2901                .child(FixedSize::new().height(100.0))
2902                .child(s)
2903                .child(FixedSize::new().height(100.0)),
2904        );
2905        tree.layout(window());
2906        (tree, s)
2907    }
2908
2909    /// Somewhere in the window the slider is not.
2910    fn away() -> Point {
2911        Point::new(200.0, 280.0)
2912    }
2913
2914    /// The readout up over the slider, as its text and where it sits, or
2915    /// `None` while none is.
2916    ///
2917    /// The surface is the tooltip module's `TooltipWidget`, which keeps its
2918    /// text private; its `Debug` is the one place the text can be read from
2919    /// here.
2920    fn readout(tree: &WidgetTree) -> Option<(String, Rect)> {
2921        let overlays = tree.overlay_manager();
2922        overlays
2923            .active_content_ids()
2924            .into_iter()
2925            .find_map(|content| {
2926                let debug = tree.widget_debug_string(content)?;
2927                let text = debug
2928                    .split("TooltipWidget { text: \"")
2929                    .nth(1)?
2930                    .split('"')
2931                    .next()?
2932                    .to_owned();
2933                let bounds = tree.overlay_content_bounds(overlays.find_by_content(content)?)?;
2934                Some((text, bounds))
2935            })
2936    }
2937
2938    /// The knob's rect, as the slider reports it.
2939    fn knob(tree: &WidgetTree, s: WidgetId) -> Rect {
2940        tree.widget_target_regions(s)
2941            .into_iter()
2942            .find(|r| r.part == SLIDER_PART_THUMB)
2943            .expect("the slider reports its knob")
2944            .rect
2945    }
2946
2947    /// The slider's raw AccessKit node, read through `f`.
2948    fn at_node<R>(
2949        tree: &mut WidgetTree,
2950        s: WidgetId,
2951        f: impl FnOnce(&teksilo_core::accesskit::Node) -> R,
2952    ) -> R {
2953        let update = tree.sync_accessibility();
2954        let nid = teksilo_core::accessibility::widget_id_to_node_id(s);
2955        let node = &update
2956            .nodes
2957            .iter()
2958            .find(|(id, _)| *id == nid)
2959            .expect("the slider publishes an AT node")
2960            .1;
2961        f(node)
2962    }
2963
2964    /// Two primary clicks at `at`, back to back.
2965    fn double_click(tree: &mut WidgetTree, at: Point) {
2966        tree.pointer_move(at);
2967        for _ in 0..2 {
2968            tree.pointer_down_button(at, PointerButton::Primary);
2969            tree.pointer_up_button(at, PointerButton::Primary);
2970        }
2971    }
2972
2973    /// One primary click at `at`.
2974    fn click(tree: &mut WidgetTree, at: Point) {
2975        tree.pointer_move(at);
2976        tree.pointer_down_button(at, PointerButton::Primary);
2977        tree.pointer_up_button(at, PointerButton::Primary);
2978    }
2979
2980    #[test]
2981    fn the_readout_is_up_at_once_while_the_pointer_is_over_the_slider() {
2982        let (mut tree, s) =
2983            horizontal_scene(Slider::new(Signal::new(-3.0_f32), -12.0, 12.0).value_tooltip(db));
2984        assert_eq!(
2985            readout(&tree),
2986            None,
2987            "nothing is up before the pointer comes"
2988        );
2989
2990        tree.pointer_move(tree.bounds(s).center());
2991        tree.layout(window());
2992        let (text, _) = readout(&tree).expect("up with no hover delay");
2993        assert_eq!(text, "-3.0 dB");
2994        // The slider's own node carries the text as its value, so the surface
2995        // must not carry it a second time.
2996        let listener = crate::common::heard_test::Listener::attach(&mut tree);
2997        assert!(
2998            !listener.finds(teksilo_core::accesskit::Role::Tooltip, "-3.0 dB"),
2999            "the readout is hidden from assistive technology",
3000        );
3001
3002        tree.pointer_move(away());
3003        tree.layout(window());
3004        assert_eq!(readout(&tree), None, "and gone when the pointer leaves");
3005    }
3006
3007    #[test]
3008    fn the_readout_follows_the_value_through_a_drag() {
3009        let value = Signal::new(0.0_f32);
3010        let (mut tree, s) = horizontal_scene(
3011            Slider::new(value.clone(), -12.0, 12.0)
3012                .step(0.5)
3013                .value_tooltip(db),
3014        );
3015        let bounds = tree.bounds(s);
3016        let start = knob(&tree, s).center();
3017        tree.pointer_move(start);
3018        tree.pointer_down_button(start, PointerButton::Primary);
3019        tree.layout(window());
3020        let surface = tree.overlay_manager().active_content_ids();
3021
3022        // Past the drag slop, then along the track, with the pointer taken off
3023        // the slider altogether: a drag carries on there, and so must the
3024        // readout.
3025        let mut last_x = f32::MIN;
3026        for dx in [10.0, 60.0, 120.0] {
3027            tree.pointer_move(Point::new(start.x + dx, bounds.bottom() + 40.0));
3028            tree.layout(window());
3029            let (text, rect) = readout(&tree).expect("up for the whole drag");
3030            assert_eq!(
3031                text,
3032                db(value.get()).resolve_now(),
3033                "the value as it stands"
3034            );
3035            let thumb = knob(&tree, s);
3036            assert!(
3037                (rect.center().x - thumb.center().x).abs() < 0.51,
3038                "centred on the thumb: {rect:?} over {thumb:?}",
3039            );
3040            assert!(rect.bottom() <= bounds.y, "above the slider: {rect:?}");
3041            assert!(rect.center().x >= last_x, "moving with the thumb");
3042            last_x = rect.center().x;
3043        }
3044        assert!(
3045            value.get() > 5.0,
3046            "the drag moved the value: {}",
3047            value.get()
3048        );
3049        assert_eq!(
3050            tree.overlay_manager().active_content_ids(),
3051            surface,
3052            "one surface, re-placed and re-worded, not a new one per value",
3053        );
3054
3055        // The slider holds the hover while the drag holds the pointer; the
3056        // first motion after the release gives it back.
3057        let off = Point::new(start.x + 120.0, bounds.bottom() + 40.0);
3058        tree.pointer_up_button(off, PointerButton::Primary);
3059        tree.pointer_move(off);
3060        tree.layout(window());
3061        assert_eq!(
3062            readout(&tree),
3063            None,
3064            "the drag is over and the pointer is not on the slider"
3065        );
3066    }
3067
3068    #[test]
3069    fn the_readout_follows_the_arrows_under_keyboard_focus() {
3070        let value = Signal::new(0.0_f32);
3071        let mut tree = readout_tree();
3072        let s = tree.add(
3073            Slider::new(value.clone(), -12.0, 12.0)
3074                .step(1.0)
3075                .value_tooltip(db),
3076        );
3077        let next = tree.add(crate::button::Button::new(lit!("Next")));
3078        let _column = tree.add(
3079            VStack::new()
3080                .child(FixedSize::new().height(100.0))
3081                .child(s)
3082                .child(next),
3083        );
3084        tree.layout(window());
3085
3086        tree.press_key(Key::Tab, Modifiers::NONE);
3087        assert_eq!(tree.focused(), Some(s));
3088        tree.layout(window());
3089        let (text, mut at) = readout(&tree).expect("keyboard focus raises the readout");
3090        assert_eq!(text, "+0.0 dB");
3091
3092        for said in ["+1.0 dB", "+2.0 dB"] {
3093            tree.press_key(Key::ArrowRight, Modifiers::NONE);
3094            tree.layout(window());
3095            let (text, rect) = readout(&tree).expect("still up");
3096            assert_eq!(text, said);
3097            assert!(
3098                rect.center().x > at.center().x,
3099                "it moved with the thumb: {rect:?} after {at:?}",
3100            );
3101            at = rect;
3102        }
3103
3104        tree.press_key(Key::Tab, Modifiers::NONE);
3105        assert_eq!(tree.focused(), Some(next));
3106        tree.layout(window());
3107        assert_eq!(readout(&tree), None, "focus leaving takes it down");
3108    }
3109
3110    #[test]
3111    fn a_clicked_slider_shows_its_readout_again_on_the_first_key() {
3112        // A click focuses the slider without making it keyboard focus, so the
3113        // readout goes with the pointer, as the focus ring stays hidden, and
3114        // comes back with the key that adjusts the value.
3115        let value = Signal::new(0.0_f32);
3116        let (mut tree, s) = horizontal_scene(
3117            Slider::new(value.clone(), -12.0, 12.0)
3118                .step(1.0)
3119                .value_tooltip(db),
3120        );
3121        let thumb = knob(&tree, s).center();
3122        tree.pointer_move(thumb);
3123        tree.pointer_down_button(thumb, PointerButton::Primary);
3124        tree.pointer_up_button(thumb, PointerButton::Primary);
3125        tree.pointer_move(away());
3126        tree.layout(window());
3127        assert_eq!(tree.focused(), Some(s), "the click focused the slider");
3128        assert_eq!(readout(&tree), None, "but not from the keyboard");
3129
3130        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3131        tree.layout(window());
3132        assert_eq!(
3133            readout(&tree).map(|(text, _)| text).as_deref(),
3134            Some("+1.0 dB")
3135        );
3136    }
3137
3138    #[test]
3139    fn escape_takes_the_readout_down_until_the_next_key() {
3140        // Content shown on focus has to be dismissible without moving focus
3141        // (WCAG 2.2 SC 1.4.13).
3142        let mut tree = readout_tree();
3143        let s = tree.add(
3144            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3145                .step(1.0)
3146                .value_tooltip(db),
3147        );
3148        tree.layout(window());
3149        tree.press_key(Key::Tab, Modifiers::NONE);
3150        tree.layout(window());
3151        assert!(readout(&tree).is_some());
3152
3153        tree.press_key(Key::Escape, Modifiers::NONE);
3154        tree.layout(window());
3155        assert_eq!(readout(&tree), None, "Escape dismisses it");
3156        assert_eq!(tree.focused(), Some(s), "and leaves focus where it was");
3157
3158        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3159        tree.layout(window());
3160        assert!(readout(&tree).is_some(), "the next key brings it back");
3161    }
3162
3163    #[test]
3164    fn a_horizontal_readout_goes_below_the_thumb_against_the_top_of_the_window() {
3165        let mut tree = readout_tree();
3166        let s = tree.add(Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db));
3167        let _column = tree.add(VStack::new().child(s).child(FixedSize::new().height(200.0)));
3168        tree.layout(window());
3169        let bounds = tree.bounds(s);
3170        assert!(bounds.y < 1.0, "the slider is at the top: {bounds:?}");
3171
3172        tree.pointer_move(bounds.center());
3173        tree.layout(window());
3174        let (_, rect) = readout(&tree).expect("up");
3175        assert!(
3176            rect.y >= bounds.bottom(),
3177            "no room above, so below the slider: {rect:?} under {bounds:?}",
3178        );
3179    }
3180
3181    #[test]
3182    fn a_vertical_readout_sits_beside_the_slider_and_rides_its_thumb() {
3183        let value = Signal::new(0.0_f32);
3184        let mut tree = readout_tree();
3185        let s = tree.add(
3186            Slider::new(value.clone(), -12.0, 12.0)
3187                .step(1.0)
3188                .orientation(Orientation::Vertical)
3189                .value_tooltip(db),
3190        );
3191        let _row = tree.add(
3192            HStack::new()
3193                .child(FixedSize::new().width(150.0))
3194                .child(FixedSize::new().height(200.0).child(s)),
3195        );
3196        tree.layout(window());
3197        let bounds = tree.bounds(s);
3198
3199        tree.pointer_move(knob(&tree, s).center());
3200        tree.layout(window());
3201        let (_, low) = readout(&tree).expect("up");
3202        assert!(
3203            low.right() <= bounds.x,
3204            "on the inline-start side, clear of the slider: {low:?} beside {bounds:?}",
3205        );
3206        let thumb = knob(&tree, s);
3207        assert!(
3208            low.y < thumb.bottom() + 8.0 && low.bottom() > thumb.y,
3209            "level with the thumb rather than somewhere along the track: {low:?} by {thumb:?}",
3210        );
3211
3212        // The pointer is still on the slider; the key moves the value under it.
3213        tree.focus(s);
3214        tree.press_key(Key::ArrowUp, Modifiers::NONE);
3215        tree.layout(window());
3216        let (_, high) = readout(&tree).expect("still up");
3217        assert!(
3218            high.y < low.y,
3219            "a higher value raises the thumb, and the readout with it: {high:?} after {low:?}",
3220        );
3221    }
3222
3223    #[test]
3224    fn the_readout_text_is_the_accessible_value() {
3225        use crate::common::heard_test::{Heard, Listener};
3226
3227        let value = Signal::new(-3.0_f32);
3228        let mut tree = WidgetTree::new();
3229        let s = tree.add(
3230            Slider::new(value.clone(), -12.0, 12.0)
3231                .step(0.5)
3232                .label(lit!("Low shelf"))
3233                .value_tooltip(db),
3234        );
3235        let p = SizeProposal::exact(200.0, 60.0);
3236        tree.layout(p);
3237        assert_eq!(
3238            at_node(&mut tree, s, |n| (
3239                n.value().map(str::to_owned),
3240                n.numeric_value()
3241            )),
3242            (Some("-3.0 dB".to_owned()), Some(-3.0)),
3243            "the text beside the number",
3244        );
3245
3246        tree.focus(s);
3247        tree.layout(p);
3248        let mut listener = Listener::attach(&mut tree);
3249        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3250        tree.layout(p);
3251        assert_eq!(
3252            listener.heard(&mut tree),
3253            vec![Heard::FocusValue("-2.5 dB".to_owned())],
3254        );
3255    }
3256
3257    #[test]
3258    fn a_double_click_resets_to_the_default_and_reports_it() {
3259        // Two clicks are a double-click by the mouse's own multi-tap rule, the
3260        // one `TapStreak` reads: within its interval, and within its slop, of
3261        // the click before. Each half of the rule is broken once below.
3262        let theme = teksilo_core::presets::intui::light();
3263        let mouse = theme.input.profile(teksilo_tokens::PointerKind::Mouse);
3264        let (interval, slop) = (mouse.multi_tap_interval, mouse.multi_tap_slop);
3265        let scene = || {
3266            let value = Signal::new(6.0_f32);
3267            let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3268            let sink = seen.clone();
3269            let (tree, s) = horizontal_scene(
3270                Slider::new(value.clone(), -12.0, 12.0)
3271                    .default_value(0.0)
3272                    .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3273            );
3274            let bounds = tree.bounds(s);
3275            let first = Point::new(bounds.x + bounds.width * 0.8, bounds.center().y);
3276            (tree, value, seen, first)
3277        };
3278
3279        // A real one: the second click a little later than the first and a
3280        // little off it, inside both limits.
3281        let (mut tree, value, seen, first) = scene();
3282        click(&mut tree, first);
3283        tree.advance_time(interval / 2);
3284        click(&mut tree, Point::new(first.x + slop / 2.0, first.y));
3285        assert_eq!(value.get(), 0.0, "the double-click put the default back");
3286        let reported = seen.borrow().clone();
3287        assert_eq!(
3288            reported.len(),
3289            2,
3290            "the first click's jump, then the reset: {reported:?}"
3291        );
3292        assert_eq!(
3293            reported.last(),
3294            Some(&0.0),
3295            "the reset is reported, and last"
3296        );
3297        // Nothing is left pressed or dragging behind it.
3298        tree.assert_no_leaked_pointer_state();
3299
3300        // Too slow: the second click is a click of its own, and jumps to
3301        // where the first one already put the value.
3302        let (mut tree, value, seen, first) = scene();
3303        click(&mut tree, first);
3304        let jumped = value.get();
3305        tree.advance_time(interval + std::time::Duration::from_millis(1));
3306        click(&mut tree, first);
3307        assert_eq!(value.get(), jumped, "two slow clicks are two clicks");
3308        assert_eq!(seen.borrow().len(), 1, "and nothing was reset");
3309
3310        // Too far apart: the second click jumps to where it landed.
3311        let (mut tree, value, seen, first) = scene();
3312        click(&mut tree, first);
3313        let jumped = value.get();
3314        tree.advance_time(interval / 2);
3315        click(&mut tree, Point::new(first.x - slop * 4.0, first.y));
3316        assert!(
3317            value.get() < jumped && value.get() != 0.0,
3318            "a click outside the slop jumps rather than resets: {} after {jumped}",
3319            value.get()
3320        );
3321        assert!(
3322            seen.borrow().iter().all(|v| *v != 0.0),
3323            "and nothing was reset: {:?}",
3324            seen.borrow()
3325        );
3326    }
3327
3328    #[test]
3329    fn an_applications_custom_action_keeps_the_reset_beside_it() {
3330        use teksilo_core::accesskit::{Action, ActionData};
3331        use teksilo_core::widget_builder::WidgetBuilder;
3332
3333        let value = Signal::new(6.0_f32);
3334        let pinned = Rc::new(Cell::new(0_u32));
3335        let sink = pinned.clone();
3336        let mut tree = WidgetTree::new();
3337        let s = tree.add(
3338            Slider::new(value.clone(), -12.0, 12.0)
3339                .default_value(0.0)
3340                .access_custom_action(lit!("Pin band"), move |_ctx| sink.set(sink.get() + 1)),
3341        );
3342        tree.layout(SizeProposal::exact(200.0, 60.0));
3343        let offered = at_node(&mut tree, s, |n| {
3344            n.custom_actions()
3345                .iter()
3346                .map(|a| (a.id, a.description.clone()))
3347                .collect::<Vec<_>>()
3348        });
3349        assert_eq!(
3350            offered,
3351            vec![
3352                (RESET_ACTION_ID, "Reset to default".to_owned()),
3353                (0, "Pin band".to_owned()),
3354            ],
3355        );
3356
3357        assert!(access(
3358            &mut tree,
3359            s,
3360            Action::CustomAction,
3361            Some(ActionData::CustomAction(RESET_ACTION_ID)),
3362        ));
3363        assert_eq!(
3364            (value.get(), pinned.get()),
3365            (0.0, 0),
3366            "the reset is the slider's"
3367        );
3368        value.set(6.0);
3369        assert!(access(
3370            &mut tree,
3371            s,
3372            Action::CustomAction,
3373            Some(ActionData::CustomAction(0)),
3374        ));
3375        assert_eq!(
3376            (value.get(), pinned.get()),
3377            (6.0, 1),
3378            "and the application's is its own"
3379        );
3380    }
3381
3382    #[test]
3383    fn the_reset_action_resets_and_reports() {
3384        use teksilo_core::accesskit::{Action, ActionData};
3385
3386        let value = Signal::new(6.0_f32);
3387        let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3388        let sink = seen.clone();
3389        let mut tree = WidgetTree::new();
3390        let s = tree.add(
3391            Slider::new(value.clone(), -12.0, 12.0)
3392                .default_value(0.0)
3393                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3394        );
3395        tree.layout(SizeProposal::exact(200.0, 60.0));
3396
3397        let (advertised, offered) = at_node(&mut tree, s, |n| {
3398            (
3399                n.supports_action(Action::CustomAction),
3400                n.custom_actions()
3401                    .iter()
3402                    .map(|a| (a.id, a.description.clone()))
3403                    .collect::<Vec<_>>(),
3404            )
3405        });
3406        assert!(
3407            advertised,
3408            "the custom action is reachable only when advertised"
3409        );
3410        assert_eq!(
3411            offered,
3412            vec![(RESET_ACTION_ID, "Reset to default".to_owned())]
3413        );
3414
3415        assert!(access(
3416            &mut tree,
3417            s,
3418            Action::CustomAction,
3419            Some(ActionData::CustomAction(RESET_ACTION_ID)),
3420        ));
3421        assert_eq!(value.get(), 0.0);
3422        assert_eq!(*seen.borrow(), vec![0.0], "reported as a user change");
3423    }
3424
3425    #[test]
3426    fn the_reset_action_is_named_in_the_users_language() {
3427        // From the widget catalogue, and resolved at each walk, so a locale
3428        // switch renames it.
3429        use crate::common::locale_switch_test::speaking;
3430
3431        let (manager, mut tree) = speaking("en-US");
3432        let s = tree.add(Slider::new(Signal::new(6.0_f32), -12.0, 12.0).default_value(0.0));
3433        let p = SizeProposal::exact(200.0, 60.0);
3434        tree.layout(p);
3435        let name = |tree: &mut WidgetTree| {
3436            at_node(tree, s, |n| {
3437                n.custom_actions()
3438                    .iter()
3439                    .find(|a| a.id == RESET_ACTION_ID)
3440                    .map(|a| a.description.clone())
3441            })
3442        };
3443        assert_eq!(name(&mut tree).as_deref(), Some("Reset to default"));
3444
3445        manager.set_locale("fr-FR".parse().unwrap());
3446        tree.set_locale("fr-FR".to_string());
3447        tree.layout(p);
3448        assert_eq!(
3449            name(&mut tree).as_deref(),
3450            Some("Rétablir la valeur par défaut")
3451        );
3452        teksilo_i18n::thread_local::clear();
3453    }
3454
3455    #[test]
3456    fn a_disabled_slider_neither_resets_nor_shows_a_readout() {
3457        use teksilo_core::accesskit::{Action, ActionData};
3458
3459        let value = Signal::new(6.0_f32);
3460        let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3461        let sink = seen.clone();
3462        let (mut tree, s) = horizontal_scene(
3463            Slider::new(value.clone(), -12.0, 12.0)
3464                .enabled(false)
3465                .default_value(0.0)
3466                .value_tooltip(db)
3467                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3468        );
3469        let bounds = tree.bounds(s);
3470
3471        tree.pointer_move(bounds.center());
3472        tree.layout(window());
3473        assert_eq!(readout(&tree), None, "no readout over a disabled slider");
3474
3475        double_click(
3476            &mut tree,
3477            Point::new(bounds.x + bounds.width * 0.8, bounds.center().y),
3478        );
3479        let _ = access(
3480            &mut tree,
3481            s,
3482            Action::CustomAction,
3483            Some(ActionData::CustomAction(RESET_ACTION_ID)),
3484        );
3485        assert_eq!(
3486            value.get(),
3487            6.0,
3488            "neither the double-click nor the action reset it"
3489        );
3490        assert!(seen.borrow().is_empty(), "and nothing was reported");
3491    }
3492
3493    #[test]
3494    fn disabling_a_slider_under_the_pointer_takes_its_readout_down() {
3495        // No slider handler runs when the application disables it, so this is
3496        // the surface's own gate at work.
3497        let enabled = Signal::new(true);
3498        let (mut tree, s) = horizontal_scene(
3499            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3500                .enabled(enabled.clone())
3501                .value_tooltip(db),
3502        );
3503        tree.pointer_move(tree.bounds(s).center());
3504        tree.layout(window());
3505        assert!(readout(&tree).is_some());
3506
3507        enabled.set(false);
3508        tree.layout(window());
3509        tree.layout(window());
3510        assert_eq!(readout(&tree), None, "disabled under the pointer");
3511        assert!(
3512            tree.active_overlays().is_empty(),
3513            "with no overlay left behind"
3514        );
3515
3516        enabled.set(true);
3517        tree.layout(window());
3518        assert!(
3519            tree.active_overlays().is_empty(),
3520            "re-enabling raises nothing until the next hover, drag or key",
3521        );
3522    }
3523
3524    /// The `on_pointer_cancel` path, and only that one: the tree takes a
3525    /// revoked press back by cancelling the recognizers silently and then
3526    /// delivering a `PointerCancel` for the pointer, so `on_drag` never hears a
3527    /// `Cancelled` here. With the dragging pointer's cancel ignored this fails;
3528    /// with the `DragPhase::Cancelled` arm removed it passes. Its twin,
3529    /// `another_contacts_cancel_leaves_a_live_drag_running`, holds the same
3530    /// handler to the dragging pointer alone.
3531    #[test]
3532    fn a_cancelled_touch_drag_leaves_no_readout() {
3533        // A contact never hovers, so the drag alone held the readout up.
3534        let value = Signal::new(0.0_f32);
3535        let (mut tree, s) =
3536            horizontal_scene(Slider::new(value.clone(), -12.0, 12.0).value_tooltip(db));
3537        let start = knob(&tree, s).center();
3538        let finger = tree.new_contact();
3539        tree.touch_down(finger, start);
3540        for dx in [30.0, 60.0] {
3541            tree.touch_move(finger, Point::new(start.x + dx, start.y));
3542        }
3543        tree.layout(window());
3544        assert!(
3545            readout(&tree).is_some(),
3546            "a finger's drag shows the readout"
3547        );
3548
3549        tree.touch_cancel(finger, Point::new(start.x + 60.0, start.y));
3550        tree.layout(window());
3551        assert_eq!(readout(&tree), None, "and a cancel ends the drag");
3552        tree.assert_no_leaked_pointer_state();
3553    }
3554
3555    #[test]
3556    fn a_slider_without_a_readout_or_a_default_is_as_it_was() {
3557        let value = Signal::new(50.0_f32);
3558        let (mut tree, s) = horizontal_scene(Slider::new(value.clone(), 0.0, 100.0));
3559        let bounds = tree.bounds(s);
3560
3561        tree.pointer_move(bounds.center());
3562        tree.focus(s);
3563        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3564        tree.layout(window());
3565        assert!(
3566            tree.active_overlays().is_empty(),
3567            "no overlay on hover or key"
3568        );
3569        let (text, custom) = at_node(&mut tree, s, |n| {
3570            (
3571                n.value().map(str::to_owned),
3572                n.supports_action(teksilo_core::accesskit::Action::CustomAction),
3573            )
3574        });
3575        assert_eq!(text, None, "no value text: a reader hears the number");
3576        assert!(!custom, "and no reset action");
3577
3578        // A double-click is two clicks on the track, and the value stays where
3579        // they landed.
3580        double_click(
3581            &mut tree,
3582            Point::new(bounds.x + bounds.width * 0.75, bounds.center().y),
3583        );
3584        assert!(
3585            (value.get() - 75.0).abs() < 2.0,
3586            "two clicks at three quarters leave the value there: {}",
3587            value.get()
3588        );
3589    }
3590
3591    // -----------------------------------------------------------------
3592    // What the readout must not do to the rest of the window
3593    // -----------------------------------------------------------------
3594
3595    /// `slider` in a column, with `above` and `below` around it and room for
3596    /// the readout above them.
3597    fn between(
3598        above: WidgetId,
3599        slider: Slider,
3600        below: WidgetId,
3601        tree: &mut WidgetTree,
3602    ) -> WidgetId {
3603        let s = tree.add(slider);
3604        let _column = tree.add(
3605            VStack::new()
3606                .child(FixedSize::new().height(100.0))
3607                .child(above)
3608                .child(s)
3609                .child(below)
3610                .child(FixedSize::new().height(60.0)),
3611        );
3612        tree.layout(window());
3613        s
3614    }
3615
3616    #[test]
3617    fn escape_leaves_focus_on_the_slider_the_click_focused() {
3618        // The readout never takes focus, so taking it down has none to give
3619        // back: not to the field that held focus when the pointer arrived.
3620        let mut tree = readout_tree();
3621        let field = tree.add(crate::button::Button::new(lit!("Field")));
3622        let next = tree.add(crate::button::Button::new(lit!("Next")));
3623        let s = between(
3624            field,
3625            Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db),
3626            next,
3627            &mut tree,
3628        );
3629        tree.focus(field);
3630
3631        let thumb = knob(&tree, s).center();
3632        tree.pointer_move(thumb);
3633        tree.layout(window());
3634        assert!(readout(&tree).is_some(), "the pointer raised it");
3635        tree.pointer_down_button(thumb, PointerButton::Primary);
3636        tree.pointer_up_button(thumb, PointerButton::Primary);
3637        tree.layout(window());
3638        assert_eq!(tree.focused(), Some(s), "the click focused the slider");
3639        assert!(readout(&tree).is_some(), "and the pointer keeps it up");
3640
3641        tree.press_key(Key::Escape, Modifiers::NONE);
3642        tree.layout(window());
3643        assert_eq!(readout(&tree), None, "Escape took it down");
3644        assert_eq!(
3645            tree.focused(),
3646            Some(s),
3647            "and left focus where the click put it"
3648        );
3649    }
3650
3651    #[test]
3652    fn a_press_and_a_hover_on_the_readout_reach_what_is_under_it() {
3653        use teksilo_core::widget_builder::WidgetBuilder;
3654
3655        let taps = Rc::new(Cell::new(0_u32));
3656        let count = taps.clone();
3657        let mut tree = readout_tree();
3658        let under = tree.add(
3659            FixedSize::new()
3660                .width(400.0)
3661                .height(40.0)
3662                .on_tap(move |_e, _c| count.set(count.get() + 1)),
3663        );
3664        let below = tree.add(FixedSize::new().height(10.0));
3665        let s = between(
3666            under,
3667            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3668                .step(1.0)
3669                .value_tooltip(db),
3670            below,
3671            &mut tree,
3672        );
3673        tree.focus(s);
3674        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3675        tree.layout(window());
3676        let (_, bubble) = readout(&tree).expect("up from the keyboard");
3677        let at = bubble.center();
3678        assert!(
3679            tree.bounds(under).contains(at),
3680            "fixture: the bubble hangs over the target, {bubble:?} over {:?}",
3681            tree.bounds(under),
3682        );
3683
3684        tree.pointer_move(at);
3685        assert_eq!(tree.hovered(), Some(under), "the hover is the target's");
3686        tree.pointer_down_button(at, PointerButton::Primary);
3687        tree.pointer_up_button(at, PointerButton::Primary);
3688        assert_eq!(taps.get(), 1, "and so is the press");
3689    }
3690
3691    #[test]
3692    fn a_press_on_the_readout_still_closes_a_popover_it_is_outside_of() {
3693        use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
3694
3695        let mut tree = readout_tree();
3696        let above = tree.add(FixedSize::new().height(40.0));
3697        let trigger = tree.add(FixedSize::new().width(60.0).height(20.0));
3698        let s = between(
3699            above,
3700            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3701                .step(1.0)
3702                .value_tooltip(db),
3703            trigger,
3704            &mut tree,
3705        );
3706        // Below its trigger, which is below the slider: nowhere near the
3707        // bubble, which goes above the slider.
3708        let panel = tree.add(FixedSize::new().width(120.0).height(30.0));
3709        tree.show_overlay(OverlayRequest::new(
3710            panel,
3711            trigger,
3712            OverlayPlacement::Below,
3713            DismissBehavior::ClickOutside,
3714        ));
3715        tree.layout(window());
3716        // Raised after the popover, so it sits above it on the stack.
3717        tree.focus(s);
3718        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3719        tree.layout(window());
3720        let (_, bubble) = readout(&tree).expect("up from the keyboard");
3721        let popover = tree
3722            .overlay_manager()
3723            .find_by_content(panel)
3724            .expect("the popover is up");
3725        let popover_bounds = tree.overlay_content_bounds(popover).expect("and laid out");
3726        assert!(
3727            !popover_bounds.contains(bubble.center()),
3728            "fixture: the press lands outside the popover"
3729        );
3730
3731        tree.pointer_down_button(bubble.center(), PointerButton::Primary);
3732        tree.pointer_up_button(bubble.center(), PointerButton::Primary);
3733        tree.layout(window());
3734        assert_eq!(
3735            tree.overlay_manager().find_by_content(panel),
3736            None,
3737            "a press outside the popover closes it, bubble or no bubble",
3738        );
3739    }
3740
3741    #[test]
3742    fn a_readout_raised_by_tab_follows_the_scroll_that_reveals_the_slider() {
3743        let mut tree = readout_tree();
3744        let s = tree.add(Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db));
3745        let page = tree.add(
3746            VStack::new()
3747                .child(FixedSize::new().height(600.0))
3748                .child(s)
3749                .child(FixedSize::new().height(60.0)),
3750        );
3751        let _scroller = tree.add(crate::scroll_area::ScrollArea::new().child(page));
3752        tree.layout(window());
3753        assert!(
3754            tree.bounds(s).y > 300.0,
3755            "fixture: the slider starts below the fold"
3756        );
3757
3758        tree.press_key(Key::Tab, Modifiers::NONE);
3759        assert_eq!(tree.focused(), Some(s));
3760        tree.layout(window());
3761        tree.layout(window());
3762        let slider = tree.bounds(s);
3763        assert!(
3764            slider.bottom() <= 300.0,
3765            "fixture: Tab scrolled it into view, {slider:?}"
3766        );
3767        let (_, bubble) = readout(&tree).expect("Tab raised it");
3768        let thumb = knob(&tree, s);
3769        assert!(
3770            (bubble.center().x - thumb.center().x).abs() < 0.51,
3771            "centred on the thumb: {bubble:?} over {thumb:?}",
3772        );
3773        assert!(
3774            bubble.bottom() <= slider.y && bubble.bottom() > slider.y - 24.0,
3775            "just above the slider where the scroll put it: {bubble:?} over {slider:?}",
3776        );
3777    }
3778
3779    #[test]
3780    fn the_readout_follows_the_thumb_when_the_window_resizes() {
3781        let value = Signal::new(6.0_f32);
3782        let (mut tree, s) = horizontal_scene(
3783            Slider::new(value.clone(), -12.0, 12.0)
3784                .step(1.0)
3785                .value_tooltip(db),
3786        );
3787        tree.focus(s);
3788        tree.press_key(Key::ArrowRight, Modifiers::NONE);
3789        tree.layout(window());
3790        let (_, wide) = readout(&tree).expect("up from the keyboard");
3791
3792        let narrow = SizeProposal::exact(250.0, 300.0);
3793        tree.layout(narrow);
3794        tree.layout(narrow);
3795        let (_, after) = readout(&tree).expect("still up");
3796        let thumb = knob(&tree, s);
3797        assert!(
3798            after.center().x < wide.center().x - 50.0,
3799            "fixture: the thumb moved with the width, {after:?} after {wide:?}"
3800        );
3801        assert!(
3802            (after.center().x - thumb.center().x).abs() < 0.51,
3803            "centred on the thumb where it is now: {after:?} over {thumb:?}",
3804        );
3805
3806        // A value the application writes moves it too, with no handler run.
3807        value.set(-6.0);
3808        tree.layout(narrow);
3809        let (text, written) = readout(&tree).expect("still up");
3810        let thumb = knob(&tree, s);
3811        assert_eq!(text, "-6.0 dB");
3812        assert!(
3813            (written.center().x - thumb.center().x).abs() < 0.51,
3814            "over the thumb the write moved: {written:?} over {thumb:?}",
3815        );
3816    }
3817
3818    #[test]
3819    fn a_hover_the_slider_was_never_told_ended_does_not_hold_its_readout() {
3820        use teksilo_core::accesskit::Action;
3821
3822        let enabled = Signal::new(true);
3823        let mut tree = readout_tree();
3824        let above = tree.add(FixedSize::new().height(10.0));
3825        let next = tree.add(crate::button::Button::new(lit!("Next")));
3826        let s = between(
3827            above,
3828            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3829                .step(1.0)
3830                .enabled(enabled.clone())
3831                .value_tooltip(db),
3832            next,
3833            &mut tree,
3834        );
3835        tree.pointer_move(tree.bounds(s).center());
3836        tree.layout(window());
3837        assert!(readout(&tree).is_some());
3838
3839        // Disabled under the pointer, which then leaves: a disabled slider
3840        // hears no `PointerLeave`.
3841        enabled.set(false);
3842        tree.layout(window());
3843        tree.pointer_move(away());
3844        enabled.set(true);
3845        tree.layout(window());
3846        tree.layout(window());
3847        assert_eq!(readout(&tree), None, "nothing is hovering it");
3848
3849        assert!(access(&mut tree, s, Action::Increment, None));
3850        tree.layout(window());
3851        assert_eq!(
3852            readout(&tree),
3853            None,
3854            "an assistive step on a slider nobody is hovering raises nothing",
3855        );
3856
3857        tree.press_key(Key::Tab, Modifiers::NONE);
3858        assert_eq!(tree.focused(), Some(s));
3859        tree.layout(window());
3860        assert!(readout(&tree).is_some(), "keyboard focus raises it");
3861        tree.press_key(Key::Tab, Modifiers::NONE);
3862        assert_eq!(tree.focused(), Some(next));
3863        tree.layout(window());
3864        assert_eq!(
3865            readout(&tree),
3866            None,
3867            "and with focus gone and nothing hovering it, it goes",
3868        );
3869    }
3870
3871    #[test]
3872    fn escape_takes_a_hover_readout_down_and_still_reaches_the_focused_field() {
3873        // A tooltip does not spend the key, and neither does the readout: the
3874        // user pressed Escape for the field they are typing in.
3875        use teksilo_core::event::EventResponse;
3876        use teksilo_core::widget_builder::WidgetBuilder;
3877
3878        let heard = Rc::new(Cell::new(0_u32));
3879        let sink = heard.clone();
3880        let mut tree = readout_tree();
3881        let above = tree.add(FixedSize::new().height(10.0));
3882        let field = tree.add(
3883            FixedSize::new()
3884                .width(100.0)
3885                .height(24.0)
3886                .focusable(true)
3887                .on_key(move |event, _ctx| match event {
3888                    WidgetEvent::KeyDown {
3889                        key: Key::Escape, ..
3890                    } => {
3891                        sink.set(sink.get() + 1);
3892                        EventResponse::Handled
3893                    }
3894                    _ => EventResponse::Ignored,
3895                }),
3896        );
3897        let s = between(
3898            above,
3899            Slider::new(Signal::new(0.0_f32), -12.0, 12.0).value_tooltip(db),
3900            field,
3901            &mut tree,
3902        );
3903        tree.focus(field);
3904        tree.pointer_move(knob(&tree, s).center());
3905        tree.layout(window());
3906        assert!(readout(&tree).is_some(), "the pointer raised it");
3907
3908        tree.press_key(Key::Escape, Modifiers::NONE);
3909        tree.layout(window());
3910        assert_eq!(readout(&tree), None, "Escape took the readout down");
3911        assert_eq!(heard.get(), 1, "and the field heard the Escape too");
3912        assert_eq!(tree.focused(), Some(field), "with focus left alone");
3913    }
3914
3915    #[test]
3916    fn clicks_after_the_second_in_a_burst_leave_the_default_alone() {
3917        let value = Signal::new(6.0_f32);
3918        let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
3919        let sink = seen.clone();
3920        let (mut tree, s) = horizontal_scene(
3921            Slider::new(value.clone(), -12.0, 12.0)
3922                .default_value(0.0)
3923                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
3924        );
3925        let bounds = tree.bounds(s);
3926        let at = Point::new(bounds.x + bounds.width * 0.8, bounds.center().y);
3927        tree.pointer_move(at);
3928        // Five clicks in one burst. `TapStreak` stops counting at three and
3929        // starts again at one on the fourth, so the third and the fourth are
3930        // the two that used to jump.
3931        for click in 1..=5 {
3932            tree.pointer_down_button(at, PointerButton::Primary);
3933            tree.pointer_up_button(at, PointerButton::Primary);
3934            if click >= 2 {
3935                assert_eq!(
3936                    value.get(),
3937                    0.0,
3938                    "click {click} of the burst left the default where it was"
3939                );
3940            }
3941            tree.advance_time(std::time::Duration::from_millis(100));
3942        }
3943        let seen = seen.borrow().clone();
3944        assert_eq!(seen.len(), 2, "one jump and one reset: {seen:?}");
3945        assert_eq!(seen[1], 0.0);
3946    }
3947
3948    #[test]
3949    fn focus_leaving_a_hovered_slider_keeps_its_readout_in_both_orientations() {
3950        for orientation in [Orientation::Horizontal, Orientation::Vertical] {
3951            let mut tree = readout_tree();
3952            let s = tree.add(
3953                Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
3954                    .orientation(orientation)
3955                    .value_tooltip(db),
3956            );
3957            let next = tree.add(crate::button::Button::new(lit!("Next")));
3958            let sized = match orientation {
3959                Orientation::Horizontal => FixedSize::new().width(160.0).child(s),
3960                Orientation::Vertical => FixedSize::new().height(150.0).child(s),
3961            };
3962            let _root = tree.add(
3963                VStack::new().child(FixedSize::new().height(80.0)).child(
3964                    HStack::new()
3965                        .child(FixedSize::new().width(120.0))
3966                        .child(sized)
3967                        .child(next),
3968                ),
3969            );
3970            tree.layout(window());
3971            tree.press_key(Key::Tab, Modifiers::NONE);
3972            assert_eq!(tree.focused(), Some(s));
3973            tree.pointer_move(knob(&tree, s).center());
3974            tree.layout(window());
3975            assert!(readout(&tree).is_some(), "{orientation:?}: up");
3976
3977            tree.press_key(Key::Tab, Modifiers::NONE);
3978            assert_eq!(tree.focused(), Some(next));
3979            tree.layout(window());
3980            assert!(
3981                readout(&tree).is_some(),
3982                "{orientation:?}: focus left, but the pointer is still on it",
3983            );
3984        }
3985    }
3986
3987    #[test]
3988    fn another_contacts_cancel_leaves_a_live_drag_running() {
3989        let value = Signal::new(0.0_f32);
3990        let (mut tree, s) = horizontal_scene(Slider::new(value.clone(), 0.0, 100.0));
3991        let bounds = tree.bounds(s);
3992        let start = Point::new(bounds.x + 40.0, bounds.center().y);
3993        let dragging = tree.new_contact();
3994        tree.touch_down(dragging, start);
3995        for dx in [30.0, 60.0] {
3996            tree.touch_move(dragging, Point::new(start.x + dx, start.y));
3997        }
3998        let held = value.get();
3999        assert!(held > 0.0, "fixture: the drag is live");
4000
4001        let other = tree.new_contact();
4002        let elsewhere = Point::new(bounds.x + 300.0, bounds.center().y);
4003        tree.touch_down(other, elsewhere);
4004        tree.touch_cancel(other, elsewhere);
4005
4006        tree.touch_move(dragging, Point::new(start.x + 150.0, start.y));
4007        assert!(
4008            value.get() > held,
4009            "the first finger's drag carried on: {} after {held}",
4010            value.get()
4011        );
4012        tree.touch_up(dragging, Point::new(start.x + 150.0, start.y));
4013        tree.assert_no_leaked_pointer_state();
4014    }
4015
4016    #[test]
4017    fn a_disabled_slider_does_not_offer_its_reset() {
4018        use teksilo_core::accesskit::Action;
4019
4020        let enabled = Signal::new(true);
4021        let mut tree = WidgetTree::new();
4022        let s = tree.add(
4023            Slider::new(Signal::new(6.0_f32), -12.0, 12.0)
4024                .enabled(enabled.clone())
4025                .default_value(0.0),
4026        );
4027        let p = SizeProposal::exact(200.0, 60.0);
4028        let offered = |tree: &mut WidgetTree| {
4029            at_node(tree, s, |n| {
4030                (
4031                    n.supports_action(Action::CustomAction),
4032                    n.custom_actions().iter().any(|a| a.id == RESET_ACTION_ID),
4033                )
4034            })
4035        };
4036        tree.layout(p);
4037        assert_eq!(offered(&mut tree), (true, true));
4038
4039        enabled.set(false);
4040        tree.layout(p);
4041        assert_eq!(
4042            offered(&mut tree),
4043            (false, false),
4044            "an action that would do nothing is not offered"
4045        );
4046
4047        enabled.set(true);
4048        tree.layout(p);
4049        assert_eq!(
4050            offered(&mut tree),
4051            (true, true),
4052            "and comes back with the slider"
4053        );
4054    }
4055
4056    // -----------------------------------------------------------------
4057    // An application's tap mask, `jump_on_press`, readout placement/style
4058    // -----------------------------------------------------------------
4059
4060    /// A slider 0..10 at 5, 200 dp wide, with `wrap` applied to it, and the
4061    /// points a quarter and three quarters along it.
4062    fn tap_mask_scene(
4063        build: impl FnOnce(Slider) -> Box<dyn Widget>,
4064    ) -> (WidgetTree, Signal<f32>, Point, Point) {
4065        let value = Signal::new(5.0_f32);
4066        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4067        let s = tree.add_boxed(build(Slider::new(value.clone(), 0.0, 10.0)));
4068        let _row = tree.add(FixedSize::new().width(200.0).height(40.0).child(s));
4069        tree.layout(SizeProposal::exact(400.0, 100.0));
4070        let b = tree.bounds(s);
4071        let at = |f: f32| Point::new(b.x + b.width * f, b.center().y);
4072        (tree, value, at(0.25), at(0.75))
4073    }
4074
4075    /// One click with `button`, then time enough that the next is a click of
4076    /// its own.
4077    fn click_with(tree: &mut WidgetTree, at: Point, button: PointerButton) {
4078        tree.pointer_move(at);
4079        tree.pointer_down_button(at, button);
4080        tree.pointer_up_button(at, button);
4081        tree.advance_time(std::time::Duration::from_secs(1));
4082    }
4083
4084    /// The way an application adds a secondary-click action to a slider: its
4085    /// own `on_tap`, widened to the secondary button. The track click stays
4086    /// the primary button's, and the secondary click is the application's
4087    /// alone.
4088    #[test]
4089    fn an_applications_secondary_tap_does_not_retarget_the_track_click() {
4090        use teksilo_core::event::ButtonMask;
4091        use teksilo_core::widget_builder::WidgetBuilder;
4092
4093        let app = Rc::new(Cell::new(0_u32));
4094        let count = app.clone();
4095        let (mut tree, value, quarter, three_quarters) = tap_mask_scene(move |s| {
4096            Box::new(
4097                s.on_tap(move |_, _| count.set(count.get() + 1))
4098                    .accept_tap_buttons(ButtonMask::SECONDARY),
4099            )
4100        });
4101
4102        click_with(&mut tree, three_quarters, PointerButton::Primary);
4103        let jumped = value.get();
4104        assert!(jumped > 6.0, "a primary click still jumps: {jumped}");
4105        assert_eq!(app.get(), 0, "and is not the application's");
4106
4107        click_with(&mut tree, quarter, PointerButton::Secondary);
4108        assert_eq!(value.get(), jumped, "a secondary click does not jump");
4109        assert_eq!(app.get(), 1, "it is the application's");
4110    }
4111
4112    /// A mask with no `on_tap` beside it reaches the slider's own tap — the
4113    /// door a `Button` opens to a right click — and the slider still jumps on
4114    /// the primary button only.
4115    #[test]
4116    fn the_track_click_is_the_primary_buttons_whatever_mask_reaches_it() {
4117        use teksilo_core::event::ButtonMask;
4118        use teksilo_core::widget_builder::WidgetBuilder;
4119
4120        let (mut tree, value, quarter, three_quarters) = tap_mask_scene(|s| {
4121            Box::new(s.accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::SECONDARY))
4122        });
4123        click_with(&mut tree, quarter, PointerButton::Secondary);
4124        assert_eq!(value.get(), 5.0, "a secondary click does not jump");
4125        click_with(&mut tree, three_quarters, PointerButton::Primary);
4126        assert!(value.get() > 6.0, "a primary one does: {}", value.get());
4127    }
4128
4129    /// A slider reporting every change into `seen`, with `jump_on_press` as
4130    /// given, in the horizontal readout scene.
4131    fn press_scene(jump: bool) -> (WidgetTree, WidgetId, Signal<f32>, Rc<RefCell<Vec<f32>>>) {
4132        let value = Signal::new(0.0_f32);
4133        let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
4134        let sink = seen.clone();
4135        let (tree, s) = horizontal_scene(
4136            Slider::new(value.clone(), 0.0, 100.0)
4137                .jump_on_press(jump)
4138                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
4139        );
4140        (tree, s, value, seen)
4141    }
4142
4143    /// The point `f` of the way along slider `s`'s track.
4144    fn along(tree: &WidgetTree, s: WidgetId, f: f32) -> Point {
4145        let b = tree.bounds(s);
4146        let r = crate::styles::recipe_slider_style::SLIDER_THUMB_DIAMETER * 0.5;
4147        Point::new(b.x + r + (b.width - 2.0 * r) * f, b.center().y)
4148    }
4149
4150    #[test]
4151    fn jump_on_press_moves_the_value_on_a_mouse_press() {
4152        let (mut tree, s, value, seen) = press_scene(true);
4153        let at = along(&tree, s, 0.75);
4154        tree.pointer_move(at);
4155        tree.pointer_down_button(at, PointerButton::Primary);
4156        assert!(
4157            (value.get() - 75.0).abs() < 1.01,
4158            "the press moved it: {}",
4159            value.get()
4160        );
4161        assert_eq!(seen.borrow().len(), 1, "and reported it as a user change");
4162
4163        // Released a few dp on, inside the tap slop: still a click, which
4164        // would jump again on its own.
4165        let release = Point::new(at.x + 4.0, at.y);
4166        tree.pointer_move(release);
4167        tree.pointer_up_button(release, PointerButton::Primary);
4168        assert_eq!(
4169            seen.borrow().len(),
4170            1,
4171            "the release adds nothing: {:?}",
4172            seen.borrow()
4173        );
4174        tree.assert_no_leaked_pointer_state();
4175    }
4176
4177    /// The drag that follows carries on from the press: crossing the drag slop
4178    /// writes nothing new, and the value then follows the pointer.
4179    #[test]
4180    fn a_drag_after_a_jumping_press_carries_on_without_a_jump() {
4181        let (mut tree, s, value, seen) = press_scene(true);
4182        let start = along(&tree, s, 0.25);
4183        tree.pointer_move(start);
4184        tree.pointer_down_button(start, PointerButton::Primary);
4185        let pressed = value.get();
4186        assert!((pressed - 25.0).abs() < 1.01, "pressed to {pressed}");
4187
4188        // Inside the slop, then across it.
4189        tree.pointer_move(Point::new(start.x + 2.0, start.y));
4190        tree.pointer_move(Point::new(start.x + 8.0, start.y));
4191        assert_eq!(
4192            *seen.borrow(),
4193            vec![pressed],
4194            "crossing the slop does not move the value again"
4195        );
4196
4197        let end = along(&tree, s, 0.6);
4198        tree.pointer_move(end);
4199        tree.pointer_up_button(end, PointerButton::Primary);
4200        assert!(
4201            (value.get() - 60.0).abs() < 1.01,
4202            "the drag moved it on: {}",
4203            value.get()
4204        );
4205        tree.assert_no_leaked_pointer_state();
4206    }
4207
4208    /// A finger's press is not a choice yet, so it writes nothing until it is
4209    /// a tap or a drag, `jump_on_press` or not.
4210    #[test]
4211    fn jump_on_press_leaves_a_touch_press_alone() {
4212        let (mut tree, s, value, _seen) = press_scene(true);
4213        let at = along(&tree, s, 0.75);
4214        let finger = tree.new_contact();
4215        tree.touch_down(finger, at);
4216        assert_eq!(value.get(), 0.0, "the contact landing wrote nothing");
4217        tree.touch_up(finger, at);
4218        assert!(
4219            (value.get() - 75.0).abs() < 1.01,
4220            "the tap did: {}",
4221            value.get()
4222        );
4223        tree.assert_no_leaked_pointer_state();
4224    }
4225
4226    #[test]
4227    fn without_jump_on_press_a_mouse_press_writes_nothing() {
4228        let (mut tree, s, value, seen) = press_scene(false);
4229        let at = along(&tree, s, 0.75);
4230        tree.pointer_move(at);
4231        tree.pointer_down_button(at, PointerButton::Primary);
4232        assert_eq!(value.get(), 0.0, "the press wrote nothing");
4233        tree.pointer_up_button(at, PointerButton::Primary);
4234        assert_eq!(seen.borrow().len(), 1, "the click wrote once, on release");
4235    }
4236
4237    /// With a default set, the second press of a double-click is the reset's:
4238    /// it does not jump first.
4239    #[test]
4240    fn a_jumping_double_click_reports_the_jump_and_then_the_reset() {
4241        let value = Signal::new(0.0_f32);
4242        let seen: Rc<RefCell<Vec<f32>>> = Rc::default();
4243        let sink = seen.clone();
4244        let (mut tree, s) = horizontal_scene(
4245            Slider::new(value.clone(), 0.0, 100.0)
4246                .jump_on_press(true)
4247                .default_value(10.0)
4248                .on_change(move |now, _ctx| sink.borrow_mut().push(now)),
4249        );
4250        let at = along(&tree, s, 0.75);
4251        click(&mut tree, at);
4252        tree.advance_time(std::time::Duration::from_millis(100));
4253        tree.pointer_move(Point::new(at.x - 3.0, at.y));
4254        tree.pointer_down_button(Point::new(at.x - 3.0, at.y), PointerButton::Primary);
4255        assert_eq!(seen.borrow().len(), 1, "the second press did not jump");
4256        tree.pointer_up_button(Point::new(at.x - 3.0, at.y), PointerButton::Primary);
4257        let reported = seen.borrow().clone();
4258        assert_eq!(reported.len(), 2, "the jump, then the reset: {reported:?}");
4259        assert_eq!(value.get(), 10.0);
4260    }
4261
4262    #[test]
4263    fn a_below_readout_hangs_under_the_thumb() {
4264        let (mut tree, s) = horizontal_scene(
4265            Slider::new(Signal::new(-3.0_f32), -12.0, 12.0)
4266                .value_tooltip(db)
4267                .value_tooltip_placement(SliderValueTooltipPlacement::Below),
4268        );
4269        let bounds = tree.bounds(s);
4270        tree.pointer_move(bounds.center());
4271        tree.layout(window());
4272        let (_, rect) = readout(&tree).expect("up");
4273        let thumb = knob(&tree, s);
4274        assert!(
4275            rect.y >= bounds.bottom(),
4276            "below the slider: {rect:?} under {bounds:?}"
4277        );
4278        assert!(
4279            (rect.center().x - thumb.center().x).abs() < 0.51,
4280            "centred on the thumb: {rect:?} under {thumb:?}",
4281        );
4282    }
4283
4284    #[test]
4285    fn an_above_readout_on_a_vertical_slider_sits_over_the_thumb() {
4286        let mut tree = readout_tree();
4287        let s = tree.add(
4288            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
4289                .orientation(Orientation::Vertical)
4290                .value_tooltip(db)
4291                .value_tooltip_placement(SliderValueTooltipPlacement::Above),
4292        );
4293        let _row = tree.add(
4294            HStack::new()
4295                .child(FixedSize::new().width(150.0))
4296                .child(FixedSize::new().height(200.0).child(s)),
4297        );
4298        tree.layout(window());
4299        tree.pointer_move(knob(&tree, s).center());
4300        tree.layout(window());
4301        let (_, rect) = readout(&tree).expect("up");
4302        let (bounds, thumb) = (tree.bounds(s), knob(&tree, s));
4303        assert!(
4304            rect.bottom() <= thumb.y,
4305            "above the thumb: {rect:?} over {thumb:?}"
4306        );
4307        assert!(
4308            (rect.center().x - bounds.center().x).abs() < 0.51,
4309            "centred on the slider: {rect:?} over {bounds:?}",
4310        );
4311    }
4312
4313    /// A stepped slider snaps its thumb; a readout that follows the pointer
4314    /// does not, and goes back to the thumb when the drag ends.
4315    #[test]
4316    fn a_follow_pointer_readout_rides_the_pointer_through_a_drag() {
4317        let (mut tree, s) = horizontal_scene(
4318            Slider::new(Signal::new(0.0_f32), -12.0, 12.0)
4319                .step(6.0)
4320                .value_tooltip(db)
4321                .value_tooltip_placement(SliderValueTooltipPlacement::FollowPointer),
4322        );
4323        let bounds = tree.bounds(s);
4324        let start = knob(&tree, s).center();
4325        tree.pointer_move(start);
4326        tree.pointer_down_button(start, PointerButton::Primary);
4327        let mut pointer = start;
4328        for dx in [10.0, 37.0, 52.0] {
4329            pointer = Point::new(start.x + dx, start.y);
4330            tree.pointer_move(pointer);
4331            tree.layout(window());
4332            let (_, rect) = readout(&tree).expect("up for the whole drag");
4333            assert!(
4334                (rect.center().x - pointer.x).abs() < 0.51,
4335                "centred on the pointer: {rect:?} at {pointer:?}",
4336            );
4337            assert!(rect.bottom() <= bounds.y, "above the slider: {rect:?}");
4338        }
4339        let thumb = knob(&tree, s);
4340        assert!(
4341            (thumb.center().x - pointer.x).abs() > 2.0,
4342            "fixture: the snapped thumb is not under the pointer"
4343        );
4344
4345        tree.pointer_up_button(pointer, PointerButton::Primary);
4346        tree.layout(window());
4347        let (_, rect) = readout(&tree).expect("still hovered");
4348        assert!(
4349            (rect.center().x - thumb.center().x).abs() < 0.51,
4350            "back on the thumb once the drag is over: {rect:?} over {thumb:?}",
4351        );
4352    }
4353
4354    #[test]
4355    fn the_readout_takes_the_style_it_is_given() {
4356        use teksilo_core::build_context::BuildContext;
4357        use teksilo_core::styles::TooltipStyleConfig;
4358
4359        struct Counting(Rc<Cell<u32>>);
4360        impl TooltipStyle for Counting {
4361            fn make_body(&self, cfg: &TooltipStyleConfig, ctx: &mut BuildContext) -> WidgetId {
4362                self.0.set(self.0.get() + 1);
4363                crate::styles::RecipeTooltipStyle::default().make_body(cfg, ctx)
4364            }
4365        }
4366
4367        let used = Rc::new(Cell::new(0_u32));
4368        let (mut tree, s) = horizontal_scene(
4369            Slider::new(Signal::new(-3.0_f32), -12.0, 12.0)
4370                .value_tooltip(db)
4371                .value_tooltip_style(Counting(used.clone())),
4372        );
4373        tree.pointer_move(tree.bounds(s).center());
4374        tree.layout(window());
4375        let (text, _) = readout(&tree).expect("up");
4376        assert_eq!(text, "-3.0 dB");
4377        assert!(used.get() > 0, "the readout was built by the given style");
4378    }
4379}