tessera_ui_basic_components/slider/
interaction.rs

1use std::sync::Arc;
2
3use tessera_ui::{
4    ComputedData, CursorEventContent, InputHandlerInput, PxPosition,
5    accesskit::{Action, Role},
6    winit::window::CursorIcon,
7};
8
9use super::{ACCESSIBILITY_STEP, SliderArgs, SliderLayout, SliderState};
10
11/// Helper: check if a cursor position is within the bounds of a component.
12pub(super) fn cursor_within_component(
13    cursor_pos: Option<PxPosition>,
14    computed: &ComputedData,
15) -> bool {
16    if let Some(pos) = cursor_pos {
17        let within_x = pos.x.0 >= 0 && pos.x.0 < computed.width.0;
18        let within_y = pos.y.0 >= 0 && pos.y.0 < computed.height.0;
19        within_x && within_y
20    } else {
21        false
22    }
23}
24
25/// Helper: compute normalized progress (0.0..1.0) from cursor X and overall width.
26/// Returns None when cursor is not available.
27pub(super) fn cursor_progress(
28    cursor_pos: Option<PxPosition>,
29    layout: &SliderLayout,
30) -> Option<f32> {
31    if layout.component_width.0 <= 0 {
32        return None;
33    }
34    cursor_pos.map(|pos| (pos.x.0 as f32 / layout.component_width.to_f32()).clamp(0.0, 1.0))
35}
36
37pub(super) fn handle_slider_state(
38    input: &mut InputHandlerInput,
39    state: &SliderState,
40    args: &SliderArgs,
41    layout: &SliderLayout,
42) {
43    if args.disabled {
44        let mut inner = state.write();
45        inner.is_hovered = false;
46        inner.is_dragging = false;
47        return;
48    }
49
50    let is_in_component = cursor_within_component(input.cursor_position_rel, &input.computed_data);
51
52    {
53        let mut inner = state.write();
54        inner.is_hovered = is_in_component;
55    }
56
57    if is_in_component {
58        input.requests.cursor_icon = CursorIcon::Pointer;
59    }
60
61    if !is_in_component && !state.read().is_dragging {
62        return;
63    }
64
65    let mut new_value: Option<f32> = None;
66
67    handle_cursor_events(input, state, &mut new_value, layout);
68    update_value_on_drag(input, state, &mut new_value, layout);
69    notify_on_change(new_value, args);
70}
71
72fn handle_cursor_events(
73    input: &mut InputHandlerInput,
74    state: &SliderState,
75    new_value: &mut Option<f32>,
76    layout: &SliderLayout,
77) {
78    for event in input.cursor_events.iter() {
79        match &event.content {
80            CursorEventContent::Pressed(_) => {
81                {
82                    let mut inner = state.write();
83                    inner.focus.request_focus();
84                    inner.is_dragging = true;
85                }
86                if let Some(v) = cursor_progress(input.cursor_position_rel, layout) {
87                    *new_value = Some(v);
88                }
89            }
90            CursorEventContent::Released(_) => {
91                state.write().is_dragging = false;
92            }
93            _ => {}
94        }
95    }
96}
97
98fn update_value_on_drag(
99    input: &InputHandlerInput,
100    state: &SliderState,
101    new_value: &mut Option<f32>,
102    layout: &SliderLayout,
103) {
104    if state.read().is_dragging
105        && let Some(v) = cursor_progress(input.cursor_position_rel, layout)
106    {
107        *new_value = Some(v);
108    }
109}
110
111fn notify_on_change(new_value: Option<f32>, args: &SliderArgs) {
112    if let Some(v) = new_value
113        && (v - args.value).abs() > f32::EPSILON
114    {
115        (args.on_change)(v);
116    }
117}
118
119pub(super) fn apply_slider_accessibility(
120    input: &mut InputHandlerInput<'_>,
121    args: &SliderArgs,
122    current_value: f32,
123    on_change: &Arc<dyn Fn(f32) + Send + Sync>,
124) {
125    let mut builder = input.accessibility().role(Role::Slider);
126
127    if let Some(label) = args.accessibility_label.as_ref() {
128        builder = builder.label(label.clone());
129    }
130    if let Some(description) = args.accessibility_description.as_ref() {
131        builder = builder.description(description.clone());
132    }
133
134    builder = builder
135        .numeric_value(current_value as f64)
136        .numeric_range(0.0, 1.0);
137
138    if args.disabled {
139        builder = builder.disabled();
140    } else {
141        builder = builder
142            .focusable()
143            .action(Action::Increment)
144            .action(Action::Decrement);
145    }
146
147    builder.commit();
148
149    if args.disabled {
150        return;
151    }
152
153    let value_for_handler = current_value;
154    let on_change = on_change.clone();
155    input.set_accessibility_action_handler(move |action| {
156        let new_value = match action {
157            Action::Increment => Some((value_for_handler + ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
158            Action::Decrement => Some((value_for_handler - ACCESSIBILITY_STEP).clamp(0.0, 1.0)),
159            _ => None,
160        };
161
162        if let Some(new_value) = new_value
163            && (new_value - value_for_handler).abs() > f32::EPSILON
164        {
165            on_change(new_value);
166        }
167    });
168}
169
170pub(crate) struct RangeSliderStateInner {
171    pub is_dragging_start: bool,
172    pub is_dragging_end: bool,
173    pub focus_start: tessera_ui::focus_state::Focus,
174    pub focus_end: tessera_ui::focus_state::Focus,
175    pub is_hovered: bool,
176}
177
178impl Default for RangeSliderStateInner {
179    fn default() -> Self {
180        Self {
181            is_dragging_start: false,
182            is_dragging_end: false,
183            focus_start: tessera_ui::focus_state::Focus::new(),
184            focus_end: tessera_ui::focus_state::Focus::new(),
185            is_hovered: false,
186        }
187    }
188}
189
190/// External state for the `range_slider` component.
191#[derive(Clone)]
192pub struct RangeSliderState {
193    inner: Arc<parking_lot::RwLock<RangeSliderStateInner>>,
194}
195
196impl Default for RangeSliderState {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl RangeSliderState {
203    /// Creates a new range slider state handle.
204    pub fn new() -> Self {
205        Self {
206            inner: Arc::new(parking_lot::RwLock::new(RangeSliderStateInner::default())),
207        }
208    }
209
210    pub(crate) fn read(&self) -> parking_lot::RwLockReadGuard<'_, RangeSliderStateInner> {
211        self.inner.read()
212    }
213
214    pub(crate) fn write(&self) -> parking_lot::RwLockWriteGuard<'_, RangeSliderStateInner> {
215        self.inner.write()
216    }
217}
218
219pub(super) fn handle_range_slider_state(
220    input: &mut InputHandlerInput,
221    state: &RangeSliderState,
222    args: &super::RangeSliderArgs,
223    layout: &SliderLayout,
224) {
225    if args.disabled {
226        let mut inner = state.write();
227        inner.is_hovered = false;
228        inner.is_dragging_start = false;
229        inner.is_dragging_end = false;
230        return;
231    }
232
233    let is_in_component = cursor_within_component(input.cursor_position_rel, &input.computed_data);
234
235    {
236        let mut inner = state.write();
237        inner.is_hovered = is_in_component;
238    }
239
240    if is_in_component {
241        input.requests.cursor_icon = CursorIcon::Pointer;
242    }
243
244    let is_dragging = {
245        let r = state.read();
246        r.is_dragging_start || r.is_dragging_end
247    };
248
249    if !is_in_component && !is_dragging {
250        return;
251    }
252
253    let mut new_start: Option<f32> = None;
254    let mut new_end: Option<f32> = None;
255
256    for event in input.cursor_events.iter() {
257        match &event.content {
258            CursorEventContent::Pressed(_) => {
259                if let Some(progress) = cursor_progress(input.cursor_position_rel, layout) {
260                    let dist_start = (progress - args.value.0).abs();
261                    let dist_end = (progress - args.value.1).abs();
262
263                    let mut inner = state.write();
264                    // Determine which handle to drag based on proximity
265                    if dist_start <= dist_end {
266                        inner.is_dragging_start = true;
267                        inner.focus_start.request_focus();
268                        new_start = Some(progress);
269                    } else {
270                        inner.is_dragging_end = true;
271                        inner.focus_end.request_focus();
272                        new_end = Some(progress);
273                    }
274                }
275            }
276            CursorEventContent::Released(_) => {
277                let mut inner = state.write();
278                inner.is_dragging_start = false;
279                inner.is_dragging_end = false;
280            }
281            _ => {}
282        }
283    }
284
285    let r = state.read();
286    if (r.is_dragging_start || r.is_dragging_end)
287        && let Some(progress) = cursor_progress(input.cursor_position_rel, layout)
288    {
289        if r.is_dragging_start {
290            new_start = Some(progress.min(args.value.1)); // Don't cross end
291        } else {
292            new_end = Some(progress.max(args.value.0)); // Don't cross start
293        }
294    }
295    drop(r);
296
297    if let Some(ns) = new_start
298        && (ns - args.value.0).abs() > f32::EPSILON
299    {
300        (args.on_change)((ns, args.value.1));
301    }
302    if let Some(ne) = new_end
303        && (ne - args.value.1).abs() > f32::EPSILON
304    {
305        (args.on_change)((args.value.0, ne));
306    }
307}
308
309pub(super) fn apply_range_slider_accessibility(
310    input: &mut InputHandlerInput<'_>,
311    args: &super::RangeSliderArgs,
312    _current_start: f32,
313    _current_end: f32,
314    _on_change: &Arc<dyn Fn((f32, f32)) + Send + Sync>,
315) {
316    // For range slider, we ideally need two accessibility nodes.
317    // However, given current limitations, we might just expose one node or the "primary" interaction.
318    // A better approach for accessibility in range sliders is usually multiple children nodes.
319    // For now, let's just make the container focusable but it might be confusing.
320    // To do this properly, we should probably split the accessibility into the two handles in the main component code
321    // by attaching accessibility info to the handle children instead of the container.
322    // But the current structure attaches to the container.
323    // TODO: Improve accessibility for range slider (requires structural changes to expose handles as children).
324
325    // Minimal implementation: report range as a string? or just focusable?
326    // Let's skip specific numeric value reporting for the container to avoid confusion,
327    // or just report the start value for now.
328    let mut builder = input.accessibility().role(Role::Slider);
329    if let Some(label) = args.accessibility_label.as_ref() {
330        builder = builder.label(label.clone());
331    }
332    if args.disabled {
333        builder = builder.disabled();
334    }
335    builder.commit();
336}