1use crate::material::{
5 Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, LiquidShape,
6};
7use crate::theme::{liquid_colors, liquid_typography};
8use cranpose_core::{mutableStateOf, remember, MutableState, SideEffect};
9use cranpose_foundation::PointerId;
10use cranpose_macros::composable;
11use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
12use cranpose_ui::widgets::{
13 Box, BoxSpec, Column, ColumnSpec, PopupDismissableWhen, Row, RowSpec, Text,
14};
15use cranpose_ui::{
16 rememberMutableInteractionSource, Modifier, PointerEventKind, PointerInputScope,
17 PressInteractionPress, Size,
18};
19use cranpose_ui_graphics::{Brush, Color, CornerRadii, GraphicsLayer, Point, Rect, RenderEffect};
20use cranpose_ui_layout::VerticalAlignment;
21use std::cell::{Cell, RefCell};
22use std::rc::Rc;
23
24#[derive(Clone, Debug, PartialEq)]
26pub struct LiquidMenuItem {
27 pub label: String,
28 pub icon: Option<&'static str>,
30 pub checked: bool,
32 pub destructive: bool,
34 pub section_start: bool,
36 pub header: bool,
38 pub keeps_open: bool,
41 pub subtitle: Option<String>,
45}
46
47impl LiquidMenuItem {
48 pub fn new(label: impl Into<String>) -> Self {
49 Self {
50 label: label.into(),
51 icon: None,
52 checked: false,
53 destructive: false,
54 section_start: false,
55 header: false,
56 keeps_open: false,
57 subtitle: None,
58 }
59 }
60
61 pub fn header(label: impl Into<String>) -> Self {
63 Self {
64 header: true,
65 ..Self::new(label)
66 }
67 }
68
69 pub fn icon(mut self, icon: &'static str) -> Self {
70 self.icon = Some(icon);
71 self
72 }
73
74 pub fn checked(mut self, checked: bool) -> Self {
75 self.checked = checked;
76 self
77 }
78
79 pub fn destructive(mut self) -> Self {
80 self.destructive = true;
81 self
82 }
83
84 pub fn section_start(mut self) -> Self {
85 self.section_start = true;
86 self
87 }
88
89 pub fn keeps_open(mut self) -> Self {
92 self.keeps_open = true;
93 self
94 }
95
96 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
98 self.subtitle = Some(subtitle.into());
99 self
100 }
101}
102
103#[derive(Clone, Debug, PartialEq)]
106pub struct LiquidMenuAbsorbedSource {
107 pub rect: Rect,
108 pub spec: crate::widgets::GlassButtonSpec,
109 pub diameter: f32,
110 pub icon_path: &'static str,
111}
112
113impl LiquidMenuAbsorbedSource {
114 pub fn new(
115 rect: Rect,
116 spec: crate::widgets::GlassButtonSpec,
117 diameter: f32,
118 icon_path: &'static str,
119 ) -> Self {
120 Self {
121 rect,
122 spec,
123 diameter,
124 icon_path,
125 }
126 }
127}
128
129#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct LiquidMenuSpec {
132 pub width: f32,
135}
136
137impl Default for LiquidMenuSpec {
138 fn default() -> Self {
139 Self { width: MENU_WIDTH }
140 }
141}
142
143impl LiquidMenuSpec {
144 pub fn new(width: f32) -> Self {
145 Self {
146 width: if width.is_finite() {
147 width.max(120.0)
148 } else {
149 MENU_WIDTH
150 },
151 }
152 }
153}
154
155const MENU_WIDTH: f32 = 250.0;
156const MENU_RADIUS: f32 = 32.0;
157const MENU_GROW_DELAY: f32 = 0.050;
158const MENU_SOURCE_SEPARATE_END: f32 = 0.06;
159const MENU_CARD_WIDTH_GROW_START: f32 = 0.16;
160const MENU_CARD_HEIGHT_GROW_START: f32 = 0.12;
161const MENU_OVERSHOOT_SCALE: f32 = 0.30;
162const MENU_GROW_STIFFNESS: f32 = 62.0;
167const MENU_REVEAL_STIFFNESS: f32 = 26.0;
168const MENU_WIDTH_EASE_POWER: f32 = 4.5;
169const MENU_HEIGHT_EASE_POWER: f32 = 18.0;
170const MENU_HEIGHT_OVERSHOOT: f32 = 0.15;
171const MENU_HEIGHT_OVERSHOOT_END: f32 = 0.52;
172const MENU_VERTICAL_REBOUND: f32 = 18.0;
173const MENU_VERTICAL_REBOUND_END: f32 = 0.70;
174const MENU_SOURCE_HEIGHT_RATIO: f32 = 0.86;
175const MENU_SOURCE_TARGET_Y_PROGRESS: f32 = 0.0;
176const ANCHOR_OVERLAP: f32 = 0.0;
181const ROW_PADDING_X: f32 = 20.0;
182const ROW_PADDING_Y: f32 = 9.25;
183const CHIP_INSET_X: f32 = 10.0;
186const MENU_CONTENT_INSET_Y: f32 = 9.5;
187const CHECK_COLUMN: f32 = 24.0;
189const ICON_SIZE: f32 = 24.0;
190const ICON_GAP: f32 = 12.0;
191const MENU_LONG_PRESS_MS: u64 = 500;
192const MENU_LONG_PRESS_SLOP: f32 = 12.0;
193const MENU_CONTENT_BLUR: f32 = 14.0;
194const MENU_CONTENT_BLUR_POWER: f32 = 0.65;
195const MENU_CONTENT_ALPHA_POWER: f32 = 0.45;
196const MENU_TRIGGER_GLASS_CUTOFF: f32 = 0.05;
197const MENU_TRIGGER_ABSORPTION_MS: u64 = 36;
198const MENU_TRIGGER_RESTORE_DELAY_MS: u64 = 205;
199const MENU_SOURCE_FOREGROUND_HIDE_MS: u64 = 200;
200const MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS: u64 = 205;
201#[derive(Clone, Copy, Debug, PartialEq)]
202struct MenuGestureSnapshot {
203 active: bool,
204 claimed: bool,
205 start: Point,
206 position: Point,
207 release: Option<(u64, Point)>,
208}
209
210impl Default for MenuGestureSnapshot {
211 fn default() -> Self {
212 Self {
213 active: false,
214 claimed: false,
215 start: Point::new(0.0, 0.0),
216 position: Point::new(0.0, 0.0),
217 release: None,
218 }
219 }
220}
221
222struct LiquidMenuGestureInner {
223 snapshot: MutableState<MenuGestureSnapshot>,
224 next_release: Cell<u64>,
225 item_rects: RefCell<Vec<Rc<Cell<Rect>>>>,
226}
227
228#[derive(Clone)]
232pub struct LiquidMenuGesture {
233 inner: Rc<LiquidMenuGestureInner>,
234}
235
236impl PartialEq for LiquidMenuGesture {
237 fn eq(&self, other: &Self) -> bool {
238 Rc::ptr_eq(&self.inner, &other.inner)
239 }
240}
241
242impl LiquidMenuGesture {
243 pub fn is_pressed(&self) -> bool {
246 self.inner.snapshot.get().active
247 }
248
249 pub fn press_point(&self) -> Option<Point> {
252 let snapshot = self.inner.snapshot.get();
253 snapshot.active.then_some(snapshot.position)
254 }
255
256 fn new() -> Self {
257 Self {
258 inner: Rc::new(LiquidMenuGestureInner {
259 snapshot: mutableStateOf(MenuGestureSnapshot::default()),
260 next_release: Cell::new(0),
261 item_rects: RefCell::new(Vec::new()),
262 }),
263 }
264 }
265
266 fn id(&self) -> usize {
267 Rc::as_ptr(&self.inner) as usize
268 }
269
270 fn begin(&self, point: Point) {
271 self.inner.snapshot.set(MenuGestureSnapshot {
272 active: true,
273 start: point,
274 position: point,
275 ..MenuGestureSnapshot::default()
276 });
277 }
278
279 fn move_to(&self, point: Point) {
280 let mut snapshot = self.inner.snapshot.get();
281 if snapshot.active {
282 snapshot.position = point;
283 self.inner.snapshot.set(snapshot);
284 }
285 }
286
287 fn claim(&self) {
288 let mut snapshot = self.inner.snapshot.get();
289 if snapshot.active && !snapshot.claimed {
290 snapshot.claimed = true;
291 self.inner.snapshot.set(snapshot);
292 }
293 }
294
295 fn release(&self, point: Point) {
296 let mut snapshot = self.inner.snapshot.get();
297 if !snapshot.active {
298 return;
299 }
300 snapshot.position = point;
301 snapshot.active = false;
302 if snapshot.claimed {
303 let sequence = self.inner.next_release.get().wrapping_add(1);
304 self.inner.next_release.set(sequence);
305 snapshot.release = Some((sequence, point));
306 }
307 self.inner.snapshot.set(snapshot);
308 }
309
310 fn cancel(&self) {
311 let mut snapshot = self.inner.snapshot.get();
312 snapshot.active = false;
313 snapshot.claimed = false;
314 snapshot.release = None;
315 self.inner.snapshot.set(snapshot);
316 }
317
318 fn snapshot(&self) -> MenuGestureSnapshot {
319 self.inner.snapshot.get()
320 }
321
322 fn item_rect(&self, index: usize) -> Rc<Cell<Rect>> {
323 let mut rects = self.inner.item_rects.borrow_mut();
324 while rects.len() <= index {
325 rects.push(Rc::new(Cell::new(Rect {
326 x: 0.0,
327 y: 0.0,
328 width: 0.0,
329 height: 0.0,
330 })));
331 }
332 Rc::clone(&rects[index])
333 }
334
335 fn item_at(&self, point: Point, items: &[LiquidMenuItem]) -> Option<usize> {
336 self.inner
337 .item_rects
338 .borrow()
339 .iter()
340 .enumerate()
341 .take(items.len())
342 .find_map(|(index, rect)| {
343 (!items[index].header && rect.get().contains(point.x, point.y)).then_some(index)
344 })
345 }
346}
347
348#[composable]
350pub fn remember_liquid_menu_gesture() -> LiquidMenuGesture {
351 remember(LiquidMenuGesture::new).with(Clone::clone)
352}
353
354#[composable]
357#[allow(non_snake_case)]
358pub fn LiquidMenuAbsorbedIconButton(
359 modifier: Modifier,
360 spec: crate::widgets::GlassButtonSpec,
361 diameter: f32,
362 transferred: bool,
363 on_click: impl Fn() + 'static,
364 icon_path: &'static str,
365) {
366 let foreground = cranpose_animation::animate_float_as_state_with_initial(
367 1.0,
368 if transferred { 0.0 } else { 1.0 },
369 cranpose_animation::AnimationType::Tween(if transferred {
370 cranpose_animation::AnimationSpec::tween(
371 MENU_SOURCE_FOREGROUND_HIDE_MS,
372 cranpose_animation::Easing::LinearEasing,
373 )
374 } else {
375 cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
376 .with_delay(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS)
377 }),
378 "menu-source-foreground-ownership",
379 );
380 crate::widgets::button::GlassIconButtonWithForegroundAlpha(
381 modifier,
382 spec,
383 diameter,
384 foreground.get(),
385 on_click,
386 icon_path,
387 );
388}
389
390#[derive(Clone, Copy, Debug, PartialEq)]
391struct MenuGeometryPhase {
392 path: f32,
393 width: f32,
394 height: f32,
395}
396
397fn menu_geometry_phase(expanded: bool, appear: f32) -> MenuGeometryPhase {
398 if expanded && appear > 1.0 {
399 let settle = 1.0 + (appear - 1.0) * MENU_OVERSHOOT_SCALE;
400 return MenuGeometryPhase {
401 path: settle,
402 width: settle,
403 height: settle,
404 };
405 }
406
407 let appear = appear.clamp(0.0, 1.0);
408 if !expanded {
409 let normalized = ((appear - 0.015) / 0.985).clamp(0.0, 1.0);
410 return MenuGeometryPhase {
411 path: normalized,
412 width: 1.0 - (1.0 - normalized).powf(2.5),
413 height: 1.0 - (1.0 - normalized).powf(14.0),
414 };
415 }
416
417 if appear < MENU_GROW_DELAY {
418 let source_merge = smoothstep(0.10, 0.58, appear / MENU_GROW_DELAY);
419 return MenuGeometryPhase {
420 path: 0.0,
421 width: source_merge,
422 height: source_merge,
423 };
424 }
425
426 let path = ((appear - MENU_GROW_DELAY) / (1.0 - MENU_GROW_DELAY)).clamp(0.0, 1.0);
427 let width_growth =
428 ((path - MENU_CARD_WIDTH_GROW_START) / (1.0 - MENU_CARD_WIDTH_GROW_START)).clamp(0.0, 1.0);
429 let height_growth = ((path - MENU_CARD_HEIGHT_GROW_START)
430 / (1.0 - MENU_CARD_HEIGHT_GROW_START))
431 .clamp(0.0, 1.0);
432 let overshoot_phase = ((path - 0.50) / 0.50).clamp(0.0, 1.0);
433 let overshoot = 0.040 * (std::f32::consts::PI * overshoot_phase).sin().max(0.0);
434 let height_overshoot_phase = (height_growth / MENU_HEIGHT_OVERSHOOT_END).clamp(0.0, 1.0);
435 let height_overshoot = MENU_HEIGHT_OVERSHOOT
436 * (std::f32::consts::PI * height_overshoot_phase)
437 .sin()
438 .max(0.0);
439 MenuGeometryPhase {
440 path,
441 width: 1.0 - (1.0 - width_growth).powf(MENU_WIDTH_EASE_POWER) + overshoot,
442 height: 1.0 - (1.0 - height_growth).powf(MENU_HEIGHT_EASE_POWER) + height_overshoot,
443 }
444}
445
446#[derive(Clone, Copy, Debug, PartialEq)]
447struct MenuShape {
448 center_x: f32,
449 center_y: f32,
450 width: f32,
451 height: f32,
452 radius: f32,
453}
454
455impl MenuShape {
456 fn capsule(center_x: f32, center_y: f32, width: f32, height: f32) -> Self {
457 Self {
458 center_x,
459 center_y,
460 width,
461 height,
462 radius: -1.0,
463 }
464 }
465
466 fn from_window_rect(rect: Rect, node_origin: Point) -> Option<Self> {
467 (rect.width > 0.0 && rect.height > 0.0).then(|| {
468 Self::capsule(
469 rect.x + rect.width * 0.5 - node_origin.x,
470 rect.y + rect.height * 0.5 - node_origin.y,
471 rect.width,
472 rect.height,
473 )
474 })
475 }
476
477 fn as_glass_shape(self) -> (f32, f32, f32, f32, f32) {
478 (
479 self.center_x,
480 self.center_y,
481 self.width,
482 self.height,
483 self.radius,
484 )
485 }
486}
487
488#[derive(Clone, Copy, Debug, PartialEq)]
489struct MenuMorphGeometry {
490 primary: MenuShape,
491 source: MenuShape,
492 target: MenuShape,
493 path: f32,
494}
495
496fn menu_source_shape(anchor: MenuShape, absorbed: &[MenuShape], target: MenuShape) -> MenuShape {
497 let mut left = anchor.center_x - anchor.width * 0.5;
498 let mut right = anchor.center_x + anchor.width * 0.5;
499 let mut top = anchor.center_y - anchor.height * 0.5;
500 let mut bottom = anchor.center_y + anchor.height * 0.5;
501 for shape in absorbed {
502 left = left.min(shape.center_x - shape.width * 0.5);
503 right = right.max(shape.center_x + shape.width * 0.5);
504 top = top.min(shape.center_y - shape.height * 0.5);
505 bottom = bottom.max(shape.center_y + shape.height * 0.5);
506 }
507
508 let width = right - left;
509 let cluster_height = bottom - top;
510 let height = cluster_height
511 .max(width * MENU_SOURCE_HEIGHT_RATIO)
512 .min(target.height);
513 let cluster_center_y = (top + bottom) * 0.5;
514 MenuShape::capsule(
515 (left + right) * 0.5,
516 cluster_center_y + (target.center_y - cluster_center_y) * MENU_SOURCE_TARGET_Y_PROGRESS,
517 width,
518 height,
519 )
520}
521
522fn interpolate_menu_shape(
523 start: MenuShape,
524 target: MenuShape,
525 width_progress: f32,
526 height_progress: f32,
527) -> MenuShape {
528 let lerp = |a: f32, b: f32, progress: f32| a + (b - a) * progress;
529 MenuShape::capsule(
530 lerp(start.center_x, target.center_x, width_progress),
531 lerp(start.center_y, target.center_y, height_progress),
532 lerp(start.width, target.width, width_progress),
533 lerp(start.height, target.height, height_progress),
534 )
535}
536
537fn menu_vertical_rebound(path: f32) -> f32 {
538 if !(0.0..MENU_VERTICAL_REBOUND_END).contains(&path) {
539 return 0.0;
540 }
541
542 let normalized = path / MENU_VERTICAL_REBOUND_END;
543 let onset = smoothstep(0.0, 0.012, path);
544 MENU_VERTICAL_REBOUND
545 * onset
546 * (std::f32::consts::PI * normalized.powf(0.45))
547 .sin()
548 .max(0.0)
549}
550
551fn menu_morph_geometry(
552 expanded: bool,
553 appear: f32,
554 anchor: MenuShape,
555 absorbed: &[MenuShape],
556 target: MenuShape,
557) -> MenuMorphGeometry {
558 let phase = menu_geometry_phase(expanded, appear);
559 let source = menu_source_shape(anchor, absorbed, target);
560 let mut primary = if expanded && phase.path < MENU_SOURCE_SEPARATE_END {
561 anchor
562 } else {
563 let start = if expanded { source } else { anchor };
564 interpolate_menu_shape(start, target, phase.width, phase.height)
565 };
566 if expanded && phase.path >= MENU_SOURCE_SEPARATE_END {
567 let descent = smoothstep(0.10, 0.90, phase.path);
572 primary.center_y = source.center_y + (target.center_y - source.center_y) * descent;
573 primary.center_y += menu_vertical_rebound(phase.path);
574 }
575 let blob_radius = primary.height * 0.5;
576 let squareness = smoothstep(0.45, 0.62, phase.path);
577 primary.radius = if !expanded {
578 blob_radius
579 } else if phase.path >= 1.0 {
580 target.radius
581 } else {
582 blob_radius + (target.radius - blob_radius) * squareness
583 };
584 MenuMorphGeometry {
585 primary,
586 source,
587 target,
588 path: phase.path,
589 }
590}
591
592fn menu_ellipse_blend(path: f32) -> f32 {
593 0.42 * smoothstep(0.08, 0.28, path) * (1.0 - smoothstep(0.45, 0.62, path))
594}
595
596fn menu_content_progress(expanded: bool, appear: f32, reveal: f32) -> f32 {
597 if expanded {
598 smoothstep(0.17, 0.82, reveal)
599 } else {
600 smoothstep(0.20, 0.75, appear)
601 }
602}
603
604fn menu_content_blur(progress: f32) -> f32 {
605 MENU_CONTENT_BLUR * (1.0 - progress.clamp(0.0, 1.0)).powf(MENU_CONTENT_BLUR_POWER)
606}
607
608fn menu_content_alpha(progress: f32) -> f32 {
609 progress.clamp(0.0, 1.0).powf(MENU_CONTENT_ALPHA_POWER)
610}
611
612fn menu_content_scale(progress: f32) -> f32 {
613 0.80 + 0.20 * progress.clamp(0.0, 1.0)
614}
615
616#[derive(Clone, Copy, Debug, PartialEq)]
617struct MenuAbsorbedVisualPhase {
618 foreground_alpha: f32,
619 backdrop_alpha: f32,
620 foreground_blur: f32,
621 scale_x: f32,
622 scale_y: f32,
623}
624
625fn menu_absorbed_visual_phase(appear: f32, path: f32) -> MenuAbsorbedVisualPhase {
626 let appear = appear.clamp(0.0, 1.0);
627 let path = path.clamp(0.0, 1.0);
628 let shrink = smoothstep(0.0, 0.24, path);
629 let base_scale = 1.0 - 0.25 * shrink;
630 let stretch = smoothstep(0.30, 0.56, path);
631 let handoff = smoothstep(0.30, 0.55, appear);
636 let readable_alpha = 1.0 + (0.40 - 1.0) * handoff;
637 MenuAbsorbedVisualPhase {
641 foreground_alpha: readable_alpha * (1.0 - smoothstep(0.45, 0.85, path)),
642 backdrop_alpha: 0.62 * smoothstep(0.45, 0.85, path),
643 foreground_blur: 7.0 * smoothstep(0.45, 0.85, path),
644 scale_x: base_scale * (1.0 + 0.20 * stretch),
645 scale_y: base_scale * (1.0 + 0.28 * stretch),
646 }
647}
648
649#[derive(Clone, Copy, Debug, PartialEq)]
650struct MenuSurfacePhase {
651 anchor_presence: f32,
652 glue: f32,
653 wobble: f32,
654 bulge: f32,
655}
656
657fn menu_surface_phase(expanded: bool, appear: f32, path: f32) -> MenuSurfacePhase {
658 let appear = appear.clamp(0.0, 1.0);
659 let path = path.clamp(0.0, 1.0);
660 let activity = (std::f32::consts::PI * path).sin().max(0.0);
661 if expanded && path <= f32::EPSILON {
662 let recoil = (appear / MENU_GROW_DELAY).clamp(0.0, 1.0);
663 return MenuSurfacePhase {
664 anchor_presence: 0.0,
665 glue: 0.0,
666 wobble: 0.18 * (std::f32::consts::PI * recoil).sin().max(0.0),
667 bulge: 0.0,
668 };
669 }
670 if expanded {
671 return MenuSurfacePhase {
672 anchor_presence: 0.0,
675 glue: 0.0,
676 wobble: 0.08 * activity,
677 bulge: 0.35 * activity,
678 };
679 }
680 MenuSurfacePhase {
681 anchor_presence: 0.0,
682 glue: 0.0,
683 wobble: 0.04 * activity,
684 bulge: 0.25 * activity,
685 }
686}
687
688fn menu_absorbed_shape_presence(path: f32) -> f32 {
689 1.0 - smoothstep(MENU_SOURCE_SEPARATE_END, 0.30, path)
690}
691
692fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
693 let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
694 t * t * (3.0 - 2.0 * t)
695}
696
697pub fn liquid_menu_trigger_input(
703 modifier: Modifier,
704 gesture: LiquidMenuGesture,
705 on_open: impl Fn() + 'static,
706) -> Modifier {
707 let gate = remember(|| {
708 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
709 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
710 0.0, runtime,
711 )))
712 })
713 .with(Rc::clone);
714 let on_open: Rc<dyn Fn()> = Rc::new(on_open);
715
716 let snapshot = gesture.snapshot();
717 let gate_progress = gate.borrow().state().value();
718 if gate_progress >= 1.0 && snapshot.active && !snapshot.claimed {
719 gesture.claim();
720 let on_open = Rc::clone(&on_open);
721 SideEffect(move || on_open());
722 }
723
724 modifier.pointer_input(gesture.id(), {
725 let gesture = gesture.clone();
726 let gate = Rc::clone(&gate);
727 let on_open = Rc::clone(&on_open);
728 move |scope: PointerInputScope| {
729 let gesture = gesture.clone();
730 let gate = Rc::clone(&gate);
731 let on_open = Rc::clone(&on_open);
732 async move {
733 scope
734 .await_pointer_event_scope(|await_scope| async move {
735 let mut active_pointer = Option::<PointerId>::None;
736 let mut moved = false;
737 loop {
738 let event = await_scope.await_pointer_event().await;
739 match event.kind {
740 PointerEventKind::Down if active_pointer.is_none() => {
741 active_pointer = Some(event.id);
742 moved = false;
743 gesture.begin(event.global_position);
744 let mut timer = gate.borrow_mut();
745 timer.snapTo(0.0);
746 timer.animateTo(
747 1.0,
748 cranpose_animation::AnimationType::Tween(
749 cranpose_animation::AnimationSpec::linear(
750 MENU_LONG_PRESS_MS,
751 ),
752 ),
753 );
754 event.consume();
755 }
756 PointerEventKind::Move if active_pointer == Some(event.id) => {
757 gesture.move_to(event.global_position);
758 let state = gesture.snapshot();
759 let dx = event.global_position.x - state.start.x;
760 let dy = event.global_position.y - state.start.y;
761 if !state.claimed
762 && dx * dx + dy * dy
763 > MENU_LONG_PRESS_SLOP * MENU_LONG_PRESS_SLOP
764 {
765 moved = true;
766 gate.borrow_mut().snapTo(0.0);
767 }
768 event.consume();
769 }
770 PointerEventKind::Up if active_pointer == Some(event.id) => {
771 active_pointer = None;
772 let claimed = gesture.snapshot().claimed;
773 gate.borrow_mut().snapTo(0.0);
774 if claimed {
775 gesture.release(event.global_position);
776 } else {
777 gesture.cancel();
778 if !moved {
779 on_open();
780 }
781 }
782 event.consume();
783 }
784 PointerEventKind::Cancel if active_pointer == Some(event.id) => {
785 active_pointer = None;
786 gate.borrow_mut().snapTo(0.0);
787 gesture.cancel();
788 event.consume();
789 }
790 _ => {}
791 }
792 }
793 })
794 .await;
795 }
796 }
797 })
798}
799
800#[allow(clippy::too_many_arguments)]
804#[composable]
805#[allow(non_snake_case)]
806pub fn LiquidMenuIconButton(
807 modifier: Modifier,
808 spec: crate::widgets::GlassButtonSpec,
809 diameter: f32,
810 covered: bool,
811 gesture: LiquidMenuGesture,
812 on_open: impl Fn() + 'static,
813 icon_path: &'static str,
814) {
815 let interaction = rememberMutableInteractionSource();
816 let (pressed_modifier, _, content_alpha) =
817 crate::motion::liquid_press_scale(Modifier::empty(), interaction.clone(), 1.12);
818 let trigger_visual = cranpose_animation::animate_float_as_state_with_initial(
819 1.0,
820 if covered { 0.0 } else { 1.0 },
821 cranpose_animation::AnimationType::Tween(if covered {
822 cranpose_animation::AnimationSpec::tween(
823 MENU_TRIGGER_ABSORPTION_MS,
824 cranpose_animation::Easing::EaseOut,
825 )
826 } else {
827 cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
828 .with_delay(MENU_TRIGGER_RESTORE_DELAY_MS)
829 }),
830 "menu-trigger-absorption",
831 );
832 let gate = remember(|| {
833 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
834 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
835 0.0, runtime,
836 )))
837 })
838 .with(Rc::clone);
839 let on_open: Rc<dyn Fn()> = Rc::new(on_open);
840
841 let snapshot = gesture.snapshot();
842 let gate_progress = gate.borrow().state().value();
843 if gate_progress >= 1.0 && snapshot.active && !snapshot.claimed {
844 gesture.claim();
845 let on_open = Rc::clone(&on_open);
846 SideEffect(move || on_open());
847 }
848
849 let input = Modifier::empty()
850 .size(Size::new(diameter, diameter))
851 .pointer_input(gesture.id(), {
852 let gesture = gesture.clone();
853 let gate = Rc::clone(&gate);
854 let interaction = interaction.clone();
855 let on_open = Rc::clone(&on_open);
856 move |scope: PointerInputScope| {
857 let gesture = gesture.clone();
858 let gate = Rc::clone(&gate);
859 let interaction = interaction.clone();
860 let on_open = Rc::clone(&on_open);
861 async move {
862 scope
863 .await_pointer_event_scope(|await_scope| async move {
864 let mut active_pointer = Option::<PointerId>::None;
865 let mut moved = false;
866 let mut press: Option<PressInteractionPress> = None;
867 loop {
868 let event = await_scope.await_pointer_event().await;
869 match event.kind {
870 PointerEventKind::Down if active_pointer.is_none() => {
871 active_pointer = Some(event.id);
872 moved = false;
873 gesture.begin(event.global_position);
874 press = Some(interaction.press(event.position));
875 let mut timer = gate.borrow_mut();
876 timer.snapTo(0.0);
877 timer.animateTo(
878 1.0,
879 cranpose_animation::AnimationType::Tween(
880 cranpose_animation::AnimationSpec::linear(
881 MENU_LONG_PRESS_MS,
882 ),
883 ),
884 );
885 event.consume();
886 }
887 PointerEventKind::Move if active_pointer == Some(event.id) => {
888 gesture.move_to(event.global_position);
889 let state = gesture.snapshot();
890 let dx = event.global_position.x - state.start.x;
891 let dy = event.global_position.y - state.start.y;
892 if !state.claimed
893 && dx * dx + dy * dy
894 > MENU_LONG_PRESS_SLOP * MENU_LONG_PRESS_SLOP
895 {
896 moved = true;
897 gate.borrow_mut().snapTo(0.0);
898 }
899 event.consume();
900 }
901 PointerEventKind::Up if active_pointer == Some(event.id) => {
902 active_pointer = None;
903 let claimed = gesture.snapshot().claimed;
904 gate.borrow_mut().snapTo(0.0);
905 if claimed {
906 gesture.release(event.global_position);
907 } else {
908 gesture.cancel();
909 if !moved {
910 on_open();
911 }
912 }
913 if let Some(active_press) = press.take() {
914 interaction.release(active_press);
915 }
916 event.consume();
917 }
918 PointerEventKind::Cancel
919 if active_pointer == Some(event.id) =>
920 {
921 active_pointer = None;
922 gate.borrow_mut().snapTo(0.0);
923 gesture.cancel();
924 if let Some(active_press) = press.take() {
925 interaction.cancel(active_press);
926 }
927 event.consume();
928 }
929 _ => {}
930 }
931 }
932 })
933 .await;
934 }
935 }
936 });
937
938 Box(
939 pressed_modifier
940 .then(modifier)
941 .size(Size::new(diameter, diameter)),
942 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
943 move || {
944 let visual_alpha = trigger_visual.get().clamp(0.0, 1.0);
945 let melt = 1.0 - visual_alpha;
946 let visual_spec = spec.clone();
947 let visual = Modifier::empty()
948 .size(Size::new(diameter, diameter))
949 .graphics_layer(move || GraphicsLayer {
950 alpha: visual_alpha * content_alpha.get().clamp(0.0, 1.0),
951 scale_x: 1.0 - 0.12 * melt,
952 scale_y: 1.0 - 0.12 * melt,
953 ..Default::default()
954 });
955 Box(
956 visual,
957 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
958 move || {
959 if visual_alpha > MENU_TRIGGER_GLASS_CUTOFF {
960 crate::widgets::GlassIconButton(
961 Modifier::empty(),
962 visual_spec.clone(),
963 diameter,
964 || {},
965 icon_path,
966 );
967 }
968 },
969 );
970 Box(input.clone(), BoxSpec::default(), || {});
971 },
972 );
973}
974
975#[composable]
976#[allow(non_snake_case)]
977fn AbsorbedSourceVisual(
978 source: LiquidMenuAbsorbedSource,
979 node_origin: Point,
980 alpha: f32,
981 blur: f32,
982 scale_x: f32,
983 scale_y: f32,
984) {
985 if alpha <= 0.001 {
986 return;
987 }
988
989 let diameter = source.diameter;
990 let foreground_spec = source.spec.clone();
991 let layer = Modifier::empty()
992 .absolute_offset(source.rect.x - node_origin.x, source.rect.y - node_origin.y)
993 .size(Size::new(diameter, diameter))
994 .graphics_layer(move || GraphicsLayer {
995 alpha,
996 scale_x,
997 scale_y,
998 render_effect: (blur > 0.35).then(|| RenderEffect::blur(blur)),
999 ..Default::default()
1000 });
1001 Box(
1002 layer,
1003 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
1004 move || {
1005 crate::widgets::button::GlassIconForeground(
1006 foreground_spec.clone(),
1007 diameter,
1008 source.icon_path,
1009 );
1010 },
1011 );
1012}
1013
1014#[composable]
1025#[allow(non_snake_case)]
1026#[allow(clippy::too_many_arguments)]
1027pub fn LiquidMenu(
1028 expanded: bool,
1029 anchor: Rect,
1030 spec: LiquidMenuSpec,
1031 absorbed: Vec<LiquidMenuAbsorbedSource>,
1032 items: Vec<LiquidMenuItem>,
1033 gesture: LiquidMenuGesture,
1034 on_item: impl Fn(usize) + 'static,
1035 on_dismiss: impl Fn() + 'static,
1036) {
1037 let menu_width = spec.width;
1038 let visible = remember(|| mutableStateOf(false)).with(|s| *s);
1042 if expanded && !visible.get() {
1043 visible.set(true);
1044 }
1045 if !expanded && !visible.get() {
1046 return;
1047 }
1048 let colors = liquid_colors();
1049 let typography = liquid_typography();
1050 let on_item: Rc<dyn Fn(usize)> = Rc::new(on_item);
1051 let on_dismiss: Rc<dyn Fn()> = Rc::new(on_dismiss);
1052 let gesture_snapshot = gesture.snapshot();
1053 let gesture_hover = (gesture_snapshot.active && gesture_snapshot.claimed)
1054 .then(|| gesture.item_at(gesture_snapshot.position, &items))
1055 .flatten();
1056 let dwell_gate = remember(|| {
1061 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
1062 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
1063 0.0f32, runtime,
1064 )))
1065 })
1066 .with(Rc::clone);
1067 let dwell_row = remember(|| Rc::new(Cell::new(Option::<usize>::None))).with(Rc::clone);
1068 let dwell_fired = remember(|| Rc::new(Cell::new(Option::<usize>::None))).with(Rc::clone);
1069 {
1070 let hover_accordion =
1071 gesture_hover.filter(|index| items.get(*index).is_some_and(|item| item.keeps_open));
1072 if hover_accordion != dwell_row.get() {
1073 dwell_row.set(hover_accordion);
1074 dwell_fired.set(None);
1075 let mut gate = dwell_gate.borrow_mut();
1076 gate.snapTo(0.0);
1077 if hover_accordion.is_some() {
1078 gate.animateTo(
1079 1.0,
1080 cranpose_animation::AnimationType::Tween(
1081 cranpose_animation::AnimationSpec::linear(450),
1082 ),
1083 );
1084 }
1085 }
1086 let gate_value = dwell_gate.borrow().state().get();
1087 if gate_value >= 1.0 {
1088 if let Some(index) = dwell_row.get() {
1089 if dwell_fired.get() != Some(index) {
1090 dwell_fired.set(Some(index));
1091 let on_item_dwell = Rc::clone(&on_item);
1092 SideEffect(move || on_item_dwell(index));
1093 }
1094 }
1095 }
1096 }
1097 let handled_release = remember(|| Rc::new(Cell::new(0u64))).with(Rc::clone);
1098 if let Some((sequence, point)) = gesture_snapshot.release {
1099 if handled_release.get() != sequence {
1100 handled_release.set(sequence);
1101 if let Some(index) = gesture.item_at(point, &items) {
1102 let keeps_open = items.get(index).is_some_and(|item| item.keeps_open);
1103 let on_item = Rc::clone(&on_item);
1104 let on_dismiss = Rc::clone(&on_dismiss);
1105 SideEffect(move || {
1106 on_item(index);
1107 if !keeps_open {
1108 on_dismiss();
1109 }
1110 });
1111 }
1112 }
1113 }
1114
1115 let grow = cranpose_animation::animate_float_as_state_with_initial(
1121 0.0,
1122 if expanded { 1.0 } else { 0.0 },
1123 if expanded {
1124 cranpose_animation::spring(0.78, MENU_GROW_STIFFNESS)
1125 } else {
1126 cranpose_animation::AnimationType::Tween(cranpose_animation::AnimationSpec::linear(205))
1127 },
1128 "menu-grow",
1129 );
1130 let reveal_anim = cranpose_animation::animate_float_as_state_with_initial(
1134 0.0,
1135 if expanded { 1.0 } else { 0.0 },
1136 if expanded {
1137 cranpose_animation::spring(1.0, MENU_REVEAL_STIFFNESS)
1138 } else {
1139 cranpose_animation::spring(1.0, 900.0)
1140 },
1141 "menu-reveal",
1142 );
1143 let appear = grow.get().max(0.0);
1147 let reveal = reveal_anim.get().clamp(0.0, 1.0);
1148 if !expanded && appear < 0.02 {
1149 visible.set(false);
1150 return;
1151 }
1152
1153 let anchor_zone = anchor.height * ANCHOR_OVERLAP;
1156 let node_size =
1157 remember(|| Rc::new(Cell::new(cranpose_ui_graphics::Size::ZERO))).with(Rc::clone);
1158 let resize_anim = remember(|| {
1163 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
1164 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
1165 1.0f32, runtime,
1166 )))
1167 })
1168 .with(Rc::clone);
1169 let resize_from_h = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
1170 let items_signature: String = items
1171 .iter()
1172 .map(|item| {
1173 format!(
1174 "{}|{}|{}{}{}{}{};",
1175 item.label,
1176 item.subtitle.as_deref().unwrap_or(""),
1177 item.checked as u8,
1178 item.destructive as u8,
1179 item.section_start as u8,
1180 item.header as u8,
1181 item.keeps_open as u8,
1182 )
1183 })
1184 .collect();
1185 let last_signature = remember(|| Rc::new(RefCell::new(String::new()))).with(Rc::clone);
1186 if *last_signature.borrow() != items_signature {
1187 let was_open = !last_signature.borrow().is_empty()
1188 && expanded
1189 && grow.get() > 0.5
1190 && node_size.get().height > 1.0;
1191 *last_signature.borrow_mut() = items_signature;
1192 if was_open {
1193 resize_from_h.set(node_size.get().height);
1194 let mut anim = resize_anim.borrow_mut();
1195 anim.snapTo(0.0);
1196 anim.animateTo(1.0, cranpose_animation::spring(0.78, 170.0));
1197 }
1198 }
1199 let resize_state = resize_anim.borrow().state();
1200 let scrim_dismiss = Rc::clone(&on_dismiss);
1207 PopupDismissableWhen(
1208 expanded,
1209 anchor,
1210 Point::new(anchor.width - menu_width, 0.0),
1211 move || scrim_dismiss(),
1212 {
1213 let absorbed = absorbed.clone();
1214 let items = items.clone();
1215 let typography = typography.clone();
1216 let on_item = Rc::clone(&on_item);
1217 let on_dismiss = Rc::clone(&on_dismiss);
1218 let node_size = Rc::clone(&node_size);
1219 let gesture = gesture.clone();
1220 move || {
1221 let anchor_center = (menu_width - anchor.width * 0.5, anchor.height * 0.5);
1226 let anchor_shape = MenuShape::capsule(
1227 anchor_center.0,
1228 anchor_center.1,
1229 anchor.width,
1230 anchor.height,
1231 );
1232 let node_origin = Point::new(anchor.x + anchor.width - menu_width, anchor.y);
1233 let absorbed_shapes: Vec<MenuShape> = absorbed
1234 .iter()
1235 .filter_map(|source| MenuShape::from_window_rect(source.rect, node_origin))
1236 .collect();
1237 let morph_size = Rc::clone(&node_size);
1238 let glass = Glass::regular()
1249 .shape(LiquidShape::RoundedRect(MENU_RADIUS))
1250 .adaptive_frost(colors.label, 0.18)
1263 .blur_radius(30.0)
1269 .saturation(if colors.is_dark { 1.90 } else { 1.55 })
1270 .lift(if colors.is_dark { 0.10 } else { 0.58 })
1271 .highlight(0.14);
1272 let glass = if colors.is_dark {
1273 glass
1278 .contrast(0.37)
1279 .tint(Color::from_rgba_u8(24, 12, 28, 155))
1280 } else {
1281 glass
1282 };
1283 let glass = glass
1284 .shadow_style(GlassShadow::new(
1285 Color::BLACK.with_alpha(if colors.is_dark { 0.60 } else { 0.11 }),
1290 if colors.is_dark { 44.0 } else { 26.0 },
1291 if colors.is_dark { 10.0 } else { 8.0 },
1292 0.0,
1293 ))
1294 .no_clip();
1295 let resize_from = Rc::clone(&resize_from_h);
1296 let glow_point: Rc<Cell<Option<(f32, f32)>>> =
1301 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
1302 let glow_for_glass = Rc::clone(&glow_point);
1303 let glass_node_origin = node_origin;
1304 let birth_milk = colors
1309 .is_dark
1310 .then(|| Color::from_rgba_u8(208, 204, 214, 240));
1311 let card = Modifier::empty()
1312 .report_size(Rc::clone(&node_size))
1313 .glass_effect_with(glass, move || {
1314 let glow_touch = glow_for_glass.get().map(|(x, y)| {
1315 (x - glass_node_origin.x, y - glass_node_origin.y, 1.0f32)
1316 });
1317 let size = morph_size.get();
1318 let measured_h = (size.height - anchor_zone).max(24.0);
1319 let resize_t = resize_state.get();
1323 let from_h = (resize_from.get() - anchor_zone).max(24.0);
1324 let menu_h = if resize_from.get() > 1.0 {
1325 from_h + (measured_h - from_h) * resize_t.max(0.0)
1326 } else {
1327 measured_h
1328 };
1329 let settle_radius = (menu_h * 0.32).clamp(26.0, MENU_RADIUS);
1333 let target = MenuShape {
1334 center_x: menu_width * 0.5,
1335 center_y: anchor_zone + menu_h * 0.5,
1336 width: menu_width,
1337 height: menu_h,
1338 radius: settle_radius,
1339 };
1340 let geometry = menu_morph_geometry(
1341 expanded,
1342 appear,
1343 anchor_shape,
1344 &absorbed_shapes,
1345 target,
1346 );
1347 let t = geometry.path;
1348 let primary = geometry.primary.as_glass_shape();
1349 let start = if expanded {
1350 geometry.source
1351 } else {
1352 anchor_shape
1353 };
1354 let target = geometry.target;
1355 let surface = menu_surface_phase(expanded, appear, t);
1356 let dir_x = target.center_x - start.center_x;
1359 let dir_y = target.center_y - start.center_y;
1360 let mut bulge_dir = dir_y.atan2(dir_x);
1361 if !expanded {
1362 bulge_dir += std::f32::consts::PI;
1363 }
1364 let mut shapes = Vec::new();
1365 if surface.anchor_presence > 0.01 {
1366 shapes.push((
1367 anchor_shape.center_x,
1368 anchor_shape.center_y,
1369 anchor_shape.width * surface.anchor_presence,
1370 anchor_shape.height * surface.anchor_presence,
1371 -1.0,
1372 ));
1373 }
1374 let absorbed_presence = menu_absorbed_shape_presence(t);
1375 if expanded && absorbed_presence > 0.01 {
1376 shapes.extend(absorbed_shapes.iter().map(|shape| {
1377 (
1378 shape.center_x,
1379 shape.center_y,
1380 shape.width * absorbed_presence,
1381 shape.height * absorbed_presence,
1382 -1.0,
1383 )
1384 }));
1385 }
1386 let glue = surface.glue;
1387 let activity = if expanded {
1388 smoothstep(0.0, 0.42, t)
1396 } else {
1397 smoothstep(0.0, 0.12, t)
1403 };
1404 GlassDynamics {
1405 activity: Some(activity),
1406 resting_tint: birth_milk,
1407 touch: glow_touch,
1408 morph: Some(GlassMorph {
1409 node_size: (size.width.max(1.0), size.height.max(1.0)),
1410 primary,
1411 shapes,
1412 glue,
1413 wobble_amplitude: surface.wobble,
1414 wobble_phase: t * 8.0,
1415 bulge_amplitude: surface.bulge,
1416 bulge_direction: bulge_dir,
1417 ellipse_blend: menu_ellipse_blend(t),
1418 deformation: None,
1419 zoom_anchor: (0.0, 0.0),
1420 }),
1421 ..Default::default()
1422 }
1423 })
1424 .width(menu_width);
1425
1426 let has_checks = items.iter().any(|item| item.checked);
1427 let hovered = remember(|| mutableStateOf(Option::<usize>::None)).with(|s| *s);
1430 let glow_row = gesture_hover.or(hovered.get());
1431 glow_point.set(glow_row.map(|index| {
1432 let rect = gesture.item_rect(index).get();
1433 (rect.x + rect.width * 0.5, rect.y + rect.height * 0.5)
1434 }));
1435 let gesture = gesture.clone();
1436 let source_phase =
1437 menu_absorbed_visual_phase(appear, menu_geometry_phase(expanded, appear).path);
1438 for source in absorbed.iter().cloned() {
1439 AbsorbedSourceVisual(
1440 source,
1441 node_origin,
1442 source_phase.backdrop_alpha,
1443 0.0,
1444 source_phase.scale_x,
1445 source_phase.scale_y,
1446 );
1447 }
1448 Box(card, BoxSpec::default(), {
1449 let items = items.clone();
1450 let typography = typography.clone();
1451 let on_item = Rc::clone(&on_item);
1452 let on_dismiss = Rc::clone(&on_dismiss);
1453 move || {
1454 Column(Modifier::empty().fill_max_width(), ColumnSpec::default(), {
1455 let items = items.clone();
1456 let typography = typography.clone();
1457 let on_item = Rc::clone(&on_item);
1458 let on_dismiss = Rc::clone(&on_dismiss);
1459 let gesture = gesture.clone();
1460 move || {
1461 Box(
1462 Modifier::empty().height(anchor_zone),
1463 BoxSpec::default(),
1464 || {},
1465 );
1466 let content = menu_content_progress(expanded, appear, reveal);
1474 let resize_t = resize_state.get().clamp(0.0, 1.0);
1478 let content =
1479 content * (0.45 + 0.55 * smoothstep(0.35, 1.0, resize_t));
1480 let content_scale = menu_content_scale(content);
1484 let content_blur = menu_content_blur(content);
1485 let content_translation_y = if expanded {
1486 menu_vertical_rebound(
1487 menu_geometry_phase(expanded, appear).path,
1488 )
1489 } else {
1490 0.0
1491 };
1492 let rows_wrap =
1493 Modifier::empty().fill_max_width().graphics_layer(move || {
1494 GraphicsLayer {
1495 alpha: menu_content_alpha(content),
1496 scale_x: content_scale,
1497 scale_y: content_scale,
1498 transform_origin:
1499 cranpose_ui_graphics::TransformOrigin {
1500 pivot_fraction_x: 1.0,
1501 pivot_fraction_y: 0.0,
1502 },
1503 translation_y: content_translation_y,
1504 render_effect: (content_blur > 0.35)
1505 .then(|| RenderEffect::blur(content_blur)),
1506 ..Default::default()
1507 }
1508 });
1509 Box(rows_wrap, BoxSpec::default(), {
1510 let items = items.clone();
1511 let typography = typography.clone();
1512 let on_item = Rc::clone(&on_item);
1513 let on_dismiss = Rc::clone(&on_dismiss);
1514 let gesture = gesture.clone();
1515 move || {
1516 Column(
1517 Modifier::empty().fill_max_width().padding_each(
1518 0.0,
1519 MENU_CONTENT_INSET_Y,
1520 0.0,
1521 MENU_CONTENT_INSET_Y,
1522 ),
1523 ColumnSpec::default(),
1524 {
1525 let items = items.clone();
1526 let typography = typography.clone();
1527 let on_item = Rc::clone(&on_item);
1528 let on_dismiss = Rc::clone(&on_dismiss);
1529 let gesture = gesture.clone();
1530 move || {
1531 for (index, item) in items.iter().enumerate() {
1532 if item.section_start && index > 0 {
1533 let separator =
1537 colors.separator.with_alpha(
1538 colors.separator.a() * 0.22,
1539 );
1540 Box(
1541 Modifier::empty()
1542 .fill_max_width()
1543 .padding_symmetric(ROW_PADDING_X, 0.0)
1544 .height(1.0)
1545 .draw_behind(move |scope| {
1546 scope.draw_rect(
1547 cranpose_ui_graphics::Brush::solid(
1548 separator,
1549 ),
1550 );
1551 }),
1552 BoxSpec::default(),
1553 || {},
1554 );
1555 }
1556
1557 if item.header {
1558 menu_header_row(
1559 item,
1560 &typography,
1561 has_checks,
1562 colors,
1563 );
1564 continue;
1565 }
1566 let expanded_header = item.keeps_open
1572 && items.get(index + 1).is_some_and(
1573 |next| {
1574 !next.keeps_open && !next.header
1575 },
1576 );
1577 menu_item_row(
1578 index,
1579 item,
1580 &typography,
1581 has_checks,
1582 colors,
1583 hovered,
1584 gesture_hover,
1585 expanded_header,
1586 gesture.item_rect(index),
1587 Rc::clone(&on_item),
1588 Rc::clone(&on_dismiss),
1589 );
1590 }
1591 }
1592 },
1593 );
1594 }
1595 });
1596 }
1597 });
1598 }
1599 });
1600 for source in absorbed.iter().cloned() {
1601 AbsorbedSourceVisual(
1602 source,
1603 node_origin,
1604 source_phase.foreground_alpha,
1605 source_phase.foreground_blur,
1606 source_phase.scale_x,
1607 source_phase.scale_y,
1608 );
1609 }
1610 }
1611 },
1612 );
1613}
1614
1615fn menu_header_row(
1617 item: &LiquidMenuItem,
1618 typography: &crate::theme::LiquidTypography,
1619 has_checks: bool,
1620 colors: crate::theme::LiquidColors,
1621) {
1622 let label = item.label.clone();
1623 let style = TextStyle {
1624 span_style: SpanStyle {
1625 color: Some(colors.secondary_label),
1626 font_size: TextUnit::Sp(13.0),
1627 ..typography.footnote.span_style.clone()
1628 },
1629 ..typography.footnote.clone()
1630 };
1631 let indent = ROW_PADDING_X + if has_checks { CHECK_COLUMN } else { 0.0 };
1632 let row = Modifier::empty()
1633 .fill_max_width()
1634 .padding_each(indent, 12.0, ROW_PADDING_X, 2.0);
1635 Row(row, RowSpec::default(), move || {
1636 Text(label.clone(), Modifier::empty(), style.clone());
1637 });
1638}
1639
1640#[allow(non_snake_case)]
1642#[allow(clippy::too_many_arguments)]
1643fn menu_item_row(
1644 index: usize,
1645 item: &LiquidMenuItem,
1646 typography: &crate::theme::LiquidTypography,
1647 has_checks: bool,
1648 colors: crate::theme::LiquidColors,
1649 hovered: cranpose_core::MutableState<Option<usize>>,
1650 gesture_hover: Option<usize>,
1651 expanded_header: bool,
1652 rect_sink: Rc<Cell<Rect>>,
1653 on_item: Rc<dyn Fn(usize)>,
1654 on_dismiss: Rc<dyn Fn()>,
1655) {
1656 let color = if item.destructive {
1657 colors.destructive
1658 } else {
1659 colors.label
1660 };
1661 let is_hovered = hovered.get() == Some(index) || gesture_hover == Some(index);
1662 let highlight = if colors.is_dark {
1663 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 44)
1664 } else {
1665 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 30)
1666 };
1667 let row_label = item.label.clone();
1668 let keeps_open = item.keeps_open;
1669 let row = Modifier::empty()
1670 .fill_max_width()
1671 .report_window_rect(rect_sink)
1672 .semantics(move |config| {
1673 config.is_button = true;
1674 config.is_clickable = true;
1675 config.content_description = Some(row_label.clone());
1676 })
1677 .pointer_input(index, {
1678 let on_item = Rc::clone(&on_item);
1679 let on_dismiss = Rc::clone(&on_dismiss);
1680 move |scope: PointerInputScope| {
1681 let on_item = Rc::clone(&on_item);
1682 let on_dismiss = Rc::clone(&on_dismiss);
1683 async move {
1684 scope
1685 .await_pointer_event_scope(|await_scope| async move {
1686 loop {
1687 let event = await_scope.await_pointer_event().await;
1688 match event.kind {
1689 PointerEventKind::Enter | PointerEventKind::Move => {
1690 hovered.set(Some(index));
1691 }
1692 PointerEventKind::Exit if hovered.get() == Some(index) => {
1693 hovered.set(None);
1694 }
1695 PointerEventKind::Down => {
1696 hovered.set(Some(index));
1697 event.consume();
1698 }
1699 PointerEventKind::Up => {
1700 hovered.set(None);
1701 on_item(index);
1702 if !keeps_open {
1703 on_dismiss();
1704 }
1705 event.consume();
1706 }
1707 _ => {}
1708 }
1709 }
1710 })
1711 .await;
1712 }
1713 }
1714 })
1715 .draw_behind(move |scope| {
1716 if expanded_header {
1717 let chip = if colors.is_dark {
1722 Color::WHITE.with_alpha(0.30)
1723 } else {
1724 Color::BLACK.with_alpha(0.08)
1725 };
1726 let size = scope.size();
1727 scope.draw_round_rect_at(
1728 Rect {
1729 x: CHIP_INSET_X,
1730 y: 0.0,
1731 width: (size.width - CHIP_INSET_X * 2.0).max(0.0),
1732 height: size.height,
1733 },
1734 Brush::solid(chip),
1735 CornerRadii::uniform(16.0),
1736 );
1737 }
1738 if is_hovered {
1739 scope.draw_round_rect(Brush::solid(highlight), CornerRadii::uniform(14.0));
1740 }
1741 })
1742 .padding_symmetric(ROW_PADDING_X, ROW_PADDING_Y);
1743
1744 let label = item.label.clone();
1745 let subtitle = item.subtitle.clone();
1746 let icon = item.icon;
1747 let checked = item.checked;
1748 let accordion_chevron = item.keeps_open && subtitle.is_some();
1749 let secondary = colors.secondary_label;
1750 let typography = typography.clone();
1751 Row(
1752 row,
1753 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
1754 move || {
1755 let label = label.clone();
1756 let subtitle = subtitle.clone();
1757 if has_checks {
1758 Box(
1761 Modifier::empty().width(CHECK_COLUMN),
1762 BoxSpec::default(),
1763 move || {
1764 if checked {
1765 crate::icons::Icon(crate::icons::CHECK, 16.0, color);
1766 }
1767 },
1768 );
1769 }
1770 if let Some(icon) = icon {
1771 crate::icons::Icon(icon, ICON_SIZE, color);
1772 Box(Modifier::empty().width(ICON_GAP), BoxSpec::default(), || {});
1773 }
1774 let style = TextStyle {
1775 span_style: SpanStyle {
1776 color: Some(color),
1777 font_weight: Some(FontWeight::NORMAL),
1778 ..typography.body.span_style.clone()
1779 },
1780 ..typography.body.clone()
1781 };
1782 if let Some(subtitle) = subtitle {
1783 let subtitle_style = TextStyle {
1786 span_style: SpanStyle {
1787 color: Some(secondary),
1788 font_size: TextUnit::Sp(13.0),
1789 ..typography.footnote.span_style.clone()
1790 },
1791 ..typography.footnote.clone()
1792 };
1793 Column(
1794 Modifier::empty().weight(1.0),
1795 ColumnSpec::default(),
1796 move || {
1797 Text(label.clone(), Modifier::empty(), style.clone());
1798 Text(subtitle.clone(), Modifier::empty(), subtitle_style.clone());
1799 },
1800 );
1801 } else {
1802 Text(label, Modifier::empty().weight(1.0), style);
1803 }
1804 if accordion_chevron {
1805 crate::icons::Icon(crate::icons::CHEVRON_DOWN, 18.0, secondary);
1806 }
1807 },
1808 );
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813 use super::*;
1814
1815 #[test]
1816 fn menu_rows_use_the_reference_leading_grid_and_vertical_rhythm() {
1817 assert_eq!(ROW_PADDING_X, 20.0);
1818 assert_eq!(CHECK_COLUMN, 24.0);
1819 assert_eq!(ICON_SIZE, 24.0);
1820 assert_eq!(ICON_GAP, 12.0);
1821
1822 let check_center = ROW_PADDING_X + 8.0;
1823 let icon_center = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE * 0.5;
1824 let label_start = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE + ICON_GAP;
1825 assert_eq!((check_center, icon_center, label_start), (28.0, 56.0, 80.0));
1826
1827 let row_height = ICON_SIZE + ROW_PADDING_Y * 2.0;
1828 assert!((42.0..=43.0).contains(&row_height));
1829 assert_eq!(MENU_CONTENT_INSET_Y, 9.5);
1830 let two_row_panel_height = row_height * 2.0 + MENU_CONTENT_INSET_Y * 2.0;
1831 assert!((103.5..=104.5).contains(&two_row_panel_height));
1832 }
1833
1834 #[test]
1835 fn menu_geometry_keeps_the_source_cluster_horizontal_before_card_growth() {
1836 let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
1837 let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
1838 let target = MenuShape {
1839 center_x: 125.0,
1840 center_y: 52.0,
1841 width: 250.0,
1842 height: 104.0,
1843 radius: 32.0,
1844 };
1845 let pose = |appear| menu_morph_geometry(true, appear, anchor, &absorbed, target).primary;
1846
1847 let initial = pose(0.0);
1848 assert_eq!((initial.width, initial.height), (44.0, 44.0));
1849
1850 let merged = pose(0.028_576);
1851 assert_eq!(merged, initial);
1852 let source = menu_source_shape(anchor, &absorbed, target);
1853 assert_eq!(source.width, 96.0);
1854 assert!((82.5..=82.6).contains(&source.height));
1855 assert_eq!(source.center_y, anchor.center_y);
1856 assert_eq!(menu_absorbed_shape_presence(0.0), 1.0);
1857 assert_eq!(menu_absorbed_shape_presence(0.30), 0.0);
1858
1859 let early = pose(0.070_208);
1860 let middle = pose(0.199_019);
1861 let broad = pose(0.539_174);
1862 assert_eq!(early, initial);
1863 assert_eq!(middle.width, source.width);
1864 assert!(middle.height >= source.height);
1865 assert!(broad.width > middle.width && broad.height <= target.height * 1.1);
1866 assert!(middle.width > middle.height);
1867 assert!(broad.width > broad.height * 2.0);
1868
1869 let swell = pose(0.701_903);
1870 assert!(
1871 (252.0..=259.0).contains(&swell.width) && (102.0..=106.0).contains(&swell.height),
1872 "the broad body must overshoot horizontally without inflating vertically: {swell:?}"
1873 );
1874 assert!(menu_ellipse_blend(0.25) > 0.3);
1875 assert_eq!(menu_ellipse_blend(0.0), 0.0);
1876 assert_eq!(menu_ellipse_blend(1.0), 0.0);
1877
1878 let overshoot = pose(1.08);
1879 assert!((250.0..=254.0).contains(&overshoot.width));
1880 assert!((104.0..=106.0).contains(&overshoot.height));
1881 assert_eq!(MENU_RADIUS, 32.0);
1882 assert!((0.045..=0.055).contains(&MENU_GROW_DELAY));
1883 assert!((55.0..=70.0).contains(&MENU_GROW_STIFFNESS));
1887 }
1888
1889 #[test]
1890 fn menu_open_spring_departs_early_then_settles_without_a_dead_interval() {
1891 let (source_phase, _) =
1892 cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.054);
1893 assert!(
1894 source_phase > MENU_GROW_DELAY,
1895 "the departing oval must be visible by the target's early frame: {source_phase}"
1896 );
1897 let (broad_phase, _) =
1898 cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.180);
1899 assert!(
1900 (0.35..=0.60).contains(&broad_phase),
1901 "the broad menu body must be established by 180ms: {broad_phase}"
1902 );
1903 let (settled_phase, _) =
1904 cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.600);
1905 assert!(settled_phase > 0.95);
1906 }
1907
1908 #[test]
1909 fn menu_body_uses_the_shared_vertical_rebound_path() {
1910 let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
1911 let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
1912 let target = MenuShape {
1913 center_x: 125.0,
1914 center_y: 52.0,
1915 width: 250.0,
1916 height: 104.0,
1917 radius: 32.0,
1918 };
1919
1920 let source = menu_source_shape(anchor, &absorbed, target);
1921 for appear in [0.199_019, 0.296_780, 0.412_956, 0.539_174] {
1922 let geometry = menu_morph_geometry(true, appear, anchor, &absorbed, target);
1923 let phase = menu_geometry_phase(true, appear);
1924 let descent = smoothstep(0.10, 0.90, phase.path);
1927 let interpolated_y = source.center_y + (target.center_y - source.center_y) * descent;
1928 let expected_y = interpolated_y + menu_vertical_rebound(phase.path);
1929 assert!(
1930 (geometry.primary.center_y - expected_y).abs() < 0.001,
1931 "body and content must resolve the same rebound path: {geometry:?}"
1932 );
1933 }
1934 assert_eq!(menu_vertical_rebound(0.0), 0.0);
1935 assert!(menu_vertical_rebound(0.25) > 0.0);
1936 assert_eq!(menu_vertical_rebound(MENU_VERTICAL_REBOUND_END), 0.0);
1937 }
1938
1939 #[test]
1940 fn menu_close_reverses_through_a_smooth_oval() {
1941 let phase = menu_geometry_phase(false, 0.6);
1942 assert!(
1943 phase.width > 0.80,
1944 "the close must retain its broad body at mid-flight: {phase:?}"
1945 );
1946 assert!(
1947 44.0 + (250.0 - 44.0) * phase.width > 1.8 * (44.0 + (104.0 - 44.0) * phase.height),
1948 "the close must pass back through the wide oval in physical dimensions: {phase:?}"
1949 );
1950 assert!(
1951 menu_content_progress(false, 0.6, 1.0) > 0.75,
1952 "content must remain coherent through the initial deflation"
1953 );
1954 assert_eq!(menu_content_progress(false, 0.2, 1.0), 0.0);
1955 let rounded_volume = menu_geometry_phase(false, 0.21);
1956 assert!(
1957 rounded_volume.width > 0.35,
1958 "the terminal body must contract continuously into the anchor: {rounded_volume:?}"
1959 );
1960 assert!(
1961 44.0 + (250.0 - 44.0) * rounded_volume.width
1962 > 44.0 + (104.0 - 44.0) * rounded_volume.height,
1963 "the terminal body must stay smooth rather than forming a vertical leaf in physical dimensions: {rounded_volume:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn menu_content_materializes_early_and_is_sharp_by_settle() {
1969 let birth = menu_content_progress(true, 0.35, 0.25);
1970 assert!(
1971 birth > 0.02 && birth < 0.08,
1972 "rows must begin as a faint smudge after the blank birth phase: {birth}"
1973 );
1974 let mid = menu_content_progress(true, 0.55, 0.55);
1975 assert!(
1976 (0.55..0.70).contains(&mid),
1977 "rows must remain visibly soft at mid-flight: {mid}"
1978 );
1979 let settle = menu_content_progress(true, 1.0, 0.92);
1980 assert!(
1981 settle > 0.99,
1982 "rows must be effectively sharp when the shape settles: {settle}"
1983 );
1984 assert!(menu_content_blur(birth) > 13.0);
1985 assert!((7.0..8.0).contains(&menu_content_blur(mid)));
1986 assert!(menu_content_blur(settle) < 0.5);
1987 assert!((20.0..=32.0).contains(&MENU_REVEAL_STIFFNESS));
1990 assert!((0.34..=0.37).contains(&menu_content_alpha(0.10)));
1991 assert!((0.79..=0.82).contains(&menu_content_alpha(0.62)));
1992 assert_eq!(menu_content_alpha(1.0), 1.0);
1993 assert!((0.85..=0.87).contains(&menu_content_scale(0.30)));
1994 assert!((0.92..=0.93).contains(&menu_content_scale(0.62)));
1995 assert_eq!(menu_content_scale(1.0), 1.0);
1996 }
1997
1998 #[test]
1999 fn menu_surface_motion_is_smooth_and_capture_cadence_independent() {
2000 let merged = menu_surface_phase(true, 0.14, 0.0);
2001 assert_eq!(merged.anchor_presence, 0.0);
2002 assert_eq!(merged.glue, 0.0);
2003 let recoil = menu_surface_phase(true, 0.275, 0.0);
2004 assert_eq!(recoil.glue, 0.0);
2005
2006 let early = menu_surface_phase(true, 0.40, 0.25);
2007 assert!(
2008 early.anchor_presence == 0.0,
2009 "the primary alone owns the anchor recoil: {early:?}"
2010 );
2011 assert_eq!(early.glue, 0.0);
2012 assert!(early.wobble <= 0.10);
2013 assert!(early.bulge <= 0.40);
2014 assert_eq!(early, menu_surface_phase(true, 0.40, 0.25));
2015
2016 let closing = menu_surface_phase(false, 0.6, 0.68);
2017 assert_eq!(closing.anchor_presence, 0.0);
2018 assert_eq!(closing.glue, 0.0);
2019 assert!(closing.wobble <= 0.05);
2020 assert!(
2021 closing.bulge <= 0.30,
2022 "close must remain smooth: {closing:?}"
2023 );
2024 }
2025
2026 #[test]
2027 fn menu_trigger_backdrop_unmounts_during_the_first_absorption_frame() {
2028 assert!((30..=40).contains(&MENU_TRIGGER_ABSORPTION_MS));
2029 assert_eq!(MENU_TRIGGER_RESTORE_DELAY_MS, 205);
2030 }
2031
2032 #[test]
2033 fn absorbed_source_foreground_stays_readable_then_stretches_into_the_surface() {
2034 let source = LiquidMenuAbsorbedSource::new(
2035 Rect {
2036 x: 10.0,
2037 y: 20.0,
2038 width: 44.0,
2039 height: 44.0,
2040 },
2041 crate::widgets::GlassButtonSpec::glass()
2042 .with_icon_backplate(Color::from_rgb_u8(0, 122, 255))
2043 .with_content_color(Color::WHITE),
2044 44.0,
2045 "M0 0",
2046 );
2047 assert_eq!(source.rect.width, 44.0);
2048 assert_eq!(source.diameter, 44.0);
2049 assert_eq!(source.icon_path, "M0 0");
2050
2051 let source = menu_absorbed_visual_phase(0.0, 0.0);
2052 assert_eq!(source.foreground_alpha, 1.0);
2053 assert_eq!(source.backdrop_alpha, 0.0);
2054
2055 let crisp = menu_absorbed_visual_phase(0.20, 0.20);
2058 assert_eq!(crisp.foreground_alpha, 1.0);
2059 assert_eq!(crisp.backdrop_alpha, 0.0);
2060 assert_eq!(crisp.foreground_blur, 0.0);
2061 assert!((0.76..=0.78).contains(&crisp.scale_x));
2062 assert!((0.76..=0.78).contains(&crisp.scale_y));
2063 let dimmed = menu_absorbed_visual_phase(0.60, 0.30);
2066 assert!((0.38..=0.42).contains(&dimmed.foreground_alpha));
2067
2068 let melt = menu_absorbed_visual_phase(0.95, 0.90);
2069 assert_eq!(melt.foreground_alpha, 0.0);
2070 assert_eq!(melt.backdrop_alpha, 0.62);
2071 assert!((0.92..=0.97).contains(&melt.scale_y));
2072 assert!((0.87..=0.905).contains(&melt.scale_x));
2073
2074 let smear = menu_absorbed_visual_phase(0.72, 0.69);
2077 assert!((0.94..=0.98).contains(&smear.scale_y));
2078 assert!((0.89..=0.91).contains(&smear.scale_x));
2079 assert!((0.10..=0.18).contains(&smear.foreground_alpha));
2080 assert!((0.36..=0.44).contains(&smear.backdrop_alpha));
2081 let transition = menu_absorbed_visual_phase(0.42, 0.382);
2083 assert!((0.60..=0.75).contains(&transition.foreground_alpha));
2084 assert_eq!(transition.backdrop_alpha, 0.0);
2085 let settled = menu_absorbed_visual_phase(1.0, 1.0);
2086 assert_eq!(settled.foreground_alpha, 0.0);
2087 assert_eq!(settled.backdrop_alpha, 0.62);
2088 assert_eq!(MENU_SOURCE_FOREGROUND_HIDE_MS, 200);
2089 assert_eq!(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS, 205);
2090 }
2091
2092 #[test]
2093 fn liquid_menu_item_builders_preserve_the_row_contract() {
2094 let item = LiquidMenuItem::new("Delete")
2095 .icon("M0 0")
2096 .checked(true)
2097 .destructive()
2098 .section_start();
2099 assert_eq!(item.label, "Delete");
2100 assert_eq!(item.icon, Some("M0 0"));
2101 assert!(item.checked);
2102 assert!(item.destructive);
2103 assert!(item.section_start);
2104 assert!(!item.header);
2105
2106 let header = LiquidMenuItem::header("Show");
2107 assert_eq!(header.label, "Show");
2108 assert!(header.header);
2109 }
2110
2111 #[test]
2112 fn claimed_menu_gesture_streams_one_release_to_an_interactive_row() {
2113 let _runtime =
2114 cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
2115 let gesture = LiquidMenuGesture::new();
2116 let items = vec![LiquidMenuItem::header("Show"), LiquidMenuItem::new("Grid")];
2117 gesture.item_rect(0).set(Rect {
2118 x: 10.0,
2119 y: 20.0,
2120 width: 100.0,
2121 height: 30.0,
2122 });
2123 gesture.item_rect(1).set(Rect {
2124 x: 10.0,
2125 y: 50.0,
2126 width: 100.0,
2127 height: 40.0,
2128 });
2129
2130 gesture.begin(Point::new(80.0, 10.0));
2131 gesture.claim();
2132 gesture.move_to(Point::new(40.0, 65.0));
2133 let held = gesture.snapshot();
2134 assert!(held.active && held.claimed);
2135 assert_eq!(gesture.item_at(held.position, &items), Some(1));
2136 assert_eq!(gesture.item_at(Point::new(40.0, 35.0), &items), None);
2137
2138 gesture.release(Point::new(40.0, 65.0));
2139 let released = gesture.snapshot();
2140 assert!(!released.active);
2141 assert_eq!(released.release, Some((1, Point::new(40.0, 65.0))));
2142 gesture.release(Point::new(40.0, 65.0));
2144 assert_eq!(gesture.snapshot().release, released.release);
2145 }
2146}