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