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_reading(
42 lens_axis: &crate::motion::LiquidDragAxis,
43 pressed: bool,
44 selected: usize,
45 segment_width: f32,
46 count: usize,
47) -> (usize, bool) {
48 let selected_x = segment_width * selected as f32;
49 let lens_x = lens_axis.value();
50 let visual_index = crate::motion::liquid_visual_index(
51 selected,
52 lens_x,
53 segment_width,
54 count,
55 crate::motion::liquid_axis_owns_visual_selection(
56 pressed,
57 lens_x,
58 selected_x,
59 segment_width,
60 ),
61 );
62 let settling = !lens_axis.is_dragging() && (lens_x - selected_x).abs() > 1.0;
63 (visual_index, settling)
64}
65
66fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
67 let progress = progress.clamp(0.0, 1.2);
68 let rest_h = SEGMENT_HEIGHT + TRACK_PADDING * 2.0 + MARKER_POKE_TOP + MARKER_POKE_BOTTOM;
69 let width_lift = 1.0 + (LENS_WIDTH_LIFT_SCALE - 1.0) * progress;
70 let height_lift = 1.0 + (LENS_HEIGHT_LIFT_SCALE - 1.0) * progress;
71 Size::new(
72 (segment_width * MARKER_WIDTH_FACTOR + 2.0 * progress) * width_lift,
73 (rest_h + LENS_OVERFLOW * progress) * height_lift,
74 )
75}
76
77fn segmented_strain(stretch: f32) -> f32 {
78 1.0 + (stretch - 1.0) * SEGMENTED_STRAIN_RESPONSE
79}
80
81struct LiquidSegment {
82 description: String,
83 content: Rc<dyn Fn(bool)>,
84}
85
86pub struct LiquidSegmentedControlScope {
91 segments: ScopeContent<LiquidSegment>,
92}
93
94impl LiquidSegmentedControlScope {
95 pub fn segment(&self, label: impl Into<String>) {
98 let label = label.into();
99 let text = label.clone();
100 self.segment_content(label, move |selected| {
101 SegmentLabel(text.clone(), selected);
102 });
103 }
104
105 pub fn segment_content(
111 &self,
112 description: impl Into<String>,
113 content: impl Fn(bool) + 'static,
114 ) {
115 self.segments.push(LiquidSegment {
116 description: description.into(),
117 content: Rc::new(content),
118 });
119 }
120}
121
122fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
123 ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
124}
125
126#[composable]
127fn SegmentLabel(label: String, selected: bool) {
128 let colors = liquid_colors();
129 let typography = liquid_typography();
130 let style = TextStyle {
131 span_style: SpanStyle {
132 color: Some(colors.label),
133 font_size: cranpose_ui::text::TextUnit::Sp(17.0),
134 font_weight: Some(if selected {
135 FontWeight::MEDIUM
136 } else {
137 FontWeight::NORMAL
138 }),
139 ..typography.subheadline.span_style.clone()
140 },
141 ..typography.subheadline
142 };
143 Text(label, Modifier::empty(), style);
144}
145
146#[composable]
160pub fn LiquidSegmentedControl(
161 modifier: Modifier,
162 selected: usize,
163 on_select: impl Fn(usize) + 'static,
164 content: impl FnOnce(&LiquidSegmentedControlScope),
165) {
166 let colors = liquid_colors();
167 let segments = collect_segments(content);
168 let count = segments.len().max(1);
169 let selected = selected.min(count - 1);
170 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
171 let segments = Rc::new(segments);
172
173 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
174
175 let track_height = SEGMENT_HEIGHT + TRACK_PADDING * 2.0;
176 let track_fill = if colors.is_dark {
177 colors.fill
178 } else {
179 Color::WHITE
180 };
181 let track = if colors.is_dark {
182 Modifier::empty()
183 } else {
184 Modifier::empty().drop_shadow(
185 cranpose_ui_graphics::LayerShape::Rounded(
186 cranpose_ui_graphics::RoundedCornerShape::uniform(track_height * 0.5),
187 ),
188 |scope| {
189 scope.radius = 6.0;
190 scope.offset.y = 4.5;
191 scope.color = Color::BLACK.with_alpha(0.10);
192 },
193 )
194 };
195 let track = track.height(track_height).draw_behind(move |scope| {
196 scope.draw_round_rect(
197 Brush::solid(track_fill),
198 CornerRadii::uniform(track_height * 0.5),
199 );
200 });
201
202 Box(track.then(modifier), BoxSpec::default(), move || {
203 let segments = Rc::clone(&segments);
204 let on_select = Rc::clone(&on_select);
205 BoxWithConstraints(Modifier::empty().padding(TRACK_PADDING), move |scope| {
206 let segments = Rc::clone(&segments);
207 let on_select = Rc::clone(&on_select);
208 let total_width = scope.constraints().max_width.max(1.0);
209 let segment_width = total_width / count as f32;
210 let selected_x = segment_width * selected as f32;
211 let lens_axis = crate::motion::remember_liquid_drag_axis(selected_x);
212 if !pressed.get() {
213 lens_axis.settle_to(selected_x, LiquidMotion::glide());
214 }
215 let (visual_index, lens_settling) = {
216 let lens_axis = Rc::clone(&lens_axis);
217 cranpose_core::derivedStateOf(move || {
218 segmented_lens_reading(
219 &lens_axis,
220 pressed.get(),
221 selected,
222 segment_width,
223 count,
224 )
225 })
226 .get()
227 };
228 let lens_target = if pressed.get() || lens_settling {
229 1.0
230 } else {
231 0.0
232 };
233 let lens_progress = animateFloatAsState(
234 lens_target,
235 if pressed.get() || lens_settling {
236 spring(0.9, 1400.0)
237 } else {
238 spring(1.0, 170.0)
239 },
240 "segmented-lens",
241 );
242
243 let semantic_selection = Rc::clone(&on_select);
244 Row(
245 Modifier::empty().selectable_group(),
246 RowSpec::default(),
247 move || {
248 for (index, segment) in segments.iter().enumerate() {
249 let is_selected = index == visual_index;
250 let description = segment.description.clone();
251 let on_select = Rc::clone(&semantic_selection);
252 let cell = Modifier::empty()
253 .size(Size::new(segment_width, SEGMENT_HEIGHT))
254 .semantics(super::selection::selection_semantics(
255 description,
256 SemanticsWidgetRole::RadioButton,
257 index,
258 selected,
259 on_select,
260 ))
261 .focusable();
262 let content = Rc::clone(&segment.content);
263 Box(
264 cell,
265 BoxSpec::default().content_alignment(Alignment::CENTER),
266 move || content(is_selected),
267 );
268 }
269 },
270 );
271
272 let gesture = Modifier::empty()
273 .size(Size::new(total_width, SEGMENT_HEIGHT))
274 .pointer_input(selected, {
275 let on_select = Rc::clone(&on_select);
276 let lens_axis = Rc::clone(&lens_axis);
277 move |scope: PointerInputScope| {
278 let on_select = Rc::clone(&on_select);
279 let lens_axis = Rc::clone(&lens_axis);
280 crate::motion::liquid_lens_gesture(
281 scope,
282 crate::motion::LiquidLensGesture {
283 axis: lens_axis,
284 cell_width: segment_width,
285 cell_offset: 0.0,
286 count,
287 tap_slop: TAP_SLOP,
288 drag_left: Rc::new(move |x| {
289 segment_lens_left(x, segment_width, count)
290 }),
291 rest_left: Rc::new(move |index| segment_width * index as f32),
292 selected,
293 on_pressed: Rc::new(move |down, _| pressed.set(down)),
294 on_touch: Rc::new(|_, _| {}),
295 on_select,
296 },
297 )
298 }
299 });
300 Box(gesture, BoxSpec::default(), || {});
301
302 let raised_size = segmented_lens_base_size(segment_width, 1.2);
303 let deformation_headroom = segmented_strain(crate::dynamics::STRETCH_MAX)
304 .max(1.0 / segmented_strain(crate::dynamics::STRETCH_MIN));
305 let node_w = raised_size.width * deformation_headroom
306 + crate::dynamics::BULGE_MAX
307 + LENS_PAD * 2.0;
308 let node_h = raised_size.height * deformation_headroom
309 + crate::dynamics::BULGE_MAX
310 + LENS_PAD * 2.0;
311 let lens_for_layer = lens_progress;
312 let layer_axis = Rc::clone(&lens_axis);
313 let physics_axis = Rc::clone(&lens_axis);
314 let lens = Modifier::empty()
315 .required_size(Size::new(node_w, node_h))
316 .offset(
317 (segment_width - node_w) * 0.5,
318 (SEGMENT_HEIGHT - node_h) * 0.5,
319 )
320 .graphics_layer(move || GraphicsLayer {
321 translation_x: layer_axis.value(),
322 alpha: 1.0,
323 ..Default::default()
324 })
325 .glass_effect_with(
326 Glass::lens()
327 .shape(LiquidShape::Capsule)
328 .tint(Color::rgba(0.0, 0.0, 0.0, 0.08))
329 .shadow_style(GlassShadow::new(
330 Color::BLACK.with_alpha(0.14),
331 12.0,
332 4.0,
333 -2.0,
334 ))
335 .rim_reflection(0.04)
336 .blur_radius(0.5)
337 .refraction_depth(1.0)
338 .refraction_curve(0.25)
339 .fold_depth(5.0)
340 .dispersion(0.85)
341 .highlight(0.04)
342 .lift(0.0)
343 .no_clip(),
344 move || {
345 let grow = lens_for_layer.get().clamp(0.0, 1.2);
346 let base_size = segmented_lens_base_size(segment_width, grow);
347 let pose = physics_axis.liquid_pose();
348 GlassDynamics {
349 activity: Some(
350 MARKER_REST_ACTIVITY
351 + (1.0 - MARKER_REST_ACTIVITY) * grow.clamp(0.0, 1.0),
352 ),
353 press_depth: Some(
354 (0.12 + 0.30 * grow.clamp(0.0, 1.0) + 0.58 * pose.energy())
355 .clamp(0.0, 1.0),
356 ),
357 morph: Some(GlassMorph {
358 node_size: (node_w, node_h),
359 primary: (
360 node_w * 0.5,
361 node_h * 0.5 - (MARKER_POKE_TOP - MARKER_POKE_BOTTOM) * 0.5,
362 base_size.width,
363 base_size.height,
364 -1.0,
365 ),
366 shapes: Vec::new(),
367 glue: 0.0,
368 wobble_amplitude: 0.0,
369 wobble_phase: 0.0,
370 bulge_amplitude: pose.bulge_amplitude.min(4.0),
371 bulge_direction: pose.bulge_direction,
372 ellipse_blend: LENS_ELLIPSE_BLEND,
373 capsule_smoothing_dp: 0.0,
374 deformation: Some(
375 crate::material::GlassDeformation::incompressible(
376 pose.axis,
377 segmented_strain(pose.stretch),
378 ),
379 ),
380 zoom_anchor: (0.0, 0.0),
381 }),
382 ..Default::default()
383 }
384 },
385 );
386 Box(lens, BoxSpec::default(), || {});
387 });
388 });
389}
390
391#[cfg(test)]
392#[path = "tests/segmented_tests.rs"]
393mod tests;