Skip to main content

cranpose_liquid/widgets/
segmented.rs

1use std::rc::Rc;
2
3use cranpose_animation::{animateFloatAsState, 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::{Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape},
16    motion::LiquidMotion,
17    theme::{liquid_colors, liquid_typography},
18    widgets::content_scope::ScopeContent,
19};
20
21const SEGMENT_HEIGHT: f32 = 41.0;
22const TRACK_PADDING: f32 = 2.0;
23const MARKER_WIDTH_FACTOR: f32 = 1.16;
24const MARKER_REST_ACTIVITY: f32 = 0.55;
25const MARKER_POKE_TOP: f32 = 1.5;
26const MARKER_POKE_BOTTOM: f32 = 0.5;
27const LENS_OVERFLOW: f32 = 8.0;
28const LENS_ELLIPSE_BLEND: f32 = 0.55;
29const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
30const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
31const LENS_PAD: f32 = 10.0;
32const SEGMENTED_STRAIN_RESPONSE: f32 = 0.30;
33const TAP_SLOP: f32 = 4.0;
34
35fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
36    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
37}
38
39fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
40    let progress = progress.clamp(0.0, 1.2);
41    let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
42    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
43    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
44    Size::new(
45        (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
46        (rest_h + LENS_OVERFLOW * progress) * height_lift,
47    )
48}
49
50fn segmented_strain(stretch: f32) -> f32 {
51    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
52}
53
54struct LiquidSegment {
55    description: String,
56    content: Rc<dyn Fn(bool)>,
57}
58
59/// The scope a segmented control's content is declared in.
60///
61/// Each call adds one equal-width segment, in the order it is made, which is
62/// also the order the indices passed to `on_select` count in.
63pub struct LiquidSegmentedControlScope {
64    segments: ScopeContent<LiquidSegment>,
65}
66
67impl LiquidSegmentedControlScope {
68    /// A segment showing `label`, styled by the control: the selected one is
69    /// told apart by weight, never by dimming the rest.
70    pub fn segment(&self, label: impl Into<String>) {
71        let label = label.into();
72        let text = label.clone();
73        self.segment_content(label, move |selected| {
74            SegmentLabel(text.clone(), selected);
75        });
76    }
77
78    /// A segment the caller draws.
79    ///
80    /// `selected` says whether this is the active segment, so content can
81    /// respond the way the built-in label's weight does. `description` is what
82    /// accessibility announces, which content alone cannot supply.
83    pub fn segment_content(
84        &self,
85        description: impl Into<String>,
86        content: impl Fn(bool) + 'static,
87    ) {
88        self.segments.push(LiquidSegment {
89            description: description.into(),
90            content: Rc::new(content),
91        });
92    }
93}
94
95fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
96    ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
97}
98
99#[composable]
100#[allow(non_snake_case)]
101fn SegmentLabel(label: String, selected: bool) {
102    let colors = liquid_colors();
103    let typography = liquid_typography();
104    let style = TextStyle {
105        span_style: SpanStyle {
106            color: Some(colors.label),
107            font_size: cranpose_ui::text::TextUnit::Sp(17.0),
108            font_weight: Some(if selected {
109                FontWeight::MEDIUM
110            } else {
111                FontWeight::NORMAL
112            }),
113            ..typography.subheadline.span_style.clone()
114        },
115        ..typography.subheadline.clone()
116    };
117    Text(label, Modifier::empty(), style);
118}
119
120/// A segmented control. `content` declares equal-width segments; `selected` is
121/// the active index; `on_select` receives the committed index. Segments tap AND
122/// swipe: dragging slides the indicator with the finger as a glass lens.
123///
124/// ```rust,ignore
125/// LiquidSegmentedControl(Modifier::empty().width(310.0), selected.get(), move |index| {
126///     selected.set(index)
127/// }, |scope| {
128///     scope.segment("Receiving");
129///     scope.segment("Sending");
130///     scope.segment("Errored");
131/// });
132/// ```
133#[composable]
134#[allow(non_snake_case)]
135pub fn LiquidSegmentedControl(
136    modifier: Modifier,
137    selected: usize,
138    on_select: impl Fn(usize) + 'static,
139    content: impl FnOnce(&LiquidSegmentedControlScope),
140) {
141    let colors = liquid_colors();
142    let segments = collect_segments(content);
143    let count = segments.len().max(1);
144    let selected = selected.min(count - 1);
145    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
146    let segments = Rc::new(segments);
147
148    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
149
150    let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
151    let track_fill = if colors.is_dark {
152        colors.fill
153    } else {
154        Color::WHITE
155    };
156    let track = if colors.is_dark {
157        Modifier::empty()
158    } else {
159        Modifier::empty().drop_shadow(
160            cranpose_ui_graphics::LayerShape::Rounded(
161                cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
162            ),
163            |scope| {
164                scope.radius = 6.0;
165                scope.offset.y = 4.5;
166                scope.color = Color::BLACK.with_alpha(0.10);
167            },
168        )
169    };
170    let track = track.height(track_height).draw_behind(move |scope| {
171        scope.draw_round_rect(
172            Brush::solid(track_fill),
173            CornerRadii::uniform(track_height * 0.5),
174        );
175    });
176
177    Box(track.then(modifier), BoxSpec::default(), move || {
178        let segments = Rc::clone(&segments);
179        let on_select = Rc::clone(&on_select);
180        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
181            let segments = Rc::clone(&segments);
182            let on_select = Rc::clone(&on_select);
183            let total_width = scope.constraints().max_width.max(1.0);
184            let segment_width = total_width / count as f32;
185            let selected_x = segment_width * selected as f32;
186            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
187            if !pressed.get() {
188                lens_axis.settle_to(selected_x, LiquidMotion::glide());
189            }
190            let lens_x = lens_axis.value();
191            let visual_index = crate::motion::liquid_visual_index(
192                selected,
193                lens_x,
194                segment_width,
195                count,
196                crate::motion::liquid_axis_owns_visual_selection(
197                    pressed.get(),
198                    lens_x,
199                    selected_x,
200                    segment_width,
201                ),
202            );
203
204            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
205            let lens_target = if pressed.get() || lens_settling {
206                1.0
207            } else {
208                0.0
209            };
210            let lens_progress = animateFloatAsState(
211                lens_target,
212                if pressed.get() || lens_settling {
213                    spring(0.9, 1400.0)
214                } else {
215                    spring(1.0, 170.0)
216                },
217                "segmented-lens",
218            );
219
220            let semantic_selection = Rc::clone(&on_select);
221            Row(
222                Modifier::empty().selectable_group(),
223                RowSpec::default(),
224                move || {
225                    for (index, segment) in segments.iter().enumerate() {
226                        let is_selected = index == visual_index;
227                        let description = segment.description.clone();
228                        let on_select = Rc::clone(&semantic_selection);
229                        let cell = Modifier::empty()
230                            .size(Size::new(segment_width, SEGMENT_HEIGHT))
231                            .semantics(super::selection::selection_semantics(
232                                description,
233                                SemanticsWidgetRole::RadioButton,
234                                index,
235                                selected,
236                                on_select,
237                            ))
238                            .focusable();
239                        let content = Rc::clone(&segment.content);
240                        Box(
241                            cell,
242                            BoxSpec::default().content_alignment(Alignment::CENTER),
243                            move || content(is_selected),
244                        );
245                    }
246                },
247            );
248
249            let gesture = Modifier::empty()
250                .size(Size::new(total_width, SEGMENT_HEIGHT))
251                .pointer_input(selected, {
252                    let on_select = Rc::clone(&on_select);
253                    let lens_axis = Rc::clone(&lens_axis);
254                    move |scope: PointerInputScope| {
255                        let on_select = Rc::clone(&on_select);
256                        let lens_axis = Rc::clone(&lens_axis);
257                        crate::motion::liquid_lens_gesture(
258                            scope,
259                            crate::motion::LiquidLensGesture {
260                                axis: lens_axis,
261                                cell_width: segment_width,
262                                cell_offset: 0.0,
263                                count,
264                                tap_slop: TAP_SLOP,
265                                drag_left: Rc::new(move |x| {
266                                    segment_lens_left(x, segment_width, count)
267                                }),
268                                rest_left: Rc::new(move |index| segment_width * index as f32),
269                                selected,
270                                on_pressed: Rc::new(move |down, _| pressed.set(down)),
271                                on_touch: Rc::new(|_, _| {}),
272                                on_select,
273                            },
274                        )
275                    }
276                });
277            Box(gesture, BoxSpec::default(), || {});
278
279            let raised_size = segmented_lens_base_size(segment_width, 1.2);
280            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
281                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
282            let node_w = raised_size.width * deformation_headroom
283                + crate::dynamics::BULGE_MAX
284                + LENS_PAD * 2.0;
285            let node_h = raised_size.height * deformation_headroom
286                + crate::dynamics::BULGE_MAX
287                + LENS_PAD * 2.0;
288            let lens_for_layer = lens_progress;
289            let physics_axis = Rc::clone(&lens_axis);
290            let lens = Modifier::empty()
291                .required_size(Size::new(node_w, node_h))
292                .offset(
293                    (segment_width - node_w) * 0.5,
294                    (SEGMENT_HEIGHT - node_h) * 0.5,
295                )
296                .graphics_layer(move || GraphicsLayer {
297                    translation_x: lens_x,
298                    alpha: 1.0,
299                    ..Default::default()
300                })
301                .glass_effect_with(
302                    Glass::lens()
303                        .shape(LiquidShape::Capsule)
304                        .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
305                        .shadow_style(GlassShadow::new(
306                            Color::BLACK.with_alpha(0.14),
307                            12.0,
308                            4.0,
309                            -2.0,
310                        ))
311                        .rim_reflection(0.04)
312                        .blur_radius(0.5)
313                        .refraction_depth(1.0)
314                        .refraction_curve(0.25)
315                        .fold_depth(5.0)
316                        .dispersion(0.85)
317                        .highlight(0.04)
318                        .lift(0.0)
319                        .no_clip(),
320                    move || {
321                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
322                        let base_size = segmented_lens_base_size(segment_width, grow);
323                        let pose = physics_axis.liquid_pose();
324                        GlassDynamics {
325                            activity: Some(
326                                MARKER_REST_ACTIVITY
327                                    + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
328                            ),
329                            press_depth: Some(
330                                (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
331                                    .clamp(0.0, 1.0),
332                            ),
333                            morph: Some(GlassMorph {
334                                node_size: (node_w, node_h),
335                                primary: (
336                                    node_w * 0.5,
337                                    node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
338                                    base_size.width,
339                                    base_size.height,
340                                    -1.0,
341                                ),
342                                shapes: Vec::new(),
343                                glue: 0.0,
344                                wobble_amplitude: 0.0,
345                                wobble_phase: 0.0,
346                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
347                                bulge_direction: pose.bulge_direction,
348                                ellipse_blend: LENS_ELLIPSE_BLEND,
349                                capsule_smoothing_dp: 0.0,
350                                deformation: Some(
351                                    crate::material::GlassDeformation::incompressible(
352                                        pose.axis,
353                                        segmented_strain(pose.stretch),
354                                    ),
355                                ),
356                                zoom_anchor: (0.0, 0.0),
357                            }),
358                            ..Default::default()
359                        }
360                    },
361                );
362            Box(lens, BoxSpec::default(), || {});
363        });
364    });
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn pointer_position_is_the_clamped_lens_center() {
373        let width = 100.0;
374        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
375        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
376        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
377        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
378        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
379    }
380
381    #[test]
382    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
383        let resting = segmented_lens_base_size(120.0, 0.0);
384        let raised = segmented_lens_base_size(120.0, 1.0);
385        assert_eq!(resting.width, 120.0 * MARKER_WIDTH_FACTOR);
386        assert!(resting.height > SEGMENT_HEIGHT + TRACK_PADDING * 2.0);
387        assert!(raised.width < resting.width * 1.10);
388        assert!(raised.height > resting.height * 1.20);
389        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.20);
390    }
391
392    #[test]
393    fn a_scope_records_what_each_segment_announces() {
394        let drawn = Rc::new(std::cell::Cell::new(0u32));
395        let counted = Rc::clone(&drawn);
396        let segments = collect_segments(|scope| {
397            scope.segment("Sending");
398            scope.segment_content("Received", move |selected| {
399                counted.set(counted.get() + u32::from(selected));
400            });
401        });
402
403        assert_eq!(segments.len(), 2);
404        assert_eq!(segments[0].description, "Sending");
405        assert_eq!(segments[1].description, "Received");
406        assert_eq!(drawn.get(), 0);
407        (segments[1].content)(true);
408        assert_eq!(drawn.get(), 1);
409    }
410
411    #[test]
412    fn a_control_with_no_segments_declares_none() {
413        assert!(collect_segments(|_| {}).is_empty());
414    }
415}