Skip to main content

cranpose_liquid/widgets/
segmented.rs

1use std::rc::Rc;
2
3use cranpose_animation::spring;
4use cranpose_core::{mutableStateOf, remember};
5use cranpose_macros::composable;
6use cranpose_ui::{
7    Modifier, PointerInputScope, SemanticsWidgetRole, Size,
8    text::{FontWeight, SpanStyle, TextStyle},
9    widgets::{Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text},
10};
11use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
12use cranpose_ui_layout::Alignment;
13
14use crate::{
15    material::{GlassDynamics, GlassMorph, LiquidModifierExt},
16    motion::LiquidMotion,
17    theme::{liquid_colors, liquid_typography},
18    widgets::content_scope::ScopeContent,
19};
20
21const SEGMENT_HEIGHT: f32 = 32.0;
22const TRACK_PADDING: f32 = 2.0;
23const LENS_PAD: f32 = 10.0;
24const TAP_SLOP: f32 = 4.0;
25
26fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
27    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
28}
29
30fn segmented_lens_reading(
31    lens_axis: &crate::motion::LiquidDragAxis,
32    pressed: bool,
33    selected: usize,
34    segment_width: f32,
35    count: usize,
36) -> usize {
37    let selected_x = segment_width * selected as f32;
38    let lens_x = lens_axis.value();
39    crate::motion::liquid_visual_index(
40        selected,
41        lens_x,
42        segment_width,
43        count,
44        crate::motion::liquid_axis_owns_visual_selection(
45            pressed,
46            lens_x,
47            selected_x,
48            segment_width,
49        ),
50    )
51}
52
53fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
54    let progress = progress.clamp(-0.1, 1.2);
55    let rest_h = SEGMENT_HEIGHT - TRACK_PADDING * 2.0;
56    Size::new(
57        segment_width - TRACK_PADDING * 2.0 + 24.0 * progress,
58        rest_h + 16.0 * progress,
59    )
60}
61
62struct LiquidSegment {
63    description: String,
64    content: Rc<dyn Fn(bool)>,
65}
66
67/// The scope a segmented control's content is declared in.
68///
69/// Each call adds one equal-width segment, in the order it is made, which is
70/// also the order the indices passed to `on_select` count in.
71pub struct LiquidSegmentedControlScope {
72    segments: ScopeContent<LiquidSegment>,
73}
74
75impl LiquidSegmentedControlScope {
76    /// A segment showing `label`, styled by the control: the selected one is
77    /// told apart by weight, never by dimming the rest.
78    pub fn segment(&self, label: impl Into<String>) {
79        let label = label.into();
80        let text = label.clone();
81        self.segment_content(label, move |selected| {
82            SegmentLabel(text.clone(), selected);
83        });
84    }
85
86    /// A segment the caller draws.
87    ///
88    /// `selected` says whether this is the active segment, so content can
89    /// respond the way the built-in label's weight does. `description` is what
90    /// accessibility announces, which content alone cannot supply.
91    pub fn segment_content(
92        &self,
93        description: impl Into<String>,
94        content: impl Fn(bool) + 'static,
95    ) {
96        self.segments.push(LiquidSegment {
97            description: description.into(),
98            content: Rc::new(content),
99        });
100    }
101}
102
103fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
104    ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
105}
106
107#[composable]
108fn SegmentLabel(label: String, selected: bool) {
109    let colors = liquid_colors();
110    let typography = liquid_typography();
111    let style = TextStyle {
112        span_style: SpanStyle {
113            color: Some(colors.label),
114            font_size: cranpose_ui::text::TextUnit::Sp(13.0),
115            font_weight: Some(if selected {
116                FontWeight::MEDIUM
117            } else {
118                FontWeight::NORMAL
119            }),
120            ..typography.subheadline.span_style.clone()
121        },
122        ..typography.subheadline
123    };
124    Text(label, Modifier::empty(), style);
125}
126
127/// A segmented control. `content` declares equal-width segments; `selected` is
128/// the active index; `on_select` receives the committed index. Segments tap AND
129/// swipe: dragging slides the indicator with the finger as a glass lens.
130///
131/// ```rust,ignore
132/// LiquidSegmentedControl(Modifier::empty().width(310.0), selected.get(), move |index| {
133///     selected.set(index)
134/// }, |scope| {
135///     scope.segment("Receiving");
136///     scope.segment("Sending");
137///     scope.segment("Errored");
138/// });
139/// ```
140#[composable]
141pub fn LiquidSegmentedControl(
142    modifier: Modifier,
143    selected: usize,
144    on_select: impl Fn(usize) + 'static,
145    content: impl FnOnce(&LiquidSegmentedControlScope),
146) {
147    let colors = liquid_colors();
148    let segments = collect_segments(content);
149    let count = segments.len().max(1);
150    let selected = selected.min(count - 1);
151    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
152    let segments = Rc::new(segments);
153
154    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
155    let contact = super::control_motion::remember_control_contact(
156        super::control_motion::ControlContactKind::Segment,
157    );
158    let (lens_progress, material_progress) = contact.states();
159
160    let track_height = SEGMENT_HEIGHT;
161    let track_fill = if colors.is_dark {
162        Color::from_rgb_u8(28, 28, 31)
163    } else {
164        Color::from_rgb_u8(238, 238, 239)
165    };
166    let marker_fill = if colors.is_dark {
167        Color::from_rgb_u8(90, 90, 95)
168    } else {
169        Color::WHITE
170    };
171    let track = Modifier::empty()
172        .height(track_height)
173        .draw_behind(move |scope| {
174            scope.draw_round_rect(
175                Brush::solid(track_fill),
176                CornerRadii::uniform(track_height * 0.5),
177            );
178        });
179
180    Box(modifier.then(track), BoxSpec::default(), move || {
181        let segments = Rc::clone(&segments);
182        let on_select = Rc::clone(&on_select);
183        let contact = Rc::clone(&contact);
184        BoxWithConstraints(Modifier::empty(), move |scope| {
185            let segments = Rc::clone(&segments);
186            let on_select = Rc::clone(&on_select);
187            let total_width = scope.constraints().max_width.max(1.0);
188            let segment_width = total_width / count as f32;
189            let selected_x = segment_width * selected as f32;
190            let lens_axis =
191                crate::motion::remember_liquid_follow_axis(selected_x, spring(1.0, 750.0));
192            let shape =
193                super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Segment);
194            if !pressed.get() {
195                lens_axis.settle_to(selected_x, LiquidMotion::glide());
196            }
197            let visual_index = {
198                let lens_axis = Rc::clone(&lens_axis);
199                cranpose_core::derivedStateOf(move || {
200                    segmented_lens_reading(
201                        &lens_axis,
202                        pressed.get(),
203                        selected,
204                        segment_width,
205                        count,
206                    )
207                })
208                .get()
209            };
210
211            let gesture = Modifier::empty()
212                .size(Size::new(total_width, SEGMENT_HEIGHT))
213                .pointer_input(selected, {
214                    let on_select = Rc::clone(&on_select);
215                    let lens_axis = Rc::clone(&lens_axis);
216                    let contact = Rc::clone(&contact);
217                    move |scope: PointerInputScope| {
218                        let on_select = Rc::clone(&on_select);
219                        let lens_axis = Rc::clone(&lens_axis);
220                        let contact = Rc::clone(&contact);
221                        crate::motion::liquid_lens_gesture(
222                            scope,
223                            crate::motion::LiquidLensGesture {
224                                axis: lens_axis,
225                                cell_width: segment_width,
226                                cell_offset: 0.0,
227                                count,
228                                tap_slop: TAP_SLOP,
229                                drag_left: Rc::new(move |x| {
230                                    segment_lens_left(x, segment_width, count)
231                                }),
232                                rest_left: Rc::new(move |index| segment_width * index as f32),
233                                selected,
234                                on_pressed: Rc::new(move |down, time| {
235                                    pressed.set(down);
236                                    contact.pressed(down, time);
237                                }),
238                                on_touch: Rc::new(|_, _| {}),
239                                on_select,
240                            },
241                        )
242                    }
243                });
244
245            let Size {
246                width: node_w,
247                height: node_h,
248            } = super::control_lens::node_size(
249                segmented_lens_base_size(segment_width, 0.0),
250                segmented_lens_base_size(segment_width, 1.0),
251                LENS_PAD,
252            );
253            let lens_for_layer = lens_progress;
254            let layer_axis = Rc::clone(&lens_axis);
255            let physics_axis = Rc::clone(&lens_axis);
256            let content_axis = Rc::clone(&lens_axis);
257            let content_shape = Rc::clone(&shape);
258            let foreground_shape = Rc::clone(&shape);
259            let foreground_axis = Rc::clone(&lens_axis);
260            let lens = Modifier::empty()
261                .required_size(Size::new(node_w, node_h))
262                .offset(
263                    (segment_width - node_w) * 0.5,
264                    (SEGMENT_HEIGHT - node_h) * 0.5,
265                )
266                .graphics_layer(move || GraphicsLayer {
267                    translation_x: layer_axis.value(),
268                    alpha: 1.0,
269                    ..Default::default()
270                })
271                .glass_effect_with(
272                    super::control_material::material(
273                        super::control_motion::ControlContactKind::Segment,
274                    ),
275                    move || {
276                        let grow = lens_for_layer.get().clamp(-0.1, 1.2);
277                        let base_size = segmented_lens_base_size(segment_width, grow);
278                        let projection = shape.projection(physics_axis.value());
279                        let material = material_progress.get().max(0.0);
280                        GlassDynamics {
281                            optical_projection: Some(projection),
282                            resting_tint: Some(marker_fill),
283                            morph: Some(GlassMorph {
284                                node_size: (node_w, node_h),
285                                primary: (
286                                    node_w * 0.5,
287                                    node_h * 0.5,
288                                    base_size.width,
289                                    base_size.height,
290                                    -1.0,
291                                ),
292                                shapes: Vec::new(),
293                                glue: 0.0,
294                                wobble_amplitude: 0.0,
295                                wobble_phase: 0.0,
296                                bulge_amplitude: 0.0,
297                                bulge_direction: 0.0,
298                                ellipse_blend: 0.0,
299                                capsule_smoothing_dp: 0.0,
300                                deformation: None,
301                                zoom_anchor: (0.0, 0.0),
302                            }),
303                            ..super::control_material::dynamics(
304                                super::control_motion::ControlContactKind::Segment,
305                                grow,
306                                material,
307                            )
308                        }
309                    },
310                );
311            Box(lens, BoxSpec::default(), || {});
312            let semantic_selection = Rc::clone(&on_select);
313            Row(
314                Modifier::empty()
315                    .selectable_group()
316                    .graphics_layer(move || {
317                        let position = content_axis.value();
318                        GraphicsLayer {
319                            render_effect: super::control_material::content_effect(
320                                Size::new(total_width, SEGMENT_HEIGHT),
321                                (position + segment_width * 0.5, SEGMENT_HEIGHT * 0.5),
322                                segmented_lens_base_size(segment_width, lens_progress.get()),
323                                content_shape.projection(position),
324                                material_progress.get().max(0.0),
325                            ),
326                            ..Default::default()
327                        }
328                    }),
329                RowSpec::default(),
330                move || {
331                    for (index, segment) in segments.iter().enumerate() {
332                        let is_selected = index == visual_index;
333                        let description = segment.description.clone();
334                        let on_select = Rc::clone(&semantic_selection);
335                        let cell = Modifier::empty()
336                            .size(Size::new(segment_width, SEGMENT_HEIGHT))
337                            .stable_semantics(super::selection::selection_semantics(
338                                description,
339                                SemanticsWidgetRole::RadioButton,
340                                index,
341                                selected,
342                                on_select,
343                            ))
344                            .focusable();
345                        let content = Rc::clone(&segment.content);
346                        Box(
347                            cell,
348                            BoxSpec::default().content_alignment(Alignment::CENTER),
349                            move || content(is_selected),
350                        );
351                    }
352                },
353            );
354            Box(
355                Modifier::empty()
356                    .required_size(Size::new(node_w, node_h))
357                    .offset(
358                        (segment_width - node_w) * 0.5,
359                        (SEGMENT_HEIGHT - node_h) * 0.5,
360                    )
361                    .graphics_layer(move || {
362                        let position = foreground_axis.value();
363                        GraphicsLayer {
364                            translation_x: position,
365                            backdrop_effect: super::control_material::foreground_effect(
366                                Size::new(node_w, node_h),
367                                segmented_lens_base_size(segment_width, lens_progress.get()),
368                                foreground_shape.projection(position),
369                                material_progress.get().max(0.0),
370                            ),
371                            ..Default::default()
372                        }
373                    }),
374                BoxSpec::default(),
375                || {},
376            );
377            Box(gesture, BoxSpec::default(), || {});
378        });
379    });
380}
381
382#[cfg(test)]
383#[path = "tests/segmented_tests.rs"]
384mod tests;