1use std::rc::Rc;
2
3use cranpose_animation::{animateFloatAsState, spring};
4use cranpose_core::{mutableStateOf, remember};
5use cranpose_macros::composable;
6use cranpose_ui::{
7 Modifier, PointerInputScope, SemanticsWidgetRole, Size,
8 text::{FontWeight, SpanStyle, TextStyle},
9 widgets::{Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text},
10};
11use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer};
12use cranpose_ui_layout::Alignment;
13
14use crate::{
15 material::{Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape},
16 motion::LiquidMotion,
17 theme::{liquid_colors, liquid_typography},
18 widgets::content_scope::ScopeContent,
19};
20
21const SEGMENT_HEIGHT: f32 = 41.0;
22const TRACK_PADDING: f32 = 2.0;
23const MARKER_WIDTH_FACTOR: f32 = 1.16;
24const MARKER_REST_ACTIVITY: f32 = 0.55;
25const MARKER_POKE_TOP: f32 = 1.5;
26const MARKER_POKE_BOTTOM: f32 = 0.5;
27const LENS_OVERFLOW: f32 = 8.0;
28const LENS_ELLIPSE_BLEND: f32 = 0.55;
29const LENS_WIDTH_LIFT_SCALE: f32 = 1.06;
30const LENS_HEIGHT_LIFT_SCALE: f32 = 1.22;
31const LENS_PAD: f32 = 10.0;
32const SEGMENTED_STRAIN_RESPONSE: f32 = 0.30;
33const TAP_SLOP: f32 = 4.0;
34
35fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
36 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
37}
38
39fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
40 let progress = progress.clamp(0.0, 1.2);
41 let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
42 let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
43 let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
44 Size::new(
45 (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
46 (rest_h + LENS_OVERFLOW * progress) * height_lift,
47 )
48}
49
50fn segmented_strain(stretch: f32) -> f32 {
51 1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
52}
53
54struct LiquidSegment {
55 description: String,
56 content: Rc<dyn Fn(bool)>,
57}
58
59pub struct LiquidSegmentedControlScope {
64 segments: ScopeContent<LiquidSegment>,
65}
66
67impl LiquidSegmentedControlScope {
68 pub fn segment(&self, label: impl Into<String>) {
71 let label = label.into();
72 let text = label.clone();
73 self.segment_content(label, move |selected| {
74 SegmentLabel(text.clone(), selected);
75 });
76 }
77
78 pub fn segment_content(
84 &self,
85 description: impl Into<String>,
86 content: impl Fn(bool) + 'static,
87 ) {
88 self.segments.push(LiquidSegment {
89 description: description.into(),
90 content: Rc::new(content),
91 });
92 }
93}
94
95fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
96 ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
97}
98
99#[composable]
100#[allow(non_snake_case)]
101fn SegmentLabel(label: String, selected: bool) {
102 let colors = liquid_colors();
103 let typography = liquid_typography();
104 let style = TextStyle {
105 span_style: SpanStyle {
106 color: Some(colors.label),
107 font_size: cranpose_ui::text::TextUnit::Sp(17.0),
108 font_weight: Some(if selected {
109 FontWeight::MEDIUM
110 } else {
111 FontWeight::NORMAL
112 }),
113 ..typography.subheadline.span_style.clone()
114 },
115 ..typography.subheadline.clone()
116 };
117 Text(label, Modifier::empty(), style);
118}
119
120#[composable]
134#[allow(non_snake_case)]
135pub fn LiquidSegmentedControl(
136 modifier: Modifier,
137 selected: usize,
138 on_select: impl Fn(usize) + 'static,
139 content: impl FnOnce(&LiquidSegmentedControlScope),
140) {
141 let colors = liquid_colors();
142 let segments = collect_segments(content);
143 let count = segments.len().max(1);
144 let selected = selected.min(count - 1);
145 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
146 let segments = Rc::new(segments);
147
148 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
149
150 let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
151 let track_fill = if colors.is_dark {
152 colors.fill
153 } else {
154 Color::WHITE
155 };
156 let track = if colors.is_dark {
157 Modifier::empty()
158 } else {
159 Modifier::empty().drop_shadow(
160 cranpose_ui_graphics::LayerShape::Rounded(
161 cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
162 ),
163 |scope| {
164 scope.radius = 6.0;
165 scope.offset.y = 4.5;
166 scope.color = Color::BLACK.with_alpha(0.10);
167 },
168 )
169 };
170 let track = track.height(track_height).draw_behind(move |scope| {
171 scope.draw_round_rect(
172 Brush::solid(track_fill),
173 CornerRadii::uniform(track_height * 0.5),
174 );
175 });
176
177 Box(track.then(modifier), BoxSpec::default(), move || {
178 let segments = Rc::clone(&segments);
179 let on_select = Rc::clone(&on_select);
180 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
181 let segments = Rc::clone(&segments);
182 let on_select = Rc::clone(&on_select);
183 let total_width = scope.constraints().max_width.max(1.0);
184 let segment_width = total_width / count as f32;
185 let selected_x = segment_width * selected as f32;
186 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
187 if !pressed.get() {
188 lens_axis.settle_to(selected_x, LiquidMotion::glide());
189 }
190 let lens_x = lens_axis.value();
191 let visual_index = crate::motion::liquid_visual_index(
192 selected,
193 lens_x,
194 segment_width,
195 count,
196 crate::motion::liquid_axis_owns_visual_selection(
197 pressed.get(),
198 lens_x,
199 selected_x,
200 segment_width,
201 ),
202 );
203
204 let lens_settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
205 let lens_target = if pressed.get() || lens_settling {
206 1.0
207 } else {
208 0.0
209 };
210 let lens_progress = animateFloatAsState(
211 lens_target,
212 if pressed.get() || lens_settling {
213 spring(0.9, 1400.0)
214 } else {
215 spring(1.0, 170.0)
216 },
217 "segmented-lens",
218 );
219
220 let semantic_selection = Rc::clone(&on_select);
221 Row(
222 Modifier::empty().selectable_group(),
223 RowSpec::default(),
224 move || {
225 for (index, segment) in segments.iter().enumerate() {
226 let is_selected = index == visual_index;
227 let description = segment.description.clone();
228 let on_select = Rc::clone(&semantic_selection);
229 let cell = Modifier::empty()
230 .size(Size::new(segment_width, SEGMENT_HEIGHT))
231 .semantics(super::selection::selection_semantics(
232 description,
233 SemanticsWidgetRole::RadioButton,
234 index,
235 selected,
236 on_select,
237 ))
238 .focusable();
239 let content = Rc::clone(&segment.content);
240 Box(
241 cell,
242 BoxSpec::default().content_alignment(Alignment::CENTER),
243 move || content(is_selected),
244 );
245 }
246 },
247 );
248
249 let gesture = Modifier::empty()
250 .size(Size::new(total_width, SEGMENT_HEIGHT))
251 .pointer_input(selected, {
252 let on_select = Rc::clone(&on_select);
253 let lens_axis = Rc::clone(&lens_axis);
254 move |scope: PointerInputScope| {
255 let on_select = Rc::clone(&on_select);
256 let lens_axis = Rc::clone(&lens_axis);
257 crate::motion::liquid_lens_gesture(
258 scope,
259 crate::motion::LiquidLensGesture {
260 axis: lens_axis,
261 cell_width: segment_width,
262 cell_offset: 0.0,
263 count,
264 tap_slop: TAP_SLOP,
265 drag_left: Rc::new(move |x| {
266 segment_lens_left(x, segment_width, count)
267 }),
268 rest_left: Rc::new(move |index| segment_width * index as f32),
269 selected,
270 on_pressed: Rc::new(move |down, _| pressed.set(down)),
271 on_touch: Rc::new(|_, _| {}),
272 on_select,
273 },
274 )
275 }
276 });
277 Box(gesture, BoxSpec::default(), || {});
278
279 let raised_size = segmented_lens_base_size(segment_width, 1.2);
280 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
281 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
282 let node_w = raised_size.width * deformation_headroom
283 + crate::dynamics::BULGE_MAX
284 + LENS_PAD * 2.0;
285 let node_h = raised_size.height * deformation_headroom
286 + crate::dynamics::BULGE_MAX
287 + LENS_PAD * 2.0;
288 let lens_for_layer = lens_progress;
289 let physics_axis = Rc::clone(&lens_axis);
290 let lens = Modifier::empty()
291 .required_size(Size::new(node_w, node_h))
292 .offset(
293 (segment_width - node_w) * 0.5,
294 (SEGMENT_HEIGHT - node_h) * 0.5,
295 )
296 .graphics_layer(move || GraphicsLayer {
297 translation_x: lens_x,
298 alpha: 1.0,
299 ..Default::default()
300 })
301 .glass_effect_with(
302 Glass::lens()
303 .shape(LiquidShape::Capsule)
304 .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
305 .shadow_style(GlassShadow::new(
306 Color::BLACK.with_alpha(0.14),
307 12.0,
308 4.0,
309 -2.0,
310 ))
311 .rim_reflection(0.04)
312 .blur_radius(0.5)
313 .refraction_depth(1.0)
314 .refraction_curve(0.25)
315 .fold_depth(5.0)
316 .dispersion(0.85)
317 .highlight(0.04)
318 .lift(0.0)
319 .no_clip(),
320 move || {
321 let grow = lens_for_layer.get().clamp(0.0, 1.2);
322 let base_size = segmented_lens_base_size(segment_width, grow);
323 let pose = physics_axis.liquid_pose();
324 GlassDynamics {
325 activity: Some(
326 MARKER_REST_ACTIVITY
327 + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
328 ),
329 press_depth: Some(
330 (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
331 .clamp(0.0, 1.0),
332 ),
333 morph: Some(GlassMorph {
334 node_size: (node_w, node_h),
335 primary: (
336 node_w * 0.5,
337 node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
338 base_size.width,
339 base_size.height,
340 -1.0,
341 ),
342 shapes: Vec::new(),
343 glue: 0.0,
344 wobble_amplitude: 0.0,
345 wobble_phase: 0.0,
346 bulge_amplitude: pose.bulge_amplitude.min(4.0),
347 bulge_direction: pose.bulge_direction,
348 ellipse_blend: LENS_ELLIPSE_BLEND,
349 capsule_smoothing_dp: 0.0,
350 deformation: Some(
351 crate::material::GlassDeformation::incompressible(
352 pose.axis,
353 segmented_strain(pose.stretch),
354 ),
355 ),
356 zoom_anchor: (0.0, 0.0),
357 }),
358 ..Default::default()
359 }
360 },
361 );
362 Box(lens, BoxSpec::default(), || {});
363 });
364 });
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn pointer_position_is_the_clamped_lens_center() {
373 let width = 100.0;
374 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
375 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
376 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
377 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
378 assert_eq!(segment_lens_left(400.0, width, 3), 200.0);
379 }
380
381 #[test]
382 fn raised_lens_lifts_in_depth_without_becoming_a_wide_worm() {
383 let resting = segmented_lens_base_size(120.0, 0.0);
384 let raised = segmented_lens_base_size(120.0, 1.0);
385 assert_eq!(resting.width, 120.0 * MARKER_WIDTH_FACTOR);
386 assert!(resting.height > SEGMENT_HEIGHT + TRACK_PADDING * 2.0);
387 assert!(raised.width < resting.width * 1.10);
388 assert!(raised.height > resting.height * 1.20);
389 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.20);
390 }
391
392 #[test]
393 fn a_scope_records_what_each_segment_announces() {
394 let drawn = Rc::new(std::cell::Cell::new(0u32));
395 let counted = Rc::clone(&drawn);
396 let segments = collect_segments(|scope| {
397 scope.segment("Sending");
398 scope.segment_content("Received", move |selected| {
399 counted.set(counted.get() + u32::from(selected));
400 });
401 });
402
403 assert_eq!(segments.len(), 2);
404 assert_eq!(segments[0].description, "Sending");
405 assert_eq!(segments[1].description, "Received");
406 assert_eq!(drawn.get(), 0);
407 (segments[1].content)(true);
408 assert_eq!(drawn.get(), 1);
409 }
410
411 #[test]
412 fn a_control_with_no_segments_declares_none() {
413 assert!(collect_segments(|_| {}).is_empty());
414 }
415}