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}