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