1use 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;
26const LENS_OVERFLOW: f32 = 8.0;
28const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
32const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
33const LENS_PAD: f32 = 10.0;
35const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
38const 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#[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 lens_axis.settle_to(selected_x, LiquidMotion::glide());
106 let lens_x = lens_axis.value();
107 let visual_index = crate::motion::liquid_visual_index(
108 selected,
109 lens_x,
110 segment_width,
111 count,
112 crate::motion::liquid_axis_owns_visual_selection(
113 pressed.get(),
114 lens_x,
115 selected_x,
116 segment_width,
117 ),
118 );
119
120 let leading = animateFloatAsState(
124 selected_x,
125 LiquidMotion::blob_leading(),
126 "segmented-leading",
127 );
128 let trailing = animateFloatAsState(
129 selected_x + segment_width,
130 LiquidMotion::blob_trailing(),
131 "segmented-trailing",
132 );
133
134 let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
137 let lens_target = if pressed.get() || lens_settling {
138 1.0
139 } else {
140 0.0
141 };
142 let lens_progress = animateFloatAsState(
143 lens_target,
144 if pressed.get() {
145 spring(0.9, 1400.0)
146 } else {
147 spring(1.0, 170.0)
148 },
149 "segmented-lens",
150 );
151
152 let indicator_color = if colors.is_dark {
153 Color::from_rgba_u8(90, 90, 96, 240)
154 } else {
155 Color::WHITE
156 };
157 let lens_for_indicator = lens_progress;
158 let indicator = Modifier::empty()
159 .size(Size::new(segment_width, SEGMENT_HEIGHT))
160 .graphics_layer(move || {
161 let lead = leading.get();
162 let trail = trailing.get().max(lead + 1.0);
163 GraphicsLayer {
164 translation_x: lead,
165 scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
166 alpha: plain_indicator_alpha(lens_for_indicator.get()),
168 transform_origin: cranpose_ui_graphics::TransformOrigin {
170 pivot_fraction_x: 0.0,
171 pivot_fraction_y: 0.5,
172 },
173 ..Default::default()
174 }
175 })
176 .draw_behind(move |scope| {
177 scope.draw_round_rect(
178 Brush::solid(indicator_color),
179 CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
180 );
181 });
182 Box(indicator, BoxSpec::default(), || {});
183
184 Row(Modifier::empty(), RowSpec::default(), move || {
188 for (index, label) in labels.iter().enumerate() {
189 let is_selected = index == visual_index;
190 let style = TextStyle {
191 span_style: SpanStyle {
192 color: Some(if is_selected {
193 colors.label
194 } else {
195 colors.secondary_label
196 }),
197 font_weight: Some(if is_selected {
198 FontWeight::SEMI_BOLD
199 } else {
200 FontWeight::MEDIUM
201 }),
202 ..typography.subheadline.span_style.clone()
203 },
204 ..typography.subheadline.clone()
205 };
206 let label_for_semantics = label.clone();
207 let cell = Modifier::empty()
208 .size(Size::new(segment_width, SEGMENT_HEIGHT))
209 .semantics(move |config| {
210 config.is_button = true;
211 config.is_clickable = true;
212 config.content_description = Some(label_for_semantics.clone());
213 });
214 let label = label.clone();
215 Box(
216 cell,
217 BoxSpec::default().content_alignment(Alignment::CENTER),
218 move || {
219 Text(label.clone(), Modifier::empty(), style.clone());
220 },
221 );
222 }
223 });
224
225 let gesture = Modifier::empty()
227 .size(Size::new(total_width, SEGMENT_HEIGHT))
228 .pointer_input(selected, {
229 let on_select = Rc::clone(&on_select);
230 let lens_axis = Rc::clone(&lens_axis);
231 move |scope: PointerInputScope| {
232 let on_select = Rc::clone(&on_select);
233 let lens_axis = Rc::clone(&lens_axis);
234 async move {
235 scope
236 .await_pointer_event_scope(|await_scope| async move {
237 let mut down_x = 0.0f32;
238 let mut moved = false;
239 let mut active_pointer = Option::<PointerId>::None;
240 loop {
241 let event = await_scope.await_pointer_event().await;
242 match event.kind {
243 PointerEventKind::Down if active_pointer.is_none() => {
244 active_pointer = Some(event.id);
245 down_x = event.position.x;
246 moved = false;
247 pressed.set(true);
248 lens_axis.begin(lens_axis.value(), event.time_ms);
253 default_haptics()
254 .perform(HapticFeedback::Selection);
255 event.consume();
256 }
257 PointerEventKind::Move
258 if active_pointer == Some(event.id) =>
259 {
260 moved |=
261 (event.position.x - down_x).abs() > TAP_SLOP;
262 if moved {
266 lens_axis.move_to(
267 segment_lens_left(
268 event.position.x,
269 segment_width,
270 count,
271 ),
272 event.time_ms,
273 );
274 }
275 event.consume();
276 }
277 PointerEventKind::Up
278 if active_pointer == Some(event.id) =>
279 {
280 active_pointer = None;
281 pressed.set(false);
282 let travelled =
283 (event.position.x - down_x).abs() > TAP_SLOP;
284 let position = if travelled {
285 event.position.x
286 } else {
287 down_x
288 };
289 let index =
290 ((position / segment_width).floor().max(0.0)
291 as usize)
292 .min(count - 1);
293 lens_axis.release_to(
294 segment_width * index as f32,
295 event.time_ms,
296 LiquidMotion::glide(),
297 );
298 default_haptics()
299 .perform(HapticFeedback::ImpactLight);
300 on_select(index);
301 event.consume();
302 }
303 PointerEventKind::Cancel
304 if active_pointer == Some(event.id) =>
305 {
306 active_pointer = None;
307 pressed.set(false);
308 lens_axis.release_to(
309 selected_x,
310 event.time_ms,
311 LiquidMotion::glide(),
312 );
313 event.consume();
314 }
315 _ => {}
316 }
317 }
318 })
319 .await;
320 }
321 }
322 });
323 Box(gesture, BoxSpec::default(), || {});
324
325 let raised_size = segmented_lens_base_size(segment_width, 1.2);
328 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
329 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
330 let node_w = raised_size.width * deformation_headroom
331 + crate::dynamics::BULGE_MAX
332 + LENS_PAD * 2.0;
333 let node_h = raised_size.height * deformation_headroom
334 + crate::dynamics::BULGE_MAX
335 + LENS_PAD * 2.0;
336 let lens_for_layer = lens_progress;
337 let physics_axis = Rc::clone(&lens_axis);
338 let lens = Modifier::empty()
339 .required_size(Size::new(node_w, node_h))
342 .offset(
343 (segment_width - node_w) * 0.5,
344 (SEGMENT_HEIGHT - node_h) * 0.5,
345 )
346 .graphics_layer(move || GraphicsLayer {
347 translation_x: lens_x,
348 alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
349 ..Default::default()
350 })
351 .glass_effect_with(
352 Glass::lens()
358 .shape(LiquidShape::Capsule)
359 .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
360 .blur_radius(0.0)
361 .refraction_depth(0.52)
362 .refraction_curve(0.25)
363 .fold_depth(5.0)
364 .dispersion(0.85)
365 .highlight(0.04)
366 .lift(0.0)
367 .no_clip(),
368 move || {
369 let grow = lens_for_layer.get().clamp(0.0, 1.2);
370 let base_size = segmented_lens_base_size(segment_width, grow);
371 let pose = physics_axis.liquid_pose();
375 GlassDynamics {
376 activity: Some(grow.clamp(0.0, 1.0)),
377 morph: Some(GlassMorph {
378 node_size: (node_w, node_h),
379 primary: (
380 node_w * 0.5,
381 node_h * 0.5,
382 base_size.width,
383 base_size.height,
384 -1.0,
385 ),
386 shapes: Vec::new(),
387 glue: 0.0,
388 wobble_amplitude: 0.0,
389 wobble_phase: 0.0,
390 bulge_amplitude: pose.bulge_amplitude.min(4.0),
391 bulge_direction: pose.bulge_direction,
392 ellipse_blend: 0.0,
393 deformation: Some(
394 crate::material::GlassDeformation::incompressible(
395 pose.axis,
396 segmented_strain(pose.stretch),
397 ),
398 ),
399 }),
400 ..Default::default()
401 }
402 },
403 );
404 Box(lens, BoxSpec::default(), || {});
405 });
406 });
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn pointer_position_is_the_clamped_lens_center() {
415 let width = 100.0;
416 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
417 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
418 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
419 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
420 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
421 }
422
423 #[test]
424 fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
425 assert_eq!(plain_indicator_alpha(1.0), 0.0);
426 assert_eq!(plain_indicator_alpha(0.5), 0.0);
427 assert_eq!(plain_indicator_alpha(0.2), 0.0);
428 assert!(plain_indicator_alpha(0.05) > 0.8);
429 assert_eq!(plain_indicator_alpha(0.0), 1.0);
430 }
431
432 #[test]
433 fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
434 let resting = segmented_lens_base_size(120.0, 0.0);
435 let raised = segmented_lens_base_size(120.0, 1.0);
436 assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
437 assert!(raised.width < resting.width * 1.10);
438 assert!(raised.height > resting.height * 1.45);
439 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
440 }
441}