Skip to main content

cranpose_liquid/widgets/
menu.rs

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