1use std::rc::Rc;
2
3use cranpose_animation::{Lerp, spring};
4use cranpose_core::{State, mutableStateOf, remember};
5use cranpose_macros::composable;
6use cranpose_ui::{
7 Modifier, PointerInputScope, SemanticsWidgetRole, SharedText, Size,
8 text::{FontWeight, TextStyle},
9 widgets::{Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Row, RowSpec, Text},
10};
11use cranpose_ui_graphics::{Brush, Color, ColorFilter, CornerRadii, GraphicsLayer};
12use cranpose_ui_layout::Alignment;
13
14use crate::{
15 material::{GlassDynamics, GlassMorph, LiquidModifierExt},
16 motion::LiquidMotion,
17 theme::{LiquidSegmentedStyle, liquid_segmented_style},
18 widgets::content_scope::ScopeContent,
19};
20
21const TRACK_PADDING: f32 = 2.0;
22const LENS_PAD: f32 = 10.0;
23const TAP_SLOP: f32 = 4.0;
24
25fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
26 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
27}
28
29fn segmented_lens_reading(
30 lens_axis: &crate::motion::LiquidDragAxis,
31 pressed: bool,
32 selected: usize,
33 segment_width: f32,
34 count: usize,
35) -> usize {
36 let selected_x = segment_width * selected as f32;
37 let lens_x = lens_axis.value();
38 crate::motion::liquid_visual_index(
39 selected,
40 lens_x,
41 segment_width,
42 count,
43 crate::motion::liquid_axis_owns_visual_selection(
44 pressed,
45 lens_x,
46 selected_x,
47 segment_width,
48 ),
49 )
50}
51
52fn segmented_lens_base_size(segment_width: f32, height: f32, progress: f32) -> Size {
53 let progress = progress.clamp(-0.1, 1.2);
54 let rest_h = height - TRACK_PADDING * 2.0;
55 Size::new(
56 segment_width - TRACK_PADDING * 2.0 + 24.0 * progress,
57 rest_h + 16.0 * progress,
58 )
59}
60
61enum LiquidSegment {
62 Label(SharedText),
64 Content {
66 description: String,
67 content: Rc<dyn Fn(bool)>,
68 },
69}
70
71impl LiquidSegment {
72 fn description(&self) -> String {
73 match self {
74 Self::Label(label) => label.to_string(),
75 Self::Content { description, .. } => description.clone(),
76 }
77 }
78}
79
80pub struct LiquidSegmentedControlScope {
85 segments: ScopeContent<LiquidSegment>,
86}
87
88impl LiquidSegmentedControlScope {
89 pub fn segment(&self, label: impl Into<String>) {
93 self.segments
94 .push(LiquidSegment::Label(SharedText::from(label.into())));
95 }
96
97 pub fn segment_content(
103 &self,
104 description: impl Into<String>,
105 content: impl Fn(bool) + 'static,
106 ) {
107 self.segments.push(LiquidSegment::Content {
108 description: description.into(),
109 content: Rc::new(content),
110 });
111 }
112}
113
114fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
115 ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
116}
117
118struct SegmentLabels {
120 text: TextStyle,
121 selected_text: TextStyle,
122 held: Option<(Color, Color)>,
125}
126
127impl SegmentLabels {
128 fn new(style: &LiquidSegmentedStyle) -> Self {
129 let mut text = style.text.clone();
130 text.span_style.color = Some(style.label);
131 let mut selected_text = style.text.clone();
132 let weight = selected_text
133 .span_style
134 .font_weight
135 .unwrap_or(FontWeight::NORMAL);
136 selected_text.span_style.font_weight = Some(FontWeight((weight.0 + 100).min(900)));
137 selected_text.span_style.color = Some(style.selected_label);
138 Self {
139 text,
140 selected_text,
141 held: (style.selected_label != style.label)
142 .then_some((style.selected_label, style.label)),
143 }
144 }
145
146 fn show(&self, label: &SharedText, selected: bool, lift: State<f32>) {
150 let modifier = match self.held.filter(|_| selected) {
151 Some((rest, held)) => Modifier::empty().graphics_layer(move || {
152 let lift = lift.get().clamp(0.0, 1.0);
153 GraphicsLayer {
154 color_filter: (lift > 0.0).then(|| ColorFilter::tint(rest.lerp(&held, lift))),
155 ..Default::default()
156 }
157 }),
158 None => Modifier::empty(),
159 };
160 let text = if selected {
161 &self.selected_text
162 } else {
163 &self.text
164 };
165 Text(label.clone(), modifier, text.clone());
166 }
167}
168
169#[composable]
184pub fn LiquidSegmentedControl(
185 modifier: Modifier,
186 selected: usize,
187 on_select: impl Fn(usize) + 'static,
188 content: impl FnOnce(&LiquidSegmentedControlScope),
189) {
190 let style = liquid_segmented_style();
191 let segments = collect_segments(content);
192 let count = segments.len().max(1);
193 let selected = selected.min(count - 1);
194 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
195 let segments = Rc::new(segments);
196
197 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
198 let contact = super::control_motion::remember_control_contact(
199 super::control_motion::ControlContactKind::Segment,
200 );
201 let (lens_progress, material_progress) = contact.states();
202
203 let height = style.height;
204 let track_fill = style.track;
205 let marker_fill = style.indicator;
206 let labels = Rc::new(SegmentLabels::new(&style));
207 let track = Modifier::empty().height(height).draw_behind(move |scope| {
208 scope.draw_round_rect(Brush::solid(track_fill), CornerRadii::uniform(height * 0.5));
209 });
210
211 Box(modifier.then(track), BoxSpec::default(), move || {
212 let segments = Rc::clone(&segments);
213 let labels = Rc::clone(&labels);
214 let on_select = Rc::clone(&on_select);
215 let contact = Rc::clone(&contact);
216 BoxWithConstraints(Modifier::empty(), move |scope| {
217 let segments = Rc::clone(&segments);
218 let labels = Rc::clone(&labels);
219 let on_select = Rc::clone(&on_select);
220 let total_width = scope.constraints().max_width.max(1.0);
221 let segment_width = total_width / count as f32;
222 let selected_x = segment_width * selected as f32;
223 let lens_axis =
224 crate::motion::remember_liquid_follow_axis(selected_x, spring(1.0, 750.0));
225 let shape =
226 super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Segment);
227 if !pressed.get() {
228 lens_axis.settle_to(selected_x, LiquidMotion::glide());
229 }
230 let visual_index = {
231 let lens_axis = Rc::clone(&lens_axis);
232 cranpose_core::derivedStateOf(move || {
233 segmented_lens_reading(
234 &lens_axis,
235 pressed.get(),
236 selected,
237 segment_width,
238 count,
239 )
240 })
241 .get()
242 };
243
244 let gesture = Modifier::empty()
245 .size(Size::new(total_width, height))
246 .pointer_input(selected, {
247 let on_select = Rc::clone(&on_select);
248 let lens_axis = Rc::clone(&lens_axis);
249 let contact = Rc::clone(&contact);
250 move |scope: PointerInputScope| {
251 let on_select = Rc::clone(&on_select);
252 let lens_axis = Rc::clone(&lens_axis);
253 let contact = Rc::clone(&contact);
254 crate::motion::liquid_lens_gesture(
255 scope,
256 crate::motion::LiquidLensGesture {
257 axis: lens_axis,
258 cell_width: segment_width,
259 cell_offset: 0.0,
260 count,
261 tap_slop: TAP_SLOP,
262 drag_left: Rc::new(move |x| {
263 segment_lens_left(x, segment_width, count)
264 }),
265 rest_left: Rc::new(move |index| segment_width * index as f32),
266 selected,
267 on_pressed: Rc::new(move |down, time| {
268 pressed.set(down);
269 contact.pressed(down, time);
270 }),
271 on_touch: Rc::new(|_, _| {}),
272 on_select,
273 },
274 )
275 }
276 });
277
278 let Size {
279 width: node_w,
280 height: node_h,
281 } = super::control_lens::node_size(
282 segmented_lens_base_size(segment_width, height, 0.0),
283 segmented_lens_base_size(segment_width, height, 1.0),
284 LENS_PAD,
285 );
286 let lens_for_layer = lens_progress;
287 let layer_axis = Rc::clone(&lens_axis);
288 let physics_axis = Rc::clone(&lens_axis);
289 let content_axis = Rc::clone(&lens_axis);
290 let content_shape = Rc::clone(&shape);
291 let foreground_shape = Rc::clone(&shape);
292 let foreground_axis = Rc::clone(&lens_axis);
293 let lens = Modifier::empty()
294 .required_size(Size::new(node_w, node_h))
295 .offset((segment_width - node_w) * 0.5, (height - node_h) * 0.5)
296 .graphics_layer(move || GraphicsLayer {
297 translation_x: layer_axis.value(),
298 alpha: 1.0,
299 ..Default::default()
300 })
301 .glass_effect_with(
302 super::control_material::material(
303 super::control_motion::ControlContactKind::Segment,
304 ),
305 move || {
306 let grow = lens_for_layer.get().clamp(-0.1, 1.2);
307 let base_size = segmented_lens_base_size(segment_width, height, grow);
308 let projection = shape.projection(physics_axis.value());
309 let material = material_progress.get().max(0.0);
310 GlassDynamics {
311 optical_projection: Some(projection),
312 resting_tint: Some(marker_fill),
313 morph: Some(GlassMorph {
314 node_size: (node_w, node_h),
315 primary: (
316 node_w * 0.5,
317 node_h * 0.5,
318 base_size.width,
319 base_size.height,
320 -1.0,
321 ),
322 shapes: Vec::new(),
323 glue: 0.0,
324 wobble_amplitude: 0.0,
325 wobble_phase: 0.0,
326 bulge_amplitude: 0.0,
327 bulge_direction: 0.0,
328 ellipse_blend: 0.0,
329 capsule_smoothing_dp: 0.0,
330 deformation: None,
331 zoom_anchor: (0.0, 0.0),
332 }),
333 ..super::control_material::dynamics(
334 super::control_motion::ControlContactKind::Segment,
335 grow,
336 material,
337 )
338 }
339 },
340 );
341 Box(lens, BoxSpec::default(), || {});
342 let semantic_selection = Rc::clone(&on_select);
343 Row(
344 Modifier::empty()
345 .selectable_group()
346 .graphics_layer(move || {
347 let position = content_axis.value();
348 GraphicsLayer {
349 render_effect: super::control_material::content_effect(
350 Size::new(total_width, height),
351 (position + segment_width * 0.5, height * 0.5),
352 segmented_lens_base_size(
353 segment_width,
354 height,
355 lens_progress.get(),
356 ),
357 content_shape.projection(position),
358 material_progress.get().max(0.0),
359 ),
360 ..Default::default()
361 }
362 }),
363 RowSpec::default(),
364 move || {
365 for (index, segment) in segments.iter().enumerate() {
366 let is_selected = index == visual_index;
367 let description = segment.description();
368 let on_select = Rc::clone(&semantic_selection);
369 let cell = Modifier::empty()
370 .size(Size::new(segment_width, height))
371 .stable_semantics(super::selection::selection_semantics(
372 description,
373 SemanticsWidgetRole::RadioButton,
374 index,
375 selected,
376 on_select,
377 ))
378 .focusable();
379 let (drawn, labels) = (Rc::clone(&segments), Rc::clone(&labels));
380 Box(
381 cell,
382 BoxSpec::default().content_alignment(Alignment::CENTER),
383 move || match &drawn[index] {
384 LiquidSegment::Label(label) => {
385 labels.show(label, is_selected, material_progress);
386 }
387 LiquidSegment::Content { content, .. } => content(is_selected),
388 },
389 );
390 }
391 },
392 );
393 Box(
394 Modifier::empty()
395 .required_size(Size::new(node_w, node_h))
396 .offset((segment_width - node_w) * 0.5, (height - node_h) * 0.5)
397 .graphics_layer(move || {
398 let position = foreground_axis.value();
399 GraphicsLayer {
400 translation_x: position,
401 backdrop_effect: super::control_material::foreground_effect(
402 Size::new(node_w, node_h),
403 segmented_lens_base_size(
404 segment_width,
405 height,
406 lens_progress.get(),
407 ),
408 foreground_shape.projection(position),
409 material_progress.get().max(0.0),
410 ),
411 ..Default::default()
412 }
413 }),
414 BoxSpec::default(),
415 || {},
416 );
417 Box(gesture, BoxSpec::default(), || {});
418 });
419 });
420}
421
422#[cfg(test)]
423#[path = "tests/segmented_tests.rs"]
424mod tests;