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