Skip to main content

cranpose_ui/widgets/
slider.rs

1//! Foundation slider with caller-owned state and composable visual content.
2
3#![allow(non_snake_case)]
4
5use std::{cell::RefCell, rc::Rc};
6
7use cranpose_core::{NodeId, State, rememberMutableStateOf, rememberUpdatedState};
8use cranpose_foundation::{PointerEventKind, PointerId};
9
10use crate::{
11    Modifier, MutableInteractionSource, composable,
12    widgets::{Box, BoxSpec, BoxWithConstraints, scopes::BoxWithConstraintsScope},
13};
14
15/// Main axis used by a [`Slider`].
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17pub enum SliderOrientation {
18    /// Values increase from start to end.
19    #[default]
20    Horizontal,
21    /// Values increase from top to bottom unless reversed.
22    Vertical,
23}
24
25/// Input and geometry policy for a [`Slider`].
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct SliderSpec {
28    pub orientation: SliderOrientation,
29    pub reverse_direction: bool,
30    pub thumb_extent: f32,
31    pub enabled: bool,
32    pub rotary_step: f32,
33}
34
35impl SliderSpec {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn orientation(mut self, orientation: SliderOrientation) -> Self {
41        self.orientation = orientation;
42        self
43    }
44
45    pub fn reverse_direction(mut self, reverse_direction: bool) -> Self {
46        self.reverse_direction = reverse_direction;
47        self
48    }
49
50    pub fn thumb_extent(mut self, thumb_extent: f32) -> Self {
51        self.thumb_extent = thumb_extent.max(0.0);
52        self
53    }
54
55    pub fn enabled(mut self, enabled: bool) -> Self {
56        self.enabled = enabled;
57        self
58    }
59
60    pub fn rotary_step(mut self, rotary_step: f32) -> Self {
61        self.rotary_step = rotary_step.abs();
62        self
63    }
64}
65
66impl Default for SliderSpec {
67    fn default() -> Self {
68        Self {
69            orientation: SliderOrientation::Horizontal,
70            reverse_direction: false,
71            thumb_extent: 0.0,
72            enabled: true,
73            rotary_step: 0.05,
74        }
75    }
76}
77
78/// Values available to custom slider track and thumb content.
79#[derive(Clone)]
80pub struct SliderScope {
81    value: f32,
82    track_extent: f32,
83    thumb_offset: f32,
84    dragging: State<bool>,
85    interaction_source: MutableInteractionSource,
86}
87
88impl SliderScope {
89    /// Current caller-owned value, clamped to `0..=1`.
90    pub fn value(&self) -> f32 {
91        self.value
92    }
93
94    /// Main-axis space through which the thumb can travel.
95    pub fn track_extent(&self) -> f32 {
96        self.track_extent
97    }
98
99    /// Main-axis offset for the leading edge of the thumb.
100    pub fn thumb_offset(&self) -> f32 {
101        self.thumb_offset
102    }
103
104    /// Whether direct pointer input is currently changing the value.
105    pub fn is_dragging(&self) -> bool {
106        self.dragging.get()
107    }
108
109    /// Interaction source shared by the slider surface.
110    pub fn interaction_source(&self) -> MutableInteractionSource {
111        self.interaction_source
112    }
113}
114
115/// A `0..=1` slider whose visuals are ordinary composables supplied by
116/// `content`. The framework owns pointer capture, cancellation, rotary input,
117/// pressed interactions, value mapping, and completion delivery.
118#[composable]
119pub fn Slider<F>(
120    modifier: Modifier,
121    value: f32,
122    on_value_change: impl Fn(f32) + 'static,
123    on_value_change_finished: impl Fn() + 'static,
124    spec: SliderSpec,
125    content: F,
126) -> NodeId
127where
128    F: FnMut(SliderScope) + 'static,
129{
130    let value = value.clamp(0.0, 1.0);
131    let current_value = rememberUpdatedState(value);
132    let on_value_change: Rc<dyn Fn(f32)> = Rc::new(on_value_change);
133    let on_value_change = rememberUpdatedState(on_value_change);
134    let on_value_change_finished: Rc<dyn Fn()> = Rc::new(on_value_change_finished);
135    let on_value_change_finished = rememberUpdatedState(on_value_change_finished);
136    let dragging = rememberMutableStateOf(|| false);
137    let interaction_source = crate::rememberMutableInteractionSource();
138    let content = Rc::new(RefCell::new(content));
139
140    BoxWithConstraints(modifier, move |constraints_scope| {
141        let constraints = constraints_scope.constraints();
142        let extent = match spec.orientation {
143            SliderOrientation::Horizontal => constraints.max_width,
144            SliderOrientation::Vertical => constraints.max_height,
145        }
146        .max(0.0);
147        let track_extent = (extent - spec.thumb_extent).max(0.0);
148        let logical_value = if spec.reverse_direction {
149            1.0 - value
150        } else {
151            value
152        };
153        let slider_scope = SliderScope {
154            value,
155            track_extent,
156            thumb_offset: track_extent * logical_value,
157            dragging: dragging.as_state(),
158            interaction_source,
159        };
160        let interaction = interaction_source;
161        let content = Rc::clone(&content);
162        let input = Modifier::empty()
163            .fill_max_size()
164            .semantics(move |config| {
165                slider_semantics(
166                    config,
167                    value,
168                    spec.enabled,
169                    on_value_change,
170                    on_value_change_finished,
171                );
172            })
173            .pointer_input(
174                (
175                    spec.orientation,
176                    spec.reverse_direction,
177                    spec.thumb_extent.to_bits(),
178                    spec.enabled,
179                    spec.rotary_step.to_bits(),
180                    extent.to_bits(),
181                ),
182                move |pointer_scope| {
183                    let interaction = interaction;
184                    async move {
185                        pointer_scope
186                            .await_pointer_event_scope(|await_scope| async move {
187                                let mut active_pointer: Option<PointerId> = None;
188                                let mut active_press = None;
189                                loop {
190                                    let event = await_scope.await_pointer_event().await;
191                                    match event.kind {
192                                        PointerEventKind::Down
193                                            if spec.enabled && active_pointer.is_none() =>
194                                        {
195                                            active_pointer = Some(event.id);
196                                            dragging.set(true);
197                                            active_press = Some(interaction.press(event.position));
198                                            let next = value_for_position(
199                                                axis_position(
200                                                    event.position.x,
201                                                    event.position.y,
202                                                    spec,
203                                                ),
204                                                extent,
205                                                spec.thumb_extent,
206                                                spec.reverse_direction,
207                                            );
208                                            (on_value_change.value())(next);
209                                            event.consume();
210                                        }
211                                        PointerEventKind::Move
212                                            if active_pointer == Some(event.id) =>
213                                        {
214                                            let next = value_for_position(
215                                                axis_position(
216                                                    event.position.x,
217                                                    event.position.y,
218                                                    spec,
219                                                ),
220                                                extent,
221                                                spec.thumb_extent,
222                                                spec.reverse_direction,
223                                            );
224                                            (on_value_change.value())(next);
225                                            event.consume();
226                                        }
227                                        PointerEventKind::Up
228                                            if active_pointer == Some(event.id) =>
229                                        {
230                                            let next = value_for_position(
231                                                axis_position(
232                                                    event.position.x,
233                                                    event.position.y,
234                                                    spec,
235                                                ),
236                                                extent,
237                                                spec.thumb_extent,
238                                                spec.reverse_direction,
239                                            );
240                                            (on_value_change.value())(next);
241                                            if let Some(press) = active_press.take() {
242                                                interaction.release(press);
243                                            }
244                                            dragging.set(false);
245                                            active_pointer = None;
246                                            (on_value_change_finished.value())();
247                                            event.consume();
248                                        }
249                                        PointerEventKind::Cancel
250                                            if active_pointer == Some(event.id) =>
251                                        {
252                                            if let Some(press) = active_press.take() {
253                                                interaction.cancel(press);
254                                            }
255                                            dragging.set(false);
256                                            active_pointer = None;
257                                            (on_value_change_finished.value())();
258                                            event.consume();
259                                        }
260                                        PointerEventKind::RotaryScroll if spec.enabled => {
261                                            let delta = event.scroll_delta.y;
262                                            if delta != 0.0 {
263                                                let direction =
264                                                    if spec.reverse_direction { 1.0 } else { -1.0 };
265                                                let next = (current_value.value()
266                                                    + direction
267                                                        * delta.signum()
268                                                        * spec.rotary_step)
269                                                    .clamp(0.0, 1.0);
270                                                (on_value_change.value())(next);
271                                                (on_value_change_finished.value())();
272                                                event.consume();
273                                            }
274                                        }
275                                        _ => {}
276                                    }
277                                }
278                            })
279                            .await;
280                    }
281                },
282            );
283        Box(input, BoxSpec::default(), move || {
284            (content.borrow_mut())(slider_scope.clone())
285        });
286    })
287}
288
289fn axis_position(x: f32, y: f32, spec: SliderSpec) -> f32 {
290    match spec.orientation {
291        SliderOrientation::Horizontal => x,
292        SliderOrientation::Vertical => y,
293    }
294}
295
296fn value_for_position(position: f32, extent: f32, thumb_extent: f32, reverse: bool) -> f32 {
297    let travel = (extent - thumb_extent).max(0.0);
298    let mut value = if travel > 0.0 {
299        ((position - thumb_extent * 0.5) / travel).clamp(0.0, 1.0)
300    } else {
301        0.0
302    };
303    if reverse {
304        value = 1.0 - value;
305    }
306    value
307}
308
309/// What a screen reader learns about the slider: its value as text, the range
310/// it holds the value in, and, when the slider is enabled, the way to move
311/// it. Compose's `progressSemantics` and `setProgress` on one control.
312fn slider_semantics(
313    config: &mut cranpose_foundation::SemanticsConfiguration,
314    value: f32,
315    enabled: bool,
316    on_value_change: cranpose_core::MutableState<Rc<dyn Fn(f32)>>,
317    on_value_change_finished: cranpose_core::MutableState<Rc<dyn Fn()>>,
318) {
319    config.enabled = enabled;
320    config.state_description = Some(format!("{}%", (value * 100.0).round() as u32));
321    config.progress = Some(cranpose_foundation::ProgressBarRangeInfo::new(
322        value, 0.0, 1.0, 0,
323    ));
324    if enabled {
325        config.set_progress = Some(cranpose_foundation::SemanticsSetProgress::new(
326            move |next: f32| {
327                (on_value_change.value())(next.clamp(0.0, 1.0));
328                (on_value_change_finished.value())();
329                true
330            },
331        ));
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn spec_builders_define_orientation_and_input_policy() {
341        let spec = SliderSpec::new()
342            .orientation(SliderOrientation::Vertical)
343            .reverse_direction(true)
344            .thumb_extent(11.0)
345            .enabled(false)
346            .rotary_step(-0.2);
347        assert_eq!(spec.orientation, SliderOrientation::Vertical);
348        assert!(spec.reverse_direction);
349        assert_eq!(spec.thumb_extent, 11.0);
350        assert!(!spec.enabled);
351        assert_eq!(spec.rotary_step, 0.2);
352    }
353
354    #[test]
355    fn pointer_position_tracks_thumb_centre_and_reverse_direction() {
356        assert_eq!(value_for_position(5.0, 110.0, 10.0, false), 0.0);
357        assert_eq!(value_for_position(105.0, 110.0, 10.0, false), 1.0);
358        assert_eq!(value_for_position(55.0, 110.0, 10.0, false), 0.5);
359        assert_eq!(value_for_position(5.0, 110.0, 10.0, true), 1.0);
360    }
361
362    #[test]
363    fn zero_travel_is_stable() {
364        assert_eq!(value_for_position(0.0, 0.0, 0.0, false), 0.0);
365        assert_eq!(value_for_position(0.0, 0.0, 0.0, true), 1.0);
366    }
367}