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