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 cranpose_animation::{animateFloatAsState, spring};
13use cranpose_core::{mutableStateOf, remember};
14use cranpose_macros::composable;
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
24/// Reference control height: the marker capsule crop is 130px at the
25/// recording's 2.89 px/dp = 45dp total, 41dp inside the track padding.
26const SEGMENT_HEIGHT: f32 = 41.0;
27const TRACK_PADDING: f32 = 2.0;
28/// The resting marker IS the glass lens at a shallow rest state — a
29/// clear puck sitting on the white body with its own bevel, drop shadow
30/// and rim iridescence from the one glass light path (user-arbitrated:
31/// no flat draw reproduces those). It spans 120dp over the 103dp cell
32/// (tap-flight f_045: 347px against the 299px cell at 2.89 px/dp).
33const MARKER_WIDTH_FACTOR: f32 = 1.16;
34/// How much of the glass activity survives at rest: enough for the
35/// shallow bevel, shadow and the iridescent whisper on the rim; the
36/// touch raise runs it to 1.
37const MARKER_REST_ACTIVITY: f32 = 0.55;
38/// The marker crests past the body top (f_045 column: the shade rides
39/// over the body edge) and only whispers past the bottom — the poke is
40/// asymmetric, biased up, and subtle.
41const MARKER_POKE_TOP: f32 = 1.5;
42const MARKER_POKE_BOTTOM: f32 = 0.5;
43/// How far the interaction lens pokes past the track vertically. With the
44/// lift scale this lands the raised body at ~1.4x the track height — the
45/// reference finger oval (segmented-drag f_025). The oval is ONE
46/// continuous silhouette; a separate glued halo circle read as two-lobed
47/// "cheeks" bulging over the track.
48const LENS_OVERFLOW: f32 = 8.0;
49/// How far the raised lens SDF blends from the capsule toward a true
50/// ellipse (activity-scaled at the material layer; the resting lens is
51/// untouched). The reference riding body is a continuously curved oval,
52/// never a flat-topped capsule (segmented-drag f_025..f_055).
53const LENS_ELLIPSE_BLEND: f32 = 0.55;
54/// Touch raises the whole optical body before directional deformation. This
55/// preserves the control's volume without letting maximum horizontal strain
56/// squash the lens below the track height.
57const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
58const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
59/// Glass node span beyond the lens shape (rim glow + bulge live here).
60const LENS_PAD: f32 = 10.0;
61/// A segmented selection stays recognizably one cell wide while its surface
62/// carries the shared incompressible fluid strain.
63/// Reference mid-drag oval elongates to ~1.15x its rest width (f_025:
64/// 138dp over the 120dp rest against 103dp cells).
65const SEGMENTED_STRAIN_RESPONSE: f32 = 0.30;
66/// Pointer travel below this is a tap, not a swipe.
67const TAP_SLOP: f32 = 4.0;
68
69fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
70 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
71}
72
73fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
74 let progress = progress.clamp(0.0, 1.2);
75 // Rest: the shallow marker puck cresting past the body. Raised: the
76 // finger oval at ~1.4x the track height (segmented-drag f_025).
77 let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
78 let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
79 let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
80 Size::new(
81 (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
82 (rest_h + LENS_OVERFLOW * progress) * height_lift,
83 )
84}
85
86fn segmented_strain(stretch: f32) -> f32 {
87 1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
88}
89
90/// A segmented control. `labels` are equal-width segments; `selected` is the
91/// active index; `on_select` receives the committed index. Segments tap AND
92/// swipe: dragging slides the indicator with the finger as a glass lens.
93#[composable]
94#[allow(non_snake_case)]
95pub fn LiquidSegmentedControl(
96 modifier: Modifier,
97 labels: Vec<String>,
98 selected: usize,
99 on_select: impl Fn(usize) + 'static,
100) {
101 let colors = liquid_colors();
102 let typography = liquid_typography();
103 let count = labels.len().max(1);
104 let selected = selected.min(count - 1);
105 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
106 let labels = Rc::new(labels);
107
108 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
109
110 // The light-scheme control body is a WHITE capsule floating on the
111 // page with a soft, below-biased drop shadow (drag f_130 corners:
112 // page ~235-240 falling to ~219-225 under the caps, body 254). The
113 // recessed marker and the raised lens live INSIDE this white body.
114 // Dark keeps a filled track for contrast.
115 let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
116 let track_fill = if colors.is_dark {
117 colors.fill
118 } else {
119 Color::WHITE
120 };
121 let track = if colors.is_dark {
122 Modifier::empty()
123 } else {
124 Modifier::empty().drop_shadow(
125 cranpose_ui_graphics::LayerShape::Rounded(
126 cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
127 ),
128 |scope| {
129 // Bottom-biased: the reference page is clean above the
130 // body's top edge (f_045 body-only column: 254 flat); the
131 // shadow pools only under the caps (219-225 on f_130).
132 scope.radius = 6.0;
133 scope.offset.y = 4.5;
134 scope.color = Color::BLACK.with_alpha(0.10);
135 },
136 )
137 };
138 let track = track.height(track_height).draw_behind(move |scope| {
139 scope.draw_round_rect(
140 Brush::solid(track_fill),
141 CornerRadii::uniform(track_height * 0.5),
142 );
143 });
144
145 Box(track.then(modifier), BoxSpec::default(), move || {
146 let labels = Rc::clone(&labels);
147 let typography = typography.clone();
148 let on_select = Rc::clone(&on_select);
149 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
150 let labels = Rc::clone(&labels);
151 let typography = typography.clone();
152 let on_select = Rc::clone(&on_select);
153 let total_width = scope.constraints().max_width.max(1.0);
154 let segment_width = total_width / count as f32;
155 let selected_x = segment_width * selected as f32;
156 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
157 if !pressed.get() {
158 lens_axis.settle_to(selected_x, LiquidMotion::glide());
159 }
160 let lens_x = lens_axis.value();
161 let visual_index = crate::motion::liquid_visual_index(
162 selected,
163 lens_x,
164 segment_width,
165 count,
166 crate::motion::liquid_axis_owns_visual_selection(
167 pressed.get(),
168 lens_x,
169 selected_x,
170 segment_width,
171 ),
172 );
173
174 // Raise state: up while touched, lingering decay on release
175 // (the glass stays deep through the settle flight, then
176 // relaxes back into the shallow resting puck).
177 let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
178 let lens_target = if pressed.get() || lens_settling {
179 1.0
180 } else {
181 0.0
182 };
183 let lens_progress = animateFloatAsState(
184 lens_target,
185 if pressed.get() || lens_settling {
186 // A tap must FLY the raised lens (reference tap-flight:
187 // ~220ms crossing with the glyphs warping through it) —
188 // the rise has to win the race against the flight.
189 spring(0.9, 1400.0)
190 } else {
191 spring(1.0, 170.0)
192 },
193 "segmented-lens",
194 );
195
196 // Labels row under the glass. The cells keep button
197 // semantics (robot/a11y); pointer handling lives on the swipe
198 // surface below.
199 Row(Modifier::empty(), RowSpec::default(), move || {
200 for (index, label) in labels.iter().enumerate() {
201 let is_selected = index == visual_index;
202 let style = TextStyle {
203 span_style: SpanStyle {
204 // Every reference label reads near-black
205 // (tap-flight/drag strips) — selection is told
206 // by weight and the pill, never by dimming the
207 // unselected cells.
208 color: Some(colors.label),
209 // Reference "Sending" spans 63dp of the 103dp
210 // cell; the subheadline's 15dp rendered 55dp.
211 font_size: cranpose_ui::text::TextUnit::Sp(17.0),
212 // The reference weight step is a whisper
213 // (regular -> medium); semibold-vs-medium read
214 // as a black blob against the airy target.
215 font_weight: Some(if is_selected {
216 FontWeight::MEDIUM
217 } else {
218 FontWeight::NORMAL
219 }),
220 ..typography.subheadline.span_style.clone()
221 },
222 ..typography.subheadline.clone()
223 };
224 let label_for_semantics = label.clone();
225 let cell = Modifier::empty()
226 .size(Size::new(segment_width, SEGMENT_HEIGHT))
227 .semantics(move |config| {
228 config.is_button = true;
229 config.is_clickable = true;
230 config.content_description = Some(label_for_semantics.clone());
231 });
232 let label = label.clone();
233 Box(
234 cell,
235 BoxSpec::default().content_alignment(Alignment::CENTER),
236 move || {
237 Text(label.clone(), Modifier::empty(), style.clone());
238 },
239 );
240 }
241 });
242
243 // Swipe/tap surface across the whole control: the shared lens
244 // gesture (crate::motion) with the segment clamp rules.
245 let gesture = Modifier::empty()
246 .size(Size::new(total_width, SEGMENT_HEIGHT))
247 .pointer_input(selected, {
248 let on_select = Rc::clone(&on_select);
249 let lens_axis = Rc::clone(&lens_axis);
250 move |scope: PointerInputScope| {
251 let on_select = Rc::clone(&on_select);
252 let lens_axis = Rc::clone(&lens_axis);
253 crate::motion::liquid_lens_gesture(
254 scope,
255 crate::motion::LiquidLensGesture {
256 axis: lens_axis,
257 cell_width: segment_width,
258 count,
259 tap_slop: TAP_SLOP,
260 drag_left: Rc::new(move |x| {
261 segment_lens_left(x, segment_width, count)
262 }),
263 rest_left: Rc::new(move |index| segment_width * index as f32),
264 selected,
265 on_pressed: Rc::new(move |down| pressed.set(down)),
266 on_touch: Rc::new(|_, _| {}),
267 on_select,
268 },
269 )
270 }
271 });
272 Box(gesture, BoxSpec::default(), || {});
273
274 // The interaction lens riding the indicator: a glass capsule that
275 // magnifies the label under it and bulges along the travel.
276 let raised_size = segmented_lens_base_size(segment_width, 1.2);
277 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
278 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
279 let node_w = raised_size.width * deformation_headroom
280 + crate::dynamics::BULGE_MAX
281 + LENS_PAD * 2.0;
282 let node_h = raised_size.height * deformation_headroom
283 + crate::dynamics::BULGE_MAX
284 + LENS_PAD * 2.0;
285 let lens_for_layer = lens_progress;
286 let physics_axis = Rc::clone(&lens_axis);
287 let lens = Modifier::empty()
288 // required_size: taller than the track; the fixed-height
289 // host keeps the control's layout put.
290 .required_size(Size::new(node_w, node_h))
291 .offset(
292 (segment_width - node_w) * 0.5,
293 (SEGMENT_HEIGHT - node_h) * 0.5,
294 )
295 .graphics_layer(move || GraphicsLayer {
296 translation_x: lens_x,
297 // The glass IS the resting marker — always present;
298 // rest vs raised is a depth change, never an alpha one.
299 alpha: 1.0,
300 ..Default::default()
301 })
302 .glass_effect_with(
303 // The reference lens body is nearly invisible on the
304 // white bar — no readable outline, no tint; it shows
305 // itself only through strong glyph refraction and
306 // saturated RGB fringes at the strokes (segmented-drag
307 // sheet, T 500/2000ms).
308 Glass::lens()
309 .shape(LiquidShape::Capsule)
310 // The raised oval's interior reads a few percent
311 // darker than the page (segmented-drag f_025..f_055
312 // interiors 236..244 on 254) and it casts a soft
313 // drop shadow while lifted.
314 .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
315 // The tab bubble's soft contact shadow (user: the
316 // segmented bubble lacked a shadow; make it like the
317 // bottom bar's "perfect shadow"). The old -5 spread
318 // against a 6 radius cancelled to an invisible sliver.
319 .shadow_style(GlassShadow::new(
320 Color::BLACK.with_alpha(0.14),
321 12.0,
322 4.0,
323 -2.0,
324 ))
325 .rim_reflection(0.04)
326 // The full continuous wcKSRD dome (example/
327 // shaders.txt): glyph warps and rim replay come from
328 // ONE mapping; soft interior per the original's blur.
329 .blur_radius(0.5)
330 .refraction_depth(1.0)
331 .refraction_curve(0.25)
332 // The reference fold is a DEEP band: glyphs crossing
333 // the rim collapse into a dense spectral blob
334 // (segmented-drag f_011), not a thin outline.
335 .fold_depth(5.0)
336 .dispersion(0.85)
337 .highlight(0.04)
338 .lift(0.0)
339 .no_clip(),
340 move || {
341 let grow = lens_for_layer.get().clamp(0.0, 1.2);
342 let base_size = segmented_lens_base_size(segment_width, grow);
343 // Droplet law over the indicator ride
344 // (crate::dynamics): speed stretches the capsule
345 // along the travel, braking swells its front.
346 let pose = physics_axis.liquid_pose();
347 GlassDynamics {
348 // Rest keeps a shallow floor of glass presence
349 // (bevel + shadow + rim whisper); touch raises
350 // depth to full.
351 activity: Some(
352 MARKER_REST_ACTIVITY
353 + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
354 ),
355 // Depth follows MOTION, not the raise: the
356 // reference distorts the glyphs only while the
357 // marker MOVES (segmented-drag spectral fringing),
358 // and keeps the text crisp under a stationary
359 // held press (tap-flight T83..T300 "Errored" stays
360 // sharp). Keying the deep dome to grow alone ran a
361 // motionless hold at full depth and washed the
362 // label to gray.
363 press_depth: Some(
364 (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
365 .clamp(0.0, 1.0),
366 ),
367 // The lens paints NO body of its own: the white
368 // pill lives BELOW the labels (plain indicator)
369 // and stays visible through the pressed dwell —
370 // a white resting_tint here sat ABOVE the labels
371 // and flashed an opaque capsule over "Errored"
372 // at the press instant.
373 morph: Some(GlassMorph {
374 node_size: (node_w, node_h),
375 primary: (
376 node_w * 0.5,
377 node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
378 base_size.width,
379 base_size.height,
380 -1.0,
381 ),
382 shapes: Vec::new(),
383 glue: 0.0,
384 wobble_amplitude: 0.0,
385 wobble_phase: 0.0,
386 bulge_amplitude: pose.bulge_amplitude.min(4.0),
387 bulge_direction: pose.bulge_direction,
388 ellipse_blend: LENS_ELLIPSE_BLEND,
389 deformation: Some(
390 crate::material::GlassDeformation::incompressible(
391 pose.axis,
392 segmented_strain(pose.stretch),
393 ),
394 ),
395 zoom_anchor: (0.0, 0.0),
396 }),
397 ..Default::default()
398 }
399 },
400 );
401 Box(lens, BoxSpec::default(), || {});
402 });
403 });
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn pointer_position_is_the_clamped_lens_center() {
412 let width = 100.0;
413 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
414 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
415 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
416 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
417 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
418 }
419
420 #[test]
421 fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
422 let resting = segmented_lens_base_size(120.0, 0.0);
423 let raised = segmented_lens_base_size(120.0, 1.0);
424 // Rest is the marker puck: 1.16x the cell, cresting past the body.
425 assert_eq!(resting.width, 120.0 * MARKER_WIDTH_FACTOR);
426 assert!(resting.height > SEGMENT_HEIGHT + TRACK_PADDING * 2.0);
427 // The raise deepens, it does not widen into a worm.
428 assert!(raised.width < resting.width * 1.10);
429 assert!(raised.height > resting.height * 1.20);
430 // Max fluid stretch elongates ~1.15x like the reference mid-drag
431 // oval — never a two-cell worm.
432 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.20);
433 }
434}