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