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                config.enabled = spec.enabled;
166                config.state_description = Some(format!("{}%", (value * 100.0).round() as u32));
167            })
168            .pointer_input(
169                (
170                    spec.orientation,
171                    spec.reverse_direction,
172                    spec.thumb_extent.to_bits(),
173                    spec.enabled,
174                    spec.rotary_step.to_bits(),
175                    extent.to_bits(),
176                ),
177                move |pointer_scope| {
178                    let interaction = interaction;
179                    async move {
180                        pointer_scope
181                            .await_pointer_event_scope(|await_scope| async move {
182                                let mut active_pointer: Option<PointerId> = None;
183                                let mut active_press = None;
184                                loop {
185                                    let event = await_scope.await_pointer_event().await;
186                                    match event.kind {
187                                        PointerEventKind::Down
188                                            if spec.enabled && active_pointer.is_none() =>
189                                        {
190                                            active_pointer = Some(event.id);
191                                            dragging.set(true);
192                                            active_press = Some(interaction.press(event.position));
193                                            let next = value_for_position(
194                                                axis_position(
195                                                    event.position.x,
196                                                    event.position.y,
197                                                    spec,
198                                                ),
199                                                extent,
200                                                spec.thumb_extent,
201                                                spec.reverse_direction,
202                                            );
203                                            (on_value_change.value())(next);
204                                            event.consume();
205                                        }
206                                        PointerEventKind::Move
207                                            if active_pointer == Some(event.id) =>
208                                        {
209                                            let next = value_for_position(
210                                                axis_position(
211                                                    event.position.x,
212                                                    event.position.y,
213                                                    spec,
214                                                ),
215                                                extent,
216                                                spec.thumb_extent,
217                                                spec.reverse_direction,
218                                            );
219                                            (on_value_change.value())(next);
220                                            event.consume();
221                                        }
222                                        PointerEventKind::Up
223                                            if active_pointer == Some(event.id) =>
224                                        {
225                                            let next = value_for_position(
226                                                axis_position(
227                                                    event.position.x,
228                                                    event.position.y,
229                                                    spec,
230                                                ),
231                                                extent,
232                                                spec.thumb_extent,
233                                                spec.reverse_direction,
234                                            );
235                                            (on_value_change.value())(next);
236                                            if let Some(press) = active_press.take() {
237                                                interaction.release(press);
238                                            }
239                                            dragging.set(false);
240                                            active_pointer = None;
241                                            (on_value_change_finished.value())();
242                                            event.consume();
243                                        }
244                                        PointerEventKind::Cancel
245                                            if active_pointer == Some(event.id) =>
246                                        {
247                                            if let Some(press) = active_press.take() {
248                                                interaction.cancel(press);
249                                            }
250                                            dragging.set(false);
251                                            active_pointer = None;
252                                            (on_value_change_finished.value())();
253                                            event.consume();
254                                        }
255                                        PointerEventKind::RotaryScroll if spec.enabled => {
256                                            let delta = event.scroll_delta.y;
257                                            if delta != 0.0 {
258                                                let direction =
259                                                    if spec.reverse_direction { 1.0 } else { -1.0 };
260                                                let next = (current_value.value()
261                                                    + direction
262                                                        * delta.signum()
263                                                        * spec.rotary_step)
264                                                    .clamp(0.0, 1.0);
265                                                (on_value_change.value())(next);
266                                                (on_value_change_finished.value())();
267                                                event.consume();
268                                            }
269                                        }
270                                        _ => {}
271                                    }
272                                }
273                            })
274                            .await;
275                    }
276                },
277            );
278        Box(input, BoxSpec::default(), move || {
279            (content.borrow_mut())(slider_scope.clone())
280        });
281    })
282}
283
284fn axis_position(x: f32, y: f32, spec: SliderSpec) -> f32 {
285    match spec.orientation {
286        SliderOrientation::Horizontal => x,
287        SliderOrientation::Vertical => y,
288    }
289}
290
291fn value_for_position(position: f32, extent: f32, thumb_extent: f32, reverse: bool) -> f32 {
292    let travel = (extent - thumb_extent).max(0.0);
293    let mut value = if travel > 0.0 {
294        ((position - thumb_extent * 0.5) / travel).clamp(0.0, 1.0)
295    } else {
296        0.0
297    };
298    if reverse {
299        value = 1.0 - value;
300    }
301    value
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn spec_builders_define_orientation_and_input_policy() {
310        let spec = SliderSpec::new()
311            .orientation(SliderOrientation::Vertical)
312            .reverse_direction(true)
313            .thumb_extent(11.0)
314            .enabled(false)
315            .rotary_step(-0.2);
316        assert_eq!(spec.orientation, SliderOrientation::Vertical);
317        assert!(spec.reverse_direction);
318        assert_eq!(spec.thumb_extent, 11.0);
319        assert!(!spec.enabled);
320        assert_eq!(spec.rotary_step, 0.2);
321    }
322
323    #[test]
324    fn pointer_position_tracks_thumb_centre_and_reverse_direction() {
325        assert_eq!(value_for_position(5.0, 110.0, 10.0, false), 0.0);
326        assert_eq!(value_for_position(105.0, 110.0, 10.0, false), 1.0);
327        assert_eq!(value_for_position(55.0, 110.0, 10.0, false), 0.5);
328        assert_eq!(value_for_position(5.0, 110.0, 10.0, true), 1.0);
329    }
330
331    #[test]
332    fn zero_travel_is_stable() {
333        assert_eq!(value_for_position(0.0, 0.0, 0.0, false), 0.0);
334        assert_eq!(value_for_position(0.0, 0.0, 0.0, true), 1.0);
335    }
336}