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