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::{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. The first action
142/// stays closest to the trigger when the menu opens upward.
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
189fn menu_bounds(anchor: Rect, viewport: Size, width: f32, height: f32) -> (Rect, bool) {
190    let width = if viewport.width > 0.0 {
191        width.min((viewport.width - 32.0).max(1.0))
192    } else {
193        width
194    };
195    let left = anchor.x + (anchor.width - width) * 0.5;
196    let left = if viewport.width > 0.0 {
197        left.clamp(16.0, (viewport.width - width - 16.0).max(16.0))
198    } else {
199        left
200    };
201    let opens_up = viewport.height > 0.0 && anchor.y + anchor.height * 0.5 > viewport.height * 0.5;
202    let top = if opens_up {
203        anchor.y + anchor.height - height
204    } else {
205        anchor.y
206    };
207    (
208        Rect {
209            x: left,
210            y: top,
211            width,
212            height,
213        },
214        opens_up,
215    )
216}
217
218fn menu_items_changed(
219    last: &RefCell<Option<Rc<Vec<LiquidMenuEntry>>>>,
220    items: &Rc<Vec<LiquidMenuEntry>>,
221) -> bool {
222    let mut previous = last.borrow_mut();
223    let changed = previous.as_ref().is_some_and(|previous| {
224        !previous
225            .iter()
226            .map(|entry| &entry.item)
227            .eq(items.iter().map(|entry| &entry.item))
228    });
229    *previous = Some(Rc::clone(items));
230    changed
231}
232
233fn menu_display_rows(
234    items: &[LiquidMenuEntry],
235    opens_up: bool,
236) -> impl Iterator<Item = (usize, bool)> + '_ {
237    let mut cursor = if opens_up { items.len() } else { 0 };
238    std::iter::from_fn(move || {
239        let (start, end) = if opens_up {
240            if cursor == 0 {
241                return None;
242            }
243            let end = cursor;
244            let start = items[..end]
245                .iter()
246                .rposition(|entry| entry.item.header || entry.item.section_start)
247                .unwrap_or(0);
248            cursor = start;
249            (start, end)
250        } else {
251            if cursor == items.len() {
252                return None;
253            }
254            let start = cursor;
255            let end = items[start + 1..]
256                .iter()
257                .position(|entry| entry.item.header || entry.item.section_start)
258                .map_or(items.len(), |offset| start + 1 + offset);
259            cursor = end;
260            (start, end)
261        };
262        Some((start, end))
263    })
264    .enumerate()
265    .flat_map(move |(group, (start, end))| {
266        let header = items[start].item.header.then_some(start);
267        let body_start = start + usize::from(header.is_some());
268        let first = header.unwrap_or(if opens_up { end - 1 } else { start });
269        header
270            .into_iter()
271            .chain((body_start..end).map(move |index| {
272                if opens_up {
273                    end - 1 - (index - body_start)
274                } else {
275                    index
276                }
277            }))
278            .map(move |index| (index, group > 0 && index == first))
279    })
280}
281
282impl LiquidMenuAbsorbedSource {
283    pub fn new(
284        rect: Rect,
285        spec: crate::widgets::GlassButtonSpec,
286        diameter: f32,
287        icon_path: &'static str,
288    ) -> Self {
289        Self {
290            rect,
291            spec,
292            diameter,
293            icon_path,
294        }
295    }
296}
297
298/// Layout parameters for a [`LiquidMenu`].
299#[derive(Clone, Copy, Debug, PartialEq)]
300pub struct LiquidMenuSpec {
301    /// Settled card width in dp (the reference menus vary: the App Store
302    /// List/Grid menu is 250, the sort/filter menu ~66% of screen width).
303    pub width: f32,
304}
305
306impl Default for LiquidMenuSpec {
307    fn default() -> Self {
308        Self { width: MENU_WIDTH }
309    }
310}
311
312impl LiquidMenuSpec {
313    pub fn new(width: f32) -> Self {
314        Self {
315            width: if width.is_finite() {
316                width.max(120.0)
317            } else {
318                MENU_WIDTH
319            },
320        }
321    }
322}
323
324const MENU_WIDTH: f32 = 250.0;
325const MENU_SHADOW_PAD: f32 = 48.0;
326const MENU_RADIUS: f32 = 32.0;
327const MENU_GROW_DELAY: f32 = 0.050;
328const MENU_SOURCE_SEPARATE_END: f32 = 0.06;
329const MENU_CARD_WIDTH_GROW_START: f32 = 0.16;
330const MENU_CARD_HEIGHT_GROW_START: f32 = 0.12;
331const MENU_OVERSHOOT_SCALE: f32 = 0.30;
332const MENU_GROW_STIFFNESS: f32 = 62.0;
333const MENU_REVEAL_STIFFNESS: f32 = 26.0;
334const MENU_WIDTH_EASE_POWER: f32 = 4.5;
335const MENU_HEIGHT_EASE_POWER: f32 = 18.0;
336const MENU_HEIGHT_OVERSHOOT: f32 = 0.15;
337const MENU_HEIGHT_OVERSHOOT_END: f32 = 0.52;
338const MENU_VERTICAL_REBOUND: f32 = 18.0;
339const MENU_VERTICAL_REBOUND_END: f32 = 0.70;
340const MENU_SOURCE_HEIGHT_RATIO: f32 = 0.86;
341const MENU_SOURCE_TARGET_Y_PROGRESS: f32 = 0.0;
342const ANCHOR_OVERLAP: f32 = 0.0;
343const ROW_PADDING_X: f32 = 28.0;
344const ROW_PADDING_Y: f32 = 9.0;
345const CHIP_INSET_X: f32 = 10.0;
346const MENU_CONTENT_INSET_Y: f32 = 10.0;
347const CHECK_COLUMN: f32 = 24.0;
348const ICON_SIZE: f32 = 24.0;
349const ICON_GAP: f32 = 12.0;
350const MENU_LONG_PRESS_MS: u64 = 500;
351const MENU_LONG_PRESS_SLOP: f32 = 12.0;
352const MENU_CONTENT_BLUR: f32 = 14.0;
353const MENU_CONTENT_BLUR_POWER: f32 = 0.65;
354const MENU_CONTENT_ALPHA_POWER: f32 = 0.45;
355const MENU_TRIGGER_GLASS_CUTOFF: f32 = 0.05;
356const MENU_TRIGGER_ABSORPTION_MS: u64 = 36;
357const MENU_TRIGGER_RESTORE_DELAY_MS: u64 = 205;
358const MENU_SOURCE_FOREGROUND_HIDE_MS: u64 = 200;
359const MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS: u64 = 205;
360#[derive(Clone, Copy, Debug, PartialEq)]
361struct MenuGestureSnapshot {
362    active: bool,
363    claimed: bool,
364    start: Point,
365    position: Point,
366    release: Option<(u64, Point)>,
367}
368
369impl Default for MenuGestureSnapshot {
370    fn default() -> Self {
371        Self {
372            active: false,
373            claimed: false,
374            start: Point::new(0.0, 0.0),
375            position: Point::new(0.0, 0.0),
376            release: None,
377        }
378    }
379}
380
381struct LiquidMenuGestureInner {
382    snapshot: MutableState<MenuGestureSnapshot>,
383    next_release: Cell<u64>,
384    item_rects: RefCell<Vec<Rc<Cell<Rect>>>>,
385}
386
387/// Shared ownership channel between a menu trigger and its popup. The trigger
388/// keeps receiving the original pointer after the popup appears; the popup
389/// reads that live position and consumes its eventual release.
390#[derive(Clone)]
391pub struct LiquidMenuGesture {
392    inner: Rc<LiquidMenuGestureInner>,
393}
394
395impl PartialEq for LiquidMenuGesture {
396    fn eq(&self, other: &Self) -> bool {
397        Rc::ptr_eq(&self.inner, &other.inner)
398    }
399}
400
401impl LiquidMenuGesture {
402    /// Whether a pointer currently owns this gesture (pressed on the
403    /// trigger or sliding through the menu).
404    pub fn is_pressed(&self) -> bool {
405        self.inner.snapshot.get().active
406    }
407
408    /// The live pointer position while the gesture is active (window
409    /// coordinates) — the trigger surface's touch-glow anchor.
410    pub fn press_point(&self) -> Option<Point> {
411        let snapshot = self.inner.snapshot.get();
412        snapshot.active.then_some(snapshot.position)
413    }
414
415    fn new() -> Self {
416        Self {
417            inner: Rc::new(LiquidMenuGestureInner {
418                snapshot: mutableStateOf(MenuGestureSnapshot::default()),
419                next_release: Cell::new(0),
420                item_rects: RefCell::new(Vec::new()),
421            }),
422        }
423    }
424
425    fn id(&self) -> usize {
426        Rc::as_ptr(&self.inner) as usize
427    }
428
429    fn begin(&self, point: Point) {
430        self.inner.snapshot.set(MenuGestureSnapshot {
431            active: true,
432            start: point,
433            position: point,
434            ..MenuGestureSnapshot::default()
435        });
436    }
437
438    fn move_to(&self, point: Point) {
439        let mut snapshot = self.inner.snapshot.get();
440        if snapshot.active {
441            snapshot.position = point;
442            self.inner.snapshot.set(snapshot);
443        }
444    }
445
446    fn claim(&self) {
447        let mut snapshot = self.inner.snapshot.get();
448        if snapshot.active && !snapshot.claimed {
449            snapshot.claimed = true;
450            self.inner.snapshot.set(snapshot);
451        }
452    }
453
454    fn release(&self, point: Point) {
455        let mut snapshot = self.inner.snapshot.get();
456        if !snapshot.active {
457            return;
458        }
459        snapshot.position = point;
460        snapshot.active = false;
461        if snapshot.claimed {
462            let sequence = self.inner.next_release.get().wrapping_add(1);
463            self.inner.next_release.set(sequence);
464            snapshot.release = Some((sequence, point));
465        }
466        self.inner.snapshot.set(snapshot);
467    }
468
469    fn cancel(&self) {
470        let mut snapshot = self.inner.snapshot.get();
471        snapshot.active = false;
472        snapshot.claimed = false;
473        snapshot.release = None;
474        self.inner.snapshot.set(snapshot);
475    }
476
477    fn snapshot(&self) -> MenuGestureSnapshot {
478        self.inner.snapshot.get()
479    }
480
481    fn item_rect(&self, index: usize) -> Rc<Cell<Rect>> {
482        let mut rects = self.inner.item_rects.borrow_mut();
483        while rects.len() <= index {
484            rects.push(Rc::new(Cell::new(Rect {
485                x: 0.0,
486                y: 0.0,
487                width: 0.0,
488                height: 0.0,
489            })));
490        }
491        Rc::clone(&rects[index])
492    }
493
494    fn item_at(&self, point: Point, items: &[LiquidMenuEntry]) -> Option<usize> {
495        self.inner
496            .item_rects
497            .borrow()
498            .iter()
499            .enumerate()
500            .take(items.len())
501            .find_map(|(index, rect)| {
502                (!items[index].item.header && rect.get().contains(point.x, point.y))
503                    .then_some(index)
504            })
505    }
506}
507
508/// Remembers one continuous menu gesture channel.
509#[composable]
510#[track_caller]
511pub fn rememberLiquidMenuGesture() -> LiquidMenuGesture {
512    remember(LiquidMenuGesture::new).with(Clone::clone)
513}
514
515/// A neighboring glass icon source that keeps its material mounted while an
516/// open menu owns and deforms its foreground.
517#[composable]
518pub fn LiquidMenuAbsorbedIconButton(
519    modifier: Modifier,
520    spec: crate::widgets::GlassButtonSpec,
521    diameter: f32,
522    transferred: bool,
523    on_click: impl Fn() + 'static,
524    icon_path: &'static str,
525) {
526    let foreground = cranpose_animation::animate_float_as_state_with_initial(
527        1.0,
528        if transferred { 0.0 } else { 1.0 },
529        cranpose_animation::AnimationType::Tween(if transferred {
530            cranpose_animation::AnimationSpec::tween(
531                MENU_SOURCE_FOREGROUND_HIDE_MS,
532                cranpose_animation::Easing::LinearEasing,
533            )
534        } else {
535            cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
536                .with_delay(MENU_SOURCE_FOREGROUND_RESTORE_DELAY_MS)
537        }),
538        "menu-source-foreground-ownership",
539    );
540    crate::widgets::button::GlassIconButtonWithForegroundAlpha(
541        modifier,
542        spec,
543        diameter,
544        foreground.get(),
545        on_click,
546        icon_path,
547    );
548}
549
550#[derive(Clone, Copy, Debug, PartialEq)]
551struct MenuGeometryPhase {
552    path: f32,
553    width: f32,
554    height: f32,
555}
556
557fn menu_geometry_phase(expanded: bool, appear: f32) -> MenuGeometryPhase {
558    if expanded && appear > 1.0 {
559        let settle = 1.0 + (appear - 1.0) * MENU_OVERSHOOT_SCALE;
560        return MenuGeometryPhase {
561            path: settle,
562            width: settle,
563            height: settle,
564        };
565    }
566
567    let appear = appear.clamp(0.0, 1.0);
568    if !expanded {
569        let normalized = ((appear - 0.015) / 0.985).clamp(0.0, 1.0);
570        return MenuGeometryPhase {
571            path: normalized,
572            width: 1.0 - (1.0 - normalized).powf(2.5),
573            height: 1.0 - (1.0 - normalized).powf(14.0),
574        };
575    }
576
577    if appear < MENU_GROW_DELAY {
578        let source_merge = smoothstep(0.10, 0.58, appear / MENU_GROW_DELAY);
579        return MenuGeometryPhase {
580            path: 0.0,
581            width: source_merge,
582            height: source_merge,
583        };
584    }
585
586    let path = ((appear - MENU_GROW_DELAY) / (1.0 - MENU_GROW_DELAY)).clamp(0.0, 1.0);
587    let width_growth =
588        ((path - MENU_CARD_WIDTH_GROW_START) / (1.0 - MENU_CARD_WIDTH_GROW_START)).clamp(0.0, 1.0);
589    let height_growth = ((path - MENU_CARD_HEIGHT_GROW_START)
590        / (1.0 - MENU_CARD_HEIGHT_GROW_START))
591        .clamp(0.0, 1.0);
592    let overshoot_phase = ((path - 0.50) / 0.50).clamp(0.0, 1.0);
593    let overshoot = 0.040 * (std::f32::consts::PI * overshoot_phase).sin().max(0.0);
594    let height_overshoot_phase = (height_growth / MENU_HEIGHT_OVERSHOOT_END).clamp(0.0, 1.0);
595    let height_overshoot = MENU_HEIGHT_OVERSHOOT
596        * (std::f32::consts::PI * height_overshoot_phase)
597            .sin()
598            .max(0.0);
599    MenuGeometryPhase {
600        path,
601        width: 1.0 - (1.0 - width_growth).powf(MENU_WIDTH_EASE_POWER) + overshoot,
602        height: 1.0 - (1.0 - height_growth).powf(MENU_HEIGHT_EASE_POWER) + height_overshoot,
603    }
604}
605
606#[derive(Clone, Copy, Debug, PartialEq)]
607struct MenuShape {
608    center_x: f32,
609    center_y: f32,
610    width: f32,
611    height: f32,
612    radius: f32,
613}
614
615impl MenuShape {
616    fn capsule(center_x: f32, center_y: f32, width: f32, height: f32) -> Self {
617        Self {
618            center_x,
619            center_y,
620            width,
621            height,
622            radius: -1.0,
623        }
624    }
625
626    fn from_window_rect(rect: Rect, node_origin: Point) -> Option<Self> {
627        (rect.width > 0.0 && rect.height > 0.0).then(|| {
628            Self::capsule(
629                rect.x + rect.width * 0.5 - node_origin.x,
630                rect.y + rect.height * 0.5 - node_origin.y,
631                rect.width,
632                rect.height,
633            )
634        })
635    }
636
637    fn as_glass_shape(self) -> (f32, f32, f32, f32, f32) {
638        (
639            self.center_x,
640            self.center_y,
641            self.width,
642            self.height,
643            self.radius,
644        )
645    }
646}
647
648#[derive(Clone, Copy, Debug, PartialEq)]
649struct MenuMorphGeometry {
650    primary: MenuShape,
651    source: MenuShape,
652    target: MenuShape,
653    path: f32,
654}
655
656fn menu_source_shape(anchor: MenuShape, absorbed: &[MenuShape], target: MenuShape) -> MenuShape {
657    let mut left = anchor.center_x - anchor.width * 0.5;
658    let mut right = anchor.center_x + anchor.width * 0.5;
659    let mut top = anchor.center_y - anchor.height * 0.5;
660    let mut bottom = anchor.center_y + anchor.height * 0.5;
661    for shape in absorbed {
662        left = left.min(shape.center_x - shape.width * 0.5);
663        right = right.max(shape.center_x + shape.width * 0.5);
664        top = top.min(shape.center_y - shape.height * 0.5);
665        bottom = bottom.max(shape.center_y + shape.height * 0.5);
666    }
667
668    let width = right - left;
669    let cluster_height = bottom - top;
670    let height = cluster_height
671        .max(width * MENU_SOURCE_HEIGHT_RATIO)
672        .min(target.height);
673    let cluster_center_y = (top + bottom) * 0.5;
674    MenuShape::capsule(
675        (left + right) * 0.5,
676        cluster_center_y + (target.center_y - cluster_center_y) * MENU_SOURCE_TARGET_Y_PROGRESS,
677        width,
678        height,
679    )
680}
681
682fn interpolate_menu_shape(
683    start: MenuShape,
684    target: MenuShape,
685    width_progress: f32,
686    height_progress: f32,
687) -> MenuShape {
688    let lerp = |a: f32, b: f32, progress: f32| a + (b - a) * progress;
689    MenuShape::capsule(
690        lerp(start.center_x, target.center_x, width_progress),
691        lerp(start.center_y, target.center_y, height_progress),
692        lerp(start.width, target.width, width_progress),
693        lerp(start.height, target.height, height_progress),
694    )
695}
696
697fn menu_vertical_rebound(path: f32) -> f32 {
698    if !(0.0..MENU_VERTICAL_REBOUND_END).contains(&path) {
699        return 0.0;
700    }
701
702    let normalized = path / MENU_VERTICAL_REBOUND_END;
703    let onset = smoothstep(0.0, 0.012, path);
704    MENU_VERTICAL_REBOUND
705        * onset
706        * (std::f32::consts::PI * normalized.powf(0.45))
707            .sin()
708            .max(0.0)
709}
710
711fn menu_morph_geometry(
712    expanded: bool,
713    appear: f32,
714    anchor: MenuShape,
715    absorbed: &[MenuShape],
716    target: MenuShape,
717) -> MenuMorphGeometry {
718    let phase = menu_geometry_phase(expanded, appear);
719    let source = menu_source_shape(anchor, absorbed, target);
720    let mut primary = if expanded && phase.path < MENU_SOURCE_SEPARATE_END {
721        anchor
722    } else {
723        let start = if expanded { source } else { anchor };
724        interpolate_menu_shape(start, target, phase.width, phase.height)
725    };
726    if expanded && phase.path >= MENU_SOURCE_SEPARATE_END {
727        let descent = smoothstep(0.10, 0.90, phase.path);
728        primary.center_y = source.center_y + (target.center_y - source.center_y) * descent;
729        primary.center_y +=
730            (target.center_y - source.center_y).signum() * menu_vertical_rebound(phase.path);
731    }
732    let blob_radius = primary.height * 0.5;
733    let squareness = smoothstep(0.55, 0.88, phase.path);
734    primary.radius = if !expanded {
735        blob_radius
736    } else if phase.path >= 1.0 {
737        target.radius
738    } else {
739        blob_radius + (target.radius - blob_radius) * squareness
740    };
741    MenuMorphGeometry {
742        primary,
743        source,
744        target,
745        path: phase.path,
746    }
747}
748
749fn menu_ellipse_blend(path: f32) -> f32 {
750    0.5 * smoothstep(0.06, 0.22, path) * (1.0 - smoothstep(0.62, 0.88, path))
751}
752
753fn menu_content_progress(expanded: bool, appear: f32, reveal: f32) -> f32 {
754    if expanded {
755        smoothstep(0.17, 0.82, reveal)
756    } else {
757        smoothstep(0.20, 0.75, appear)
758    }
759}
760
761fn menu_content_blur(progress: f32) -> f32 {
762    MENU_CONTENT_BLUR * (1.0 - progress.clamp(0.0, 1.0)).powf(MENU_CONTENT_BLUR_POWER)
763}
764
765fn menu_content_alpha(progress: f32) -> f32 {
766    progress.clamp(0.0, 1.0).powf(MENU_CONTENT_ALPHA_POWER)
767}
768
769fn menu_content_scale(progress: f32) -> f32 {
770    0.80 + 0.20 * progress.clamp(0.0, 1.0)
771}
772
773#[derive(Clone, Copy, Debug, PartialEq)]
774struct MenuAbsorbedVisualPhase {
775    foreground_alpha: f32,
776    backdrop_alpha: f32,
777    foreground_blur: f32,
778    scale_x: f32,
779    scale_y: f32,
780}
781
782fn menu_absorbed_visual_phase(appear: f32, path: f32) -> MenuAbsorbedVisualPhase {
783    let appear = appear.clamp(0.0, 1.0);
784    let path = path.clamp(0.0, 1.0);
785    let shrink = smoothstep(0.0, 0.24, path);
786    let base_scale = 1.0 - 0.25 * shrink;
787    let stretch = smoothstep(0.30, 0.56, path);
788    let handoff = smoothstep(0.30, 0.55, appear);
789    let readable_alpha = 1.0 + (0.40 - 1.0) * handoff;
790    MenuAbsorbedVisualPhase {
791        foreground_alpha: readable_alpha * (1.0 - smoothstep(0.45, 0.85, path)),
792        backdrop_alpha: 0.62 * smoothstep(0.45, 0.85, path),
793        foreground_blur: 7.0 * smoothstep(0.45, 0.85, path),
794        scale_x: base_scale * (1.0 + 0.20 * stretch),
795        scale_y: base_scale * (1.0 + 0.28 * stretch),
796    }
797}
798
799#[derive(Clone, Copy, Debug, PartialEq)]
800struct MenuSurfacePhase {
801    anchor_presence: f32,
802    glue: f32,
803    wobble: f32,
804    bulge: f32,
805}
806
807fn menu_surface_phase(expanded: bool, appear: f32, path: f32) -> MenuSurfacePhase {
808    let appear = appear.clamp(0.0, 1.0);
809    let path = path.clamp(0.0, 1.0);
810    let activity = (std::f32::consts::PI * path).sin().max(0.0);
811    if expanded && path <= f32::EPSILON {
812        let recoil = (appear / MENU_GROW_DELAY).clamp(0.0, 1.0);
813        return MenuSurfacePhase {
814            anchor_presence: 0.0,
815            glue: 0.0,
816            wobble: 0.18 * (std::f32::consts::PI * recoil).sin().max(0.0),
817            bulge: 0.0,
818        };
819    }
820    if expanded {
821        return MenuSurfacePhase {
822            anchor_presence: 0.0,
823            glue: 0.0,
824            wobble: 0.08 * activity,
825            bulge: 0.35 * activity,
826        };
827    }
828    MenuSurfacePhase {
829        anchor_presence: 0.0,
830        glue: 0.0,
831        wobble: 0.04 * activity,
832        bulge: 0.25 * activity,
833    }
834}
835
836fn menu_absorbed_shape_presence(path: f32) -> f32 {
837    1.0 - smoothstep(MENU_SOURCE_SEPARATE_END, 0.30, path)
838}
839
840fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
841    let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
842    t * t * (3.0 - 2.0 * t)
843}
844
845/// Attaches the shared menu-trigger gesture to ANY surface (the circular
846/// nav button, a filter pill, ...): a tap opens the menu; a long press
847/// claims the gesture and opens it while the finger is still down, and the
848/// SAME stream then slides through the opened menu's rows — keeps-open
849/// accordion rows included — committing on release.
850pub fn liquid_menu_trigger_input(
851    modifier: Modifier,
852    gesture: LiquidMenuGesture,
853    on_open: impl Fn() + 'static,
854) -> Modifier {
855    let gate = remember(|| {
856        let runtime = cranpose_core::with_current_composer(cranpose_core::Composer::runtime_handle);
857        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
858            0.0, runtime,
859        )))
860    })
861    .with(Rc::clone);
862    let on_open: Rc<dyn Fn()> = Rc::new(on_open);
863
864    let snapshot = gesture.snapshot();
865    let gate_progress = gate.borrow().state().value();
866    if gate_progress >= 1.0 && snapshot.active && !snapshot.claimed {
867        gesture.claim();
868        let on_open = Rc::clone(&on_open);
869        SideEffect(move || on_open());
870    }
871
872    modifier.pointer_input(gesture.id(), {
873        let gate = Rc::clone(&gate);
874        let on_open = Rc::clone(&on_open);
875        move |scope: PointerInputScope| {
876            let gesture = gesture.clone();
877            let gate = Rc::clone(&gate);
878            let on_open = Rc::clone(&on_open);
879            async move {
880                scope
881                    .await_pointer_event_scope(|await_scope| async move {
882                        let mut active_pointer = Option::<PointerId>::None;
883                        let mut moved = false;
884                        loop {
885                            let event = await_scope.await_pointer_event().await;
886                            match event.kind {
887                                PointerEventKind::Down if active_pointer.is_none() => {
888                                    active_pointer = Some(event.id);
889                                    moved = false;
890                                    gesture.begin(event.global_position);
891                                    let mut timer = gate.borrow_mut();
892                                    timer.snapTo(0.0);
893                                    timer.animateTo(
894                                        1.0,
895                                        cranpose_animation::AnimationType::Tween(
896                                            cranpose_animation::AnimationSpec::linear(
897                                                MENU_LONG_PRESS_MS,
898                                            ),
899                                        ),
900                                    );
901                                    event.consume();
902                                }
903                                PointerEventKind::Move if active_pointer == Some(event.id) => {
904                                    gesture.move_to(event.global_position);
905                                    let state = gesture.snapshot();
906                                    let dx = event.global_position.x - state.start.x;
907                                    let dy = event.global_position.y - state.start.y;
908                                    if !state.claimed
909                                        && dx * dx + dy * dy
910                                            > MENU_LONG_PRESS_SLOP * MENU_LONG_PRESS_SLOP
911                                    {
912                                        moved = true;
913                                        gate.borrow_mut().snapTo(0.0);
914                                    }
915                                    event.consume();
916                                }
917                                PointerEventKind::Up if active_pointer == Some(event.id) => {
918                                    active_pointer = None;
919                                    let claimed = gesture.snapshot().claimed;
920                                    gate.borrow_mut().snapTo(0.0);
921                                    if claimed {
922                                        gesture.release(event.global_position);
923                                    } else {
924                                        gesture.cancel();
925                                        if !moved {
926                                            on_open();
927                                        }
928                                    }
929                                    event.consume();
930                                }
931                                PointerEventKind::Cancel if active_pointer == Some(event.id) => {
932                                    active_pointer = None;
933                                    gate.borrow_mut().snapTo(0.0);
934                                    gesture.cancel();
935                                    event.consume();
936                                }
937                                _ => {}
938                            }
939                        }
940                    })
941                    .await;
942            }
943        }
944    })
945}
946
947/// A glass icon trigger that owns one continuous menu gesture. A short click
948/// opens normally; a hold opens while still pressed, then the same pointer can
949/// slide over popup rows and release to fire one.
950#[composable]
951pub fn LiquidMenuIconButton(
952    modifier: Modifier,
953    spec: crate::widgets::GlassButtonSpec,
954    diameter: f32,
955    covered: bool,
956    gesture: LiquidMenuGesture,
957    on_open: impl Fn() + 'static,
958    icon_path: &'static str,
959) {
960    let interaction = rememberMutableInteractionSource();
961    let (pressed_modifier, _, content_alpha) =
962        crate::motion::liquid_press_scale(Modifier::empty(), interaction, 1.12);
963    let trigger_visual = menu_trigger_visibility(covered);
964    let gate = remember(|| {
965        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
966        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
967            0.0, runtime,
968        )))
969    })
970    .with(Rc::clone);
971    let on_open: Rc<dyn Fn()> = Rc::new(on_open);
972
973    let snapshot = gesture.snapshot();
974    let gate_progress = gate.borrow().state().value();
975    if gate_progress >= 1.0 && snapshot.active && !snapshot.claimed {
976        gesture.claim();
977        let on_open = Rc::clone(&on_open);
978        SideEffect(move || on_open());
979    }
980
981    let input = Modifier::empty()
982        .size(Size::new(diameter, diameter))
983        .pointer_input(gesture.id(), {
984            let gate = Rc::clone(&gate);
985            let on_open = Rc::clone(&on_open);
986            move |scope: PointerInputScope| {
987                let gesture = gesture.clone();
988                let gate = Rc::clone(&gate);
989                let on_open = Rc::clone(&on_open);
990                async move {
991                    scope
992                        .await_pointer_event_scope(|await_scope| async move {
993                            let mut active_pointer = Option::<PointerId>::None;
994                            let mut moved = false;
995                            let mut press: Option<PressInteractionPress> = None;
996                            loop {
997                                let event = await_scope.await_pointer_event().await;
998                                match event.kind {
999                                    PointerEventKind::Down if active_pointer.is_none() => {
1000                                        active_pointer = Some(event.id);
1001                                        moved = false;
1002                                        gesture.begin(event.global_position);
1003                                        press = Some(interaction.press(event.position));
1004                                        let mut timer = gate.borrow_mut();
1005                                        timer.snapTo(0.0);
1006                                        timer.animateTo(
1007                                            1.0,
1008                                            cranpose_animation::AnimationType::Tween(
1009                                                cranpose_animation::AnimationSpec::linear(
1010                                                    MENU_LONG_PRESS_MS,
1011                                                ),
1012                                            ),
1013                                        );
1014                                        event.consume();
1015                                    }
1016                                    PointerEventKind::Move if active_pointer == Some(event.id) => {
1017                                        gesture.move_to(event.global_position);
1018                                        let state = gesture.snapshot();
1019                                        let dx = event.global_position.x - state.start.x;
1020                                        let dy = event.global_position.y - state.start.y;
1021                                        if !state.claimed
1022                                            && dx * dx + dy * dy
1023                                                > MENU_LONG_PRESS_SLOP * MENU_LONG_PRESS_SLOP
1024                                        {
1025                                            moved = true;
1026                                            gate.borrow_mut().snapTo(0.0);
1027                                        }
1028                                        event.consume();
1029                                    }
1030                                    PointerEventKind::Up if active_pointer == Some(event.id) => {
1031                                        active_pointer = None;
1032                                        let claimed = gesture.snapshot().claimed;
1033                                        gate.borrow_mut().snapTo(0.0);
1034                                        if claimed {
1035                                            gesture.release(event.global_position);
1036                                        } else {
1037                                            gesture.cancel();
1038                                            if !moved {
1039                                                on_open();
1040                                            }
1041                                        }
1042                                        if let Some(active_press) = press.take() {
1043                                            interaction.release(active_press);
1044                                        }
1045                                        event.consume();
1046                                    }
1047                                    PointerEventKind::Cancel
1048                                        if active_pointer == Some(event.id) =>
1049                                    {
1050                                        active_pointer = None;
1051                                        gate.borrow_mut().snapTo(0.0);
1052                                        gesture.cancel();
1053                                        if let Some(active_press) = press.take() {
1054                                            interaction.cancel(active_press);
1055                                        }
1056                                        event.consume();
1057                                    }
1058                                    _ => {}
1059                                }
1060                            }
1061                        })
1062                        .await;
1063                }
1064            }
1065        });
1066
1067    Box(
1068        pressed_modifier.size(Size::new(diameter, diameter)),
1069        BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
1070        move || {
1071            let modifier = modifier.clone();
1072            let visual_alpha = trigger_visual.get().clamp(0.0, 1.0);
1073            let melt = 1.0 - visual_alpha;
1074            let visual_spec = spec.clone();
1075            let visual = Modifier::empty()
1076                .size(Size::new(diameter, diameter))
1077                .graphics_layer(move || GraphicsLayer {
1078                    alpha: visual_alpha * content_alpha.get().clamp(0.0, 1.0),
1079                    scale_x: 1.0 - 0.12 * melt,
1080                    scale_y: 1.0 - 0.12 * melt,
1081                    ..Default::default()
1082                });
1083            Box(
1084                visual,
1085                BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
1086                move || {
1087                    if visual_alpha > MENU_TRIGGER_GLASS_CUTOFF {
1088                        crate::widgets::GlassIconButton(
1089                            modifier.clone(),
1090                            visual_spec.clone(),
1091                            diameter,
1092                            || {},
1093                            icon_path,
1094                        );
1095                    }
1096                },
1097            );
1098            Box(input.clone(), BoxSpec::default(), || {});
1099        },
1100    );
1101}
1102
1103#[composable]
1104fn AbsorbedSourceVisual(
1105    source: LiquidMenuAbsorbedSource,
1106    node_origin: Point,
1107    alpha: f32,
1108    blur: f32,
1109    scale_x: f32,
1110    scale_y: f32,
1111) {
1112    if alpha <= 0.001 {
1113        return;
1114    }
1115
1116    let diameter = source.diameter;
1117    let foreground_spec = source.spec.clone();
1118    let layer = Modifier::empty()
1119        .absolute_offset(source.rect.x - node_origin.x, source.rect.y - node_origin.y)
1120        .size(Size::new(diameter, diameter))
1121        .graphics_layer(move || GraphicsLayer {
1122            alpha,
1123            scale_x,
1124            scale_y,
1125            render_effect: (blur > 0.35).then(|| RenderEffect::blur(blur)),
1126            ..Default::default()
1127        });
1128    Box(
1129        layer,
1130        BoxSpec::default().content_alignment(cranpose_ui_layout::Alignment::CENTER),
1131        move || {
1132            crate::widgets::button::GlassIconForeground(
1133                foreground_spec.clone(),
1134                diameter,
1135                source.icon_path,
1136            );
1137        },
1138    );
1139}
1140
1141/// A glass popup menu anchored to `anchor` (window coordinates of the button
1142/// that opened it). `absorbed` contains adjacent glass controls whose combined
1143/// source volume and foreground feed the opening droplet. While `expanded`,
1144/// taps outside dismiss via `on_dismiss`; a tap on a row runs that row's own
1145/// action and then dismisses, unless the row keeps the menu open.
1146///
1147/// ```rust,ignore
1148/// LiquidMenu(expanded, anchor, spec, Vec::new(), gesture, on_dismiss, |scope| {
1149///     scope.header("Show");
1150///     scope.item(LiquidMenuItem::new("List").checked(!grid), move || grid_state.set(false));
1151///     scope.item(LiquidMenuItem::new("Grid").checked(grid), move || grid_state.set(true));
1152/// });
1153/// ```
1154///
1155/// Layout follows the iOS menu: an optional leading checkmark column (present
1156/// on every row once any item is checkable, so labels align), then the icon
1157/// column, then the label. Sections split with full-width hairlines; headers
1158/// are gray non-interactive rows.
1159#[composable]
1160pub fn LiquidMenu(
1161    expanded: bool,
1162    anchor: Rect,
1163    spec: LiquidMenuSpec,
1164    absorbed: Vec<LiquidMenuAbsorbedSource>,
1165    gesture: LiquidMenuGesture,
1166    on_dismiss: impl Fn() + 'static,
1167    content: impl FnOnce(&LiquidMenuScope),
1168) {
1169    let items = Rc::new(collect_entries(content));
1170    let viewport = cranpose_ui::widgets::popup::local_popup_viewport()
1171        .current()
1172        .get();
1173    let visible = remember(|| mutableStateOf(false)).with(|s| *s);
1174    if expanded && !visible.get() {
1175        visible.set(true);
1176    }
1177    if !expanded && !visible.get() {
1178        return;
1179    }
1180    let colors = liquid_colors();
1181    let typography = liquid_typography();
1182    let entries = Rc::clone(&items);
1183    let on_item: Rc<dyn Fn(usize)> = Rc::new(move |index| {
1184        if let Some(action) = entries.get(index).and_then(|entry| entry.action.as_ref()) {
1185            action();
1186        }
1187    });
1188    let on_dismiss: Rc<dyn Fn()> = Rc::new(on_dismiss);
1189    let gesture_snapshot = gesture.snapshot();
1190    let gesture_hover = (gesture_snapshot.active && gesture_snapshot.claimed)
1191        .then(|| gesture.item_at(gesture_snapshot.position, &items))
1192        .flatten();
1193    let dwell_gate = remember(|| {
1194        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
1195        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
1196            0.0f32, runtime,
1197        )))
1198    })
1199    .with(Rc::clone);
1200    let dwell_row = remember(|| Rc::new(Cell::new(Option::<usize>::None))).with(Rc::clone);
1201    let dwell_fired = remember(|| Rc::new(Cell::new(Option::<usize>::None))).with(Rc::clone);
1202    {
1203        let hover_accordion = gesture_hover
1204            .filter(|index| items.get(*index).is_some_and(|entry| entry.item.keeps_open));
1205        if hover_accordion != dwell_row.get() {
1206            dwell_row.set(hover_accordion);
1207            dwell_fired.set(None);
1208            let mut gate = dwell_gate.borrow_mut();
1209            gate.snapTo(0.0);
1210            if hover_accordion.is_some() {
1211                gate.animateTo(
1212                    1.0,
1213                    cranpose_animation::AnimationType::Tween(
1214                        cranpose_animation::AnimationSpec::linear(450),
1215                    ),
1216                );
1217            }
1218        }
1219        let gate_value = dwell_gate.borrow().state().get();
1220        if gate_value >= 1.0 {
1221            if let Some(index) = dwell_row.get() {
1222                if dwell_fired.get() != Some(index) {
1223                    dwell_fired.set(Some(index));
1224                    let on_item_dwell = Rc::clone(&on_item);
1225                    SideEffect(move || on_item_dwell(index));
1226                }
1227            }
1228        }
1229    }
1230    let handled_release = remember(|| Rc::new(Cell::new(0u64))).with(Rc::clone);
1231    if let Some((sequence, point)) = gesture_snapshot.release {
1232        if handled_release.get() != sequence {
1233            handled_release.set(sequence);
1234            if let Some(index) = gesture.item_at(point, &items) {
1235                let keeps_open = items.get(index).is_some_and(|entry| entry.item.keeps_open);
1236                let on_item = Rc::clone(&on_item);
1237                let on_dismiss = Rc::clone(&on_dismiss);
1238                SideEffect(move || commit_menu_row(index, keeps_open, &on_item, &on_dismiss));
1239            }
1240        }
1241    }
1242
1243    let grow = cranpose_animation::animate_float_as_state_with_initial(
1244        0.0,
1245        if expanded { 1.0 } else { 0.0 },
1246        if expanded {
1247            cranpose_animation::spring(0.78, MENU_GROW_STIFFNESS)
1248        } else {
1249            cranpose_animation::AnimationType::Tween(cranpose_animation::AnimationSpec::linear(205))
1250        },
1251        "menu-grow",
1252    );
1253    let reveal_anim = cranpose_animation::animate_float_as_state_with_initial(
1254        0.0,
1255        if expanded { 1.0 } else { 0.0 },
1256        if expanded {
1257            cranpose_animation::spring(1.0, MENU_REVEAL_STIFFNESS)
1258        } else {
1259            cranpose_animation::spring(1.0, 900.0)
1260        },
1261        "menu-reveal",
1262    );
1263    let appear = grow.get().max(0.0);
1264    let reveal = reveal_anim.get().clamp(0.0, 1.0);
1265    if !expanded && appear < 0.02 {
1266        visible.set(false);
1267        return;
1268    }
1269
1270    let anchor_zone = anchor.height * ANCHOR_OVERLAP;
1271    let node_size = rememberMutableStateOf(|| cranpose_ui_graphics::Size::ZERO);
1272    let resize_anim = remember(|| {
1273        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
1274        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
1275            1.0f32, runtime,
1276        )))
1277    })
1278    .with(Rc::clone);
1279    let resize_from_h = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
1280    let changed = remember(|| RefCell::new(None::<Rc<Vec<LiquidMenuEntry>>>))
1281        .with(|last| menu_items_changed(last, &items));
1282    if changed && expanded && grow.get() > 0.5 && node_size.get().height > 1.0 {
1283        resize_from_h.set(node_size.get().height);
1284        let mut anim = resize_anim.borrow_mut();
1285        anim.snapTo(0.0);
1286        anim.animateTo(1.0, cranpose_animation::spring(0.78, 170.0));
1287    }
1288    let resize_state = resize_anim.borrow().state();
1289    let menu_height = (node_size.get().height - MENU_SHADOW_PAD * 2.0).max(0.0);
1290    let (bounds, opens_up) = menu_bounds(anchor, viewport, spec.width, menu_height);
1291    let menu_width = bounds.width;
1292    let menu_left = bounds.x;
1293    let pivot_y = u8::from(opens_up) as f32;
1294    let content_top_inset = if menu_display_rows(&items, opens_up)
1295        .next()
1296        .is_some_and(|(index, _)| items[index].item.header)
1297    {
1298        0.0
1299    } else {
1300        MENU_CONTENT_INSET_Y
1301    };
1302    let node_origin = Point::new(menu_left - MENU_SHADOW_PAD, bounds.y - MENU_SHADOW_PAD);
1303    let scrim_dismiss = Rc::clone(&on_dismiss);
1304    PopupDismissableWhen(
1305        expanded,
1306        anchor,
1307        Point::new(node_origin.x - anchor.x, node_origin.y - anchor.y),
1308        move || scrim_dismiss(),
1309        {
1310            let on_item = Rc::clone(&on_item);
1311            let on_dismiss = Rc::clone(&on_dismiss);
1312            move || {
1313                let anchor_center = (
1314                    anchor.x + anchor.width * 0.5 - node_origin.x,
1315                    anchor.y + anchor.height * 0.5 - node_origin.y,
1316                );
1317                let anchor_shape = MenuShape::capsule(
1318                    anchor_center.0,
1319                    anchor_center.1,
1320                    anchor.width,
1321                    anchor.height,
1322                );
1323                let absorbed_shapes: Vec<MenuShape> = absorbed
1324                    .iter()
1325                    .filter_map(|source| MenuShape::from_window_rect(source.rect, node_origin))
1326                    .collect();
1327                let morph_size = node_size;
1328                let glass = super::glass_surface::control_surface_material(colors.label)
1329                    .shape(LiquidShape::RoundedRect(MENU_RADIUS))
1330                    .blur_radius(30.0);
1331                let glass = glass
1332                    .shadow_style(GlassShadow::new(
1333                        Color::BLACK.with_alpha(if colors.is_dark { 0.60 } else { 0.11 }),
1334                        if colors.is_dark { 32.0 } else { 26.0 },
1335                        if colors.is_dark { 10.0 } else { 8.0 },
1336                        0.0,
1337                    ))
1338                    .no_clip();
1339                let resize_from = Rc::clone(&resize_from_h);
1340                let glow_point: Rc<Cell<Option<(f32, f32)>>> =
1341                    remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
1342                let glow_for_glass = Rc::clone(&glow_point);
1343                let glass_node_origin = node_origin;
1344                let birth_milk = Some(if colors.is_dark {
1345                    Color::from_rgba_u8(208, 204, 214, 240)
1346                } else {
1347                    Color::from_rgba_u8(246, 247, 250, 210)
1348                });
1349                let card = Modifier::empty()
1350                    .role(SemanticsWidgetRole::Menu)
1351                    .pane_title("Menu")
1352                    .report_size_state(node_size)
1353                    .glass_effect_with(glass, move || {
1354                        let glow_touch = glow_for_glass.get().map(|(x, y)| {
1355                            (x - glass_node_origin.x, y - glass_node_origin.y, 1.0f32)
1356                        });
1357                        let size = morph_size.get();
1358                        let measured_h =
1359                            (size.height - anchor_zone - MENU_SHADOW_PAD * 2.0).max(24.0);
1360                        let resize_t = resize_state.get();
1361                        let from_h =
1362                            (resize_from.get() - anchor_zone - MENU_SHADOW_PAD * 2.0).max(24.0);
1363                        let menu_h = if resize_from.get() > 1.0 {
1364                            from_h + (measured_h - from_h) * resize_t.max(0.0)
1365                        } else {
1366                            measured_h
1367                        };
1368                        let settle_radius = (menu_h * 0.32).clamp(26.0, MENU_RADIUS);
1369                        let target = MenuShape {
1370                            center_x: menu_width * 0.5 + MENU_SHADOW_PAD,
1371                            center_y: anchor_zone + MENU_SHADOW_PAD + menu_h * 0.5,
1372                            width: menu_width,
1373                            height: menu_h,
1374                            radius: settle_radius,
1375                        };
1376                        let geometry = menu_morph_geometry(
1377                            expanded,
1378                            appear,
1379                            anchor_shape,
1380                            &absorbed_shapes,
1381                            target,
1382                        );
1383                        let t = geometry.path;
1384                        let primary = geometry.primary.as_glass_shape();
1385                        let start = if expanded {
1386                            geometry.source
1387                        } else {
1388                            anchor_shape
1389                        };
1390                        let target = geometry.target;
1391                        let surface = menu_surface_phase(expanded, appear, t);
1392                        let dir_x = target.center_x - start.center_x;
1393                        let dir_y = target.center_y - start.center_y;
1394                        let mut bulge_dir = dir_y.atan2(dir_x);
1395                        if !expanded {
1396                            bulge_dir += std::f32::consts::PI;
1397                        }
1398                        let mut shapes = Vec::new();
1399                        if surface.anchor_presence > 0.01 {
1400                            shapes.push((
1401                                anchor_shape.center_x,
1402                                anchor_shape.center_y,
1403                                anchor_shape.width * surface.anchor_presence,
1404                                anchor_shape.height * surface.anchor_presence,
1405                                -1.0,
1406                            ));
1407                        }
1408                        let absorbed_presence = menu_absorbed_shape_presence(t);
1409                        if expanded && absorbed_presence > 0.01 {
1410                            shapes.extend(absorbed_shapes.iter().map(|shape| {
1411                                (
1412                                    shape.center_x,
1413                                    shape.center_y,
1414                                    shape.width * absorbed_presence,
1415                                    shape.height * absorbed_presence,
1416                                    -1.0,
1417                                )
1418                            }));
1419                        }
1420                        let glue = surface.glue;
1421                        let activity = if expanded {
1422                            smoothstep(0.0, 0.42, t)
1423                        } else {
1424                            smoothstep(0.0, 0.12, t)
1425                        };
1426                        GlassDynamics {
1427                            activity: Some(activity),
1428                            resting_tint: birth_milk,
1429                            touch: glow_touch,
1430                            morph: Some(GlassMorph {
1431                                node_size: (size.width.max(1.0), size.height.max(1.0)),
1432                                primary,
1433                                shapes,
1434                                glue,
1435                                wobble_amplitude: surface.wobble,
1436                                wobble_phase: t * 8.0,
1437                                bulge_amplitude: surface.bulge,
1438                                bulge_direction: bulge_dir,
1439                                ellipse_blend: menu_ellipse_blend(t),
1440                                capsule_smoothing_dp: 0.0,
1441                                deformation: None,
1442                                zoom_anchor: (0.0, 0.0),
1443                            }),
1444                            ..Default::default()
1445                        }
1446                    })
1447                    .width(menu_width + MENU_SHADOW_PAD * 2.0);
1448
1449                let has_checks = items.iter().any(|entry| entry.item.checked);
1450                let hovered = remember(|| mutableStateOf(Option::<usize>::None)).with(|s| *s);
1451                let hovered_row = hovered.get();
1452                let glow_row = gesture_hover.or(hovered_row);
1453                glow_point.set(glow_row.map(|index| {
1454                    let rect = gesture.item_rect(index).get();
1455                    (rect.x + rect.width * 0.5, rect.y + rect.height * 0.5)
1456                }));
1457                let gesture = gesture.clone();
1458                let source_phase =
1459                    menu_absorbed_visual_phase(appear, menu_geometry_phase(expanded, appear).path);
1460                for source in absorbed.iter().cloned() {
1461                    AbsorbedSourceVisual(
1462                        source,
1463                        node_origin,
1464                        source_phase.backdrop_alpha,
1465                        0.0,
1466                        source_phase.scale_x,
1467                        source_phase.scale_y,
1468                    );
1469                }
1470                Box(card, BoxSpec::default(), {
1471                    let items = items.clone();
1472                    let typography = typography.clone();
1473                    let on_item = Rc::clone(&on_item);
1474                    let on_dismiss = Rc::clone(&on_dismiss);
1475                    move || {
1476                        Column(
1477                            Modifier::empty().fill_max_width().padding(MENU_SHADOW_PAD),
1478                            ColumnSpec::default(),
1479                            {
1480                                let items = items.clone();
1481                                let typography = typography.clone();
1482                                let on_item = Rc::clone(&on_item);
1483                                let on_dismiss = Rc::clone(&on_dismiss);
1484                                let gesture = gesture.clone();
1485                                move || {
1486                                    Box(
1487                                        Modifier::empty().height(anchor_zone),
1488                                        BoxSpec::default(),
1489                                        || {},
1490                                    );
1491                                    let content = menu_content_progress(expanded, appear, reveal);
1492                                    let resize_t = resize_state.get().clamp(0.0, 1.0);
1493                                    let content =
1494                                        content * (0.45 + 0.55 * smoothstep(0.35, 1.0, resize_t));
1495                                    let content_scale = menu_content_scale(content);
1496                                    let content_blur = menu_content_blur(content);
1497                                    let content_translation_y = if expanded {
1498                                        menu_vertical_rebound(
1499                                            menu_geometry_phase(expanded, appear).path,
1500                                        ) * (1.0 - 2.0 * pivot_y)
1501                                    } else {
1502                                        0.0
1503                                    };
1504                                    let rows_wrap = Modifier::empty()
1505                                        .fill_max_width()
1506                                        .graphics_layer(move || GraphicsLayer {
1507                                            alpha: menu_content_alpha(content),
1508                                            scale_x: content_scale,
1509                                            scale_y: content_scale,
1510                                            transform_origin:
1511                                                cranpose_ui_graphics::TransformOrigin {
1512                                                    pivot_fraction_x: ((anchor.x
1513                                                        + anchor.width * 0.5
1514                                                        - menu_left)
1515                                                        / menu_width)
1516                                                        .clamp(0.0, 1.0),
1517                                                    pivot_fraction_y: pivot_y,
1518                                                },
1519                                            translation_y: content_translation_y,
1520                                            render_effect: (content_blur > 0.35)
1521                                                .then(|| RenderEffect::blur(content_blur)),
1522                                            ..Default::default()
1523                                        });
1524                                    Box(rows_wrap, BoxSpec::default(), {
1525                                        let items = items.clone();
1526                                        let typography = typography.clone();
1527                                        let on_item = Rc::clone(&on_item);
1528                                        let on_dismiss = Rc::clone(&on_dismiss);
1529                                        let gesture = gesture.clone();
1530                                        move || {
1531                                            Column(
1532                                                Modifier::empty().fill_max_width().padding_each(
1533                                                    0.0,
1534                                                    content_top_inset,
1535                                                    0.0,
1536                                                    MENU_CONTENT_INSET_Y,
1537                                                ),
1538                                                ColumnSpec::default(),
1539                                                {
1540                                                    let items = items.clone();
1541                                                    let typography = typography.clone();
1542                                                    let on_item = Rc::clone(&on_item);
1543                                                    let on_dismiss = Rc::clone(&on_dismiss);
1544                                                    let gesture = gesture.clone();
1545                                                    move || {
1546                                                        for (index, starts_section) in
1547                                                            menu_display_rows(&items, opens_up)
1548                                                        {
1549                                                            let item = &items[index].item;
1550                                                            if starts_section {
1551                                                                let separator =
1552                                                                    colors.separator.with_alpha(
1553                                                                        colors.separator.a() * 0.22,
1554                                                                    );
1555                                                                Box(
1556                                                        Modifier::empty()
1557                                                            .fill_max_width()
1558                                                            .padding_symmetric(ROW_PADDING_X, 0.0)
1559                                                            .height(11.0)
1560                                                            .draw_behind(move |scope| {
1561                                                                scope.draw_rect_at(Rect { x: 0.0, y: 5.0, width: scope.size().width, height: 1.0 / 3.0 }, Brush::solid(separator));
1562                                                            }),
1563                                                        BoxSpec::default(),
1564                                                        || {},
1565                                                    );
1566                                                            }
1567
1568                                                            if item.header {
1569                                                                menu_header_row(
1570                                                                    item,
1571                                                                    &typography,
1572                                                                    has_checks,
1573                                                                    colors,
1574                                                                );
1575                                                                continue;
1576                                                            }
1577                                                            let expanded_header = item.keeps_open
1578                                                                && items
1579                                                                    .get(index + 1)
1580                                                                    .is_some_and(|next| {
1581                                                                        !next.item.keeps_open
1582                                                                            && !next.item.header
1583                                                                    });
1584                                                            menu_item_row(
1585                                                                index,
1586                                                                item,
1587                                                                &typography,
1588                                                                has_checks,
1589                                                                colors,
1590                                                                hovered,
1591                                                                gesture_hover,
1592                                                                expanded_header,
1593                                                                gesture.item_rect(index),
1594                                                                Rc::clone(&on_item),
1595                                                                Rc::clone(&on_dismiss),
1596                                                            );
1597                                                        }
1598                                                    }
1599                                                },
1600                                            );
1601                                        }
1602                                    });
1603                                }
1604                            },
1605                        );
1606                    }
1607                });
1608                for source in absorbed.iter().cloned() {
1609                    AbsorbedSourceVisual(
1610                        source,
1611                        node_origin,
1612                        source_phase.foreground_alpha,
1613                        source_phase.foreground_blur,
1614                        source_phase.scale_x,
1615                        source_phase.scale_y,
1616                    );
1617                }
1618            }
1619        },
1620    );
1621}
1622
1623/// Renders an anchor and positions a [`LiquidMenu`] from the anchor's measured
1624/// window rectangle, without application-owned coordinate calculations.
1625#[composable]
1626pub fn LiquidDropdownMenu<A>(
1627    modifier: Modifier,
1628    expanded: bool,
1629    spec: LiquidDropdownMenuSpec,
1630    on_dismiss: impl Fn() + 'static,
1631    anchor_content: A,
1632    content: impl Fn(&LiquidMenuScope) + 'static,
1633) where
1634    A: FnMut() + 'static,
1635{
1636    let anchor = rememberMutableStateOf(|| Rect {
1637        x: 0.0,
1638        y: 0.0,
1639        width: 0.0,
1640        height: 0.0,
1641    });
1642    let gesture = rememberLiquidMenuGesture();
1643    let trigger_visual = menu_trigger_visibility(expanded);
1644    Box(
1645        modifier
1646            .report_window_rect_state(anchor)
1647            .graphics_layer(move || GraphicsLayer {
1648                alpha: trigger_visual.get(),
1649                ..Default::default()
1650            }),
1651        BoxSpec::default(),
1652        anchor_content,
1653    );
1654    LiquidMenu(
1655        expanded,
1656        anchor.get(),
1657        spec.menu,
1658        spec.absorbed,
1659        gesture,
1660        on_dismiss,
1661        content,
1662    );
1663}
1664
1665#[track_caller]
1666fn menu_trigger_visibility(covered: bool) -> cranpose_core::State<f32> {
1667    cranpose_animation::animate_float_as_state_with_initial(
1668        1.0,
1669        if covered { 0.0 } else { 1.0 },
1670        cranpose_animation::AnimationType::Tween(if covered {
1671            cranpose_animation::AnimationSpec::tween(
1672                MENU_TRIGGER_ABSORPTION_MS,
1673                cranpose_animation::Easing::EaseOut,
1674            )
1675        } else {
1676            cranpose_animation::AnimationSpec::tween(5, cranpose_animation::Easing::EaseOut)
1677                .with_delay(MENU_TRIGGER_RESTORE_DELAY_MS)
1678        }),
1679        "menu-trigger-absorption",
1680    )
1681}
1682
1683fn commit_menu_row(
1684    index: usize,
1685    keeps_open: bool,
1686    on_item: &Rc<dyn Fn(usize)>,
1687    on_dismiss: &Rc<dyn Fn()>,
1688) {
1689    on_item(index);
1690    if !keeps_open {
1691        on_dismiss();
1692    }
1693}
1694
1695fn menu_header_row(
1696    item: &LiquidMenuItem,
1697    typography: &crate::theme::LiquidTypography,
1698    has_checks: bool,
1699    colors: crate::theme::LiquidColors,
1700) {
1701    let label = item.label.clone();
1702    let style = TextStyle {
1703        span_style: SpanStyle {
1704            color: Some(colors.secondary_label),
1705            font_size: TextUnit::Sp(13.0),
1706            ..typography.footnote.span_style.clone()
1707        },
1708        ..typography.footnote.clone()
1709    };
1710    let indent = ROW_PADDING_X + if has_checks { CHECK_COLUMN } else { 0.0 };
1711    let row = Modifier::empty()
1712        .fill_max_width()
1713        .padding_each(indent, 12.0, ROW_PADDING_X, 11.0);
1714    Row(row, RowSpec::default(), move || {
1715        Text(label.clone(), Modifier::empty(), style.clone());
1716    });
1717}
1718
1719/// What a screen reader reads for a menu row: its label, that it can be
1720/// pressed, and whether it is the picked one when the menu carries checks.
1721/// Without the last part a reader hears no difference between the picked row
1722/// and the rest, because the check mark beside it is drawn, not spoken.
1723fn menu_row_semantics(label: String, has_checks: bool, checked: bool) -> SemanticsSpec {
1724    let spec = SemanticsSpec::new()
1725        .content_description(label)
1726        .role(SemanticsWidgetRole::MenuItem)
1727        .clickable();
1728    match has_checks {
1729        true => spec.toggled(checked),
1730        false => spec,
1731    }
1732}
1733
1734#[expect(clippy::too_many_arguments)]
1735fn menu_item_row(
1736    index: usize,
1737    item: &LiquidMenuItem,
1738    typography: &crate::theme::LiquidTypography,
1739    has_checks: bool,
1740    colors: crate::theme::LiquidColors,
1741    hovered: cranpose_core::MutableState<Option<usize>>,
1742    gesture_hover: Option<usize>,
1743    expanded_header: bool,
1744    rect_sink: Rc<Cell<Rect>>,
1745    on_item: Rc<dyn Fn(usize)>,
1746    on_dismiss: Rc<dyn Fn()>,
1747) {
1748    let color = if item.destructive {
1749        colors.destructive
1750    } else {
1751        colors.label
1752    };
1753    let is_hovered = hovered.get() == Some(index) || gesture_hover == Some(index);
1754    let highlight = if colors.is_dark {
1755        cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 44)
1756    } else {
1757        cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 30)
1758    };
1759    let row_label = item.label.clone();
1760    let checked = item.checked;
1761    let keeps_open = item.keeps_open;
1762    let row = Modifier::empty()
1763        .fill_max_width()
1764        .height_in(42.0, f32::INFINITY)
1765        .report_window_rect(rect_sink)
1766        .semantics_spec(menu_row_semantics(row_label, has_checks, checked))
1767        .focusable()
1768        .pointer_input(index, {
1769            let on_item = Rc::clone(&on_item);
1770            let on_dismiss = Rc::clone(&on_dismiss);
1771            move |scope: PointerInputScope| {
1772                let on_item = Rc::clone(&on_item);
1773                let on_dismiss = Rc::clone(&on_dismiss);
1774                async move {
1775                    scope
1776                        .await_pointer_event_scope(|await_scope| async move {
1777                            loop {
1778                                let event = await_scope.await_pointer_event().await;
1779                                match event.kind {
1780                                    PointerEventKind::Enter | PointerEventKind::Move => {
1781                                        hovered.set(Some(index));
1782                                    }
1783                                    PointerEventKind::Exit if hovered.get() == Some(index) => {
1784                                        hovered.set(None);
1785                                    }
1786                                    PointerEventKind::Down => {
1787                                        hovered.set(Some(index));
1788                                        event.consume();
1789                                    }
1790                                    PointerEventKind::Up => {
1791                                        hovered.set(None);
1792                                        commit_menu_row(index, keeps_open, &on_item, &on_dismiss);
1793                                        event.consume();
1794                                    }
1795                                    _ => {}
1796                                }
1797                            }
1798                        })
1799                        .await;
1800                }
1801            }
1802        })
1803        .draw_behind(move |scope| {
1804            if expanded_header {
1805                let chip = if colors.is_dark {
1806                    Color::from_rgb_u8(255, 224, 248).with_alpha(0.52)
1807                } else {
1808                    Color::BLACK.with_alpha(0.08)
1809                };
1810                let size = scope.size();
1811                scope.draw_round_rect_at(
1812                    Rect {
1813                        x: CHIP_INSET_X,
1814                        y: 0.0,
1815                        width: (size.width - CHIP_INSET_X * 2.0).max(0.0),
1816                        height: size.height,
1817                    },
1818                    Brush::solid(chip),
1819                    CornerRadii::uniform(16.0),
1820                );
1821            }
1822            if is_hovered {
1823                scope.draw_round_rect(Brush::solid(highlight), CornerRadii::uniform(14.0));
1824            }
1825        })
1826        .padding_symmetric(ROW_PADDING_X, ROW_PADDING_Y);
1827
1828    let label = item.label.clone();
1829    let subtitle = item.subtitle.clone();
1830    let icon = item.icon;
1831    let accordion_chevron = item.keeps_open && subtitle.is_some();
1832    let secondary = colors.secondary_label;
1833    let typography = typography.clone();
1834    Row(
1835        row,
1836        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
1837        move || {
1838            let label = label.clone();
1839            let subtitle = subtitle.clone();
1840            if has_checks {
1841                Box(
1842                    Modifier::empty().width(CHECK_COLUMN),
1843                    BoxSpec::default(),
1844                    move || {
1845                        if checked {
1846                            crate::icons::Icon(crate::icons::CHECK, None, 16.0, color);
1847                        }
1848                    },
1849                );
1850            }
1851            if let Some(icon) = icon {
1852                crate::icons::Icon(icon, None, ICON_SIZE, color);
1853                Box(Modifier::empty().width(ICON_GAP), BoxSpec::default(), || {});
1854            }
1855            let style = TextStyle {
1856                span_style: SpanStyle {
1857                    color: Some(color),
1858                    font_weight: Some(FontWeight::NORMAL),
1859                    ..typography.body.span_style.clone()
1860                },
1861                ..typography.body.clone()
1862            };
1863            if let Some(subtitle) = subtitle {
1864                let subtitle_style = TextStyle {
1865                    span_style: SpanStyle {
1866                        color: Some(secondary),
1867                        font_size: TextUnit::Sp(13.0),
1868                        ..typography.footnote.span_style.clone()
1869                    },
1870                    ..typography.footnote.clone()
1871                };
1872                Column(
1873                    Modifier::empty().weight(1.0),
1874                    ColumnSpec::default(),
1875                    move || {
1876                        Text(label.clone(), Modifier::empty(), style.clone());
1877                        Text(subtitle.clone(), Modifier::empty(), subtitle_style.clone());
1878                    },
1879                );
1880            } else {
1881                Text(label, Modifier::empty().weight(1.0), style);
1882            }
1883            if accordion_chevron {
1884                crate::icons::Icon(crate::icons::CHEVRON_DOWN, None, 18.0, secondary);
1885            }
1886        },
1887    );
1888}
1889
1890#[cfg(test)]
1891#[path = "tests/menu_tests.rs"]
1892mod tests;