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, GlassSurfaceProfile, 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_PAD: f32 = 10.0;
30const TAP_SLOP: f32 = 4.0;
32
33fn plain_indicator_alpha(lens_progress: f32) -> f32 {
34 ((0.18 - lens_progress) / 0.16).clamp(0.0, 1.0)
35}
36
37fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
38 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
39}
40
41#[composable]
45#[allow(non_snake_case)]
46pub fn LiquidSegmentedControl(
47 modifier: Modifier,
48 labels: Vec<String>,
49 selected: usize,
50 on_select: impl Fn(usize) + 'static,
51) {
52 let colors = liquid_colors();
53 let typography = liquid_typography();
54 let count = labels.len().max(1);
55 let selected = selected.min(count - 1);
56 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
57 let labels = Rc::new(labels);
58
59 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
60
61 let track_fill = colors.fill;
62 let track = Modifier::empty()
63 .height(SEGMENT_HEIGHT + TRACK_PADDING * 2.0)
64 .draw_behind(move |scope| {
65 scope.draw_round_rect(
66 Brush::solid(track_fill),
67 CornerRadii::uniform((SEGMENT_HEIGHT + TRACK_PADDING * 2.0) * 0.5),
68 );
69 });
70
71 Box(track.then(modifier), BoxSpec::default(), move || {
72 let labels = Rc::clone(&labels);
73 let typography = typography.clone();
74 let on_select = Rc::clone(&on_select);
75 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
76 let labels = Rc::clone(&labels);
77 let typography = typography.clone();
78 let on_select = Rc::clone(&on_select);
79 let total_width = scope.constraints().max_width.max(1.0);
80 let segment_width = total_width / count as f32;
81 let selected_x = segment_width * selected as f32;
82 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
83 lens_axis.settle_to(selected_x, LiquidMotion::glide());
84 let lens_x = lens_axis.value();
85
86 let leading = animateFloatAsState(
90 selected_x,
91 LiquidMotion::blob_leading(),
92 "segmented-leading",
93 );
94 let trailing = animateFloatAsState(
95 selected_x + segment_width,
96 LiquidMotion::blob_trailing(),
97 "segmented-trailing",
98 );
99
100 let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
103 let lens_target = if pressed.get() || lens_settling {
104 1.0
105 } else {
106 0.0
107 };
108 let lens_progress = animateFloatAsState(
109 lens_target,
110 if pressed.get() {
111 spring(0.9, 1400.0)
112 } else {
113 spring(1.0, 170.0)
114 },
115 "segmented-lens",
116 );
117
118 let indicator_color = if colors.is_dark {
119 Color::from_rgba_u8(90, 90, 96, 240)
120 } else {
121 Color::WHITE
122 };
123 let lens_for_indicator = lens_progress;
124 let indicator = Modifier::empty()
125 .size(Size::new(segment_width, SEGMENT_HEIGHT))
126 .graphics_layer(move || {
127 let lead = leading.get();
128 let trail = trailing.get().max(lead + 1.0);
129 GraphicsLayer {
130 translation_x: lead,
131 scale_x: ((trail - lead) / segment_width.max(1.0)).max(0.01),
132 alpha: plain_indicator_alpha(lens_for_indicator.get()),
134 transform_origin: cranpose_ui_graphics::TransformOrigin {
136 pivot_fraction_x: 0.0,
137 pivot_fraction_y: 0.5,
138 },
139 ..Default::default()
140 }
141 })
142 .draw_behind(move |scope| {
143 scope.draw_round_rect(
144 Brush::solid(indicator_color),
145 CornerRadii::uniform(SEGMENT_HEIGHT * 0.5),
146 );
147 });
148 Box(indicator, BoxSpec::default(), || {});
149
150 Row(Modifier::empty(), RowSpec::default(), move || {
154 for (index, label) in labels.iter().enumerate() {
155 let is_selected = index == selected;
156 let style = TextStyle {
157 span_style: SpanStyle {
158 color: Some(if is_selected {
159 colors.label
160 } else {
161 colors.secondary_label
162 }),
163 font_weight: Some(if is_selected {
164 FontWeight::SEMI_BOLD
165 } else {
166 FontWeight::MEDIUM
167 }),
168 ..typography.subheadline.span_style.clone()
169 },
170 ..typography.subheadline.clone()
171 };
172 let label_for_semantics = label.clone();
173 let cell = Modifier::empty()
174 .size(Size::new(segment_width, SEGMENT_HEIGHT))
175 .semantics(move |config| {
176 config.is_button = true;
177 config.is_clickable = true;
178 config.content_description = Some(label_for_semantics.clone());
179 });
180 let label = label.clone();
181 Box(
182 cell,
183 BoxSpec::default().content_alignment(Alignment::CENTER),
184 move || {
185 Text(label.clone(), Modifier::empty(), style.clone());
186 },
187 );
188 }
189 });
190
191 let gesture = Modifier::empty()
193 .size(Size::new(total_width, SEGMENT_HEIGHT))
194 .pointer_input(selected, {
195 let on_select = Rc::clone(&on_select);
196 let lens_axis = Rc::clone(&lens_axis);
197 move |scope: PointerInputScope| {
198 let on_select = Rc::clone(&on_select);
199 let lens_axis = Rc::clone(&lens_axis);
200 async move {
201 scope
202 .await_pointer_event_scope(|await_scope| async move {
203 let mut down_x = 0.0f32;
204 let mut active_pointer = Option::<PointerId>::None;
205 loop {
206 let event = await_scope.await_pointer_event().await;
207 match event.kind {
208 PointerEventKind::Down if active_pointer.is_none() => {
209 active_pointer = Some(event.id);
210 down_x = event.position.x;
211 pressed.set(true);
212 lens_axis.begin(
213 segment_lens_left(
214 event.position.x,
215 segment_width,
216 count,
217 ),
218 event.time_ms,
219 );
220 default_haptics()
221 .perform(HapticFeedback::Selection);
222 event.consume();
223 }
224 PointerEventKind::Move
225 if active_pointer == Some(event.id) =>
226 {
227 lens_axis.move_to(
228 segment_lens_left(
229 event.position.x,
230 segment_width,
231 count,
232 ),
233 event.time_ms,
234 );
235 event.consume();
236 }
237 PointerEventKind::Up
238 if active_pointer == Some(event.id) =>
239 {
240 active_pointer = None;
241 pressed.set(false);
242 let travelled =
243 (event.position.x - down_x).abs() > TAP_SLOP;
244 let position = if travelled {
245 event.position.x
246 } else {
247 down_x
248 };
249 let index =
250 ((position / segment_width).floor().max(0.0)
251 as usize)
252 .min(count - 1);
253 lens_axis.release_to(
254 segment_width * index as f32,
255 event.time_ms,
256 LiquidMotion::glide(),
257 );
258 default_haptics()
259 .perform(HapticFeedback::ImpactLight);
260 on_select(index);
261 event.consume();
262 }
263 PointerEventKind::Cancel
264 if active_pointer == Some(event.id) =>
265 {
266 active_pointer = None;
267 pressed.set(false);
268 lens_axis.release_to(
269 selected_x,
270 event.time_ms,
271 LiquidMotion::glide(),
272 );
273 event.consume();
274 }
275 _ => {}
276 }
277 }
278 })
279 .await;
280 }
281 }
282 });
283 Box(gesture, BoxSpec::default(), || {});
284
285 if lens_progress.get() > 0.01 {
288 let lens_h = SEGMENT_HEIGHT + LENS_OVERFLOW;
289 let deformation_headroom =
290 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
291 let node_w = (segment_width + 4.0) * deformation_headroom
292 + crate::dynamics::BULGE_MAX
293 + LENS_PAD * 2.0;
294 let node_h =
295 lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + LENS_PAD * 2.0;
296 let lens_for_layer = lens_progress;
297 let physics_axis = Rc::clone(&lens_axis);
298 let lens = Modifier::empty()
299 .required_size(Size::new(node_w, node_h))
302 .offset(
303 (segment_width - node_w) * 0.5,
304 (SEGMENT_HEIGHT - node_h) * 0.5,
305 )
306 .graphics_layer(move || GraphicsLayer {
307 translation_x: lens_x,
308 alpha: (lens_for_layer.get() * 2.5).clamp(0.0, 1.0),
309 ..Default::default()
310 })
311 .glass_effect_with(
312 Glass::lens()
313 .shape(LiquidShape::Capsule)
314 .tint(Color::rgba(1.0, 1.0, 1.0, 0.05))
315 .surface_profile(
316 GlassSurfaceProfile::lens()
317 .with_depth(6.8)
318 .expect("segmented surface depth is valid"),
319 )
320 .highlight(0.52)
321 .chromatic_aberration(2.4)
322 .displacement(24.0)
323 .content_recolor(colors.accent, 1.0)
324 .no_clip(),
325 move || {
326 let grow = lens_for_layer.get().clamp(0.0, 1.2);
327 let base_w = segment_width + 4.0 * grow;
328 let base_h = SEGMENT_HEIGHT + LENS_OVERFLOW * grow;
329 let pose = physics_axis.liquid_pose();
333 GlassDynamics {
334 morph: Some(GlassMorph {
335 node_size: (node_w, node_h),
336 primary: (node_w * 0.5, node_h * 0.5, base_w, base_h, -1.0),
337 shapes: Vec::new(),
338 glue: 0.0,
339 wobble_amplitude: 0.0,
340 wobble_phase: 0.0,
341 bulge_amplitude: pose.bulge_amplitude.min(6.0),
342 bulge_direction: pose.bulge_direction,
343 ellipse_blend: 0.0,
344 deformation: Some(pose.deformation()),
345 }),
346 surface_depth_boost: 0.18,
347 ..Default::default()
348 }
349 },
350 );
351 Box(lens, BoxSpec::default(), || {});
352 }
353 });
354 });
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn pointer_position_is_the_clamped_lens_center() {
363 let width = 100.0;
364 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
365 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
366 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
367 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
368 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
369 }
370
371 #[test]
372 fn plain_indicator_stays_hidden_until_the_lens_is_almost_gone() {
373 assert_eq!(plain_indicator_alpha(1.0), 0.0);
374 assert_eq!(plain_indicator_alpha(0.5), 0.0);
375 assert_eq!(plain_indicator_alpha(0.2), 0.0);
376 assert!(plain_indicator_alpha(0.05) > 0.8);
377 assert_eq!(plain_indicator_alpha(0.0), 1.0);
378 }
379}