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