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