Skip to main content

cranpose_liquid/widgets/
segmented.rs

1use std::rc::Rc;
2
3use cranpose_animation::{Lerp, spring};
4use cranpose_core::{State, mutableStateOf, remember};
5use cranpose_macros::composable;
6use cranpose_ui::{
7    Modifier, PointerInputScope, SemanticsWidgetRole, SharedText, Size,
8    text::{FontWeight, TextStyle},
9    widgets::{Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text},
10};
11use cranpose_ui_graphics::{Brush, Color, ColorFilter, CornerRadii, GraphicsLayer};
12use cranpose_ui_layout::Alignment;
13
14use crate::{
15    material::{GlassDynamics, GlassMorph, LiquidModifierExt},
16    motion::LiquidMotion,
17    theme::{LiquidSegmentedStyle, liquid_segmented_style},
18    widgets::content_scope::ScopeContent,
19};
20
21const TRACK_PADDING: f32 = 2.0;
22const LENS_PAD: f32 = 10.0;
23const TAP_SLOP: f32 = 4.0;
24
25fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
26    (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
27}
28
29fn segmented_lens_reading(
30    lens_axis: &crate::motion::LiquidDragAxis,
31    pressed: bool,
32    selected: usize,
33    segment_width: f32,
34    count: usize,
35) -> usize {
36    let selected_x = segment_width * selected as f32;
37    let lens_x = lens_axis.value();
38    crate::motion::liquid_visual_index(
39        selected,
40        lens_x,
41        segment_width,
42        count,
43        crate::motion::liquid_axis_owns_visual_selection(
44            pressed,
45            lens_x,
46            selected_x,
47            segment_width,
48        ),
49    )
50}
51
52fn segmented_lens_base_size(segment_width: f32, height: f32, progress: f32) -> Size {
53    let progress = progress.clamp(-0.1, 1.2);
54    let rest_h = height - TRACK_PADDING * 2.0;
55    Size::new(
56        segment_width - TRACK_PADDING * 2.0 + 24.0 * progress,
57        rest_h + 16.0 * progress,
58    )
59}
60
61enum LiquidSegment {
62    /// A label the control draws in its style.
63    Label(SharedText),
64    /// Content the caller draws, and what accessibility announces for it.
65    Content {
66        description: String,
67        content: Rc<dyn Fn(bool)>,
68    },
69}
70
71impl LiquidSegment {
72    fn description(&self) -> String {
73        match self {
74            Self::Label(label) => label.to_string(),
75            Self::Content { description, .. } => description.clone(),
76        }
77    }
78}
79
80/// The scope a segmented control's content is declared in.
81///
82/// Each call adds one equal-width segment, in the order it is made, which is
83/// also the order the indices passed to `on_select` count in.
84pub struct LiquidSegmentedControlScope {
85    segments: ScopeContent<LiquidSegment>,
86}
87
88impl LiquidSegmentedControlScope {
89    /// A segment showing `label` in the control's
90    /// [`LiquidSegmentedStyle`]: the selected one is told apart by weight
91    /// and by the selected label color, never by dimming the rest.
92    pub fn segment(&self, label: impl Into<String>) {
93        self.segments
94            .push(LiquidSegment::Label(SharedText::from(label.into())));
95    }
96
97    /// A segment the caller draws.
98    ///
99    /// `selected` says whether this is the active segment, so content can
100    /// respond the way the built-in label's weight does. `description` is what
101    /// accessibility announces, which content alone cannot supply.
102    pub fn segment_content(
103        &self,
104        description: impl Into<String>,
105        content: impl Fn(bool) + 'static,
106    ) {
107        self.segments.push(LiquidSegment::Content {
108            description: description.into(),
109            content: Rc::new(content),
110        });
111    }
112}
113
114fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
115    ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
116}
117
118/// The labels' text, worked out once for every segment of a control.
119struct SegmentLabels {
120    text: TextStyle,
121    selected_text: TextStyle,
122    /// The chosen label's color at rest and while the lens is held, when
123    /// they differ.
124    held: Option<(Color, Color)>,
125}
126
127impl SegmentLabels {
128    fn new(style: &LiquidSegmentedStyle) -> Self {
129        let mut text = style.text.clone();
130        text.span_style.color = Some(style.label);
131        let mut selected_text = style.text.clone();
132        let weight = selected_text
133            .span_style
134            .font_weight
135            .unwrap_or(FontWeight::NORMAL);
136        selected_text.span_style.font_weight = Some(FontWeight((weight.0 + 100).min(900)));
137        selected_text.span_style.color = Some(style.selected_label);
138        Self {
139            text,
140            selected_text,
141            held: (style.selected_label != style.label)
142                .then_some((style.selected_label, style.label)),
143        }
144    }
145
146    /// Draws `label`. The chosen label follows the indicator: as a held lens
147    /// clears to glass, the label moves to the color of the others, which
148    /// reads on the track.
149    fn show(&self, label: &SharedText, selected: bool, lift: State<f32>) {
150        let modifier = match self.held.filter(|_| selected) {
151            Some((rest, held)) => Modifier::empty().graphics_layer(move || {
152                let lift = lift.get().clamp(0.0, 1.0);
153                GraphicsLayer {
154                    color_filter: (lift > 0.0).then(|| ColorFilter::tint(rest.lerp(&held, lift))),
155                    ..Default::default()
156                }
157            }),
158            None => Modifier::empty(),
159        };
160        let text = if selected {
161            &self.selected_text
162        } else {
163            &self.text
164        };
165        Text(label.clone(), modifier, text.clone());
166    }
167}
168
169/// A segmented control. `content` declares equal-width segments; `selected` is
170/// the active index; `on_select` receives the committed index. Segments tap AND
171/// swipe: dragging slides the indicator with the finger as a glass lens. The
172/// look comes from [`liquid_segmented_style`].
173///
174/// ```rust,ignore
175/// LiquidSegmentedControl(Modifier::empty().width(310.0), selected.get(), move |index| {
176///     selected.set(index)
177/// }, |scope| {
178///     scope.segment("Receiving");
179///     scope.segment("Sending");
180///     scope.segment("Errored");
181/// });
182/// ```
183#[composable]
184pub fn LiquidSegmentedControl(
185    modifier: Modifier,
186    selected: usize,
187    on_select: impl Fn(usize) + 'static,
188    content: impl FnOnce(&LiquidSegmentedControlScope),
189) {
190    let style = liquid_segmented_style();
191    let segments = collect_segments(content);
192    let count = segments.len().max(1);
193    let selected = selected.min(count - 1);
194    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
195    let segments = Rc::new(segments);
196
197    let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
198    let contact = super::control_motion::remember_control_contact(
199        super::control_motion::ControlContactKind::Segment,
200    );
201    let (lens_progress, material_progress) = contact.states();
202
203    let height = style.height;
204    let track_fill = style.track;
205    let marker_fill = style.indicator;
206    let labels = Rc::new(SegmentLabels::new(&style));
207    let track = Modifier::empty().height(height).draw_behind(move |scope| {
208        scope.draw_round_rect(Brush::solid(track_fill), CornerRadii::uniform(height * 0.5));
209    });
210
211    Box(modifier.then(track), BoxSpec::default(), move || {
212        let segments = Rc::clone(&segments);
213        let labels = Rc::clone(&labels);
214        let on_select = Rc::clone(&on_select);
215        let contact = Rc::clone(&contact);
216        BoxWithConstraints(Modifier::empty(), move |scope| {
217            let segments = Rc::clone(&segments);
218            let labels = Rc::clone(&labels);
219            let on_select = Rc::clone(&on_select);
220            let total_width = scope.constraints().max_width.max(1.0);
221            let segment_width = total_width / count as f32;
222            let selected_x = segment_width * selected as f32;
223            let lens_axis =
224                crate::motion::remember_liquid_follow_axis(selected_x, spring(1.0, 750.0));
225            let shape =
226                super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Segment);
227            if !pressed.get() {
228                lens_axis.settle_to(selected_x, LiquidMotion::glide());
229            }
230            let visual_index = {
231                let lens_axis = Rc::clone(&lens_axis);
232                cranpose_core::derivedStateOf(move || {
233                    segmented_lens_reading(
234                        &lens_axis,
235                        pressed.get(),
236                        selected,
237                        segment_width,
238                        count,
239                    )
240                })
241                .get()
242            };
243
244            let gesture = Modifier::empty()
245                .size(Size::new(total_width, height))
246                .pointer_input(selected, {
247                    let on_select = Rc::clone(&on_select);
248                    let lens_axis = Rc::clone(&lens_axis);
249                    let contact = Rc::clone(&contact);
250                    move |scope: PointerInputScope| {
251                        let on_select = Rc::clone(&on_select);
252                        let lens_axis = Rc::clone(&lens_axis);
253                        let contact = Rc::clone(&contact);
254                        crate::motion::liquid_lens_gesture(
255                            scope,
256                            crate::motion::LiquidLensGesture {
257                                axis: lens_axis,
258                                cell_width: segment_width,
259                                cell_offset: 0.0,
260                                count,
261                                tap_slop: TAP_SLOP,
262                                drag_left: Rc::new(move |x| {
263                                    segment_lens_left(x, segment_width, count)
264                                }),
265                                rest_left: Rc::new(move |index| segment_width * index as f32),
266                                selected,
267                                on_pressed: Rc::new(move |down, time| {
268                                    pressed.set(down);
269                                    contact.pressed(down, time);
270                                }),
271                                on_touch: Rc::new(|_, _| {}),
272                                on_select,
273                            },
274                        )
275                    }
276                });
277
278            let Size {
279                width: node_w,
280                height: node_h,
281            } = super::control_lens::node_size(
282                segmented_lens_base_size(segment_width, height, 0.0),
283                segmented_lens_base_size(segment_width, height, 1.0),
284                LENS_PAD,
285            );
286            let lens_for_layer = lens_progress;
287            let layer_axis = Rc::clone(&lens_axis);
288            let physics_axis = Rc::clone(&lens_axis);
289            let content_axis = Rc::clone(&lens_axis);
290            let content_shape = Rc::clone(&shape);
291            let foreground_shape = Rc::clone(&shape);
292            let foreground_axis = Rc::clone(&lens_axis);
293            let lens = Modifier::empty()
294                .required_size(Size::new(node_w, node_h))
295                .offset((segment_width - node_w) * 0.5, (height - node_h) * 0.5)
296                .graphics_layer(move || GraphicsLayer {
297                    translation_x: layer_axis.value(),
298                    alpha: 1.0,
299                    ..Default::default()
300                })
301                .glass_effect_with(
302                    super::control_material::material(
303                        super::control_motion::ControlContactKind::Segment,
304                    ),
305                    move || {
306                        let grow = lens_for_layer.get().clamp(-0.1, 1.2);
307                        let base_size = segmented_lens_base_size(segment_width, height, grow);
308                        let projection = shape.projection(physics_axis.value());
309                        let material = material_progress.get().max(0.0);
310                        GlassDynamics {
311                            optical_projection: Some(projection),
312                            resting_tint: Some(marker_fill),
313                            morph: Some(GlassMorph {
314                                node_size: (node_w, node_h),
315                                primary: (
316                                    node_w * 0.5,
317                                    node_h * 0.5,
318                                    base_size.width,
319                                    base_size.height,
320                                    -1.0,
321                                ),
322                                shapes: Vec::new(),
323                                glue: 0.0,
324                                wobble_amplitude: 0.0,
325                                wobble_phase: 0.0,
326                                bulge_amplitude: 0.0,
327                                bulge_direction: 0.0,
328                                ellipse_blend: 0.0,
329                                capsule_smoothing_dp: 0.0,
330                                deformation: None,
331                                zoom_anchor: (0.0, 0.0),
332                            }),
333                            ..super::control_material::dynamics(
334                                super::control_motion::ControlContactKind::Segment,
335                                grow,
336                                material,
337                            )
338                        }
339                    },
340                );
341            Box(lens, BoxSpec::default(), || {});
342            let semantic_selection = Rc::clone(&on_select);
343            Row(
344                Modifier::empty()
345                    .selectable_group()
346                    .graphics_layer(move || {
347                        let position = content_axis.value();
348                        GraphicsLayer {
349                            render_effect: super::control_material::content_effect(
350                                Size::new(total_width, height),
351                                (position + segment_width * 0.5, height * 0.5),
352                                segmented_lens_base_size(
353                                    segment_width,
354                                    height,
355                                    lens_progress.get(),
356                                ),
357                                content_shape.projection(position),
358                                material_progress.get().max(0.0),
359                            ),
360                            ..Default::default()
361                        }
362                    }),
363                RowSpec::default(),
364                move || {
365                    for (index, segment) in segments.iter().enumerate() {
366                        let is_selected = index == visual_index;
367                        let description = segment.description();
368                        let on_select = Rc::clone(&semantic_selection);
369                        let cell = Modifier::empty()
370                            .size(Size::new(segment_width, height))
371                            .stable_semantics(super::selection::selection_semantics(
372                                description,
373                                SemanticsWidgetRole::RadioButton,
374                                index,
375                                selected,
376                                on_select,
377                            ))
378                            .focusable();
379                        let (drawn, labels) = (Rc::clone(&segments), Rc::clone(&labels));
380                        Box(
381                            cell,
382                            BoxSpec::default().content_alignment(Alignment::CENTER),
383                            move || match &drawn[index] {
384                                LiquidSegment::Label(label) => {
385                                    labels.show(label, is_selected, material_progress);
386                                }
387                                LiquidSegment::Content { content, .. } => content(is_selected),
388                            },
389                        );
390                    }
391                },
392            );
393            Box(
394                Modifier::empty()
395                    .required_size(Size::new(node_w, node_h))
396                    .offset((segment_width - node_w) * 0.5, (height - node_h) * 0.5)
397                    .graphics_layer(move || {
398                        let position = foreground_axis.value();
399                        GraphicsLayer {
400                            translation_x: position,
401                            backdrop_effect: super::control_material::foreground_effect(
402                                Size::new(node_w, node_h),
403                                segmented_lens_base_size(
404                                    segment_width,
405                                    height,
406                                    lens_progress.get(),
407                                ),
408                                foreground_shape.projection(position),
409                                material_progress.get().max(0.0),
410                            ),
411                            ..Default::default()
412                        }
413                    }),
414                BoxSpec::default(),
415                || {},
416            );
417            Box(gesture, BoxSpec::default(), || {});
418        });
419    });
420}
421
422#[cfg(test)]
423#[path = "tests/segmented_tests.rs"]
424mod tests;