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