Skip to main content

cranpose_ui/widgets/
slider.rs

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