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 Row(Modifier::empty(), RowSpec::default(), move || {
221 for (index, segment) in segments.iter().enumerate() {
222 let is_selected = index == visual_index;
223 let description = segment.description.clone();
224 let cell = Modifier::empty()
225 .size(Size::new(segment_width, SEGMENT_HEIGHT))
226 .semantics(move |config| {
227 config.role = Some(SemanticsWidgetRole::Button);
228 config.is_clickable = true;
229 config.content_description = Some(description.clone());
230 });
231 let content = Rc::clone(&segment.content);
232 Box(
233 cell,
234 BoxSpec::default().content_alignment(Alignment::CENTER),
235 move || content(is_selected),
236 );
237 }
238 });
239
240 let gesture = Modifier::empty()
241 .size(Size::new(total_width, SEGMENT_HEIGHT))
242 .pointer_input(selected, {
243 let on_select = Rc::clone(&on_select);
244 let lens_axis = Rc::clone(&lens_axis);
245 move |scope: PointerInputScope| {
246 let on_select = Rc::clone(&on_select);
247 let lens_axis = Rc::clone(&lens_axis);
248 crate::motion::liquid_lens_gesture(
249 scope,
250 crate::motion::LiquidLensGesture {
251 axis: lens_axis,
252 cell_width: segment_width,
253 cell_offset: 0.0,
254 count,
255 tap_slop: TAP_SLOP,
256 drag_left: Rc::new(move |x| {
257 segment_lens_left(x, segment_width, count)
258 }),
259 rest_left: Rc::new(move |index| segment_width * index as f32),
260 selected,
261 on_pressed: Rc::new(move |down, _| pressed.set(down)),
262 on_touch: Rc::new(|_, _| {}),
263 on_select,
264 },
265 )
266 }
267 });
268 Box(gesture, BoxSpec::default(), || {});
269
270 let raised_size = segmented_lens_base_size(segment_width, 1.2);
271 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
272 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
273 let node_w = raised_size.width * deformation_headroom
274 + crate::dynamics::BULGE_MAX
275 + LENS_PAD * 2.0;
276 let node_h = raised_size.height * deformation_headroom
277 + crate::dynamics::BULGE_MAX
278 + LENS_PAD * 2.0;
279 let lens_for_layer = lens_progress;
280 let physics_axis = Rc::clone(&lens_axis);
281 let lens = Modifier::empty()
282 .required_size(Size::new(node_w, node_h))
283 .offset(
284 (segment_width - node_w) * 0.5,
285 (SEGMENT_HEIGHT - node_h) * 0.5,
286 )
287 .graphics_layer(move || GraphicsLayer {
288 translation_x: lens_x,
289 alpha: 1.0,
290 ..Default::default()
291 })
292 .glass_effect_with(
293 Glass::lens()
294 .shape(LiquidShape::Capsule)
295 .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
296 .shadow_style(GlassShadow::new(
297 Color::BLACK.with_alpha(0.14),
298 12.0,
299 4.0,
300 -2.0,
301 ))
302 .rim_reflection(0.04)
303 .blur_radius(0.5)
304 .refraction_depth(1.0)
305 .refraction_curve(0.25)
306 .fold_depth(5.0)
307 .dispersion(0.85)
308 .highlight(0.04)
309 .lift(0.0)
310 .no_clip(),
311 move || {
312 let grow = lens_for_layer.get().clamp(0.0, 1.2);
313 let base_size = segmented_lens_base_size(segment_width, grow);
314 let pose = physics_axis.liquid_pose();
315 GlassDynamics {
316 activity: Some(
317 MARKER_REST_ACTIVITY
318 + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
319 ),
320 press_depth: Some(
321 (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
322 .clamp(0.0, 1.0),
323 ),
324 morph: Some(GlassMorph {
325 node_size: (node_w, node_h),
326 primary: (
327 node_w * 0.5,
328 node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
329 base_size.width,
330 base_size.height,
331 -1.0,
332 ),
333 shapes: Vec::new(),
334 glue: 0.0,
335 wobble_amplitude: 0.0,
336 wobble_phase: 0.0,
337 bulge_amplitude: pose.bulge_amplitude.min(4.0),
338 bulge_direction: pose.bulge_direction,
339 ellipse_blend: LENS_ELLIPSE_BLEND,
340 capsule_smoothing_dp: 0.0,
341 deformation: Some(
342 crate::material::GlassDeformation::incompressible(
343 pose.axis,
344 segmented_strain(pose.stretch),
345 ),
346 ),
347 zoom_anchor: (0.0, 0.0),
348 }),
349 ..Default::default()
350 }
351 },
352 );
353 Box(lens, BoxSpec::default(), || {});
354 });
355 });
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn pointer_position_is_the_clamped_lens_center() {
364 let width = 100.0;
365 assert_eq!(segment_lens_left(50.0, width, 3), 0.0);
366 assert_eq!(segment_lens_left(150.0, width, 3), 100.0);
367 assert_eq!(segment_lens_left(250.0, width, 3), 200.0);
368 assert_eq!(segment_lens_left(-50.0, width, 3), 0.0);
369 assert_eq!(segment_lens_left(400.0, width, 3), 200.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.width, 120.0 * MARKER_WIDTH_FACTOR);
377 assert!(resting.height > SEGMENT_HEIGHT + TRACK_PADDING * 2.0);
378 assert!(raised.width < resting.width * 1.10);
379 assert!(raised.height > resting.height * 1.20);
380 assert!(segmented_strain(crate::dynamics::STRETCH_MAX) < 1.20);
381 }
382
383 #[test]
384 fn a_scope_records_what_each_segment_announces() {
385 let drawn = Rc::new(std::cell::Cell::new(0u32));
386 let counted = Rc::clone(&drawn);
387 let segments = collect_segments(|scope| {
388 scope.segment("Sending");
389 scope.segment_content("Received", move |selected| {
390 counted.set(counted.get() + u32::from(selected));
391 });
392 });
393
394 assert_eq!(segments.len(), 2);
395 assert_eq!(segments[0].description, "Sending");
396 assert_eq!(segments[1].description, "Received");
397 assert_eq!(drawn.get(), 0);
398 (segments[1].content)(true);
399 assert_eq!(drawn.get(), 1);
400 }
401
402 #[test]
403 fn a_control_with_no_segments_declares_none() {
404 assert!(collect_segments(|_| {}).is_empty());
405 }
406}