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, PopupDismissable, Row, RowSpec, Text,
14};
15use cranpose_ui::{
16 rememberMutableInteractionSource, Modifier, PointerEventKind, PointerInputScope,
17 PressInteractionPress, Size,
18};
19use cranpose_ui_graphics::{
20 Brush, Color, CornerRadii, GlassSurfaceProfile, GraphicsLayer, Point, Rect, RenderEffect,
21};
22use cranpose_ui_layout::VerticalAlignment;
23use std::cell::{Cell, RefCell};
24use std::rc::Rc;
25
26#[derive(Clone, Debug, PartialEq)]
28pub struct LiquidMenuItem {
29 pub label: String,
30 pub icon: Option<&'static str>,
32 pub checked: bool,
34 pub destructive: bool,
36 pub section_start: bool,
38 pub header: bool,
40}
41
42impl LiquidMenuItem {
43 pub fn new(label: impl Into<String>) -> Self {
44 Self {
45 label: label.into(),
46 icon: None,
47 checked: false,
48 destructive: false,
49 section_start: false,
50 header: false,
51 }
52 }
53
54 pub fn header(label: impl Into<String>) -> Self {
56 Self {
57 header: true,
58 ..Self::new(label)
59 }
60 }
61
62 pub fn icon(mut self, icon: &'static str) -> Self {
63 self.icon = Some(icon);
64 self
65 }
66
67 pub fn checked(mut self, checked: bool) -> Self {
68 self.checked = checked;
69 self
70 }
71
72 pub fn destructive(mut self) -> Self {
73 self.destructive = true;
74 self
75 }
76
77 pub fn section_start(mut self) -> Self {
78 self.section_start = true;
79 self
80 }
81}
82
83#[derive(Clone, Debug, PartialEq)]
86pub struct LiquidMenuAbsorbedSource {
87 pub rect: Rect,
88 pub spec: crate::widgets::GlassButtonSpec,
89 pub diameter: f32,
90 pub icon_path: &'static str,
91}
92
93impl LiquidMenuAbsorbedSource {
94 pub fn new(
95 rect: Rect,
96 spec: crate::widgets::GlassButtonSpec,
97 diameter: f32,
98 icon_path: &'static str,
99 ) -> Self {
100 Self {
101 rect,
102 spec,
103 diameter,
104 icon_path,
105 }
106 }
107}
108
109const MENU_WIDTH: f32 = 250.0;
110const MENU_RADIUS: f32 = 32.0;
111const MENU_GROW_DELAY: f32 = 0.050;
112const MENU_OVERSHOOT_SCALE: f32 = 0.30;
113const MENU_GROW_STIFFNESS: f32 = 60.0;
114const MENU_REVEAL_STIFFNESS: f32 = 200.0;
115const MENU_WIDTH_EASE_POWER: f32 = 4.5;
116const MENU_HEIGHT_EASE_POWER: f32 = 18.0;
117const MENU_HEIGHT_OVERSHOOT: f32 = 0.15;
118const MENU_HEIGHT_OVERSHOOT_END: f32 = 0.52;
119const MENU_VERTICAL_REBOUND: f32 = 14.0;
120const MENU_VERTICAL_REBOUND_END: f32 = 0.70;
121const MENU_SOURCE_HEIGHT_RATIO: f32 = 0.80;
122const MENU_SOURCE_TARGET_Y_PROGRESS: f32 = 0.76;
123const ANCHOR_OVERLAP: f32 = 0.0;
128const ROW_PADDING_X: f32 = 20.0;
129const ROW_PADDING_Y: f32 = 9.25;
130const MENU_CONTENT_INSET_Y: f32 = 9.5;
131const CHECK_COLUMN: f32 = 24.0;
133const ICON_SIZE: f32 = 24.0;
134const ICON_GAP: f32 = 12.0;
135const MENU_LONG_PRESS_MS: u64 = 500;
136const MENU_LONG_PRESS_SLOP: f32 = 12.0;
137const MENU_CONTENT_BLUR: f32 = 14.0;
138const MENU_CONTENT_BLUR_POWER: f32 = 0.65;
139const MENU_CONTENT_ALPHA_POWER: f32 = 0.45;
140const MENU_TRIGGER_GLASS_CUTOFF: f32 = 0.05;
141const MENU_TRIGGER_ABSORPTION_MS: u64 = 36;
142const MENU_TRIGGER_RESTORE_DELAY_MS: u64 = 205;
143const MENU_SOURCE_FOREGROUND_HIDE_MS: u64 = 5;
144const MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS: u64 = 205;
145fn menu_surface_profile() -> GlassSurfaceProfile {
146 GlassSurfaceProfile::regular()
147 .with_depth(3.0)
148 .expect("menu surface depth is valid")
149}
150
151#[derive(Clone, Copy, Debug, PartialEq)]
152struct MenuGestureSnapshot {
153 active: bool,
154 claimed: bool,
155 start: Point,
156 position: Point,
157 release: Option<(u64, Point)>,
158}
159
160impl Default for MenuGestureSnapshot {
161 fn default() -> Self {
162 Self {
163 active: false,
164 claimed: false,
165 start: Point::new(0.0, 0.0),
166 position: Point::new(0.0, 0.0),
167 release: None,
168 }
169 }
170}
171
172struct LiquidMenuGestureInner {
173 snapshot: MutableState<MenuGestureSnapshot>,
174 next_release: Cell<u64>,
175 item_rects: RefCell<Vec<Rc<Cell<Rect>>>>,
176}
177
178#[derive(Clone)]
182pub struct LiquidMenuGesture {
183 inner: Rc<LiquidMenuGestureInner>,
184}
185
186impl PartialEq for LiquidMenuGesture {
187 fn eq(&self, other: &Self) -> bool {
188 Rc::ptr_eq(&self.inner, &other.inner)
189 }
190}
191
192impl LiquidMenuGesture {
193 fn new() -> Self {
194 Self {
195 inner: Rc::new(LiquidMenuGestureInner {
196 snapshot: mutableStateOf(MenuGestureSnapshot::default()),
197 next_release: Cell::new(0),
198 item_rects: RefCell::new(Vec::new()),
199 }),
200 }
201 }
202
203 fn id(&self) -> usize {
204 Rc::as_ptr(&self.inner) as usize
205 }
206
207 fn begin(&self, point: Point) {
208 self.inner.snapshot.set(MenuGestureSnapshot {
209 active: true,
210 start: point,
211 position: point,
212 ..MenuGestureSnapshot::default()
213 });
214 }
215
216 fn move_to(&self, point: Point) {
217 let mut snapshot = self.inner.snapshot.get();
218 if snapshot.active {
219 snapshot.position = point;
220 self.inner.snapshot.set(snapshot);
221 }
222 }
223
224 fn claim(&self) {
225 let mut snapshot = self.inner.snapshot.get();
226 if snapshot.active && !snapshot.claimed {
227 snapshot.claimed = true;
228 self.inner.snapshot.set(snapshot);
229 }
230 }
231
232 fn release(&self, point: Point) {
233 let mut snapshot = self.inner.snapshot.get();
234 if !snapshot.active {
235 return;
236 }
237 snapshot.position = point;
238 snapshot.active = false;
239 if snapshot.claimed {
240 let sequence = self.inner.next_release.get().wrapping_add(1);
241 self.inner.next_release.set(sequence);
242 snapshot.release = Some((sequence, point));
243 }
244 self.inner.snapshot.set(snapshot);
245 }
246
247 fn cancel(&self) {
248 let mut snapshot = self.inner.snapshot.get();
249 snapshot.active = false;
250 snapshot.claimed = false;
251 snapshot.release = None;
252 self.inner.snapshot.set(snapshot);
253 }
254
255 fn snapshot(&self) -> MenuGestureSnapshot {
256 self.inner.snapshot.get()
257 }
258
259 fn item_rect(&self, index: usize) -> Rc<Cell<Rect>> {
260 let mut rects = self.inner.item_rects.borrow_mut();
261 while rects.len() <= index {
262 rects.push(Rc::new(Cell::new(Rect {
263 x: 0.0,
264 y: 0.0,
265 width: 0.0,
266 height: 0.0,
267 })));
268 }
269 Rc::clone(&rects[index])
270 }
271
272 fn item_at(&self, point: Point, items: &[LiquidMenuItem]) -> Option<usize> {
273 self.inner
274 .item_rects
275 .borrow()
276 .iter()
277 .enumerate()
278 .take(items.len())
279 .find_map(|(index, rect)| {
280 (!items[index].header && rect.get().contains(point.x, point.y)).then_some(index)
281 })
282 }
283}
284
285#[composable]
287pub fn remember_liquid_menu_gesture() -> LiquidMenuGesture {
288 remember(LiquidMenuGesture::new).with(Clone::clone)
289}
290
291#[composable]
294#[allow(non_snake_case)]
295pub fn LiquidMenuAbsorbedIconButton(
296 modifier: Modifier,
297 spec: crate::widgets::GlassButtonSpec,
298 diameter: f32,
299 transferred: bool,
300 on_click: impl Fn() + 'static,
301 icon_path: &'static str,
302) {
303 let foreground = cranpose_animation::animate_float_as_state_with_initial(
304 1.0,
305 if transferred { 0.0 } else { 1.0 },
306 cranpose_animation::AnimationType::Tween(if transferred {
307 cranpose_animation::AnimationSpec::tween(
308 MENU_SOURCE_FOREGROUND_HIDE_MS,
309 cranpose_animation::Easing::LinearEasing,
310 )
311 } else {
312 cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
313 .with_delay(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS)
314 }),
315 "menu-source-foreground-ownership",
316 );
317 crate::widgets::button::GlassIconButtonWithForegroundAlpha(
318 modifier,
319 spec,
320 diameter,
321 foreground.get(),
322 on_click,
323 icon_path,
324 );
325}
326
327#[derive(Clone, Copy, Debug, PartialEq)]
328struct MenuGeometryPhase {
329 path: f32,
330 width: f32,
331 height: f32,
332}
333
334fn menu_geometry_phase(expanded: bool, appear: f32) -> MenuGeometryPhase {
335 if expanded && appear > 1.0 {
336 let settle = 1.0 + (appear - 1.0) * MENU_OVERSHOOT_SCALE;
337 return MenuGeometryPhase {
338 path: settle,
339 width: settle,
340 height: settle,
341 };
342 }
343
344 let appear = appear.clamp(0.0, 1.0);
345 if !expanded {
346 let normalized = ((appear - 0.015) / 0.985).clamp(0.0, 1.0);
347 return MenuGeometryPhase {
348 path: normalized,
349 width: 1.0 - (1.0 - normalized).powf(2.5),
350 height: 1.0 - (1.0 - normalized).powf(14.0),
351 };
352 }
353
354 if appear < MENU_GROW_DELAY {
355 let source_merge = smoothstep(0.10, 0.58, appear / MENU_GROW_DELAY);
356 return MenuGeometryPhase {
357 path: 0.0,
358 width: source_merge,
359 height: source_merge,
360 };
361 }
362
363 let path = ((appear - MENU_GROW_DELAY) / (1.0 - MENU_GROW_DELAY)).clamp(0.0, 1.0);
364 let overshoot_phase = ((path - 0.50) / 0.50).clamp(0.0, 1.0);
365 let overshoot = 0.040 * (std::f32::consts::PI * overshoot_phase).sin().max(0.0);
366 let height_overshoot_phase = (path / MENU_HEIGHT_OVERSHOOT_END).clamp(0.0, 1.0);
367 let height_overshoot = MENU_HEIGHT_OVERSHOOT
368 * (std::f32::consts::PI * height_overshoot_phase)
369 .sin()
370 .max(0.0);
371 MenuGeometryPhase {
372 path,
373 width: 1.0 - (1.0 - path).powf(MENU_WIDTH_EASE_POWER) + overshoot,
374 height: 1.0 - (1.0 - path).powf(MENU_HEIGHT_EASE_POWER) + height_overshoot,
375 }
376}
377
378#[derive(Clone, Copy, Debug, PartialEq)]
379struct MenuShape {
380 center_x: f32,
381 center_y: f32,
382 width: f32,
383 height: f32,
384 radius: f32,
385}
386
387impl MenuShape {
388 fn capsule(center_x: f32, center_y: f32, width: f32, height: f32) -> Self {
389 Self {
390 center_x,
391 center_y,
392 width,
393 height,
394 radius: -1.0,
395 }
396 }
397
398 fn from_window_rect(rect: Rect, node_origin: Point) -> Option<Self> {
399 (rect.width > 0.0 && rect.height > 0.0).then(|| {
400 Self::capsule(
401 rect.x + rect.width * 0.5 - node_origin.x,
402 rect.y + rect.height * 0.5 - node_origin.y,
403 rect.width,
404 rect.height,
405 )
406 })
407 }
408
409 fn as_glass_shape(self) -> (f32, f32, f32, f32, f32) {
410 (
411 self.center_x,
412 self.center_y,
413 self.width,
414 self.height,
415 self.radius,
416 )
417 }
418}
419
420#[derive(Clone, Copy, Debug, PartialEq)]
421struct MenuMorphGeometry {
422 primary: MenuShape,
423 source: MenuShape,
424 target: MenuShape,
425 path: f32,
426}
427
428fn menu_source_shape(anchor: MenuShape, absorbed: &[MenuShape], target: MenuShape) -> MenuShape {
429 let mut left = anchor.center_x - anchor.width * 0.5;
430 let mut right = anchor.center_x + anchor.width * 0.5;
431 let mut top = anchor.center_y - anchor.height * 0.5;
432 let mut bottom = anchor.center_y + anchor.height * 0.5;
433 for shape in absorbed {
434 left = left.min(shape.center_x - shape.width * 0.5);
435 right = right.max(shape.center_x + shape.width * 0.5);
436 top = top.min(shape.center_y - shape.height * 0.5);
437 bottom = bottom.max(shape.center_y + shape.height * 0.5);
438 }
439
440 let width = right - left;
441 let cluster_height = bottom - top;
442 let height = cluster_height
443 .max(width * MENU_SOURCE_HEIGHT_RATIO)
444 .min(target.height);
445 let cluster_center_y = (top + bottom) * 0.5;
446 MenuShape::capsule(
447 (left + right) * 0.5,
448 cluster_center_y + (target.center_y - cluster_center_y) * MENU_SOURCE_TARGET_Y_PROGRESS,
449 width,
450 height,
451 )
452}
453
454fn interpolate_menu_shape(
455 start: MenuShape,
456 target: MenuShape,
457 width_progress: f32,
458 height_progress: f32,
459) -> MenuShape {
460 let lerp = |a: f32, b: f32, progress: f32| a + (b - a) * progress;
461 MenuShape::capsule(
462 lerp(start.center_x, target.center_x, width_progress),
463 lerp(start.center_y, target.center_y, height_progress),
464 lerp(start.width, target.width, width_progress),
465 lerp(start.height, target.height, height_progress),
466 )
467}
468
469fn menu_vertical_rebound(path: f32) -> f32 {
470 if !(0.0..MENU_VERTICAL_REBOUND_END).contains(&path) {
471 return 0.0;
472 }
473
474 let normalized = path / MENU_VERTICAL_REBOUND_END;
475 let onset = smoothstep(0.0, 0.012, path);
476 MENU_VERTICAL_REBOUND
477 * onset
478 * (std::f32::consts::PI * normalized.powf(0.45))
479 .sin()
480 .max(0.0)
481}
482
483fn menu_morph_geometry(
484 expanded: bool,
485 appear: f32,
486 anchor: MenuShape,
487 absorbed: &[MenuShape],
488 target: MenuShape,
489) -> MenuMorphGeometry {
490 let phase = menu_geometry_phase(expanded, appear);
491 let source = menu_source_shape(anchor, absorbed, target);
492 let mut primary = if expanded && appear < MENU_GROW_DELAY {
493 interpolate_menu_shape(anchor, source, phase.width, phase.height)
494 } else {
495 let start = if expanded { source } else { anchor };
496 interpolate_menu_shape(start, target, phase.width, phase.height)
497 };
498 if expanded {
499 primary.center_y += menu_vertical_rebound(phase.path);
500 }
501 let blob_radius = primary.height * 0.5;
502 let squareness = smoothstep(0.68, 1.0, phase.path);
503 primary.radius = if !expanded {
504 blob_radius
505 } else if phase.path >= 1.0 {
506 target.radius
507 } else {
508 blob_radius + (target.radius - blob_radius) * squareness
509 };
510 MenuMorphGeometry {
511 primary,
512 source,
513 target,
514 path: phase.path,
515 }
516}
517
518fn menu_ellipse_blend(path: f32) -> f32 {
519 0.42 * smoothstep(0.08, 0.28, path) * (1.0 - smoothstep(0.72, 0.96, path))
520}
521
522fn menu_content_progress(expanded: bool, appear: f32, reveal: f32) -> f32 {
523 if expanded {
524 smoothstep(0.17, 0.82, reveal)
525 } else {
526 ((appear - 0.45) / 0.55).clamp(0.0, 1.0)
527 }
528}
529
530fn menu_content_blur(progress: f32) -> f32 {
531 MENU_CONTENT_BLUR * (1.0 - progress.clamp(0.0, 1.0)).powf(MENU_CONTENT_BLUR_POWER)
532}
533
534fn menu_content_alpha(progress: f32) -> f32 {
535 progress.clamp(0.0, 1.0).powf(MENU_CONTENT_ALPHA_POWER)
536}
537
538fn menu_content_scale(progress: f32) -> f32 {
539 0.80 + 0.20 * progress.clamp(0.0, 1.0)
540}
541
542#[derive(Clone, Copy, Debug, PartialEq)]
543struct MenuAbsorbedVisualPhase {
544 foreground_alpha: f32,
545 backdrop_alpha: f32,
546 foreground_blur: f32,
547 scale_x: f32,
548 scale_y: f32,
549}
550
551fn menu_absorbed_visual_phase(appear: f32, path: f32) -> MenuAbsorbedVisualPhase {
552 let appear = appear.clamp(0.0, 1.0);
553 let path = path.clamp(0.0, 1.0);
554 let shrink = smoothstep(0.0, 0.24, path);
555 let base_scale = 1.0 - 0.25 * shrink;
556 let stretch = smoothstep(0.30, 0.56, path);
557 let handoff = smoothstep(0.0, 0.035, appear);
558 let readable_alpha = 1.0 + (0.40 - 1.0) * handoff;
559 MenuAbsorbedVisualPhase {
560 foreground_alpha: readable_alpha * (1.0 - smoothstep(0.16, 0.36, path)),
561 backdrop_alpha: smoothstep(0.16, 0.36, path),
562 foreground_blur: 4.0 * smoothstep(0.16, 0.36, path),
563 scale_x: base_scale * (1.0 + 0.20 * stretch),
564 scale_y: base_scale * (1.0 + 0.933 * stretch),
565 }
566}
567
568#[derive(Clone, Copy, Debug, PartialEq)]
569struct MenuSurfacePhase {
570 anchor_presence: f32,
571 glue: f32,
572 wobble: f32,
573 bulge: f32,
574}
575
576fn menu_surface_phase(expanded: bool, appear: f32, path: f32) -> MenuSurfacePhase {
577 let appear = appear.clamp(0.0, 1.0);
578 let path = path.clamp(0.0, 1.0);
579 let activity = (std::f32::consts::PI * path).sin().max(0.0);
580 if expanded && path <= f32::EPSILON {
581 let recoil = (appear / MENU_GROW_DELAY).clamp(0.0, 1.0);
582 return MenuSurfacePhase {
583 anchor_presence: 0.0,
584 glue: 0.0,
585 wobble: 0.18 * (std::f32::consts::PI * recoil).sin().max(0.0),
586 bulge: 0.0,
587 };
588 }
589 if expanded {
590 return MenuSurfacePhase {
591 anchor_presence: 0.0,
594 glue: 0.0,
595 wobble: 0.08 * activity,
596 bulge: 0.35 * activity,
597 };
598 }
599 MenuSurfacePhase {
600 anchor_presence: 0.0,
601 glue: 0.0,
602 wobble: 0.04 * activity,
603 bulge: 0.25 * activity,
604 }
605}
606
607fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
608 let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
609 t * t * (3.0 - 2.0 * t)
610}
611
612#[allow(clippy::too_many_arguments)]
616#[composable]
617#[allow(non_snake_case)]
618pub fn LiquidMenuIconButton(
619 modifier: Modifier,
620 spec: crate::widgets::GlassButtonSpec,
621 diameter: f32,
622 covered: bool,
623 gesture: LiquidMenuGesture,
624 on_open: impl Fn() + 'static,
625 icon_path: &'static str,
626) {
627 let interaction = rememberMutableInteractionSource();
628 let (pressed_modifier, _, content_alpha) =
629 crate::motion::liquid_press_scale(Modifier::empty(), interaction.clone(), 1.12);
630 let trigger_visual = cranpose_animation::animate_float_as_state_with_initial(
631 1.0,
632 if covered { 0.0 } else { 1.0 },
633 cranpose_animation::AnimationType::Tween(if covered {
634 cranpose_animation::AnimationSpec::tween(
635 MENU_TRIGGER_ABSORPTION_MS,
636 cranpose_animation::Easing::EaseOut,
637 )
638 } else {
639 cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
640 .with_delay(MENU_TRIGGER_RESTORE_DELAY_MS)
641 }),
642 "menu-trigger-absorption",
643 );
644 let gate = remember(|| {
645 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
646 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
647 0.0, runtime,
648 )))
649 })
650 .with(Rc::clone);
651 let on_open: Rc<dyn Fn()> = Rc::new(on_open);
652
653 let snapshot = gesture.snapshot();
654 let gate_progress = gate.borrow().state().value();
655 if gate_progress >= 1.0 && snapshot.active && !snapshot.claimed {
656 gesture.claim();
657 let on_open = Rc::clone(&on_open);
658 SideEffect(move || on_open());
659 }
660
661 let input = Modifier::empty()
662 .size(Size::new(diameter, diameter))
663 .pointer_input(gesture.id(), {
664 let gesture = gesture.clone();
665 let gate = Rc::clone(&gate);
666 let interaction = interaction.clone();
667 let on_open = Rc::clone(&on_open);
668 move |scope: PointerInputScope| {
669 let gesture = gesture.clone();
670 let gate = Rc::clone(&gate);
671 let interaction = interaction.clone();
672 let on_open = Rc::clone(&on_open);
673 async move {
674 scope
675 .await_pointer_event_scope(|await_scope| async move {
676 let mut active_pointer = Option::<PointerId>::None;
677 let mut moved = false;
678 let mut press: Option<PressInteractionPress> = None;
679 loop {
680 let event = await_scope.await_pointer_event().await;
681 match event.kind {
682 PointerEventKind::Down if active_pointer.is_none() => {
683 active_pointer = Some(event.id);
684 moved = false;
685 gesture.begin(event.global_position);
686 press = Some(interaction.press(event.position));
687 let mut timer = gate.borrow_mut();
688 timer.snapTo(0.0);
689 timer.animateTo(
690 1.0,
691 cranpose_animation::AnimationType::Tween(
692 cranpose_animation::AnimationSpec::linear(
693 MENU_LONG_PRESS_MS,
694 ),
695 ),
696 );
697 event.consume();
698 }
699 PointerEventKind::Move if active_pointer == Some(event.id) => {
700 gesture.move_to(event.global_position);
701 let state = gesture.snapshot();
702 let dx = event.global_position.x - state.start.x;
703 let dy = event.global_position.y - state.start.y;
704 if !state.claimed
705 && dx * dx + dy * dy
706 > MENU_LONG_PRESS_SLOP * MENU_LONG_PRESS_SLOP
707 {
708 moved = true;
709 gate.borrow_mut().snapTo(0.0);
710 }
711 event.consume();
712 }
713 PointerEventKind::Up if active_pointer == Some(event.id) => {
714 active_pointer = None;
715 let claimed = gesture.snapshot().claimed;
716 gate.borrow_mut().snapTo(0.0);
717 if claimed {
718 gesture.release(event.global_position);
719 } else {
720 gesture.cancel();
721 if !moved {
722 on_open();
723 }
724 }
725 if let Some(active_press) = press.take() {
726 interaction.release(active_press);
727 }
728 event.consume();
729 }
730 PointerEventKind::Cancel
731 if active_pointer == Some(event.id) =>
732 {
733 active_pointer = None;
734 gate.borrow_mut().snapTo(0.0);
735 gesture.cancel();
736 if let Some(active_press) = press.take() {
737 interaction.cancel(active_press);
738 }
739 event.consume();
740 }
741 _ => {}
742 }
743 }
744 })
745 .await;
746 }
747 }
748 });
749
750 Box(
751 pressed_modifier
752 .then(modifier)
753 .size(Size::new(diameter, diameter)),
754 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
755 move || {
756 let visual_alpha = trigger_visual.get().clamp(0.0, 1.0);
757 let melt = 1.0 - visual_alpha;
758 let visual_spec = spec.clone();
759 let visual = Modifier::empty()
760 .size(Size::new(diameter, diameter))
761 .graphics_layer(move || GraphicsLayer {
762 alpha: visual_alpha * content_alpha.get().clamp(0.0, 1.0),
763 scale_x: 1.0 - 0.12 * melt,
764 scale_y: 1.0 - 0.12 * melt,
765 ..Default::default()
766 });
767 Box(
768 visual,
769 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
770 move || {
771 if visual_alpha > MENU_TRIGGER_GLASS_CUTOFF {
772 crate::widgets::GlassIconButton(
773 Modifier::empty(),
774 visual_spec.clone(),
775 diameter,
776 || {},
777 icon_path,
778 );
779 }
780 },
781 );
782 Box(input.clone(), BoxSpec::default(), || {});
783 },
784 );
785}
786
787#[composable]
788#[allow(non_snake_case)]
789fn AbsorbedSourceVisual(
790 source: LiquidMenuAbsorbedSource,
791 node_origin: Point,
792 alpha: f32,
793 blur: f32,
794 scale_x: f32,
795 scale_y: f32,
796) {
797 if alpha <= 0.001 {
798 return;
799 }
800
801 let diameter = source.diameter;
802 let foreground_spec = source.spec.clone();
803 let layer = Modifier::empty()
804 .absolute_offset(source.rect.x - node_origin.x, source.rect.y - node_origin.y)
805 .size(Size::new(diameter, diameter))
806 .graphics_layer(move || GraphicsLayer {
807 alpha,
808 scale_x,
809 scale_y,
810 render_effect: (blur > 0.35).then(|| RenderEffect::blur(blur)),
811 ..Default::default()
812 });
813 Box(
814 layer,
815 BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
816 move || {
817 crate::widgets::button::GlassIconForeground(
818 foreground_spec.clone(),
819 diameter,
820 source.icon_path,
821 );
822 },
823 );
824}
825
826#[composable]
837#[allow(non_snake_case)]
838pub fn LiquidMenu(
839 expanded: bool,
840 anchor: Rect,
841 absorbed: Vec<LiquidMenuAbsorbedSource>,
842 items: Vec<LiquidMenuItem>,
843 gesture: LiquidMenuGesture,
844 on_item: impl Fn(usize) + 'static,
845 on_dismiss: impl Fn() + 'static,
846) {
847 let visible = remember(|| mutableStateOf(false)).with(|s| *s);
851 if expanded && !visible.get() {
852 visible.set(true);
853 }
854 if !expanded && !visible.get() {
855 return;
856 }
857 let colors = liquid_colors();
858 let typography = liquid_typography();
859 let on_item: Rc<dyn Fn(usize)> = Rc::new(on_item);
860 let on_dismiss: Rc<dyn Fn()> = Rc::new(on_dismiss);
861 let gesture_snapshot = gesture.snapshot();
862 let gesture_hover = (gesture_snapshot.active && gesture_snapshot.claimed)
863 .then(|| gesture.item_at(gesture_snapshot.position, &items))
864 .flatten();
865 let handled_release = remember(|| Rc::new(Cell::new(0u64))).with(Rc::clone);
866 if let Some((sequence, point)) = gesture_snapshot.release {
867 if handled_release.get() != sequence {
868 handled_release.set(sequence);
869 if let Some(index) = gesture.item_at(point, &items) {
870 let on_item = Rc::clone(&on_item);
871 let on_dismiss = Rc::clone(&on_dismiss);
872 SideEffect(move || {
873 on_item(index);
874 on_dismiss();
875 });
876 }
877 }
878 }
879
880 let grow = cranpose_animation::animate_float_as_state_with_initial(
886 0.0,
887 if expanded { 1.0 } else { 0.0 },
888 if expanded {
889 cranpose_animation::spring(0.78, MENU_GROW_STIFFNESS)
890 } else {
891 cranpose_animation::AnimationType::Tween(cranpose_animation::AnimationSpec::linear(205))
892 },
893 "menu-grow",
894 );
895 let reveal_anim = cranpose_animation::animate_float_as_state_with_initial(
899 0.0,
900 if expanded { 1.0 } else { 0.0 },
901 if expanded {
902 cranpose_animation::spring(1.0, MENU_REVEAL_STIFFNESS)
903 } else {
904 cranpose_animation::spring(1.0, 900.0)
905 },
906 "menu-reveal",
907 );
908 let appear = grow.get().max(0.0);
912 let reveal = reveal_anim.get().clamp(0.0, 1.0);
913 if !expanded && appear < 0.02 {
914 visible.set(false);
915 return;
916 }
917
918 let anchor_zone = anchor.height * ANCHOR_OVERLAP;
921 let node_size =
922 remember(|| Rc::new(Cell::new(cranpose_ui_graphics::Size::ZERO))).with(Rc::clone);
923 let scrim_dismiss = Rc::clone(&on_dismiss);
928 let scrim_active = expanded;
929 PopupDismissable(
930 anchor,
931 Point::new(anchor.width - MENU_WIDTH, 0.0),
932 move || {
933 if scrim_active {
934 scrim_dismiss()
935 }
936 },
937 {
938 let absorbed = absorbed.clone();
939 let items = items.clone();
940 let typography = typography.clone();
941 let on_item = Rc::clone(&on_item);
942 let on_dismiss = Rc::clone(&on_dismiss);
943 let node_size = Rc::clone(&node_size);
944 let gesture = gesture.clone();
945 move || {
946 let anchor_center = (MENU_WIDTH - anchor.width * 0.5, anchor.height * 0.5);
951 let anchor_shape = MenuShape::capsule(
952 anchor_center.0,
953 anchor_center.1,
954 anchor.width,
955 anchor.height,
956 );
957 let node_origin = Point::new(anchor.x + anchor.width - MENU_WIDTH, anchor.y);
958 let absorbed_shapes: Vec<MenuShape> = absorbed
959 .iter()
960 .filter_map(|source| MenuShape::from_window_rect(source.rect, node_origin))
961 .collect();
962 let morph_size = Rc::clone(&node_size);
963 let glass = Glass::regular()
966 .shape(LiquidShape::RoundedRect(MENU_RADIUS))
967 .blur_radius(13.0)
968 .saturation(1.55)
969 .lift(0.58)
970 .highlight(0.08)
971 .surface_profile(menu_surface_profile())
972 .sheen(0.04)
973 .chromatic_aberration(0.12)
974 .displacement(12.0)
975 .shadow_style(GlassShadow::new(
976 Color::BLACK.with_alpha(if colors.is_dark { 0.18 } else { 0.075 }),
977 26.0,
978 8.0,
979 0.0,
980 ))
981 .no_clip();
982 let card = Modifier::empty()
983 .report_size(Rc::clone(&node_size))
984 .glass_effect_with(glass, move || {
985 let size = morph_size.get();
986 let menu_h = (size.height - anchor_zone).max(24.0);
987 let settle_radius = (menu_h * 0.32).clamp(26.0, MENU_RADIUS);
991 let target = MenuShape {
992 center_x: MENU_WIDTH * 0.5,
993 center_y: anchor_zone + menu_h * 0.5,
994 width: MENU_WIDTH,
995 height: menu_h,
996 radius: settle_radius,
997 };
998 let geometry = menu_morph_geometry(
999 expanded,
1000 appear,
1001 anchor_shape,
1002 &absorbed_shapes,
1003 target,
1004 );
1005 let t = geometry.path;
1006 let primary = geometry.primary.as_glass_shape();
1007 let start = if expanded {
1008 geometry.source
1009 } else {
1010 anchor_shape
1011 };
1012 let target = geometry.target;
1013 let surface = menu_surface_phase(expanded, appear, t);
1014 let dir_x = target.center_x - start.center_x;
1017 let dir_y = target.center_y - start.center_y;
1018 let mut bulge_dir = dir_y.atan2(dir_x);
1019 if !expanded {
1020 bulge_dir += std::f32::consts::PI;
1021 }
1022 let mut shapes = Vec::new();
1023 if surface.anchor_presence > 0.01 {
1024 shapes.push((
1025 anchor_shape.center_x,
1026 anchor_shape.center_y,
1027 anchor_shape.width * surface.anchor_presence,
1028 anchor_shape.height * surface.anchor_presence,
1029 -1.0,
1030 ));
1031 }
1032 let glue = surface.glue;
1033 GlassDynamics {
1034 morph: Some(GlassMorph {
1035 node_size: (size.width.max(1.0), size.height.max(1.0)),
1036 primary,
1037 shapes,
1038 glue,
1039 wobble_amplitude: surface.wobble,
1040 wobble_phase: t * 8.0,
1041 bulge_amplitude: surface.bulge,
1042 bulge_direction: bulge_dir,
1043 ellipse_blend: menu_ellipse_blend(t),
1044 deformation: None,
1045 }),
1046 ..Default::default()
1047 }
1048 })
1049 .width(MENU_WIDTH);
1050
1051 let has_checks = items.iter().any(|item| item.checked);
1052 let hovered = remember(|| mutableStateOf(Option::<usize>::None)).with(|s| *s);
1055 let gesture = gesture.clone();
1056 let source_phase =
1057 menu_absorbed_visual_phase(appear, menu_geometry_phase(expanded, appear).path);
1058 for source in absorbed.iter().cloned() {
1059 AbsorbedSourceVisual(
1060 source,
1061 node_origin,
1062 source_phase.backdrop_alpha,
1063 0.0,
1064 source_phase.scale_x,
1065 source_phase.scale_y,
1066 );
1067 }
1068 Box(card, BoxSpec::default(), {
1069 let items = items.clone();
1070 let typography = typography.clone();
1071 let on_item = Rc::clone(&on_item);
1072 let on_dismiss = Rc::clone(&on_dismiss);
1073 move || {
1074 Column(Modifier::empty().fill_max_width(), ColumnSpec::default(), {
1075 let items = items.clone();
1076 let typography = typography.clone();
1077 let on_item = Rc::clone(&on_item);
1078 let on_dismiss = Rc::clone(&on_dismiss);
1079 let gesture = gesture.clone();
1080 move || {
1081 Box(
1082 Modifier::empty().height(anchor_zone),
1083 BoxSpec::default(),
1084 || {},
1085 );
1086 let content = menu_content_progress(expanded, appear, reveal);
1094 let content_scale = menu_content_scale(content);
1098 let content_blur = menu_content_blur(content);
1099 let content_translation_y = if expanded {
1100 menu_vertical_rebound(
1101 menu_geometry_phase(expanded, appear).path,
1102 )
1103 } else {
1104 0.0
1105 };
1106 let rows_wrap =
1107 Modifier::empty().fill_max_width().graphics_layer(move || {
1108 GraphicsLayer {
1109 alpha: menu_content_alpha(content),
1110 scale_x: content_scale,
1111 scale_y: content_scale,
1112 transform_origin:
1113 cranpose_ui_graphics::TransformOrigin {
1114 pivot_fraction_x: 1.0,
1115 pivot_fraction_y: 0.0,
1116 },
1117 translation_y: content_translation_y,
1118 render_effect: (content_blur > 0.35)
1119 .then(|| RenderEffect::blur(content_blur)),
1120 ..Default::default()
1121 }
1122 });
1123 Box(rows_wrap, BoxSpec::default(), {
1124 let items = items.clone();
1125 let typography = typography.clone();
1126 let on_item = Rc::clone(&on_item);
1127 let on_dismiss = Rc::clone(&on_dismiss);
1128 let gesture = gesture.clone();
1129 move || {
1130 Column(
1131 Modifier::empty().fill_max_width().padding_each(
1132 0.0,
1133 MENU_CONTENT_INSET_Y,
1134 0.0,
1135 MENU_CONTENT_INSET_Y,
1136 ),
1137 ColumnSpec::default(),
1138 {
1139 let items = items.clone();
1140 let typography = typography.clone();
1141 let on_item = Rc::clone(&on_item);
1142 let on_dismiss = Rc::clone(&on_dismiss);
1143 let gesture = gesture.clone();
1144 move || {
1145 for (index, item) in items.iter().enumerate() {
1146 if item.section_start && index > 0 {
1147 let separator =
1151 colors.separator.with_alpha(
1152 colors.separator.a() * 0.22,
1153 );
1154 Box(
1155 Modifier::empty()
1156 .fill_max_width()
1157 .padding_symmetric(ROW_PADDING_X, 0.0)
1158 .height(1.0)
1159 .draw_behind(move |scope| {
1160 scope.draw_rect(
1161 cranpose_ui_graphics::Brush::solid(
1162 separator,
1163 ),
1164 );
1165 }),
1166 BoxSpec::default(),
1167 || {},
1168 );
1169 }
1170
1171 if item.header {
1172 menu_header_row(
1173 item,
1174 &typography,
1175 has_checks,
1176 colors,
1177 );
1178 continue;
1179 }
1180 menu_item_row(
1181 index,
1182 item,
1183 &typography,
1184 has_checks,
1185 colors,
1186 hovered,
1187 gesture_hover,
1188 gesture.item_rect(index),
1189 Rc::clone(&on_item),
1190 Rc::clone(&on_dismiss),
1191 );
1192 }
1193 }
1194 },
1195 );
1196 }
1197 });
1198 }
1199 });
1200 }
1201 });
1202 for source in absorbed.iter().cloned() {
1203 AbsorbedSourceVisual(
1204 source,
1205 node_origin,
1206 source_phase.foreground_alpha,
1207 source_phase.foreground_blur,
1208 source_phase.scale_x,
1209 source_phase.scale_y,
1210 );
1211 }
1212 }
1213 },
1214 );
1215}
1216
1217fn menu_header_row(
1219 item: &LiquidMenuItem,
1220 typography: &crate::theme::LiquidTypography,
1221 has_checks: bool,
1222 colors: crate::theme::LiquidColors,
1223) {
1224 let label = item.label.clone();
1225 let style = TextStyle {
1226 span_style: SpanStyle {
1227 color: Some(colors.secondary_label),
1228 font_size: TextUnit::Sp(13.0),
1229 ..typography.footnote.span_style.clone()
1230 },
1231 ..typography.footnote.clone()
1232 };
1233 let indent = ROW_PADDING_X + if has_checks { CHECK_COLUMN } else { 0.0 };
1234 let row = Modifier::empty()
1235 .fill_max_width()
1236 .padding_each(indent, 12.0, ROW_PADDING_X, 2.0);
1237 Row(row, RowSpec::default(), move || {
1238 Text(label.clone(), Modifier::empty(), style.clone());
1239 });
1240}
1241
1242#[allow(non_snake_case)]
1244#[allow(clippy::too_many_arguments)]
1245fn menu_item_row(
1246 index: usize,
1247 item: &LiquidMenuItem,
1248 typography: &crate::theme::LiquidTypography,
1249 has_checks: bool,
1250 colors: crate::theme::LiquidColors,
1251 hovered: cranpose_core::MutableState<Option<usize>>,
1252 gesture_hover: Option<usize>,
1253 rect_sink: Rc<Cell<Rect>>,
1254 on_item: Rc<dyn Fn(usize)>,
1255 on_dismiss: Rc<dyn Fn()>,
1256) {
1257 let color = if item.destructive {
1258 colors.destructive
1259 } else {
1260 colors.label
1261 };
1262 let is_hovered = hovered.get() == Some(index) || gesture_hover == Some(index);
1263 let highlight = if colors.is_dark {
1264 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 72)
1265 } else {
1266 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 48)
1267 };
1268 let row_label = item.label.clone();
1269 let row = Modifier::empty()
1270 .fill_max_width()
1271 .report_window_rect(rect_sink)
1272 .semantics(move |config| {
1273 config.is_button = true;
1274 config.is_clickable = true;
1275 config.content_description = Some(row_label.clone());
1276 })
1277 .pointer_input(index, {
1278 let on_item = Rc::clone(&on_item);
1279 let on_dismiss = Rc::clone(&on_dismiss);
1280 move |scope: PointerInputScope| {
1281 let on_item = Rc::clone(&on_item);
1282 let on_dismiss = Rc::clone(&on_dismiss);
1283 async move {
1284 scope
1285 .await_pointer_event_scope(|await_scope| async move {
1286 loop {
1287 let event = await_scope.await_pointer_event().await;
1288 match event.kind {
1289 PointerEventKind::Enter | PointerEventKind::Move => {
1290 hovered.set(Some(index));
1291 }
1292 PointerEventKind::Exit if hovered.get() == Some(index) => {
1293 hovered.set(None);
1294 }
1295 PointerEventKind::Down => {
1296 hovered.set(Some(index));
1297 event.consume();
1298 }
1299 PointerEventKind::Up => {
1300 hovered.set(None);
1301 on_item(index);
1302 on_dismiss();
1303 event.consume();
1304 }
1305 _ => {}
1306 }
1307 }
1308 })
1309 .await;
1310 }
1311 }
1312 })
1313 .draw_behind(move |scope| {
1314 if is_hovered {
1315 scope.draw_round_rect(Brush::solid(highlight), CornerRadii::uniform(14.0));
1316 }
1317 })
1318 .padding_symmetric(ROW_PADDING_X, ROW_PADDING_Y);
1319
1320 let label = item.label.clone();
1321 let icon = item.icon;
1322 let checked = item.checked;
1323 let typography = typography.clone();
1324 Row(
1325 row,
1326 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
1327 move || {
1328 let label = label.clone();
1329 if has_checks {
1330 Box(
1333 Modifier::empty().width(CHECK_COLUMN),
1334 BoxSpec::default(),
1335 move || {
1336 if checked {
1337 crate::icons::Icon(crate::icons::CHECK, 16.0, color);
1338 }
1339 },
1340 );
1341 }
1342 if let Some(icon) = icon {
1343 crate::icons::Icon(icon, ICON_SIZE, color);
1344 Box(Modifier::empty().width(ICON_GAP), BoxSpec::default(), || {});
1345 }
1346 let style = TextStyle {
1347 span_style: SpanStyle {
1348 color: Some(color),
1349 font_weight: Some(FontWeight::NORMAL),
1350 ..typography.body.span_style.clone()
1351 },
1352 ..typography.body.clone()
1353 };
1354 Text(label, Modifier::empty().weight(1.0), style);
1355 },
1356 );
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362
1363 #[test]
1364 fn menu_rows_use_the_reference_leading_grid_and_vertical_rhythm() {
1365 assert_eq!(ROW_PADDING_X, 20.0);
1366 assert_eq!(CHECK_COLUMN, 24.0);
1367 assert_eq!(ICON_SIZE, 24.0);
1368 assert_eq!(ICON_GAP, 12.0);
1369
1370 let check_center = ROW_PADDING_X + 8.0;
1371 let icon_center = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE * 0.5;
1372 let label_start = ROW_PADDING_X + CHECK_COLUMN + ICON_SIZE + ICON_GAP;
1373 assert_eq!((check_center, icon_center, label_start), (28.0, 56.0, 80.0));
1374
1375 let row_height = ICON_SIZE + ROW_PADDING_Y * 2.0;
1376 assert!((42.0..=43.0).contains(&row_height));
1377 assert_eq!(MENU_CONTENT_INSET_Y, 9.5);
1378 let two_row_panel_height = row_height * 2.0 + MENU_CONTENT_INSET_Y * 2.0;
1379 assert!((103.5..=104.5).contains(&two_row_panel_height));
1380 }
1381
1382 #[test]
1383 fn menu_geometry_merges_sources_and_matches_the_measured_growth_contour() {
1384 let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
1385 let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
1386 let target = MenuShape {
1387 center_x: 125.0,
1388 center_y: 52.0,
1389 width: 250.0,
1390 height: 104.0,
1391 radius: 32.0,
1392 };
1393 let pose = |appear| menu_morph_geometry(true, appear, anchor, &absorbed, target).primary;
1394
1395 let initial = pose(0.0);
1396 assert_eq!((initial.width, initial.height), (44.0, 44.0));
1397
1398 let merged = pose(0.028_576);
1399 assert!(
1400 (94.0..=98.0).contains(&merged.width) && (75.0..=79.0).contains(&merged.height),
1401 "+33ms must be one smooth aggregate source droplet: {merged:?}"
1402 );
1403 assert!(
1404 (199.0..=204.0).contains(&merged.center_x) && (43.0..=46.0).contains(&merged.center_y),
1405 "the aggregate source must move toward the panel on both axes: {merged:?}"
1406 );
1407
1408 for (label, appear, width, height) in [
1409 ("+54ms", 0.070_208, 108.0..=113.0, 84.0..=89.0),
1410 ("+75ms", 0.124_188, 139.0..=147.0, 96.0..=102.0),
1411 ("+100ms", 0.199_019, 174.0..=182.0, 103.0..=109.0),
1412 ("+130ms", 0.296_780, 205.0..=216.0, 106.0..=110.0),
1413 ("+165ms", 0.412_956, 228.0..=238.0, 104.0..=109.0),
1414 ("+205ms", 0.539_174, 241.0..=249.0, 102.0..=106.0),
1415 ] {
1416 let shape = pose(appear);
1417 assert!(
1418 width.contains(&shape.width) && height.contains(&shape.height),
1419 "{label} contour mismatch: {shape:?}"
1420 );
1421 }
1422
1423 let swell = pose(0.701_903);
1424 assert!(
1425 (252.0..=259.0).contains(&swell.width) && (102.0..=106.0).contains(&swell.height),
1426 "the broad body must overshoot horizontally without inflating vertically: {swell:?}"
1427 );
1428 assert!((0.40..=0.43).contains(&menu_ellipse_blend(0.5)));
1429 assert_eq!(menu_ellipse_blend(0.0), 0.0);
1430 assert_eq!(menu_ellipse_blend(1.0), 0.0);
1431
1432 let overshoot = pose(1.08);
1433 assert!((250.0..=254.0).contains(&overshoot.width));
1434 assert!((104.0..=106.0).contains(&overshoot.height));
1435 assert_eq!(MENU_RADIUS, 32.0);
1436 assert!((0.045..=0.055).contains(&MENU_GROW_DELAY));
1437 assert!((59.0..=61.0).contains(&MENU_GROW_STIFFNESS));
1438 assert!((2.8..=3.2).contains(&menu_surface_profile().depth()));
1439 }
1440
1441 #[test]
1442 fn menu_open_spring_reaches_the_measured_geometry_phase_at_130ms() {
1443 let (appear, _) =
1444 cranpose_animation::advance_spring(0.0, 0.0, 1.0, 0.78, MENU_GROW_STIFFNESS, 0.130);
1445 assert!(
1446 (0.29..=0.30).contains(&appear),
1447 "130ms spring phase must preserve the measured contour mapping: {appear}"
1448 );
1449 }
1450
1451 #[test]
1452 fn menu_body_and_content_share_the_measured_vertical_rebound() {
1453 let anchor = MenuShape::capsule(228.0, 22.0, 44.0, 44.0);
1454 let absorbed = [MenuShape::capsule(176.0, 22.0, 44.0, 44.0)];
1455 let target = MenuShape {
1456 center_x: 125.0,
1457 center_y: 52.0,
1458 width: 250.0,
1459 height: 104.0,
1460 radius: 32.0,
1461 };
1462
1463 for (label, appear, expected_offset) in [
1464 ("+67ms", 0.070_208, 3.0..=6.0),
1465 ("+100ms", 0.199_019, 12.0..=16.0),
1466 ("+130ms", 0.296_780, 11.0..=15.0),
1467 ("+165ms", 0.412_956, 8.0..=12.0),
1468 ("+205ms", 0.539_174, 4.0..=8.0),
1469 ("+265ms", 0.701_903, -1.0..=2.0),
1470 ] {
1471 let geometry = menu_morph_geometry(true, appear, anchor, &absorbed, target);
1472 let offset = geometry.primary.center_y - target.center_y;
1473 assert!(
1474 expected_offset.contains(&offset),
1475 "{label} vertical rebound mismatch: {geometry:?}"
1476 );
1477 }
1478 }
1479
1480 #[test]
1481 fn menu_close_reverses_through_a_smooth_oval() {
1482 let phase = menu_geometry_phase(false, 0.6);
1483 assert!(
1484 phase.width > 0.80,
1485 "the close must retain its broad body at mid-flight: {phase:?}"
1486 );
1487 assert!(
1488 44.0 + (250.0 - 44.0) * phase.width > 1.8 * (44.0 + (104.0 - 44.0) * phase.height),
1489 "the close must pass back through the wide oval in physical dimensions: {phase:?}"
1490 );
1491 assert!(
1492 menu_content_progress(false, 0.6, 1.0) < 0.3,
1493 "content must disappear with the contracting surface"
1494 );
1495 let rounded_volume = menu_geometry_phase(false, 0.21);
1496 assert!(
1497 rounded_volume.width > 0.35,
1498 "the terminal body must contract continuously into the anchor: {rounded_volume:?}"
1499 );
1500 assert!(
1501 44.0 + (250.0 - 44.0) * rounded_volume.width
1502 > 44.0 + (104.0 - 44.0) * rounded_volume.height,
1503 "the terminal body must stay smooth rather than forming a vertical leaf in physical dimensions: {rounded_volume:?}"
1504 );
1505 }
1506
1507 #[test]
1508 fn menu_content_materializes_early_and_is_sharp_by_settle() {
1509 let birth = menu_content_progress(true, 0.35, 0.25);
1510 assert!(
1511 birth > 0.02 && birth < 0.08,
1512 "rows must begin as a faint smudge after the blank birth phase: {birth}"
1513 );
1514 let mid = menu_content_progress(true, 0.55, 0.55);
1515 assert!(
1516 (0.55..0.70).contains(&mid),
1517 "rows must remain visibly soft at mid-flight: {mid}"
1518 );
1519 let settle = menu_content_progress(true, 1.0, 0.92);
1520 assert!(
1521 settle > 0.99,
1522 "rows must be effectively sharp when the shape settles: {settle}"
1523 );
1524 assert!(menu_content_blur(birth) > 13.0);
1525 assert!((7.0..8.0).contains(&menu_content_blur(mid)));
1526 assert!(menu_content_blur(settle) < 0.5);
1527 assert!((190.0..=210.0).contains(&MENU_REVEAL_STIFFNESS));
1528 assert!((0.34..=0.37).contains(&menu_content_alpha(0.10)));
1529 assert!((0.79..=0.82).contains(&menu_content_alpha(0.62)));
1530 assert_eq!(menu_content_alpha(1.0), 1.0);
1531 assert!((0.85..=0.87).contains(&menu_content_scale(0.30)));
1532 assert!((0.92..=0.93).contains(&menu_content_scale(0.62)));
1533 assert_eq!(menu_content_scale(1.0), 1.0);
1534 }
1535
1536 #[test]
1537 fn menu_surface_motion_is_smooth_and_capture_cadence_independent() {
1538 let merged = menu_surface_phase(true, 0.14, 0.0);
1539 assert_eq!(merged.anchor_presence, 0.0);
1540 assert_eq!(merged.glue, 0.0);
1541 let recoil = menu_surface_phase(true, 0.275, 0.0);
1542 assert_eq!(recoil.glue, 0.0);
1543
1544 let early = menu_surface_phase(true, 0.40, 0.25);
1545 assert!(
1546 early.anchor_presence == 0.0,
1547 "the primary alone owns the anchor recoil: {early:?}"
1548 );
1549 assert_eq!(early.glue, 0.0);
1550 assert!(early.wobble <= 0.10);
1551 assert!(early.bulge <= 0.40);
1552 assert_eq!(early, menu_surface_phase(true, 0.40, 0.25));
1553
1554 let closing = menu_surface_phase(false, 0.6, 0.68);
1555 assert_eq!(closing.anchor_presence, 0.0);
1556 assert_eq!(closing.glue, 0.0);
1557 assert!(closing.wobble <= 0.05);
1558 assert!(
1559 closing.bulge <= 0.30,
1560 "close must remain smooth: {closing:?}"
1561 );
1562 }
1563
1564 #[test]
1565 fn menu_trigger_backdrop_unmounts_during_the_first_absorption_frame() {
1566 assert!((30..=40).contains(&MENU_TRIGGER_ABSORPTION_MS));
1567 assert_eq!(MENU_TRIGGER_RESTORE_DELAY_MS, 205);
1568 }
1569
1570 #[test]
1571 fn absorbed_source_foreground_stays_readable_then_stretches_into_the_surface() {
1572 let source = LiquidMenuAbsorbedSource::new(
1573 Rect {
1574 x: 10.0,
1575 y: 20.0,
1576 width: 44.0,
1577 height: 44.0,
1578 },
1579 crate::widgets::GlassButtonSpec::glass()
1580 .with_icon_backplate(Color::from_rgb_u8(0, 122, 255))
1581 .with_content_color(Color::WHITE),
1582 44.0,
1583 "M0 0",
1584 );
1585 assert_eq!(source.rect.width, 44.0);
1586 assert_eq!(source.diameter, 44.0);
1587 assert_eq!(source.icon_path, "M0 0");
1588
1589 let source = menu_absorbed_visual_phase(0.0, 0.0);
1590 assert_eq!(source.foreground_alpha, 1.0);
1591 assert_eq!(source.backdrop_alpha, 0.0);
1592
1593 let crisp = menu_absorbed_visual_phase(0.08, 0.15);
1594 assert!((0.38..=0.42).contains(&crisp.foreground_alpha));
1595 assert_eq!(crisp.backdrop_alpha, 0.0);
1596 assert_eq!(crisp.foreground_blur, 0.0);
1597 assert!((0.81..=0.85).contains(&crisp.scale_x));
1598 assert!((0.81..=0.85).contains(&crisp.scale_y));
1599
1600 let melt = menu_absorbed_visual_phase(0.54, 0.52);
1601 assert_eq!(melt.foreground_alpha, 0.0);
1602 assert_eq!(melt.backdrop_alpha, 1.0);
1603 assert!((1.36..=1.42).contains(&melt.scale_y));
1604 assert!((0.87..=0.90).contains(&melt.scale_x));
1605
1606 let smear = menu_absorbed_visual_phase(0.72, 0.69);
1607 assert!((1.42..=1.46).contains(&smear.scale_y));
1608 assert!((0.89..=0.91).contains(&smear.scale_x));
1609 assert_eq!(smear.foreground_alpha, 0.0);
1610 assert_eq!(smear.backdrop_alpha, 1.0);
1611 let transition = menu_absorbed_visual_phase(0.42, 0.382);
1612 assert_eq!(transition.foreground_alpha, 0.0);
1613 assert_eq!(transition.backdrop_alpha, 1.0);
1614 let settled = menu_absorbed_visual_phase(1.0, 1.0);
1615 assert_eq!(settled.foreground_alpha, 0.0);
1616 assert_eq!(settled.backdrop_alpha, 1.0);
1617 assert_eq!(MENU_SOURCE_FOREGROUND_HIDE_MS, 5);
1618 assert_eq!(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS, 205);
1619 }
1620
1621 #[test]
1622 fn liquid_menu_item_builders_preserve_the_row_contract() {
1623 let item = LiquidMenuItem::new("Delete")
1624 .icon("M0 0")
1625 .checked(true)
1626 .destructive()
1627 .section_start();
1628 assert_eq!(item.label, "Delete");
1629 assert_eq!(item.icon, Some("M0 0"));
1630 assert!(item.checked);
1631 assert!(item.destructive);
1632 assert!(item.section_start);
1633 assert!(!item.header);
1634
1635 let header = LiquidMenuItem::header("Show");
1636 assert_eq!(header.label, "Show");
1637 assert!(header.header);
1638 }
1639
1640 #[test]
1641 fn claimed_menu_gesture_streams_one_release_to_an_interactive_row() {
1642 let _runtime =
1643 cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
1644 let gesture = LiquidMenuGesture::new();
1645 let items = vec![LiquidMenuItem::header("Show"), LiquidMenuItem::new("Grid")];
1646 gesture.item_rect(0).set(Rect {
1647 x: 10.0,
1648 y: 20.0,
1649 width: 100.0,
1650 height: 30.0,
1651 });
1652 gesture.item_rect(1).set(Rect {
1653 x: 10.0,
1654 y: 50.0,
1655 width: 100.0,
1656 height: 40.0,
1657 });
1658
1659 gesture.begin(Point::new(80.0, 10.0));
1660 gesture.claim();
1661 gesture.move_to(Point::new(40.0, 65.0));
1662 let held = gesture.snapshot();
1663 assert!(held.active && held.claimed);
1664 assert_eq!(gesture.item_at(held.position, &items), Some(1));
1665 assert_eq!(gesture.item_at(Point::new(40.0, 35.0), &items), None);
1666
1667 gesture.release(Point::new(40.0, 65.0));
1668 let released = gesture.snapshot();
1669 assert!(!released.active);
1670 assert_eq!(released.release, Some((1, Point::new(40.0, 65.0))));
1671 gesture.release(Point::new(40.0, 65.0));
1673 assert_eq!(gesture.snapshot().release, released.release);
1674 }
1675}