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