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