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::{
8    Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape,
9};
10use crate::motion::LiquidMotion;
11use crate::theme::{liquid_colors, liquid_typography};
12use cranpose_animation::{animateFloatAsState, spring};
13use cranpose_core::{mutableStateOf, remember};
14use cranpose_macros::composable;
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
24/// Reference control height: the marker capsule crop is 130px at the
25/// recording's 2.89 px/dp = 45dp total, 41dp inside the track padding.
26const SEGMENT_HEIGHT: f32 = 41.0;
27const TRACK_PADDING: f32 = 2.0;
28/// The resting marker IS the glass lens at a shallow rest state — a
29/// clear puck sitting on the white body with its own bevel, drop shadow
30/// and rim iridescence from the one glass light path (user-arbitrated:
31/// no flat draw reproduces those). It spans 120dp over the 103dp cell
32/// (tap-flight f_045: 347px against the 299px cell at 2.89 px/dp).
33const MARKER_WIDTH_FACTOR: f32 = 1.16;
34/// How much of the glass activity survives at rest: enough for the
35/// shallow bevel, shadow and the iridescent whisper on the rim; the
36/// touch raise runs it to 1.
37const MARKER_REST_ACTIVITY: f32 = 0.55;
38/// The marker crests past the body top (f_045 column: the shade rides
39/// over the body edge) and only whispers past the bottom — the poke is
40/// asymmetric, biased up, and subtle.
41const MARKER_POKE_TOP: f32 = 1.5;
42const MARKER_POKE_BOTTOM: f32 = 0.5;
43/// How far the interaction lens pokes past the track vertically. With the
44/// lift scale this lands the raised body at ~1.4x the track height — the
45/// reference finger oval (segmented-drag f_025). The oval is ONE
46/// continuous silhouette; a separate glued halo circle read as two-lobed
47/// "cheeks" bulging over the track.
48const LENS_OVERFLOW: f32 = 8.0;
49/// How far the raised lens SDF blends from the capsule toward a true
50/// ellipse (activity-scaled at the material layer; the resting lens is
51/// untouched). The reference riding body is a continuously curved oval,
52/// never a flat-topped capsule (segmented-drag f_025..f_055).
53const LENS_ELLIPSE_BLEND: f32 = 0.55;
54/// Touch raises the whole optical body before directional deformation. This
55/// preserves the control's volume without letting maximum horizontal strain
56/// squash the lens below the track height.
57const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
58const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
59/// Glass node span beyond the lens shape (rim glow + bulge live here).
60const LENS_PAD: f32 = 10.0;
61/// A segmented selection stays recognizably one cell wide while its surface
62/// carries the shared incompressible fluid strain.
63/// Reference mid-drag oval elongates to ~1.15x its rest width (f_025:
64/// 138dp over the 120dp rest against 103dp cells).
65const SEGMENTED_STRAIN_RESPONSE: f32 = 0.30;
66/// Pointer travel below this is a tap, not a swipe.
67const TAP_SLOP: f32 = 4.0;
68
69fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
70    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
71}
72
73fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
74    let progress = progress.clamp(0.0, 1.2);
75    // Rest: the shallow marker puck cresting past the body. Raised: the
76    // finger oval at ~1.4x the track height (segmented-drag f_025).
77    let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
78    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
79    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
80    Size::new(
81        (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
82        (rest_h + LENS_OVERFLOW * progress) * height_lift,
83    )
84}
85
86fn segmented_strain(stretch: f32) -> f32 {
87    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
88}
89
90/// A segmented control. `labels` are equal-width segments; `selected` is the
91/// active index; `on_select` receives the committed index. Segments tap AND
92/// swipe: dragging slides the indicator with the finger as a glass lens.
93#[composable]
94#[allow(non_snake_case)]
95pub fn LiquidSegmentedControl(
96    modifier: Modifier,
97    labels: Vec<String>,
98    selected: usize,
99    on_select: impl Fn(usize) + 'static,
100) {
101    let colors = liquid_colors();
102    let typography = liquid_typography();
103    let count = labels.len().max(1);
104    let selected = selected.min(count - 1);
105    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
106    let labels = Rc::new(labels);
107
108    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
109
110    // The light-scheme control body is a WHITE capsule floating on the
111    // page with a soft, below-biased drop shadow (drag f_130 corners:
112    // page ~235-240 falling to ~219-225 under the caps, body 254). The
113    // recessed marker and the raised lens live INSIDE this white body.
114    // Dark keeps a filled track for contrast.
115    let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
116    let track_fill = if colors.is_dark {
117        colors.fill
118    } else {
119        Color::WHITE
120    };
121    let track = if colors.is_dark {
122        Modifier::empty()
123    } else {
124        Modifier::empty().drop_shadow(
125            cranpose_ui_graphics::LayerShape::Rounded(
126                cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
127            ),
128            |scope| {
129                // Bottom-biased: the reference page is clean above the
130                // body's top edge (f_045 body-only column: 254 flat); the
131                // shadow pools only under the caps (219-225 on f_130).
132                scope.radius = 6.0;
133                scope.offset.y = 4.5;
134                scope.color = Color::BLACK.with_alpha(0.10);
135            },
136        )
137    };
138    let track = track.height(track_height).draw_behind(move |scope| {
139        scope.draw_round_rect(
140            Brush::solid(track_fill),
141            CornerRadii::uniform(track_height * 0.5),
142        );
143    });
144
145    Box(track.then(modifier), BoxSpec::default(), move || {
146        let labels = Rc::clone(&labels);
147        let typography = typography.clone();
148        let on_select = Rc::clone(&on_select);
149        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
150            let labels = Rc::clone(&labels);
151            let typography = typography.clone();
152            let on_select = Rc::clone(&on_select);
153            let total_width = scope.constraints().max_width.max(1.0);
154            let segment_width = total_width / count as f32;
155            let selected_x = segment_width * selected as f32;
156            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
157            if !pressed.get() {
158                lens_axis.settle_to(selected_x, LiquidMotion::glide());
159            }
160            let lens_x = lens_axis.value();
161            let visual_index = crate::motion::liquid_visual_index(
162                selected,
163                lens_x,
164                segment_width,
165                count,
166                crate::motion::liquid_axis_owns_visual_selection(
167                    pressed.get(),
168                    lens_x,
169                    selected_x,
170                    segment_width,
171                ),
172            );
173
174            // Raise state: up while touched, lingering decay on release
175            // (the glass stays deep through the settle flight, then
176            // relaxes back into the shallow resting puck).
177            let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
178            let lens_target = if pressed.get() || lens_settling {
179                1.0
180            } else {
181                0.0
182            };
183            let lens_progress = animateFloatAsState(
184                lens_target,
185                if pressed.get() || lens_settling {
186                    // A tap must FLY the raised lens (reference tap-flight:
187                    // ~220ms crossing with the glyphs warping through it) —
188                    // the rise has to win the race against the flight.
189                    spring(0.9, 1400.0)
190                } else {
191                    spring(1.0, 170.0)
192                },
193                "segmented-lens",
194            );
195
196            // Labels row under the glass. The cells keep button
197            // semantics (robot/a11y); pointer handling lives on the swipe
198            // surface below.
199            Row(Modifier::empty(), RowSpec::default(), move || {
200                for (index, label) in labels.iter().enumerate() {
201                    let is_selected = index == visual_index;
202                    let style = TextStyle {
203                        span_style: SpanStyle {
204                            // Every reference label reads near-black
205                            // (tap-flight/drag strips) — selection is told
206                            // by weight and the pill, never by dimming the
207                            // unselected cells.
208                            color: Some(colors.label),
209                            // Reference "Sending" spans 63dp of the 103dp
210                            // cell; the subheadline's 15dp rendered 55dp.
211                            font_size: cranpose_ui::text::TextUnit::Sp(17.0),
212                            // The reference weight step is a whisper
213                            // (regular -> medium); semibold-vs-medium read
214                            // as a black blob against the airy target.
215                            font_weight: Some(if is_selected {
216                                FontWeight::MEDIUM
217                            } else {
218                                FontWeight::NORMAL
219                            }),
220                            ..typography.subheadline.span_style.clone()
221                        },
222                        ..typography.subheadline.clone()
223                    };
224                    let label_for_semantics = label.clone();
225                    let cell = Modifier::empty()
226                        .size(Size::new(segment_width, SEGMENT_HEIGHT))
227                        .semantics(move |config| {
228                            config.is_button = true;
229                            config.is_clickable = true;
230                            config.content_description = Some(label_for_semantics.clone());
231                        });
232                    let label = label.clone();
233                    Box(
234                        cell,
235                        BoxSpec::default().content_alignment(Alignment::CENTER),
236                        move || {
237                            Text(label.clone(), Modifier::empty(), style.clone());
238                        },
239                    );
240                }
241            });
242
243            // Swipe/tap surface across the whole control: the shared lens
244            // gesture (crate::motion) with the segment clamp rules.
245            let gesture = Modifier::empty()
246                .size(Size::new(total_width, SEGMENT_HEIGHT))
247                .pointer_input(selected, {
248                    let on_select = Rc::clone(&on_select);
249                    let lens_axis = Rc::clone(&lens_axis);
250                    move |scope: PointerInputScope| {
251                        let on_select = Rc::clone(&on_select);
252                        let lens_axis = Rc::clone(&lens_axis);
253                        crate::motion::liquid_lens_gesture(
254                            scope,
255                            crate::motion::LiquidLensGesture {
256                                axis: lens_axis,
257                                cell_width: segment_width,
258                                count,
259                                tap_slop: TAP_SLOP,
260                                drag_left: Rc::new(move |x| {
261                                    segment_lens_left(x, segment_width, count)
262                                }),
263                                rest_left: Rc::new(move |index| segment_width * index as f32),
264                                selected,
265                                on_pressed: Rc::new(move |down| pressed.set(down)),
266                                on_touch: Rc::new(|_, _| {}),
267                                on_select,
268                            },
269                        )
270                    }
271                });
272            Box(gesture, BoxSpec::default(), || {});
273
274            // The interaction lens riding the indicator: a glass capsule that
275            // magnifies the label under it and bulges along the travel.
276            let raised_size = segmented_lens_base_size(segment_width, 1.2);
277            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
278                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
279            let node_w = raised_size.width * deformation_headroom
280                + crate::dynamics::BULGE_MAX
281                + LENS_PAD * 2.0;
282            let node_h = raised_size.height * deformation_headroom
283                + crate::dynamics::BULGE_MAX
284                + LENS_PAD * 2.0;
285            let lens_for_layer = lens_progress;
286            let physics_axis = Rc::clone(&lens_axis);
287            let lens = Modifier::empty()
288                // required_size: taller than the track; the fixed-height
289                // host keeps the control's layout put.
290                .required_size(Size::new(node_w, node_h))
291                .offset(
292                    (segment_width - node_w) * 0.5,
293                    (SEGMENT_HEIGHT - node_h) * 0.5,
294                )
295                .graphics_layer(move || GraphicsLayer {
296                    translation_x: lens_x,
297                    // The glass IS the resting marker — always present;
298                    // rest vs raised is a depth change, never an alpha one.
299                    alpha: 1.0,
300                    ..Default::default()
301                })
302                .glass_effect_with(
303                    // The reference lens body is nearly invisible on the
304                    // white bar — no readable outline, no tint; it shows
305                    // itself only through strong glyph refraction and
306                    // saturated RGB fringes at the strokes (segmented-drag
307                    // sheet, T 500/2000ms).
308                    Glass::lens()
309                        .shape(LiquidShape::Capsule)
310                        // The raised oval's interior reads a few percent
311                        // darker than the page (segmented-drag f_025..f_055
312                        // interiors 236..244 on 254) and it casts a soft
313                        // drop shadow while lifted.
314                        .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
315                        // The tab bubble's soft contact shadow (user: the
316                        // segmented bubble lacked a shadow; make it like the
317                        // bottom bar's "perfect shadow"). The old -5 spread
318                        // against a 6 radius cancelled to an invisible sliver.
319                        .shadow_style(GlassShadow::new(
320                            Color::BLACK.with_alpha(0.14),
321                            12.0,
322                            4.0,
323                            -2.0,
324                        ))
325                        .rim_reflection(0.04)
326                        // The full continuous wcKSRD dome (example/
327                        // shaders.txt): glyph warps and rim replay come from
328                        // ONE mapping; soft interior per the original's blur.
329                        .blur_radius(0.5)
330                        .refraction_depth(1.0)
331                        .refraction_curve(0.25)
332                        // The reference fold is a DEEP band: glyphs crossing
333                        // the rim collapse into a dense spectral blob
334                        // (segmented-drag f_011), not a thin outline.
335                        .fold_depth(5.0)
336                        .dispersion(0.85)
337                        .highlight(0.04)
338                        .lift(0.0)
339                        .no_clip(),
340                    move || {
341                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
342                        let base_size = segmented_lens_base_size(segment_width, grow);
343                        // Droplet law over the indicator ride
344                        // (crate::dynamics): speed stretches the capsule
345                        // along the travel, braking swells its front.
346                        let pose = physics_axis.liquid_pose();
347                        GlassDynamics {
348                            // Rest keeps a shallow floor of glass presence
349                            // (bevel + shadow + rim whisper); touch raises
350                            // depth to full.
351                            activity: Some(
352                                MARKER_REST_ACTIVITY
353                                    + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
354                            ),
355                            // Depth follows MOTION, not the raise: the
356                            // reference distorts the glyphs only while the
357                            // marker MOVES (segmented-drag spectral fringing),
358                            // and keeps the text crisp under a stationary
359                            // held press (tap-flight T83..T300 "Errored" stays
360                            // sharp). Keying the deep dome to grow alone ran a
361                            // motionless hold at full depth and washed the
362                            // label to gray.
363                            press_depth: Some(
364                                (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
365                                    .clamp(0.0, 1.0),
366                            ),
367                            // The lens paints NO body of its own: the white
368                            // pill lives BELOW the labels (plain indicator)
369                            // and stays visible through the pressed dwell —
370                            // a white resting_tint here sat ABOVE the labels
371                            // and flashed an opaque capsule over "Errored"
372                            // at the press instant.
373                            morph: Some(GlassMorph {
374                                node_size: (node_w, node_h),
375                                primary: (
376                                    node_w * 0.5,
377                                    node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
378                                    base_size.width,
379                                    base_size.height,
380                                    -1.0,
381                                ),
382                                shapes: Vec::new(),
383                                glue: 0.0,
384                                wobble_amplitude: 0.0,
385                                wobble_phase: 0.0,
386                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
387                                bulge_direction: pose.bulge_direction,
388                                ellipse_blend: LENS_ELLIPSE_BLEND,
389                                deformation: Some(
390                                    crate::material::GlassDeformation::incompressible(
391                                        pose.axis,
392                                        segmented_strain(pose.stretch),
393                                    ),
394                                ),
395                                zoom_anchor: (0.0, 0.0),
396                            }),
397                            ..Default::default()
398                        }
399                    },
400                );
401            Box(lens, BoxSpec::default(), || {});
402        });
403    });
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn pointer_position_is_the_clamped_lens_center() {
412        let width = 100.0;
413        assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
414        assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
415        assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
416        assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
417        assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
418    }
419
420    #[test]
421    fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
422        let resting = segmented_lens_base_size(120.0, 0.0);
423        let raised = segmented_lens_base_size(120.0, 1.0);
424        // Rest is the marker puck: 1.16x the cell, cresting past the body.
425        assert_eq!(resting.width, 120.0 * MARKER_WIDTH_FACTOR);
426        assert!(resting.height > SEGMENT_HEIGHT + TRACK_PADDING * 2.0);
427        // The raise deepens, it does not widen into a worm.
428        assert!(raised.width < resting.width * 1.10);
429        assert!(raised.height > resting.height * 1.20);
430        // Max fluid stretch elongates ~1.15x like the reference mid-drag
431        // oval — never a two-cell worm.
432        assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.20);
433    }
434}