Skip to main content

cranpose_liquid/widgets/
segmented.rs

1use std::rc::Rc;
2
3use cranpose_animation::{animateFloatAsState, spring};
4use cranpose_core::{mutableStateOf, remember};
5use cranpose_macros::composable;
6use cranpose_ui::{
7    Modifier, PointerInputScope, SemanticsWidgetRole, Size,
8    text::{FontWeight, SpanStyle, TextStyle},
9    widgets::{Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text},
10};
11use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
12use cranpose_ui_layout::Alignment;
13
14use crate::{
15    material::{Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape},
16    motion::LiquidMotion,
17    theme::{liquid_colors, liquid_typography},
18    widgets::content_scope::ScopeContent,
19};
20
21const SEGMENT_HEIGHT: f32 = 41.0;
22const TRACK_PADDING: f32 = 2.0;
23const MARKER_WIDTH_FACTOR: f32 = 1.16;
24const MARKER_REST_ACTIVITY: f32 = 0.55;
25const MARKER_POKE_TOP: f32 = 1.5;
26const MARKER_POKE_BOTTOM: f32 = 0.5;
27const LENS_OVERFLOW: f32 = 8.0;
28const LENS_ELLIPSE_BLEND: f32 = 0.55;
29const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
30const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
31const LENS_PAD: f32 = 10.0;
32const SEGMENTED_STRAIN_RESPONSE: f32 = 0.30;
33const TAP_SLOP: f32 = 4.0;
34
35fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
36    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
37}
38
39/// The segment the lens is over, and whether the lens is still gliding home
40/// after a release.
41fn segmented_lens_reading(
42    lens_axis: &crate::motion::LiquidDragAxis,
43    pressed: bool,
44    selected: usize,
45    segment_width: f32,
46    count: usize,
47) -> (usize, bool) {
48    let selected_x = segment_width * selected as f32;
49    let lens_x = lens_axis.value();
50    let visual_index = crate::motion::liquid_visual_index(
51        selected,
52        lens_x,
53        segment_width,
54        count,
55        crate::motion::liquid_axis_owns_visual_selection(
56            pressed,
57            lens_x,
58            selected_x,
59            segment_width,
60        ),
61    );
62    let settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
63    (visual_index, settling)
64}
65
66fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
67    let progress = progress.clamp(0.0, 1.2);
68    let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
69    let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
70    let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
71    Size::new(
72        (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
73        (rest_h + LENS_OVERFLOW * progress) * height_lift,
74    )
75}
76
77fn segmented_strain(stretch: f32) -> f32 {
78    1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
79}
80
81struct LiquidSegment {
82    description: String,
83    content: Rc<dyn Fn(bool)>,
84}
85
86/// The scope a segmented control's content is declared in.
87///
88/// Each call adds one equal-width segment, in the order it is made, which is
89/// also the order the indices passed to `on_select` count in.
90pub struct LiquidSegmentedControlScope {
91    segments: ScopeContent<LiquidSegment>,
92}
93
94impl LiquidSegmentedControlScope {
95    /// A segment showing `label`, styled by the control: the selected one is
96    /// told apart by weight, never by dimming the rest.
97    pub fn segment(&self, label: impl Into<String>) {
98        let label = label.into();
99        let text = label.clone();
100        self.segment_content(label, move |selected| {
101            SegmentLabel(text.clone(), selected);
102        });
103    }
104
105    /// A segment the caller draws.
106    ///
107    /// `selected` says whether this is the active segment, so content can
108    /// respond the way the built-in label's weight does. `description` is what
109    /// accessibility announces, which content alone cannot supply.
110    pub fn segment_content(
111        &self,
112        description: impl Into<String>,
113        content: impl Fn(bool) + 'static,
114    ) {
115        self.segments.push(LiquidSegment {
116            description: description.into(),
117            content: Rc::new(content),
118        });
119    }
120}
121
122fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
123    ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
124}
125
126#[composable]
127fn SegmentLabel(label: String, selected: bool) {
128    let colors = liquid_colors();
129    let typography = liquid_typography();
130    let style = TextStyle {
131        span_style: SpanStyle {
132            color: Some(colors.label),
133            font_size: cranpose_ui::text::TextUnit::Sp(17.0),
134            font_weight: Some(if selected {
135                FontWeight::MEDIUM
136            } else {
137                FontWeight::NORMAL
138            }),
139            ..typography.subheadline.span_style.clone()
140        },
141        ..typography.subheadline
142    };
143    Text(label, Modifier::empty(), style);
144}
145
146/// A segmented control. `content` declares equal-width segments; `selected` is
147/// the active index; `on_select` receives the committed index. Segments tap AND
148/// swipe: dragging slides the indicator with the finger as a glass lens.
149///
150/// ```rust,ignore
151/// LiquidSegmentedControl(Modifier::empty().width(310.0), selected.get(), move |index| {
152///     selected.set(index)
153/// }, |scope| {
154///     scope.segment("Receiving");
155///     scope.segment("Sending");
156///     scope.segment("Errored");
157/// });
158/// ```
159#[composable]
160pub fn LiquidSegmentedControl(
161    modifier: Modifier,
162    selected: usize,
163    on_select: impl Fn(usize) + 'static,
164    content: impl FnOnce(&LiquidSegmentedControlScope),
165) {
166    let colors = liquid_colors();
167    let segments = collect_segments(content);
168    let count = segments.len().max(1);
169    let selected = selected.min(count - 1);
170    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
171    let segments = Rc::new(segments);
172
173    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
174
175    let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
176    let track_fill = if colors.is_dark {
177        colors.fill
178    } else {
179        Color::WHITE
180    };
181    let track = if colors.is_dark {
182        Modifier::empty()
183    } else {
184        Modifier::empty().drop_shadow(
185            cranpose_ui_graphics::LayerShape::Rounded(
186                cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
187            ),
188            |scope| {
189                scope.radius = 6.0;
190                scope.offset.y = 4.5;
191                scope.color = Color::BLACK.with_alpha(0.10);
192            },
193        )
194    };
195    let track = track.height(track_height).draw_behind(move |scope| {
196        scope.draw_round_rect(
197            Brush::solid(track_fill),
198            CornerRadii::uniform(track_height * 0.5),
199        );
200    });
201
202    Box(track.then(modifier), BoxSpec::default(), move || {
203        let segments = Rc::clone(&segments);
204        let on_select = Rc::clone(&on_select);
205        BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
206            let segments = Rc::clone(&segments);
207            let on_select = Rc::clone(&on_select);
208            let total_width = scope.constraints().max_width.max(1.0);
209            let segment_width = total_width / count as f32;
210            let selected_x = segment_width * selected as f32;
211            let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
212            if !pressed.get() {
213                lens_axis.settle_to(selected_x, LiquidMotion::glide());
214            }
215            let (visual_index, lens_settling) = {
216                let lens_axis = Rc::clone(&lens_axis);
217                cranpose_core::derivedStateOf(move || {
218                    segmented_lens_reading(
219                        &lens_axis,
220                        pressed.get(),
221                        selected,
222                        segment_width,
223                        count,
224                    )
225                })
226                .get()
227            };
228            let lens_target = if pressed.get() || lens_settling {
229                1.0
230            } else {
231                0.0
232            };
233            let lens_progress = animateFloatAsState(
234                lens_target,
235                if pressed.get() || lens_settling {
236                    spring(0.9, 1400.0)
237                } else {
238                    spring(1.0, 170.0)
239                },
240                "segmented-lens",
241            );
242
243            let semantic_selection = Rc::clone(&on_select);
244            Row(
245                Modifier::empty().selectable_group(),
246                RowSpec::default(),
247                move || {
248                    for (index, segment) in segments.iter().enumerate() {
249                        let is_selected = index == visual_index;
250                        let description = segment.description.clone();
251                        let on_select = Rc::clone(&semantic_selection);
252                        let cell = Modifier::empty()
253                            .size(Size::new(segment_width, SEGMENT_HEIGHT))
254                            .semantics(super::selection::selection_semantics(
255                                description,
256                                SemanticsWidgetRole::RadioButton,
257                                index,
258                                selected,
259                                on_select,
260                            ))
261                            .focusable();
262                        let content = Rc::clone(&segment.content);
263                        Box(
264                            cell,
265                            BoxSpec::default().content_alignment(Alignment::CENTER),
266                            move || content(is_selected),
267                        );
268                    }
269                },
270            );
271
272            let gesture = Modifier::empty()
273                .size(Size::new(total_width, SEGMENT_HEIGHT))
274                .pointer_input(selected, {
275                    let on_select = Rc::clone(&on_select);
276                    let lens_axis = Rc::clone(&lens_axis);
277                    move |scope: PointerInputScope| {
278                        let on_select = Rc::clone(&on_select);
279                        let lens_axis = Rc::clone(&lens_axis);
280                        crate::motion::liquid_lens_gesture(
281                            scope,
282                            crate::motion::LiquidLensGesture {
283                                axis: lens_axis,
284                                cell_width: segment_width,
285                                cell_offset: 0.0,
286                                count,
287                                tap_slop: TAP_SLOP,
288                                drag_left: Rc::new(move |x| {
289                                    segment_lens_left(x, segment_width, count)
290                                }),
291                                rest_left: Rc::new(move |index| segment_width * index as f32),
292                                selected,
293                                on_pressed: Rc::new(move |down, _| pressed.set(down)),
294                                on_touch: Rc::new(|_, _| {}),
295                                on_select,
296                            },
297                        )
298                    }
299                });
300            Box(gesture, BoxSpec::default(), || {});
301
302            let raised_size = segmented_lens_base_size(segment_width, 1.2);
303            let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
304                .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
305            let node_w = raised_size.width * deformation_headroom
306                + crate::dynamics::BULGE_MAX
307                + LENS_PAD * 2.0;
308            let node_h = raised_size.height * deformation_headroom
309                + crate::dynamics::BULGE_MAX
310                + LENS_PAD * 2.0;
311            let lens_for_layer = lens_progress;
312            let layer_axis = Rc::clone(&lens_axis);
313            let physics_axis = Rc::clone(&lens_axis);
314            let lens = Modifier::empty()
315                .required_size(Size::new(node_w, node_h))
316                .offset(
317                    (segment_width - node_w) * 0.5,
318                    (SEGMENT_HEIGHT - node_h) * 0.5,
319                )
320                .graphics_layer(move || GraphicsLayer {
321                    translation_x: layer_axis.value(),
322                    alpha: 1.0,
323                    ..Default::default()
324                })
325                .glass_effect_with(
326                    Glass::lens()
327                        .shape(LiquidShape::Capsule)
328                        .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
329                        .shadow_style(GlassShadow::new(
330                            Color::BLACK.with_alpha(0.14),
331                            12.0,
332                            4.0,
333                            -2.0,
334                        ))
335                        .rim_reflection(0.04)
336                        .blur_radius(0.5)
337                        .refraction_depth(1.0)
338                        .refraction_curve(0.25)
339                        .fold_depth(5.0)
340                        .dispersion(0.85)
341                        .highlight(0.04)
342                        .lift(0.0)
343                        .no_clip(),
344                    move || {
345                        let grow = lens_for_layer.get().clamp(0.0, 1.2);
346                        let base_size = segmented_lens_base_size(segment_width, grow);
347                        let pose = physics_axis.liquid_pose();
348                        GlassDynamics {
349                            activity: Some(
350                                MARKER_REST_ACTIVITY
351                                    + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
352                            ),
353                            press_depth: Some(
354                                (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
355                                    .clamp(0.0, 1.0),
356                            ),
357                            morph: Some(GlassMorph {
358                                node_size: (node_w, node_h),
359                                primary: (
360                                    node_w * 0.5,
361                                    node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
362                                    base_size.width,
363                                    base_size.height,
364                                    -1.0,
365                                ),
366                                shapes: Vec::new(),
367                                glue: 0.0,
368                                wobble_amplitude: 0.0,
369                                wobble_phase: 0.0,
370                                bulge_amplitude: pose.bulge_amplitude.min(4.0),
371                                bulge_direction: pose.bulge_direction,
372                                ellipse_blend: LENS_ELLIPSE_BLEND,
373                                capsule_smoothing_dp: 0.0,
374                                deformation: Some(
375                                    crate::material::GlassDeformation::incompressible(
376                                        pose.axis,
377                                        segmented_strain(pose.stretch),
378                                    ),
379                                ),
380                                zoom_anchor: (0.0, 0.0),
381                            }),
382                            ..Default::default()
383                        }
384                    },
385                );
386            Box(lens, BoxSpec::default(), || {});
387        });
388    });
389}
390
391#[cfg(test)]
392#[path = "tests/segmented_tests.rs"]
393mod tests;