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