1use std::rc::Rc;
2
3use cranpose_animation::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::{GlassDynamics, GlassMorph, LiquidModifierExt},
16 motion::LiquidMotion,
17 theme::{liquid_colors, liquid_typography},
18 widgets::content_scope::ScopeContent,
19};
20
21const SEGMENT_HEIGHT: f32 = 32.0;
22const TRACK_PADDING: f32 = 2.0;
23const LENS_PAD: f32 = 10.0;
24const TAP_SLOP: f32 = 4.0;
25
26fn segment_lens_left(pointer_x: f32, segment_width: f32, count: usize) -> f32 {
27 (pointer_x - segment_width * 0.5).clamp(0.0, segment_width * (count.saturating_sub(1)) as f32)
28}
29
30fn segmented_lens_reading(
31 lens_axis: &crate::motion::LiquidDragAxis,
32 pressed: bool,
33 selected: usize,
34 segment_width: f32,
35 count: usize,
36) -> usize {
37 let selected_x = segment_width * selected as f32;
38 let lens_x = lens_axis.value();
39 crate::motion::liquid_visual_index(
40 selected,
41 lens_x,
42 segment_width,
43 count,
44 crate::motion::liquid_axis_owns_visual_selection(
45 pressed,
46 lens_x,
47 selected_x,
48 segment_width,
49 ),
50 )
51}
52
53fn segmented_lens_base_size(segment_width: f32, progress: f32) -> Size {
54 let progress = progress.clamp(-0.1, 1.2);
55 let rest_h = SEGMENT_HEIGHT - TRACK_PADDING * 2.0;
56 Size::new(
57 segment_width - TRACK_PADDING * 2.0 + 24.0 * progress,
58 rest_h + 16.0 * progress,
59 )
60}
61
62struct LiquidSegment {
63 description: String,
64 content: Rc<dyn Fn(bool)>,
65}
66
67pub struct LiquidSegmentedControlScope {
72 segments: ScopeContent<LiquidSegment>,
73}
74
75impl LiquidSegmentedControlScope {
76 pub fn segment(&self, label: impl Into<String>) {
79 let label = label.into();
80 let text = label.clone();
81 self.segment_content(label, move |selected| {
82 SegmentLabel(text.clone(), selected);
83 });
84 }
85
86 pub fn segment_content(
92 &self,
93 description: impl Into<String>,
94 content: impl Fn(bool) + 'static,
95 ) {
96 self.segments.push(LiquidSegment {
97 description: description.into(),
98 content: Rc::new(content),
99 });
100 }
101}
102
103fn collect_segments(content: impl FnOnce(&LiquidSegmentedControlScope)) -> Vec<LiquidSegment> {
104 ScopeContent::collect(|segments| LiquidSegmentedControlScope { segments }, content)
105}
106
107#[composable]
108fn SegmentLabel(label: String, selected: bool) {
109 let colors = liquid_colors();
110 let typography = liquid_typography();
111 let style = TextStyle {
112 span_style: SpanStyle {
113 color: Some(colors.label),
114 font_size: cranpose_ui::text::TextUnit::Sp(13.0),
115 font_weight: Some(if selected {
116 FontWeight::MEDIUM
117 } else {
118 FontWeight::NORMAL
119 }),
120 ..typography.subheadline.span_style.clone()
121 },
122 ..typography.subheadline
123 };
124 Text(label, Modifier::empty(), style);
125}
126
127#[composable]
141pub fn LiquidSegmentedControl(
142 modifier: Modifier,
143 selected: usize,
144 on_select: impl Fn(usize) + 'static,
145 content: impl FnOnce(&LiquidSegmentedControlScope),
146) {
147 let colors = liquid_colors();
148 let segments = collect_segments(content);
149 let count = segments.len().max(1);
150 let selected = selected.min(count - 1);
151 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
152 let segments = Rc::new(segments);
153
154 let pressed = remember(|| mutableStateOf(false)).with(|s| *s);
155 let contact = super::control_motion::remember_control_contact(
156 super::control_motion::ControlContactKind::Segment,
157 );
158 let (lens_progress, material_progress) = contact.states();
159
160 let track_height = SEGMENT_HEIGHT;
161 let track_fill = if colors.is_dark {
162 Color::from_rgb_u8(28, 28, 31)
163 } else {
164 Color::from_rgb_u8(238, 238, 239)
165 };
166 let marker_fill = if colors.is_dark {
167 Color::from_rgb_u8(90, 90, 95)
168 } else {
169 Color::WHITE
170 };
171 let track = Modifier::empty()
172 .height(track_height)
173 .draw_behind(move |scope| {
174 scope.draw_round_rect(
175 Brush::solid(track_fill),
176 CornerRadii::uniform(track_height * 0.5),
177 );
178 });
179
180 Box(modifier.then(track), BoxSpec::default(), move || {
181 let segments = Rc::clone(&segments);
182 let on_select = Rc::clone(&on_select);
183 let contact = Rc::clone(&contact);
184 BoxWithConstraints(Modifier::empty(), move |scope| {
185 let segments = Rc::clone(&segments);
186 let on_select = Rc::clone(&on_select);
187 let total_width = scope.constraints().max_width.max(1.0);
188 let segment_width = total_width / count as f32;
189 let selected_x = segment_width * selected as f32;
190 let lens_axis =
191 crate::motion::remember_liquid_follow_axis(selected_x, spring(1.0, 750.0));
192 let shape =
193 super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Segment);
194 if !pressed.get() {
195 lens_axis.settle_to(selected_x, LiquidMotion::glide());
196 }
197 let visual_index = {
198 let lens_axis = Rc::clone(&lens_axis);
199 cranpose_core::derivedStateOf(move || {
200 segmented_lens_reading(
201 &lens_axis,
202 pressed.get(),
203 selected,
204 segment_width,
205 count,
206 )
207 })
208 .get()
209 };
210
211 let gesture = Modifier::empty()
212 .size(Size::new(total_width, SEGMENT_HEIGHT))
213 .pointer_input(selected, {
214 let on_select = Rc::clone(&on_select);
215 let lens_axis = Rc::clone(&lens_axis);
216 let contact = Rc::clone(&contact);
217 move |scope: PointerInputScope| {
218 let on_select = Rc::clone(&on_select);
219 let lens_axis = Rc::clone(&lens_axis);
220 let contact = Rc::clone(&contact);
221 crate::motion::liquid_lens_gesture(
222 scope,
223 crate::motion::LiquidLensGesture {
224 axis: lens_axis,
225 cell_width: segment_width,
226 cell_offset: 0.0,
227 count,
228 tap_slop: TAP_SLOP,
229 drag_left: Rc::new(move |x| {
230 segment_lens_left(x, segment_width, count)
231 }),
232 rest_left: Rc::new(move |index| segment_width * index as f32),
233 selected,
234 on_pressed: Rc::new(move |down, time| {
235 pressed.set(down);
236 contact.pressed(down, time);
237 }),
238 on_touch: Rc::new(|_, _| {}),
239 on_select,
240 },
241 )
242 }
243 });
244
245 let Size {
246 width: node_w,
247 height: node_h,
248 } = super::control_lens::node_size(
249 segmented_lens_base_size(segment_width, 0.0),
250 segmented_lens_base_size(segment_width, 1.0),
251 LENS_PAD,
252 );
253 let lens_for_layer = lens_progress;
254 let layer_axis = Rc::clone(&lens_axis);
255 let physics_axis = Rc::clone(&lens_axis);
256 let content_axis = Rc::clone(&lens_axis);
257 let content_shape = Rc::clone(&shape);
258 let foreground_shape = Rc::clone(&shape);
259 let foreground_axis = Rc::clone(&lens_axis);
260 let lens = Modifier::empty()
261 .required_size(Size::new(node_w, node_h))
262 .offset(
263 (segment_width - node_w) * 0.5,
264 (SEGMENT_HEIGHT - node_h) * 0.5,
265 )
266 .graphics_layer(move || GraphicsLayer {
267 translation_x: layer_axis.value(),
268 alpha: 1.0,
269 ..Default::default()
270 })
271 .glass_effect_with(
272 super::control_material::material(
273 super::control_motion::ControlContactKind::Segment,
274 ),
275 move || {
276 let grow = lens_for_layer.get().clamp(-0.1, 1.2);
277 let base_size = segmented_lens_base_size(segment_width, grow);
278 let projection = shape.projection(physics_axis.value());
279 let material = material_progress.get().max(0.0);
280 GlassDynamics {
281 optical_projection: Some(projection),
282 resting_tint: Some(marker_fill),
283 morph: Some(GlassMorph {
284 node_size: (node_w, node_h),
285 primary: (
286 node_w * 0.5,
287 node_h * 0.5,
288 base_size.width,
289 base_size.height,
290 -1.0,
291 ),
292 shapes: Vec::new(),
293 glue: 0.0,
294 wobble_amplitude: 0.0,
295 wobble_phase: 0.0,
296 bulge_amplitude: 0.0,
297 bulge_direction: 0.0,
298 ellipse_blend: 0.0,
299 capsule_smoothing_dp: 0.0,
300 deformation: None,
301 zoom_anchor: (0.0, 0.0),
302 }),
303 ..super::control_material::dynamics(
304 super::control_motion::ControlContactKind::Segment,
305 grow,
306 material,
307 )
308 }
309 },
310 );
311 Box(lens, BoxSpec::default(), || {});
312 let semantic_selection = Rc::clone(&on_select);
313 Row(
314 Modifier::empty()
315 .selectable_group()
316 .graphics_layer(move || {
317 let position = content_axis.value();
318 GraphicsLayer {
319 render_effect: super::control_material::content_effect(
320 Size::new(total_width, SEGMENT_HEIGHT),
321 (position + segment_width * 0.5, SEGMENT_HEIGHT * 0.5),
322 segmented_lens_base_size(segment_width, lens_progress.get()),
323 content_shape.projection(position),
324 material_progress.get().max(0.0),
325 ),
326 ..Default::default()
327 }
328 }),
329 RowSpec::default(),
330 move || {
331 for (index, segment) in segments.iter().enumerate() {
332 let is_selected = index == visual_index;
333 let description = segment.description.clone();
334 let on_select = Rc::clone(&semantic_selection);
335 let cell = Modifier::empty()
336 .size(Size::new(segment_width, SEGMENT_HEIGHT))
337 .stable_semantics(super::selection::selection_semantics(
338 description,
339 SemanticsWidgetRole::RadioButton,
340 index,
341 selected,
342 on_select,
343 ))
344 .focusable();
345 let content = Rc::clone(&segment.content);
346 Box(
347 cell,
348 BoxSpec::default().content_alignment(Alignment::CENTER),
349 move || content(is_selected),
350 );
351 }
352 },
353 );
354 Box(
355 Modifier::empty()
356 .required_size(Size::new(node_w, node_h))
357 .offset(
358 (segment_width - node_w) * 0.5,
359 (SEGMENT_HEIGHT - node_h) * 0.5,
360 )
361 .graphics_layer(move || {
362 let position = foreground_axis.value();
363 GraphicsLayer {
364 translation_x: position,
365 backdrop_effect: super::control_material::foreground_effect(
366 Size::new(node_w, node_h),
367 segmented_lens_base_size(segment_width, lens_progress.get()),
368 foreground_shape.projection(position),
369 material_progress.get().max(0.0),
370 ),
371 ..Default::default()
372 }
373 }),
374 BoxSpec::default(),
375 || {},
376 );
377 Box(gesture, BoxSpec::default(), || {});
378 });
379 });
380}
381
382#[cfg(test)]
383#[path = "tests/segmented_tests.rs"]
384mod tests;