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/// How far the interaction lens pokes past the track vertically.
25const LENS_OVERFLOW: f32 = 8.0;
26/// Touch raises the whole optical body before directional deformation. This
27/// preserves the control's volume without letting maximum horizontal strain
28/// squash the lens below the track height.
29const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
30const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
31/// Glass node span beyond the lens shape (rim glow + bulge live here).
32const LENS_PAD: f32 = 10.0;
33/// A segmented selection stays recognizably one cell wide while its surface
34/// carries the shared incompressible fluid strain.
35const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
36/// Pointer travel below this is a tap, not a swipe.
37const TAP_SLOP: f32 = 4.0;
38
39fn plain_indicator_alpha(lens_progress: f32) -> f32 {
40    ((0.18 - lens_progress) / 0.16).clamp(0.0, 1.0)
41}
42
43fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
44    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
45}
46
47fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
48    let progress = progress.clamp(0.0, 1.2);
49    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
50    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
51    Size::new(
52        (segment_width + 4.0 * progress) * width_lift,
53        (SEGMENT_HEIGHT + LENS_OVERFLOW * progress) * height_lift,
54    )
55}
56
57fn segmented_strain(stretch: f32) -> f32 {
58    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
59}
60
61/// A segmented control. `labels` are equal-width segments; `selected` is the
62/// active index; `on_select` receives the committed index. Segments tap AND
63/// swipe: dragging slides the indicator with the finger as a glass lens.
64#[composable]
65#[allow(non_snake_case)]
66pub fn LiquidSegmentedControl(
67    modifier: Modifier,
68    labels: Vec<String>,
69    selected: usize,
70    on_select: impl Fn(usize) + 'static,
71) {
72    let colors = liquid_colors();
73    let typography = liquid_typography();
74    let count = labels.len().max(1);
75    let selected = selected.min(count - 1);
76    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
77    let labels = Rc::new(labels);
78
79    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
80
81    let track_fill = colors.fill;
82    let track = Modifier::empty()
83        .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
84        .draw_behind(move |scope| {
85            scope.draw_round_rect(
86                Brush::solid(track_fill),
87                CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
88            );
89        });
90
91    Box(track.then(modifier), BoxSpec::default(), move || {
92        let labels = Rc::clone(&labels);
93        let typography = typography.clone();
94        let on_select = Rc::clone(&on_select);
95        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
96            let labels = Rc::clone(&labels);
97            let typography = typography.clone();
98            let on_select = Rc::clone(&on_select);
99            let total_width = scope.constraints().max_width.max(1.0);
100            let segment_width = total_width / count as f32;
101            let selected_x = segment_width * selected as f32;
102            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
103            if !pressed.get() {
104                lens_axis.settle_to(selected_x, LiquidMotion::glide());
105            }
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() || lens_settling {
145                    // A tap must FLY the raised lens (reference tap-flight:
146                    // ~220ms crossing with the glyphs warping through it) —
147                    // the rise has to win the race against the flight.
148                    spring(0.9, 1400.0)
149                } else {
150                    spring(1.0, 170.0)
151                },
152                "segmented-lens",
153            );
154
155            let indicator_color = if colors.is_dark {
156                Color::from_rgba_u8(90, 90, 96, 240)
157            } else {
158                Color::WHITE
159            };
160            let lens_for_indicator = lens_progress;
161            let indicator = Modifier::empty()
162                .size(Size::new(segment_width, SEGMENT_HEIGHT))
163                .graphics_layer(move || {
164                    let lead = leading.get();
165                    let trail = trailing.get().max(lead + 1.0);
166                    GraphicsLayer {
167                        translation_x: lead,
168                        scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
169                        // The plain fill hides while the lens is up.
170                        alpha: plain_indicator_alpha(lens_for_indicator.get()),
171                        // Scale from the leading edge so translation stays exact.
172                        transform_origin: cranpose_ui_graphics::TransformOrigin {
173                            pivot_fraction_x: 0.0,
174                            pivot_fraction_y: 0.5,
175                        },
176                        ..Default::default()
177                    }
178                })
179                .draw_behind(move |scope| {
180                    scope.draw_round_rect(
181                        Brush::solid(indicator_color),
182                        CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
183                    );
184                });
185            Box(indicator, BoxSpec::default(), || {});
186
187            // Labels row on top of the indicator. The cells keep button
188            // semantics (robot/a11y); pointer handling lives on the swipe
189            // surface below.
190            Row(Modifier::empty(), RowSpec::default(), move || {
191                for (index, label) in labels.iter().enumerate() {
192                    let is_selected = index == visual_index;
193                    let style = TextStyle {
194                        span_style: SpanStyle {
195                            color: Some(if is_selected {
196                                colors.label
197                            } else {
198                                colors.secondary_label
199                            }),
200                            font_weight: Some(if is_selected {
201                                FontWeight::SEMI_BOLD
202                            } else {
203                                FontWeight::MEDIUM
204                            }),
205                            ..typography.subheadline.span_style.clone()
206                        },
207                        ..typography.subheadline.clone()
208                    };
209                    let label_for_semantics = label.clone();
210                    let cell = Modifier::empty()
211                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
212                        .semantics(move |config| {
213                            config.is_button = true;
214                            config.is_clickable = true;
215                            config.content_description = Some(label_for_semantics.clone());
216                        });
217                    let label = label.clone();
218                    Box(
219                        cell,
220                        BoxSpec::default().content_alignment(Alignment::CENTER),
221                        move || {
222                            Text(label.clone(), Modifier::empty(), style.clone());
223                        },
224                    );
225                }
226            });
227
228            // Swipe/tap surface across the whole control: the shared lens
229            // gesture (crate::motion) with the segment clamp rules.
230            let gesture = Modifier::empty()
231                .size(Size::new(total_width, SEGMENT_HEIGHT))
232                .pointer_input(selected, {
233                    let on_select = Rc::clone(&on_select);
234                    let lens_axis = Rc::clone(&lens_axis);
235                    move |scope: PointerInputScope| {
236                        let on_select = Rc::clone(&on_select);
237                        let lens_axis = Rc::clone(&lens_axis);
238                        crate::motion::liquid_lens_gesture(
239                            scope,
240                            crate::motion::LiquidLensGesture {
241                                axis: lens_axis,
242                                cell_width: segment_width,
243                                count,
244                                tap_slop: TAP_SLOP,
245                                drag_left: Rc::new(move |x| {
246                                    segment_lens_left(x, segment_width, count)
247                                }),
248                                rest_left: Rc::new(move |index| segment_width * index as f32),
249                                selected,
250                                on_pressed: Rc::new(move |down| pressed.set(down)),
251                                on_touch: Rc::new(|_, _| {}),
252                                on_select,
253                            },
254                        )
255                    }
256                });
257            Box(gesture, BoxSpec::default(), || {});
258
259            // The interaction lens riding the indicator: a glass capsule that
260            // magnifies the label under it and bulges along the travel.
261            let raised_size = segmented_lens_base_size(segment_width, 1.2);
262            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
263                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
264            let node_w = raised_size.width * deformation_headroom
265                + crate::dynamics::BULGE_MAX
266                + LENS_PAD * 2.0;
267            let node_h = raised_size.height * deformation_headroom
268                + crate::dynamics::BULGE_MAX
269                + LENS_PAD * 2.0;
270            let lens_for_layer = lens_progress;
271            let physics_axis = Rc::clone(&lens_axis);
272            let lens = Modifier::empty()
273                // required_size: taller than the track; the fixed-height
274                // host keeps the control's layout put.
275                .required_size(Size::new(node_w, node_h))
276                .offset(
277                    (segment_width - node_w) * 0.5,
278                    (SEGMENT_HEIGHT - node_h) * 0.5,
279                )
280                .graphics_layer(move || GraphicsLayer {
281                    translation_x: lens_x,
282                    alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
283                    ..Default::default()
284                })
285                .glass_effect_with(
286                    // The reference lens body is nearly invisible on the
287                    // white bar — no readable outline, no tint; it shows
288                    // itself only through strong glyph refraction and
289                    // saturated RGB fringes at the strokes (segmented-drag
290                    // sheet, T 500/2000ms).
291                    Glass::lens()
292                        .shape(LiquidShape::Capsule)
293                        .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
294                        // The reference body is invisible inside the track:
295                        // no drop shadow under the riding lens.
296                        .shadow(false)
297                        .rim_reflection(0.12)
298                        // The full continuous wcKSRD dome (example/
299                        // shaders.txt): glyph warps and rim replay come from
300                        // ONE mapping; soft interior per the original's blur.
301                        .blur_radius(0.5)
302                        .refraction_depth(1.0)
303                        .refraction_curve(0.25)
304                        .dispersion(0.85)
305                        .highlight(0.04)
306                        .lift(0.0)
307                        .no_clip(),
308                    move || {
309                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
310                        let base_size = segmented_lens_base_size(segment_width, grow);
311                        // Droplet law over the indicator ride
312                        // (crate::dynamics): speed stretches the capsule
313                        // along the travel, braking swells its front.
314                        let pose = physics_axis.liquid_pose();
315                        GlassDynamics {
316                            activity: Some(grow.clamp(0.0, 1.0)),
317                            morph: Some(GlassMorph {
318                                node_size: (node_w, node_h),
319                                primary: (
320                                    node_w * 0.5,
321                                    node_h * 0.5,
322                                    base_size.width,
323                                    base_size.height,
324                                    -1.0,
325                                ),
326                                shapes: Vec::new(),
327                                glue: 0.0,
328                                wobble_amplitude: 0.0,
329                                wobble_phase: 0.0,
330                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
331                                bulge_direction: pose.bulge_direction,
332                                ellipse_blend: 0.0,
333                                deformation: Some(
334                                    crate::material::GlassDeformation::incompressible(
335                                        pose.axis,
336                                        segmented_strain(pose.stretch),
337                                    ),
338                                ),
339                            }),
340                            ..Default::default()
341                        }
342                    },
343                );
344            Box(lens, BoxSpec::default(), || {});
345        });
346    });
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn pointer_position_is_the_clamped_lens_center() {
355        let width = 100.0;
356        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
357        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
358        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
359        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
360        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
361    }
362
363    #[test]
364    fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
365        assert_eq!(plain_indicator_alpha(1.0), 0.0);
366        assert_eq!(plain_indicator_alpha(0.5), 0.0);
367        assert_eq!(plain_indicator_alpha(0.2), 0.0);
368        assert!(plain_indicator_alpha(0.05) > 0.8);
369        assert_eq!(plain_indicator_alpha(0.0), 1.0);
370    }
371
372    #[test]
373    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
374        let resting = segmented_lens_base_size(120.0, 0.0);
375        let raised = segmented_lens_base_size(120.0, 1.0);
376        assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
377        assert!(raised.width < resting.width * 1.10);
378        assert!(raised.height > resting.height * 1.45);
379        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
380    }
381}