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