Skip to main content

cranpose_liquid/widgets/
menu.rs

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