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            if !pressed.get() {
106                lens_axis.settle_to(selected_x, LiquidMotion::glide());
107            }
108            let lens_x = lens_axis.value();
109            let visual_index = crate::motion::liquid_visual_index(
110                selected,
111                lens_x,
112                segment_width,
113                count,
114                crate::motion::liquid_axis_owns_visual_selection(
115                    pressed.get(),
116                    lens_x,
117                    selected_x,
118                    segment_width,
119                ),
120            );
121
122            // The resting indicator belongs to controlled state. The
123            // interaction lens has a separate direct-drag axis: it reads the
124            // raw pointer while held and only springs after release.
125            let leading = animateFloatAsState(
126                selected_x,
127                LiquidMotion::blob_leading(),
128                "segmented-leading",
129            );
130            let trailing = animateFloatAsState(
131                selected_x + segment_width,
132                LiquidMotion::blob_trailing(),
133                "segmented-trailing",
134            );
135
136            // Lens presence: up while touched, lingering decay on release
137            // (the indicator stays liquid through the settle flight).
138            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
139            let lens_target = if pressed.get() || lens_settling {
140                1.0
141            } else {
142                0.0
143            };
144            let lens_progress = animateFloatAsState(
145                lens_target,
146                if pressed.get() || lens_settling {
147                    // A tap must FLY the raised lens (reference tap-flight:
148                    // ~220ms crossing with the glyphs warping through it) —
149                    // the rise has to win the race against the flight.
150                    spring(0.9, 1400.0)
151                } else {
152                    spring(1.0, 170.0)
153                },
154                "segmented-lens",
155            );
156
157            let indicator_color = if colors.is_dark {
158                Color::from_rgba_u8(90, 90, 96, 240)
159            } else {
160                Color::WHITE
161            };
162            let lens_for_indicator = lens_progress;
163            let indicator = Modifier::empty()
164                .size(Size::new(segment_width, SEGMENT_HEIGHT))
165                .graphics_layer(move || {
166                    let lead = leading.get();
167                    let trail = trailing.get().max(lead + 1.0);
168                    GraphicsLayer {
169                        translation_x: lead,
170                        scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
171                        // The plain fill hides while the lens is up.
172                        alpha: plain_indicator_alpha(lens_for_indicator.get()),
173                        // Scale from the leading edge so translation stays exact.
174                        transform_origin: cranpose_ui_graphics::TransformOrigin {
175                            pivot_fraction_x: 0.0,
176                            pivot_fraction_y: 0.5,
177                        },
178                        ..Default::default()
179                    }
180                })
181                .draw_behind(move |scope| {
182                    scope.draw_round_rect(
183                        Brush::solid(indicator_color),
184                        CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
185                    );
186                });
187            Box(indicator, BoxSpec::default(), || {});
188
189            // Labels row on top of the indicator. The cells keep button
190            // semantics (robot/a11y); pointer handling lives on the swipe
191            // surface below.
192            Row(Modifier::empty(), RowSpec::default(), move || {
193                for (index, label) in labels.iter().enumerate() {
194                    let is_selected = index == visual_index;
195                    let style = TextStyle {
196                        span_style: SpanStyle {
197                            color: Some(if is_selected {
198                                colors.label
199                            } else {
200                                colors.secondary_label
201                            }),
202                            font_weight: Some(if is_selected {
203                                FontWeight::SEMI_BOLD
204                            } else {
205                                FontWeight::MEDIUM
206                            }),
207                            ..typography.subheadline.span_style.clone()
208                        },
209                        ..typography.subheadline.clone()
210                    };
211                    let label_for_semantics = label.clone();
212                    let cell = Modifier::empty()
213                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
214                        .semantics(move |config| {
215                            config.is_button = true;
216                            config.is_clickable = true;
217                            config.content_description = Some(label_for_semantics.clone());
218                        });
219                    let label = label.clone();
220                    Box(
221                        cell,
222                        BoxSpec::default().content_alignment(Alignment::CENTER),
223                        move || {
224                            Text(label.clone(), Modifier::empty(), style.clone());
225                        },
226                    );
227                }
228            });
229
230            // Swipe/tap surface across the whole control.
231            let gesture = Modifier::empty()
232                .size(Size::new(total_width, SEGMENT_HEIGHT))
233                .pointer_input(selected, {
234                    let on_select = Rc::clone(&on_select);
235                    let lens_axis = Rc::clone(&lens_axis);
236                    move |scope: PointerInputScope| {
237                        let on_select = Rc::clone(&on_select);
238                        let lens_axis = Rc::clone(&lens_axis);
239                        async move {
240                            scope
241                                .await_pointer_event_scope(|await_scope| async move {
242                                    let mut down_x = 0.0f32;
243                                    let mut moved = false;
244                                    let mut active_pointer = Option::<PointerId>::None;
245                                    loop {
246                                        let event = await_scope.await_pointer_event().await;
247                                        match event.kind {
248                                            PointerEventKind::Down if active_pointer.is_none() => {
249                                                active_pointer = Some(event.id);
250                                                down_x = event.position.x;
251                                                moved = false;
252                                                pressed.set(true);
253                                                // Touch-down ATTRACTS the lens: it
254                                                // glides toward the held finger
255                                                // (live report) — never a teleport;
256                                                // a real drag attaches directly
257                                                // once past the slop.
258                                                lens_axis.release_to(
259                                                    segment_lens_left(
260                                                        event.position.x,
261                                                        segment_width,
262                                                        count,
263                                                    ),
264                                                    event.time_ms,
265                                                    LiquidMotion::glide(),
266                                                );
267                                                default_haptics()
268                                                    .perform(HapticFeedback::Selection);
269                                                event.consume();
270                                            }
271                                            PointerEventKind::Move
272                                                if active_pointer == Some(event.id) =>
273                                            {
274                                                moved |=
275                                                    (event.position.x - down_x).abs() > TAP_SLOP;
276                                                // Below the slop this is still a tap:
277                                                // feeding micro-jitter into the direct
278                                                // axis teleports the lens to the finger.
279                                                if moved {
280                                                    if !lens_axis.is_dragging() {
281                                                        lens_axis.begin(
282                                                            lens_axis.value(),
283                                                            event.time_ms,
284                                                        );
285                                                    }
286                                                    lens_axis.move_to(
287                                                        segment_lens_left(
288                                                            event.position.x,
289                                                            segment_width,
290                                                            count,
291                                                        ),
292                                                        event.time_ms,
293                                                    );
294                                                }
295                                                event.consume();
296                                            }
297                                            PointerEventKind::Up
298                                                if active_pointer == Some(event.id) =>
299                                            {
300                                                active_pointer = None;
301                                                pressed.set(false);
302                                                let travelled =
303                                                    (event.position.x - down_x).abs() > TAP_SLOP;
304                                                let position = if travelled {
305                                                    event.position.x
306                                                } else {
307                                                    down_x
308                                                };
309                                                let index =
310                                                    ((position / segment_width).floor().max(0.0)
311                                                        as usize)
312                                                        .min(count - 1);
313                                                lens_axis.release_to(
314                                                    segment_width * index as f32,
315                                                    event.time_ms,
316                                                    LiquidMotion::glide(),
317                                                );
318                                                default_haptics()
319                                                    .perform(HapticFeedback::ImpactLight);
320                                                on_select(index);
321                                                event.consume();
322                                            }
323                                            PointerEventKind::Cancel
324                                                if active_pointer == Some(event.id) =>
325                                            {
326                                                active_pointer = None;
327                                                pressed.set(false);
328                                                lens_axis.release_to(
329                                                    selected_x,
330                                                    event.time_ms,
331                                                    LiquidMotion::glide(),
332                                                );
333                                                event.consume();
334                                            }
335                                            _ => {}
336                                        }
337                                    }
338                                })
339                                .await;
340                        }
341                    }
342                });
343            Box(gesture, BoxSpec::default(), || {});
344
345            // The interaction lens riding the indicator: a glass capsule that
346            // magnifies the label under it and bulges along the travel.
347            let raised_size = segmented_lens_base_size(segment_width, 1.2);
348            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
349                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
350            let node_w = raised_size.width * deformation_headroom
351                + crate::dynamics::BULGE_MAX
352                + LENS_PAD * 2.0;
353            let node_h = raised_size.height * deformation_headroom
354                + crate::dynamics::BULGE_MAX
355                + LENS_PAD * 2.0;
356            let lens_for_layer = lens_progress;
357            let physics_axis = Rc::clone(&lens_axis);
358            let lens = Modifier::empty()
359                // required_size: taller than the track; the fixed-height
360                // host keeps the control's layout put.
361                .required_size(Size::new(node_w, node_h))
362                .offset(
363                    (segment_width - node_w) * 0.5,
364                    (SEGMENT_HEIGHT - node_h) * 0.5,
365                )
366                .graphics_layer(move || GraphicsLayer {
367                    translation_x: lens_x,
368                    alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
369                    ..Default::default()
370                })
371                .glass_effect_with(
372                    // The reference lens body is nearly invisible on the
373                    // white bar — no readable outline, no tint; it shows
374                    // itself only through strong glyph refraction and
375                    // saturated RGB fringes at the strokes (segmented-drag
376                    // sheet, T 500/2000ms).
377                    Glass::lens()
378                        .shape(LiquidShape::Capsule)
379                        .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
380                        // The reference body is invisible inside the track:
381                        // no drop shadow under the riding lens.
382                        .shadow(false)
383                        .rim_reflection(0.12)
384                        // The full continuous wcKSRD dome (example/
385                        // shaders.txt): glyph warps and rim replay come from
386                        // ONE mapping; soft interior per the original's blur.
387                        .blur_radius(0.5)
388                        .refraction_depth(1.0)
389                        .refraction_curve(0.25)
390                        .dispersion(0.85)
391                        .highlight(0.04)
392                        .lift(0.0)
393                        .no_clip(),
394                    move || {
395                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
396                        let base_size = segmented_lens_base_size(segment_width, grow);
397                        // Droplet law over the indicator ride
398                        // (crate::dynamics): speed stretches the capsule
399                        // along the travel, braking swells its front.
400                        let pose = physics_axis.liquid_pose();
401                        GlassDynamics {
402                            activity: Some(grow.clamp(0.0, 1.0)),
403                            morph: Some(GlassMorph {
404                                node_size: (node_w, node_h),
405                                primary: (
406                                    node_w * 0.5,
407                                    node_h * 0.5,
408                                    base_size.width,
409                                    base_size.height,
410                                    -1.0,
411                                ),
412                                shapes: Vec::new(),
413                                glue: 0.0,
414                                wobble_amplitude: 0.0,
415                                wobble_phase: 0.0,
416                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
417                                bulge_direction: pose.bulge_direction,
418                                ellipse_blend: 0.0,
419                                deformation: Some(
420                                    crate::material::GlassDeformation::incompressible(
421                                        pose.axis,
422                                        segmented_strain(pose.stretch),
423                                    ),
424                                ),
425                            }),
426                            ..Default::default()
427                        }
428                    },
429                );
430            Box(lens, BoxSpec::default(), || {});
431        });
432    });
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn pointer_position_is_the_clamped_lens_center() {
441        let width = 100.0;
442        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
443        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
444        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
445        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
446        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
447    }
448
449    #[test]
450    fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
451        assert_eq!(plain_indicator_alpha(1.0), 0.0);
452        assert_eq!(plain_indicator_alpha(0.5), 0.0);
453        assert_eq!(plain_indicator_alpha(0.2), 0.0);
454        assert!(plain_indicator_alpha(0.05) > 0.8);
455        assert_eq!(plain_indicator_alpha(0.0), 1.0);
456    }
457
458    #[test]
459    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
460        let resting = segmented_lens_base_size(120.0, 0.0);
461        let raised = segmented_lens_base_size(120.0, 1.0);
462        assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
463        assert!(raised.width < resting.width * 1.10);
464        assert!(raised.height > resting.height * 1.45);
465        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
466    }
467}