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_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;
24const LENS_OVERFLOW: f32 = 8.0;
26const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
30const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
31const LENS_PAD: f32 = 10.0;
33const SEGMENTED_STRAIN_RESPONSE: f32 = 0.18;
36const TAP_SLOP: f32 = 4.0;
38
39fn plain_indicator_alpha(lens_progress: f32) -> f32 {
40 ((0.18 - lens_progress) / 0.16).clamp(0.0, 1.0)
41}
42
43fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
44 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
45}
46
47fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
48 let progress = progress.clamp(0.0, 1.2);
49 let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
50 let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
51 Size::new(
52 (segment_width + 4.0 * progress) * width_lift,
53 (SEGMENT_HEIGHT + LENS_OVERFLOW * progress) * height_lift,
54 )
55}
56
57fn segmented_strain(stretch: f32) -> f32 {
58 1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
59}
60
61#[composable]
65#[allow(non_snake_case)]
66pub fn LiquidSegmentedControl(
67 modifier: Modifier,
68 labels: Vec<String>,
69 selected: usize,
70 on_select: impl Fn(usize) + 'static,
71) {
72 let colors = liquid_colors();
73 let typography = liquid_typography();
74 let count = labels.len().max(1);
75 let selected = selected.min(count - 1);
76 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
77 let labels = Rc::new(labels);
78
79 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
80
81 let track_fill = colors.fill;
82 let track = Modifier::empty()
83 .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
84 .draw_behind(move |scope| {
85 scope.draw_round_rect(
86 Brush::solid(track_fill),
87 CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
88 );
89 });
90
91 Box(track.then(modifier), BoxSpec::default(), move || {
92 let labels = Rc::clone(&labels);
93 let typography = typography.clone();
94 let on_select = Rc::clone(&on_select);
95 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
96 let labels = Rc::clone(&labels);
97 let typography = typography.clone();
98 let on_select = Rc::clone(&on_select);
99 let total_width = scope.constraints().max_width.max(1.0);
100 let segment_width = total_width / count as f32;
101 let selected_x = segment_width * selected as f32;
102 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
103 if !pressed.get() {
104 lens_axis.settle_to(selected_x, LiquidMotion::glide());
105 }
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() || lens_settling {
145 spring(0.9, 1400.0)
149 } else {
150 spring(1.0, 170.0)
151 },
152 "segmented-lens",
153 );
154
155 let indicator_color = if colors.is_dark {
156 Color::from_rgba_u8(90, 90, 96, 240)
157 } else {
158 Color::WHITE
159 };
160 let lens_for_indicator = lens_progress;
161 let indicator = Modifier::empty()
162 .size(Size::new(segment_width, SEGMENT_HEIGHT))
163 .graphics_layer(move || {
164 let lead = leading.get();
165 let trail = trailing.get().max(lead + 1.0);
166 GraphicsLayer {
167 translation_x: lead,
168 scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
169 alpha: plain_indicator_alpha(lens_for_indicator.get()),
171 transform_origin: cranpose_ui_graphics::TransformOrigin {
173 pivot_fraction_x: 0.0,
174 pivot_fraction_y: 0.5,
175 },
176 ..Default::default()
177 }
178 })
179 .draw_behind(move |scope| {
180 scope.draw_round_rect(
181 Brush::solid(indicator_color),
182 CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
183 );
184 });
185 Box(indicator, BoxSpec::default(), || {});
186
187 Row(Modifier::empty(), RowSpec::default(), move || {
191 for (index, label) in labels.iter().enumerate() {
192 let is_selected = index == visual_index;
193 let style = TextStyle {
194 span_style: SpanStyle {
195 color: Some(if is_selected {
196 colors.label
197 } else {
198 colors.secondary_label
199 }),
200 font_weight: Some(if is_selected {
201 FontWeight::SEMI_BOLD
202 } else {
203 FontWeight::MEDIUM
204 }),
205 ..typography.subheadline.span_style.clone()
206 },
207 ..typography.subheadline.clone()
208 };
209 let label_for_semantics = label.clone();
210 let cell = Modifier::empty()
211 .size(Size::new(segment_width, SEGMENT_HEIGHT))
212 .semantics(move |config| {
213 config.is_button = true;
214 config.is_clickable = true;
215 config.content_description = Some(label_for_semantics.clone());
216 });
217 let label = label.clone();
218 Box(
219 cell,
220 BoxSpec::default().content_alignment(Alignment::CENTER),
221 move || {
222 Text(label.clone(), Modifier::empty(), style.clone());
223 },
224 );
225 }
226 });
227
228 let gesture = Modifier::empty()
231 .size(Size::new(total_width, SEGMENT_HEIGHT))
232 .pointer_input(selected, {
233 let on_select = Rc::clone(&on_select);
234 let lens_axis = Rc::clone(&lens_axis);
235 move |scope: PointerInputScope| {
236 let on_select = Rc::clone(&on_select);
237 let lens_axis = Rc::clone(&lens_axis);
238 crate::motion::liquid_lens_gesture(
239 scope,
240 crate::motion::LiquidLensGesture {
241 axis: lens_axis,
242 cell_width: segment_width,
243 count,
244 tap_slop: TAP_SLOP,
245 drag_left: Rc::new(move |x| {
246 segment_lens_left(x, segment_width, count)
247 }),
248 rest_left: Rc::new(move |index| segment_width * index as f32),
249 selected,
250 on_pressed: Rc::new(move |down| pressed.set(down)),
251 on_touch: Rc::new(|_, _| {}),
252 on_select,
253 },
254 )
255 }
256 });
257 Box(gesture, BoxSpec::default(), || {});
258
259 let raised_size = segmented_lens_base_size(segment_width, 1.2);
262 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
263 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
264 let node_w = raised_size.width * deformation_headroom
265 + crate::dynamics::BULGE_MAX
266 + LENS_PAD * 2.0;
267 let node_h = raised_size.height * deformation_headroom
268 + crate::dynamics::BULGE_MAX
269 + LENS_PAD * 2.0;
270 let lens_for_layer = lens_progress;
271 let physics_axis = Rc::clone(&lens_axis);
272 let lens = Modifier::empty()
273 .required_size(Size::new(node_w, node_h))
276 .offset(
277 (segment_width - node_w) * 0.5,
278 (SEGMENT_HEIGHT - node_h) * 0.5,
279 )
280 .graphics_layer(move || GraphicsLayer {
281 translation_x: lens_x,
282 alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
283 ..Default::default()
284 })
285 .glass_effect_with(
286 Glass::lens()
292 .shape(LiquidShape::Capsule)
293 .tint(Color::rgba(1.0, 1.0, 1.0, 0.02))
294 .shadow(false)
297 .rim_reflection(0.12)
298 .blur_radius(0.5)
302 .refraction_depth(1.0)
303 .refraction_curve(0.25)
304 .dispersion(0.85)
305 .highlight(0.04)
306 .lift(0.0)
307 .no_clip(),
308 move || {
309 let grow = lens_for_layer.get().clamp(0.0, 1.2);
310 let base_size = segmented_lens_base_size(segment_width, grow);
311 let pose = physics_axis.liquid_pose();
315 GlassDynamics {
316 activity: Some(grow.clamp(0.0, 1.0)),
317 morph: Some(GlassMorph {
318 node_size: (node_w, node_h),
319 primary: (
320 node_w * 0.5,
321 node_h * 0.5,
322 base_size.width,
323 base_size.height,
324 -1.0,
325 ),
326 shapes: Vec::new(),
327 glue: 0.0,
328 wobble_amplitude: 0.0,
329 wobble_phase: 0.0,
330 bulge_amplitude: pose.bulge_amplitude.min(4.0),
331 bulge_direction: pose.bulge_direction,
332 ellipse_blend: 0.0,
333 deformation: Some(
334 crate::material::GlassDeformation::incompressible(
335 pose.axis,
336 segmented_strain(pose.stretch),
337 ),
338 ),
339 }),
340 ..Default::default()
341 }
342 },
343 );
344 Box(lens, BoxSpec::default(), || {});
345 });
346 });
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn pointer_position_is_the_clamped_lens_center() {
355 let width = 100.0;
356 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
357 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
358 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
359 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
360 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
361 }
362
363 #[test]
364 fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
365 assert_eq!(plain_indicator_alpha(1.0), 0.0);
366 assert_eq!(plain_indicator_alpha(0.5), 0.0);
367 assert_eq!(plain_indicator_alpha(0.2), 0.0);
368 assert!(plain_indicator_alpha(0.05) > 0.8);
369 assert_eq!(plain_indicator_alpha(0.0), 1.0);
370 }
371
372 #[test]
373 fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
374 let resting = segmented_lens_base_size(120.0, 0.0);
375 let raised = segmented_lens_base_size(120.0, 1.0);
376 assert_eq!(resting, Size::new(120.0, SEGMENT_HEIGHT));
377 assert!(raised.width < resting.width * 1.10);
378 assert!(raised.height > resting.height * 1.45);
379 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.10);
380 }
381}