Skip to main content

cranpose_liquid/widgets/
menu.rs

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