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/// Finger-halo circle diameter over the pill height while touched
25/// (segmented-drag f_025: the circle spans ~1.4x the track height).
26const SEGMENT_HALO_FACTOR: f32 = 1.4;
27/// How far the interaction lens pokes past the track vertically.
28const LENS_OVERFLOW: f32 = 8.0;
29/// Touch raises the whole optical body before directional deformation. This
30/// preserves the control's volume without letting maximum horizontal strain
31/// squash the lens below the track height.
32const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
33const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
34/// Glass node span beyond the lens shape (rim glow + bulge live here).
35const LENS_PAD: f32 = 10.0;
36/// A segmented selection stays recognizably one cell wide while its surface
37/// carries the shared incompressible fluid strain.
38const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
39/// Pointer travel below this is a tap, not a swipe.
40const TAP_SLOP: f32 = 4.0;
41
42/// The white pill dissolves with TRAVEL, never with the press itself: the
43/// reference keeps the pill (and the label through it) visible for the whole
44/// pressed dwell (tap-flight f_0000..f_0383), dissolves it at the origin as
45/// the lens departs, and re-materializes it under the destination as the
46/// lens arrives. Keying it on lens rise painted a white body over the label
47/// at the press instant.
48fn plain_indicator_alpha(travel_fraction: f32) -> f32 {
49 ((0.45 - travel_fraction.abs()) / 0.30).clamp(0.0, 1.0)
50}
51
52fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
53 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
54}
55
56fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
57 let progress = progress.clamp(0.0, 1.2);
58 let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
59 let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
60 Size::new(
61 (segment_width + 4.0 * progress) * width_lift,
62 (SEGMENT_HEIGHT + LENS_OVERFLOW * progress) * height_lift,
63 )
64}
65
66fn segmented_strain(stretch: f32) -> f32 {
67 1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
68}
69
70/// A segmented control. `labels` are equal-width segments; `selected` is the
71/// active index; `on_select` receives the committed index. Segments tap AND
72/// swipe: dragging slides the indicator with the finger as a glass lens.
73#[composable]
74#[allow(non_snake_case)]
75pub fn LiquidSegmentedControl(
76 modifier: Modifier,
77 labels: Vec<String>,
78 selected: usize,
79 on_select: impl Fn(usize) + 'static,
80) {
81 let colors = liquid_colors();
82 let typography = liquid_typography();
83 let count = labels.len().max(1);
84 let selected = selected.min(count - 1);
85 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
86 let labels = Rc::new(labels);
87
88 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
89
90 let track_fill = colors.fill;
91 let track = Modifier::empty()
92 .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
93 .draw_behind(move |scope| {
94 scope.draw_round_rect(
95 Brush::solid(track_fill),
96 CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
97 );
98 });
99
100 Box(track.then(modifier), BoxSpec::default(), move || {
101 let labels = Rc::clone(&labels);
102 let typography = typography.clone();
103 let on_select = Rc::clone(&on_select);
104 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
105 let labels = Rc::clone(&labels);
106 let typography = typography.clone();
107 let on_select = Rc::clone(&on_select);
108 let total_width = scope.constraints().max_width.max(1.0);
109 let segment_width = total_width / count as f32;
110 let selected_x = segment_width * selected as f32;
111 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
112 if !pressed.get() {
113 lens_axis.settle_to(selected_x, LiquidMotion::glide());
114 }
115 let lens_x = lens_axis.value();
116 let visual_index = crate::motion::liquid_visual_index(
117 selected,
118 lens_x,
119 segment_width,
120 count,
121 crate::motion::liquid_axis_owns_visual_selection(
122 pressed.get(),
123 lens_x,
124 selected_x,
125 segment_width,
126 ),
127 );
128
129 // The resting indicator belongs to controlled state. The
130 // interaction lens has a separate direct-drag axis: it reads the
131 // raw pointer while held and only springs after release.
132 let leading = animateFloatAsState(
133 selected_x,
134 LiquidMotion::blob_leading(),
135 "segmented-leading",
136 );
137 let trailing = animateFloatAsState(
138 selected_x + segment_width,
139 LiquidMotion::blob_trailing(),
140 "segmented-trailing",
141 );
142
143 // Lens presence: up while touched, lingering decay on release
144 // (the indicator stays liquid through the settle flight).
145 let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
146 let lens_target = if pressed.get() || lens_settling {
147 1.0
148 } else {
149 0.0
150 };
151 let lens_progress = animateFloatAsState(
152 lens_target,
153 if pressed.get() || lens_settling {
154 // A tap must FLY the raised lens (reference tap-flight:
155 // ~220ms crossing with the glyphs warping through it) —
156 // the rise has to win the race against the flight.
157 spring(0.9, 1400.0)
158 } else {
159 spring(1.0, 170.0)
160 },
161 "segmented-lens",
162 );
163 // The finger halo: while touched, a circle rides the pill center
164 // and the union silhouette bulges above/below the track
165 // (segmented-drag f_025/f_055 — visible through transit and a
166 // parked hold, gone at rest).
167 let halo_presence = animateFloatAsState(
168 if pressed.get() { 1.0 } else { 0.0 },
169 spring(1.0, 900.0),
170 "segmented-halo",
171 );
172
173 let indicator_color = if colors.is_dark {
174 Color::from_rgba_u8(90, 90, 96, 240)
175 } else {
176 Color::WHITE
177 };
178 let indicator_axis = Rc::clone(&lens_axis);
179 // Direct manipulation: while the lens gesture owns the control
180 // (pressed, or still settling after release) the white pill IS
181 // the dragged body — it snaps to the lens's nearest cell and
182 // dissolves/materializes with the lens's distance to that cell
183 // (reference: pill visible through the pressed dwell AND parked
184 // mid-drag holds, gone mid-gap, re-forming under the landing
185 // cell). Controlled-state changes keep the blob springs.
186 let lens_engaged = pressed.get() || lens_settling;
187 let visual_cell_x = segment_width * visual_index as f32;
188 let indicator = Modifier::empty()
189 .size(Size::new(segment_width, SEGMENT_HEIGHT))
190 .graphics_layer(move || {
191 let lead = leading.get();
192 let trail = trailing.get().max(lead + 1.0);
193 if lens_engaged {
194 let travel =
195 (indicator_axis.value() - visual_cell_x) / segment_width.max(1.0);
196 GraphicsLayer {
197 translation_x: visual_cell_x,
198 alpha: plain_indicator_alpha(travel),
199 ..Default::default()
200 }
201 } else {
202 GraphicsLayer {
203 translation_x: lead,
204 scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
205 alpha: 1.0,
206 // Scale from the leading edge so translation
207 // stays exact.
208 transform_origin: cranpose_ui_graphics::TransformOrigin {
209 pivot_fraction_x: 0.0,
210 pivot_fraction_y: 0.5,
211 },
212 ..Default::default()
213 }
214 }
215 })
216 .draw_behind(move |scope| {
217 scope.draw_round_rect(
218 Brush::solid(indicator_color),
219 CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
220 );
221 });
222 Box(indicator, BoxSpec::default(), || {});
223
224 // Labels row on top of the indicator. The cells keep button
225 // semantics (robot/a11y); pointer handling lives on the swipe
226 // surface below.
227 Row(Modifier::empty(), RowSpec::default(), move || {
228 for (index, label) in labels.iter().enumerate() {
229 let is_selected = index == visual_index;
230 let style = TextStyle {
231 span_style: SpanStyle {
232 // Every reference label reads near-black
233 // (tap-flight/drag strips) — selection is told
234 // by weight and the pill, never by dimming the
235 // unselected cells.
236 color: Some(colors.label),
237 font_weight: Some(if is_selected {
238 FontWeight::SEMI_BOLD
239 } else {
240 FontWeight::MEDIUM
241 }),
242 ..typography.subheadline.span_style.clone()
243 },
244 ..typography.subheadline.clone()
245 };
246 let label_for_semantics = label.clone();
247 let cell = Modifier::empty()
248 .size(Size::new(segment_width, SEGMENT_HEIGHT))
249 .semantics(move |config| {
250 config.is_button = true;
251 config.is_clickable = true;
252 config.content_description = Some(label_for_semantics.clone());
253 });
254 let label = label.clone();
255 Box(
256 cell,
257 BoxSpec::default().content_alignment(Alignment::CENTER),
258 move || {
259 Text(label.clone(), Modifier::empty(), style.clone());
260 },
261 );
262 }
263 });
264
265 // Swipe/tap surface across the whole control: the shared lens
266 // gesture (crate::motion) with the segment clamp rules.
267 let gesture = Modifier::empty()
268 .size(Size::new(total_width, SEGMENT_HEIGHT))
269 .pointer_input(selected, {
270 let on_select = Rc::clone(&on_select);
271 let lens_axis = Rc::clone(&lens_axis);
272 move |scope: PointerInputScope| {
273 let on_select = Rc::clone(&on_select);
274 let lens_axis = Rc::clone(&lens_axis);
275 crate::motion::liquid_lens_gesture(
276 scope,
277 crate::motion::LiquidLensGesture {
278 axis: lens_axis,
279 cell_width: segment_width,
280 count,
281 tap_slop: TAP_SLOP,
282 drag_left: Rc::new(move |x| {
283 segment_lens_left(x, segment_width, count)
284 }),
285 rest_left: Rc::new(move |index| segment_width * index as f32),
286 selected,
287 on_pressed: Rc::new(move |down| pressed.set(down)),
288 on_touch: Rc::new(|_, _| {}),
289 on_select,
290 },
291 )
292 }
293 });
294 Box(gesture, BoxSpec::default(), || {});
295
296 // The interaction lens riding the indicator: a glass capsule that
297 // magnifies the label under it and bulges along the travel.
298 let raised_size = segmented_lens_base_size(segment_width, 1.2);
299 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
300 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
301 let node_w = raised_size.width * deformation_headroom
302 + crate::dynamics::BULGE_MAX
303 + LENS_PAD * 2.0;
304 let node_h = raised_size.height * deformation_headroom
305 + crate::dynamics::BULGE_MAX
306 + LENS_PAD * 2.0;
307 let lens_for_layer = lens_progress;
308 let physics_axis = Rc::clone(&lens_axis);
309 let lens = Modifier::empty()
310 // required_size: taller than the track; the fixed-height
311 // host keeps the control's layout put.
312 .required_size(Size::new(node_w, node_h))
313 .offset(
314 (segment_width - node_w) * 0.5,
315 (SEGMENT_HEIGHT - node_h) * 0.5,
316 )
317 .graphics_layer(move || GraphicsLayer {
318 translation_x: lens_x,
319 alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
320 ..Default::default()
321 })
322 .glass_effect_with(
323 // The reference lens body is nearly invisible on the
324 // white bar — no readable outline, no tint; it shows
325 // itself only through strong glyph refraction and
326 // saturated RGB fringes at the strokes (segmented-drag
327 // sheet, T 500/2000ms).
328 Glass::lens()
329 .shape(LiquidShape::Capsule)
330 .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
331 // The reference body is invisible inside the track:
332 // no drop shadow under the riding lens.
333 .shadow(false)
334 .rim_reflection(0.12)
335 // The full continuous wcKSRD dome (example/
336 // shaders.txt): glyph warps and rim replay come from
337 // ONE mapping; soft interior per the original's blur.
338 .blur_radius(0.5)
339 .refraction_depth(1.0)
340 .refraction_curve(0.25)
341 // The reference fold is a DEEP band: glyphs crossing
342 // the rim collapse into a dense spectral blob
343 // (segmented-drag f_011), not a thin outline.
344 .fold_depth(5.0)
345 .dispersion(0.85)
346 .highlight(0.04)
347 .lift(0.0)
348 .no_clip(),
349 move || {
350 let grow = lens_for_layer.get().clamp(0.0, 1.2);
351 let base_size = segmented_lens_base_size(segment_width, grow);
352 // Droplet law over the indicator ride
353 // (crate::dynamics): speed stretches the capsule
354 // along the travel, braking swells its front.
355 let pose = physics_axis.liquid_pose();
356 // The halo circle grows concentric with the pill;
357 // below the track height it hides inside the capsule,
358 // so presence needs no alpha of its own.
359 let halo = halo_presence.get().clamp(0.0, 1.0);
360 let halo_diameter = base_size.height * SEGMENT_HALO_FACTOR * halo;
361 let shapes = if halo_diameter > base_size.height {
362 vec![(
363 node_w * 0.5,
364 node_h * 0.5,
365 halo_diameter,
366 halo_diameter,
367 -1.0,
368 )]
369 } else {
370 Vec::new()
371 };
372 GlassDynamics {
373 activity: Some(grow.clamp(0.0, 1.0)),
374 // The lens paints NO body of its own: the white
375 // pill lives BELOW the labels (plain indicator)
376 // and stays visible through the pressed dwell —
377 // a white resting_tint here sat ABOVE the labels
378 // and flashed an opaque capsule over "Errored"
379 // at the press instant.
380 morph: Some(GlassMorph {
381 node_size: (node_w, node_h),
382 primary: (
383 node_w * 0.5,
384 node_h * 0.5,
385 base_size.width,
386 base_size.height,
387 -1.0,
388 ),
389 shapes,
390 glue: 0.0,
391 wobble_amplitude: 0.0,
392 wobble_phase: 0.0,
393 bulge_amplitude: pose.bulge_amplitude.min(4.0),
394 bulge_direction: pose.bulge_direction,
395 ellipse_blend: 0.0,
396 deformation: Some(
397 crate::material::GlassDeformation::incompressible(
398 pose.axis,
399 segmented_strain(pose.stretch),
400 ),
401 ),
402 zoom_anchor: (0.0, 0.0),
403 }),
404 ..Default::default()
405 }
406 },
407 );
408 Box(lens, BoxSpec::default(), || {});
409 });
410 });
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn pointer_position_is_the_clamped_lens_center() {
419 let width = 100.0;
420 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
421 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
422 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
423 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
424 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
425 }
426
427 #[test]
428 fn plain_indicator_dissolves_with_travel_not_with_the_press() {
429 // Pressed dwell (no travel): the pill and its label stay visible.
430 assert_eq!(plain_indicator_alpha(0.0), 1.0);
431 assert_eq!(plain_indicator_alpha(0.10), 1.0);
432 // Departing the origin: dissolved by mid-cell.
433 assert_eq!(plain_indicator_alpha(0.5), 0.0);
434 assert_eq!(plain_indicator_alpha(1.0), 0.0);
435 // Arriving works from either side.
436 assert_eq!(plain_indicator_alpha(-0.10), 1.0);
437 assert_eq!(plain_indicator_alpha(-0.5), 0.0);
438 // The fade band between dwell and gone is continuous.
439 assert!(plain_indicator_alpha(0.3) > 0.4);
440 }
441
442 #[test]
443 fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
444 let resting = segmented_lens_base_size(120.0, 0.0);
445 let raised = segmented_lens_base_size(120.0, 1.0);
446 assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
447 assert!(raised.width < resting.width * 1.10);
448 assert!(raised.height > resting.height * 1.45);
449 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
450 }
451}