Skip to main content

cranpose_liquid/widgets/
segmented.rs

1//! Segmented control with a liquid selection blob: the glass pill behind the
2//! selected segment runs its leading and trailing edges on different springs,
3//! so it stretches like a droplet while traveling and settles round. Touching
4//! it lifts the indicator into a magnifying glass lens that follows the finger
5//! across the segments (the reference control swipes, it doesn't just tap).
6
7use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
8use crate::motion::LiquidMotion;
9use crate::theme::{liquid_colors, liquid_typography};
10use cranpose_animation::{animateFloatAsState, spring};
11use cranpose_core::{mutableStateOf, remember};
12use cranpose_foundation::{PointerEventKind, PointerId};
13use cranpose_macros::composable;
14use cranpose_services::{default_haptics, HapticFeedback};
15use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
16use cranpose_ui::widgets::{
17    Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text,
18};
19use cranpose_ui::{Modifier, PointerInputScope, Size};
20use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
21use cranpose_ui_layout::Alignment;
22use std::rc::Rc;
23
24const SEGMENT_HEIGHT: f32 = 36.0;
25const TRACK_PADDING: f32 = 2.0;
26/// How far the interaction lens pokes past the track vertically.
27const LENS_OVERFLOW: f32 = 8.0;
28/// Touch raises the whole optical body before directional deformation. This
29/// preserves the control's volume without letting maximum horizontal strain
30/// squash the lens below the track height.
31const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
32const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
33/// Glass node span beyond the lens shape (rim glow + bulge live here).
34const LENS_PAD: f32 = 10.0;
35/// A segmented selection stays recognizably one cell wide while its surface
36/// carries the shared incompressible fluid strain.
37const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
38/// Pointer travel below this is a tap, not a swipe.
39const TAP_SLOP: f32 = 4.0;
40
41fn plain_indicator_alpha(lens_progress: f32) -> f32 {
42    ((0.18 - lens_progress) / 0.16).clamp(0.0, 1.0)
43}
44
45fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
46    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
47}
48
49fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
50    let progress = progress.clamp(0.0, 1.2);
51    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
52    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
53    Size::new(
54        (segment_width + 4.0 * progress) * width_lift,
55        (SEGMENT_HEIGHT + LENS_OVERFLOW * progress) * height_lift,
56    )
57}
58
59fn segmented_strain(stretch: f32) -> f32 {
60    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
61}
62
63/// A segmented control. `labels` are equal-width segments; `selected` is the
64/// active index; `on_select` receives the committed index. Segments tap AND
65/// swipe: dragging slides the indicator with the finger as a glass lens.
66#[composable]
67#[allow(non_snake_case)]
68pub fn LiquidSegmentedControl(
69    modifier: Modifier,
70    labels: Vec<String>,
71    selected: usize,
72    on_select: impl Fn(usize) + 'static,
73) {
74    let colors = liquid_colors();
75    let typography = liquid_typography();
76    let count = labels.len().max(1);
77    let selected = selected.min(count - 1);
78    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
79    let labels = Rc::new(labels);
80
81    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
82
83    let track_fill = colors.fill;
84    let track = Modifier::empty()
85        .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
86        .draw_behind(move |scope| {
87            scope.draw_round_rect(
88                Brush::solid(track_fill),
89                CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
90            );
91        });
92
93    Box(track.then(modifier), BoxSpec::default(), move || {
94        let labels = Rc::clone(&labels);
95        let typography = typography.clone();
96        let on_select = Rc::clone(&on_select);
97        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
98            let labels = Rc::clone(&labels);
99            let typography = typography.clone();
100            let on_select = Rc::clone(&on_select);
101            let total_width = scope.constraints().max_width.max(1.0);
102            let segment_width = total_width / count as f32;
103            let selected_x = segment_width * selected as f32;
104            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
105            lens_axis.settle_to(selected_x, LiquidMotion::glide());
106            let lens_x = lens_axis.value();
107            let visual_index = crate::motion::liquid_visual_index(
108                selected,
109                lens_x,
110                segment_width,
111                count,
112                crate::motion::liquid_axis_owns_visual_selection(
113                    pressed.get(),
114                    lens_x,
115                    selected_x,
116                    segment_width,
117                ),
118            );
119
120            // The resting indicator belongs to controlled state. The
121            // interaction lens has a separate direct-drag axis: it reads the
122            // raw pointer while held and only springs after release.
123            let leading = animateFloatAsState(
124                selected_x,
125                LiquidMotion::blob_leading(),
126                "segmented-leading",
127            );
128            let trailing = animateFloatAsState(
129                selected_x + segment_width,
130                LiquidMotion::blob_trailing(),
131                "segmented-trailing",
132            );
133
134            // Lens presence: up while touched, lingering decay on release
135            // (the indicator stays liquid through the settle flight).
136            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
137            let lens_target = if pressed.get() || lens_settling {
138                1.0
139            } else {
140                0.0
141            };
142            let lens_progress = animateFloatAsState(
143                lens_target,
144                if pressed.get() {
145                    spring(0.9, 1400.0)
146                } else {
147                    spring(1.0, 170.0)
148                },
149                "segmented-lens",
150            );
151
152            let indicator_color = if colors.is_dark {
153                Color::from_rgba_u8(90, 90, 96, 240)
154            } else {
155                Color::WHITE
156            };
157            let lens_for_indicator = lens_progress;
158            let indicator = Modifier::empty()
159                .size(Size::new(segment_width, SEGMENT_HEIGHT))
160                .graphics_layer(move || {
161                    let lead = leading.get();
162                    let trail = trailing.get().max(lead + 1.0);
163                    GraphicsLayer {
164                        translation_x: lead,
165                        scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
166                        // The plain fill hides while the lens is up.
167                        alpha: plain_indicator_alpha(lens_for_indicator.get()),
168                        // Scale from the leading edge so translation stays exact.
169                        transform_origin: cranpose_ui_graphics::TransformOrigin {
170                            pivot_fraction_x: 0.0,
171                            pivot_fraction_y: 0.5,
172                        },
173                        ..Default::default()
174                    }
175                })
176                .draw_behind(move |scope| {
177                    scope.draw_round_rect(
178                        Brush::solid(indicator_color),
179                        CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
180                    );
181                });
182            Box(indicator, BoxSpec::default(), || {});
183
184            // Labels row on top of the indicator. The cells keep button
185            // semantics (robot/a11y); pointer handling lives on the swipe
186            // surface below.
187            Row(Modifier::empty(), RowSpec::default(), move || {
188                for (index, label) in labels.iter().enumerate() {
189                    let is_selected = index == visual_index;
190                    let style = TextStyle {
191                        span_style: SpanStyle {
192                            color: Some(if is_selected {
193                                colors.label
194                            } else {
195                                colors.secondary_label
196                            }),
197                            font_weight: Some(if is_selected {
198                                FontWeight::SEMI_BOLD
199                            } else {
200                                FontWeight::MEDIUM
201                            }),
202                            ..typography.subheadline.span_style.clone()
203                        },
204                        ..typography.subheadline.clone()
205                    };
206                    let label_for_semantics = label.clone();
207                    let cell = Modifier::empty()
208                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
209                        .semantics(move |config| {
210                            config.is_button = true;
211                            config.is_clickable = true;
212                            config.content_description = Some(label_for_semantics.clone());
213                        });
214                    let label = label.clone();
215                    Box(
216                        cell,
217                        BoxSpec::default().content_alignment(Alignment::CENTER),
218                        move || {
219                            Text(label.clone(), Modifier::empty(), style.clone());
220                        },
221                    );
222                }
223            });
224
225            // Swipe/tap surface across the whole control.
226            let gesture = Modifier::empty()
227                .size(Size::new(total_width, SEGMENT_HEIGHT))
228                .pointer_input(selected, {
229                    let on_select = Rc::clone(&on_select);
230                    let lens_axis = Rc::clone(&lens_axis);
231                    move |scope: PointerInputScope| {
232                        let on_select = Rc::clone(&on_select);
233                        let lens_axis = Rc::clone(&lens_axis);
234                        async move {
235                            scope
236                                .await_pointer_event_scope(|await_scope| async move {
237                                    let mut down_x = 0.0f32;
238                                    let mut moved = false;
239                                    let mut active_pointer = Option::<PointerId>::None;
240                                    loop {
241                                        let event = await_scope.await_pointer_event().await;
242                                        match event.kind {
243                                            PointerEventKind::Down if active_pointer.is_none() => {
244                                                active_pointer = Some(event.id);
245                                                down_x = event.position.x;
246                                                moved = false;
247                                                pressed.set(true);
248                                                // Raise IN PLACE: a tap on another
249                                                // cell must FLY the lens there on
250                                                // release (reference tap-flight),
251                                                // never teleport it to the finger.
252                                                lens_axis.begin(lens_axis.value(), event.time_ms);
253                                                default_haptics()
254                                                    .perform(HapticFeedback::Selection);
255                                                event.consume();
256                                            }
257                                            PointerEventKind::Move
258                                                if active_pointer == Some(event.id) =>
259                                            {
260                                                moved |=
261                                                    (event.position.x - down_x).abs() > TAP_SLOP;
262                                                // Below the slop this is still a tap:
263                                                // feeding micro-jitter into the direct
264                                                // axis teleports the lens to the finger.
265                                                if moved {
266                                                    lens_axis.move_to(
267                                                        segment_lens_left(
268                                                            event.position.x,
269                                                            segment_width,
270                                                            count,
271                                                        ),
272                                                        event.time_ms,
273                                                    );
274                                                }
275                                                event.consume();
276                                            }
277                                            PointerEventKind::Up
278                                                if active_pointer == Some(event.id) =>
279                                            {
280                                                active_pointer = None;
281                                                pressed.set(false);
282                                                let travelled =
283                                                    (event.position.x - down_x).abs() > TAP_SLOP;
284                                                let position = if travelled {
285                                                    event.position.x
286                                                } else {
287                                                    down_x
288                                                };
289                                                let index =
290                                                    ((position / segment_width).floor().max(0.0)
291                                                        as usize)
292                                                        .min(count - 1);
293                                                lens_axis.release_to(
294                                                    segment_width * index as f32,
295                                                    event.time_ms,
296                                                    LiquidMotion::glide(),
297                                                );
298                                                default_haptics()
299                                                    .perform(HapticFeedback::ImpactLight);
300                                                on_select(index);
301                                                event.consume();
302                                            }
303                                            PointerEventKind::Cancel
304                                                if active_pointer == Some(event.id) =>
305                                            {
306                                                active_pointer = None;
307                                                pressed.set(false);
308                                                lens_axis.release_to(
309                                                    selected_x,
310                                                    event.time_ms,
311                                                    LiquidMotion::glide(),
312                                                );
313                                                event.consume();
314                                            }
315                                            _ => {}
316                                        }
317                                    }
318                                })
319                                .await;
320                        }
321                    }
322                });
323            Box(gesture, BoxSpec::default(), || {});
324
325            // The interaction lens riding the indicator: a glass capsule that
326            // magnifies the label under it and bulges along the travel.
327            let raised_size = segmented_lens_base_size(segment_width, 1.2);
328            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
329                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
330            let node_w = raised_size.width * deformation_headroom
331                + crate::dynamics::BULGE_MAX
332                + LENS_PAD * 2.0;
333            let node_h = raised_size.height * deformation_headroom
334                + crate::dynamics::BULGE_MAX
335                + LENS_PAD * 2.0;
336            let lens_for_layer = lens_progress;
337            let physics_axis = Rc::clone(&lens_axis);
338            let lens = Modifier::empty()
339                // required_size: taller than the track; the fixed-height
340                // host keeps the control's layout put.
341                .required_size(Size::new(node_w, node_h))
342                .offset(
343                    (segment_width - node_w) * 0.5,
344                    (SEGMENT_HEIGHT - node_h) * 0.5,
345                )
346                .graphics_layer(move || GraphicsLayer {
347                    translation_x: lens_x,
348                    alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
349                    ..Default::default()
350                })
351                .glass_effect_with(
352                    // The reference lens body is nearly invisible on the
353                    // white bar — no readable outline, no tint; it shows
354                    // itself only through strong glyph refraction and
355                    // saturated RGB fringes at the strokes (segmented-drag
356                    // sheet, T 500/2000ms).
357                    Glass::lens()
358                        .shape(LiquidShape::Capsule)
359                        .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
360                        .blur_radius(0.0)
361                        .refraction_depth(0.52)
362                        .refraction_curve(0.25)
363                        .fold_depth(5.0)
364                        .dispersion(0.85)
365                        .highlight(0.04)
366                        .lift(0.0)
367                        .no_clip(),
368                    move || {
369                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
370                        let base_size = segmented_lens_base_size(segment_width, grow);
371                        // Droplet law over the indicator ride
372                        // (crate::dynamics): speed stretches the capsule
373                        // along the travel, braking swells its front.
374                        let pose = physics_axis.liquid_pose();
375                        GlassDynamics {
376                            activity: Some(grow.clamp(0.0, 1.0)),
377                            morph: Some(GlassMorph {
378                                node_size: (node_w, node_h),
379                                primary: (
380                                    node_w * 0.5,
381                                    node_h * 0.5,
382                                    base_size.width,
383                                    base_size.height,
384                                    -1.0,
385                                ),
386                                shapes: Vec::new(),
387                                glue: 0.0,
388                                wobble_amplitude: 0.0,
389                                wobble_phase: 0.0,
390                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
391                                bulge_direction: pose.bulge_direction,
392                                ellipse_blend: 0.0,
393                                deformation: Some(
394                                    crate::material::GlassDeformation::incompressible(
395                                        pose.axis,
396                                        segmented_strain(pose.stretch),
397                                    ),
398                                ),
399                            }),
400                            ..Default::default()
401                        }
402                    },
403                );
404            Box(lens, BoxSpec::default(), || {});
405        });
406    });
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn pointer_position_is_the_clamped_lens_center() {
415        let width = 100.0;
416        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
417        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
418        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
419        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
420        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
421    }
422
423    #[test]
424    fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
425        assert_eq!(plain_indicator_alpha(1.0), 0.0);
426        assert_eq!(plain_indicator_alpha(0.5), 0.0);
427        assert_eq!(plain_indicator_alpha(0.2), 0.0);
428        assert!(plain_indicator_alpha(0.05) > 0.8);
429        assert_eq!(plain_indicator_alpha(0.0), 1.0);
430    }
431
432    #[test]
433    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
434        let resting = segmented_lens_base_size(120.0, 0.0);
435        let raised = segmented_lens_base_size(120.0, 1.0);
436        assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
437        assert!(raised.width < resting.width * 1.10);
438        assert!(raised.height > resting.height * 1.45);
439        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
440    }
441}