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