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