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_macros::composable;
13use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
14use cranpose_ui::widgets::{
15    Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text,
16};
17use cranpose_ui::{Modifier, PointerInputScope, Size};
18use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
19use cranpose_ui_layout::Alignment;
20use std::rc::Rc;
21
22const SEGMENT_HEIGHT: f32 = 36.0;
23const TRACK_PADDING: f32 = 2.0;
24/// Finger-halo circle diameter over the pill height while touched
25/// (segmented-drag f_025: the circle spans ~1.4x the track height).
26const SEGMENT_HALO_FACTOR: f32 = 1.4;
27/// How far the interaction lens pokes past the track vertically.
28const LENS_OVERFLOW: f32 = 8.0;
29/// Touch raises the whole optical body before directional deformation. This
30/// preserves the control's volume without letting maximum horizontal strain
31/// squash the lens below the track height.
32const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
33const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
34/// Glass node span beyond the lens shape (rim glow + bulge live here).
35const LENS_PAD: f32 = 10.0;
36/// A segmented selection stays recognizably one cell wide while its surface
37/// carries the shared incompressible fluid strain.
38const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
39/// Pointer travel below this is a tap, not a swipe.
40const TAP_SLOP: f32 = 4.0;
41
42/// The white pill dissolves with TRAVEL, never with the press itself: the
43/// reference keeps the pill (and the label through it) visible for the whole
44/// pressed dwell (tap-flight f_0000..f_0383), dissolves it at the origin as
45/// the lens departs, and re-materializes it under the destination as the
46/// lens arrives. Keying it on lens rise painted a white body over the label
47/// at the press instant.
48fn plain_indicator_alpha(travel_fraction: f32) -> f32 {
49    ((0.45 - travel_fraction.abs()) / 0.30).clamp(0.0, 1.0)
50}
51
52fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
53    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
54}
55
56fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
57    let progress = progress.clamp(0.0, 1.2);
58    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
59    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
60    Size::new(
61        (segment_width + 4.0 * progress) * width_lift,
62        (SEGMENT_HEIGHT + LENS_OVERFLOW * progress) * height_lift,
63    )
64}
65
66fn segmented_strain(stretch: f32) -> f32 {
67    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
68}
69
70/// A segmented control. `labels` are equal-width segments; `selected` is the
71/// active index; `on_select` receives the committed index. Segments tap AND
72/// swipe: dragging slides the indicator with the finger as a glass lens.
73#[composable]
74#[allow(non_snake_case)]
75pub fn LiquidSegmentedControl(
76    modifier: Modifier,
77    labels: Vec<String>,
78    selected: usize,
79    on_select: impl Fn(usize) + 'static,
80) {
81    let colors = liquid_colors();
82    let typography = liquid_typography();
83    let count = labels.len().max(1);
84    let selected = selected.min(count - 1);
85    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
86    let labels = Rc::new(labels);
87
88    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
89
90    let track_fill = colors.fill;
91    let track = Modifier::empty()
92        .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
93        .draw_behind(move |scope| {
94            scope.draw_round_rect(
95                Brush::solid(track_fill),
96                CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
97            );
98        });
99
100    Box(track.then(modifier), BoxSpec::default(), move || {
101        let labels = Rc::clone(&labels);
102        let typography = typography.clone();
103        let on_select = Rc::clone(&on_select);
104        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
105            let labels = Rc::clone(&labels);
106            let typography = typography.clone();
107            let on_select = Rc::clone(&on_select);
108            let total_width = scope.constraints().max_width.max(1.0);
109            let segment_width = total_width / count as f32;
110            let selected_x = segment_width * selected as f32;
111            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
112            if !pressed.get() {
113                lens_axis.settle_to(selected_x, LiquidMotion::glide());
114            }
115            let lens_x = lens_axis.value();
116            let visual_index = crate::motion::liquid_visual_index(
117                selected,
118                lens_x,
119                segment_width,
120                count,
121                crate::motion::liquid_axis_owns_visual_selection(
122                    pressed.get(),
123                    lens_x,
124                    selected_x,
125                    segment_width,
126                ),
127            );
128
129            // The resting indicator belongs to controlled state. The
130            // interaction lens has a separate direct-drag axis: it reads the
131            // raw pointer while held and only springs after release.
132            let leading = animateFloatAsState(
133                selected_x,
134                LiquidMotion::blob_leading(),
135                "segmented-leading",
136            );
137            let trailing = animateFloatAsState(
138                selected_x + segment_width,
139                LiquidMotion::blob_trailing(),
140                "segmented-trailing",
141            );
142
143            // Lens presence: up while touched, lingering decay on release
144            // (the indicator stays liquid through the settle flight).
145            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
146            let lens_target = if pressed.get() || lens_settling {
147                1.0
148            } else {
149                0.0
150            };
151            let lens_progress = animateFloatAsState(
152                lens_target,
153                if pressed.get() || lens_settling {
154                    // A tap must FLY the raised lens (reference tap-flight:
155                    // ~220ms crossing with the glyphs warping through it) —
156                    // the rise has to win the race against the flight.
157                    spring(0.9, 1400.0)
158                } else {
159                    spring(1.0, 170.0)
160                },
161                "segmented-lens",
162            );
163            // The finger halo: while touched, a circle rides the pill center
164            // and the union silhouette bulges above/below the track
165            // (segmented-drag f_025/f_055 — visible through transit and a
166            // parked hold, gone at rest).
167            let halo_presence = animateFloatAsState(
168                if pressed.get() { 1.0 } else { 0.0 },
169                spring(1.0, 900.0),
170                "segmented-halo",
171            );
172
173            let indicator_color = if colors.is_dark {
174                Color::from_rgba_u8(90, 90, 96, 240)
175            } else {
176                Color::WHITE
177            };
178            let indicator_axis = Rc::clone(&lens_axis);
179            // Direct manipulation: while the lens gesture owns the control
180            // (pressed, or still settling after release) the white pill IS
181            // the dragged body — it snaps to the lens's nearest cell and
182            // dissolves/materializes with the lens's distance to that cell
183            // (reference: pill visible through the pressed dwell AND parked
184            // mid-drag holds, gone mid-gap, re-forming under the landing
185            // cell). Controlled-state changes keep the blob springs.
186            let lens_engaged = pressed.get() || lens_settling;
187            let visual_cell_x = segment_width * visual_index as f32;
188            let indicator = Modifier::empty()
189                .size(Size::new(segment_width, SEGMENT_HEIGHT))
190                .graphics_layer(move || {
191                    let lead = leading.get();
192                    let trail = trailing.get().max(lead + 1.0);
193                    if lens_engaged {
194                        let travel =
195                            (indicator_axis.value() - visual_cell_x) / segment_width.max(1.0);
196                        GraphicsLayer {
197                            translation_x: visual_cell_x,
198                            alpha: plain_indicator_alpha(travel),
199                            ..Default::default()
200                        }
201                    } else {
202                        GraphicsLayer {
203                            translation_x: lead,
204                            scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
205                            alpha: 1.0,
206                            // Scale from the leading edge so translation
207                            // stays exact.
208                            transform_origin: cranpose_ui_graphics::TransformOrigin {
209                                pivot_fraction_x: 0.0,
210                                pivot_fraction_y: 0.5,
211                            },
212                            ..Default::default()
213                        }
214                    }
215                })
216                .draw_behind(move |scope| {
217                    scope.draw_round_rect(
218                        Brush::solid(indicator_color),
219                        CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
220                    );
221                });
222            Box(indicator, BoxSpec::default(), || {});
223
224            // Labels row on top of the indicator. The cells keep button
225            // semantics (robot/a11y); pointer handling lives on the swipe
226            // surface below.
227            Row(Modifier::empty(), RowSpec::default(), move || {
228                for (index, label) in labels.iter().enumerate() {
229                    let is_selected = index == visual_index;
230                    let style = TextStyle {
231                        span_style: SpanStyle {
232                            // Every reference label reads near-black
233                            // (tap-flight/drag strips) — selection is told
234                            // by weight and the pill, never by dimming the
235                            // unselected cells.
236                            color: Some(colors.label),
237                            font_weight: Some(if is_selected {
238                                FontWeight::SEMI_BOLD
239                            } else {
240                                FontWeight::MEDIUM
241                            }),
242                            ..typography.subheadline.span_style.clone()
243                        },
244                        ..typography.subheadline.clone()
245                    };
246                    let label_for_semantics = label.clone();
247                    let cell = Modifier::empty()
248                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
249                        .semantics(move |config| {
250                            config.is_button = true;
251                            config.is_clickable = true;
252                            config.content_description = Some(label_for_semantics.clone());
253                        });
254                    let label = label.clone();
255                    Box(
256                        cell,
257                        BoxSpec::default().content_alignment(Alignment::CENTER),
258                        move || {
259                            Text(label.clone(), Modifier::empty(), style.clone());
260                        },
261                    );
262                }
263            });
264
265            // Swipe/tap surface across the whole control: the shared lens
266            // gesture (crate::motion) with the segment clamp rules.
267            let gesture = Modifier::empty()
268                .size(Size::new(total_width, SEGMENT_HEIGHT))
269                .pointer_input(selected, {
270                    let on_select = Rc::clone(&on_select);
271                    let lens_axis = Rc::clone(&lens_axis);
272                    move |scope: PointerInputScope| {
273                        let on_select = Rc::clone(&on_select);
274                        let lens_axis = Rc::clone(&lens_axis);
275                        crate::motion::liquid_lens_gesture(
276                            scope,
277                            crate::motion::LiquidLensGesture {
278                                axis: lens_axis,
279                                cell_width: segment_width,
280                                count,
281                                tap_slop: TAP_SLOP,
282                                drag_left: Rc::new(move |x| {
283                                    segment_lens_left(x, segment_width, count)
284                                }),
285                                rest_left: Rc::new(move |index| segment_width * index as f32),
286                                selected,
287                                on_pressed: Rc::new(move |down| pressed.set(down)),
288                                on_touch: Rc::new(|_, _| {}),
289                                on_select,
290                            },
291                        )
292                    }
293                });
294            Box(gesture, BoxSpec::default(), || {});
295
296            // The interaction lens riding the indicator: a glass capsule that
297            // magnifies the label under it and bulges along the travel.
298            let raised_size = segmented_lens_base_size(segment_width, 1.2);
299            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
300                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
301            let node_w = raised_size.width * deformation_headroom
302                + crate::dynamics::BULGE_MAX
303                + LENS_PAD * 2.0;
304            let node_h = raised_size.height * deformation_headroom
305                + crate::dynamics::BULGE_MAX
306                + LENS_PAD * 2.0;
307            let lens_for_layer = lens_progress;
308            let physics_axis = Rc::clone(&lens_axis);
309            let lens = Modifier::empty()
310                // required_size: taller than the track; the fixed-height
311                // host keeps the control's layout put.
312                .required_size(Size::new(node_w, node_h))
313                .offset(
314                    (segment_width - node_w) * 0.5,
315                    (SEGMENT_HEIGHT - node_h) * 0.5,
316                )
317                .graphics_layer(move || GraphicsLayer {
318                    translation_x: lens_x,
319                    alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
320                    ..Default::default()
321                })
322                .glass_effect_with(
323                    // The reference lens body is nearly invisible on the
324                    // white bar — no readable outline, no tint; it shows
325                    // itself only through strong glyph refraction and
326                    // saturated RGB fringes at the strokes (segmented-drag
327                    // sheet, T 500/2000ms).
328                    Glass::lens()
329                        .shape(LiquidShape::Capsule)
330                        .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
331                        // The reference body is invisible inside the track:
332                        // no drop shadow under the riding lens.
333                        .shadow(false)
334                        .rim_reflection(0.12)
335                        // The full continuous wcKSRD dome (example/
336                        // shaders.txt): glyph warps and rim replay come from
337                        // ONE mapping; soft interior per the original's blur.
338                        .blur_radius(0.5)
339                        .refraction_depth(1.0)
340                        .refraction_curve(0.25)
341                        // The reference fold is a DEEP band: glyphs crossing
342                        // the rim collapse into a dense spectral blob
343                        // (segmented-drag f_011), not a thin outline.
344                        .fold_depth(5.0)
345                        .dispersion(0.85)
346                        .highlight(0.04)
347                        .lift(0.0)
348                        .no_clip(),
349                    move || {
350                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
351                        let base_size = segmented_lens_base_size(segment_width, grow);
352                        // Droplet law over the indicator ride
353                        // (crate::dynamics): speed stretches the capsule
354                        // along the travel, braking swells its front.
355                        let pose = physics_axis.liquid_pose();
356                        // The halo circle grows concentric with the pill;
357                        // below the track height it hides inside the capsule,
358                        // so presence needs no alpha of its own.
359                        let halo = halo_presence.get().clamp(0.0, 1.0);
360                        let halo_diameter = base_size.height * SEGMENT_HALO_FACTOR * halo;
361                        let shapes = if halo_diameter > base_size.height {
362                            vec![(
363                                node_w * 0.5,
364                                node_h * 0.5,
365                                halo_diameter,
366                                halo_diameter,
367                                -1.0,
368                            )]
369                        } else {
370                            Vec::new()
371                        };
372                        GlassDynamics {
373                            activity: Some(grow.clamp(0.0, 1.0)),
374                            // The lens paints NO body of its own: the white
375                            // pill lives BELOW the labels (plain indicator)
376                            // and stays visible through the pressed dwell —
377                            // a white resting_tint here sat ABOVE the labels
378                            // and flashed an opaque capsule over "Errored"
379                            // at the press instant.
380                            morph: Some(GlassMorph {
381                                node_size: (node_w, node_h),
382                                primary: (
383                                    node_w * 0.5,
384                                    node_h * 0.5,
385                                    base_size.width,
386                                    base_size.height,
387                                    -1.0,
388                                ),
389                                shapes,
390                                glue: 0.0,
391                                wobble_amplitude: 0.0,
392                                wobble_phase: 0.0,
393                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
394                                bulge_direction: pose.bulge_direction,
395                                ellipse_blend: 0.0,
396                                deformation: Some(
397                                    crate::material::GlassDeformation::incompressible(
398                                        pose.axis,
399                                        segmented_strain(pose.stretch),
400                                    ),
401                                ),
402                                zoom_anchor: (0.0, 0.0),
403                            }),
404                            ..Default::default()
405                        }
406                    },
407                );
408            Box(lens, BoxSpec::default(), || {});
409        });
410    });
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn pointer_position_is_the_clamped_lens_center() {
419        let width = 100.0;
420        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
421        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
422        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
423        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
424        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
425    }
426
427    #[test]
428    fn plain_indicator_dissolves_with_travel_not_with_the_press() {
429        // Pressed dwell (no travel): the pill and its label stay visible.
430        assert_eq!(plain_indicator_alpha(0.0), 1.0);
431        assert_eq!(plain_indicator_alpha(0.10), 1.0);
432        // Departing the origin: dissolved by mid-cell.
433        assert_eq!(plain_indicator_alpha(0.5), 0.0);
434        assert_eq!(plain_indicator_alpha(1.0), 0.0);
435        // Arriving works from either side.
436        assert_eq!(plain_indicator_alpha(-0.10), 1.0);
437        assert_eq!(plain_indicator_alpha(-0.5), 0.0);
438        // The fade band between dwell and gone is continuous.
439        assert!(plain_indicator_alpha(0.3) > 0.4);
440    }
441
442    #[test]
443    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
444        let resting = segmented_lens_base_size(120.0, 0.0);
445        let raised = segmented_lens_base_size(120.0, 1.0);
446        assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
447        assert!(raised.width < resting.width * 1.10);
448        assert!(raised.height > resting.height * 1.45);
449        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
450    }
451}