cranpose_liquid/widgets/menu.rs
1//! A popup menu that morphs out of its anchor: the glass bubble springs from
2//! the anchor corner while its items fade in (the WWDC "Show" menu).
3
4use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
5use crate::theme::{liquid_colors, liquid_typography};
6use cranpose_core::{mutableStateOf, remember};
7use cranpose_macros::composable;
8use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
9use cranpose_ui::widgets::{
10 Box, BoxSpec, Column, ColumnSpec, PopupDismissable, Row, RowSpec, Text,
11};
12use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope};
13use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer, Point, Rect, RenderEffect};
14use cranpose_ui_layout::VerticalAlignment;
15use std::cell::Cell;
16use std::rc::Rc;
17
18/// One menu entry.
19#[derive(Clone, Debug, PartialEq)]
20pub struct LiquidMenuItem {
21 pub label: String,
22 /// Optional leading icon (24×24 path data).
23 pub icon: Option<&'static str>,
24 /// Draws the leading checkmark (selected state).
25 pub checked: bool,
26 /// Destructive styling.
27 pub destructive: bool,
28 /// Starts a new visual section (full-width hairline above).
29 pub section_start: bool,
30 /// A non-interactive gray section header ("Show").
31 pub header: bool,
32}
33
34impl LiquidMenuItem {
35 pub fn new(label: impl Into<String>) -> Self {
36 Self {
37 label: label.into(),
38 icon: None,
39 checked: false,
40 destructive: false,
41 section_start: false,
42 header: false,
43 }
44 }
45
46 /// A gray, non-interactive section header row.
47 pub fn header(label: impl Into<String>) -> Self {
48 Self {
49 header: true,
50 ..Self::new(label)
51 }
52 }
53
54 pub fn icon(mut self, icon: &'static str) -> Self {
55 self.icon = Some(icon);
56 self
57 }
58
59 pub fn checked(mut self, checked: bool) -> Self {
60 self.checked = checked;
61 self
62 }
63
64 pub fn destructive(mut self) -> Self {
65 self.destructive = true;
66 self
67 }
68
69 pub fn section_start(mut self) -> Self {
70 self.section_start = true;
71 self
72 }
73}
74
75const MENU_WIDTH: f32 = 250.0;
76const MENU_RADIUS: f32 = 40.0;
77/// How far the card's top edge sits below the anchor's top: the settled menu
78/// swallows the anchor button ENTIRELY (the reference "…" disappears under
79/// the glass, reading as a smudge; only mid-flight does its bump ride the
80/// droplet edge).
81const ANCHOR_OVERLAP: f32 = 0.0;
82const ROW_PADDING_X: f32 = 16.0;
83const ROW_PADDING_Y: f32 = 12.0;
84/// Width reserved for the leading checkmark column when any item is checkable.
85const CHECK_COLUMN: f32 = 24.0;
86const ICON_SIZE: f32 = 20.0;
87const ICON_GAP: f32 = 12.0;
88/// Content materialization blur (dp) at the start of the morph — rows fade in
89/// from behind the glass, sharpening over the flight (reference frames 6-9).
90const CONTENT_BLUR: f32 = 10.0;
91
92/// A glass popup menu anchored to `anchor` (window coordinates of the button
93/// that opened it). `neighbors` are the window-coord rects of other glass
94/// controls near the anchor (the rest of the nav cluster): the growing
95/// bubble SDF-glues to them in passing, exactly like the reference
96/// keyframes. While `expanded`, taps outside dismiss via `on_dismiss`; item
97/// taps call `on_item` with the index then dismiss.
98///
99/// Layout follows the iOS menu: an optional leading checkmark column (present
100/// on every row once any item is checkable, so labels align), then the icon
101/// column, then the label. Sections split with full-width hairlines; headers
102/// are gray non-interactive rows.
103#[composable]
104#[allow(non_snake_case)]
105pub fn LiquidMenu(
106 expanded: bool,
107 anchor: Rect,
108 neighbors: Vec<Rect>,
109 items: Vec<LiquidMenuItem>,
110 on_item: impl Fn(usize) + 'static,
111 on_dismiss: impl Fn() + 'static,
112) {
113 // The menu outlives `expanded` by one collapse animation: dismissing
114 // deflates the droplet back into the anchor (the reference close morph)
115 // before the popup unmounts.
116 let visible = remember(|| mutableStateOf(false)).with(|s| *s);
117 if expanded && !visible.get() {
118 visible.set(true);
119 }
120 if !expanded && !visible.get() {
121 return;
122 }
123 let colors = liquid_colors();
124 let typography = liquid_typography();
125 let on_item: Rc<dyn Fn(usize)> = Rc::new(on_item);
126 let on_dismiss: Rc<dyn Fn()> = Rc::new(on_dismiss);
127
128 // The droplet spring: opening uses the bouncy morph spring (visible size
129 // overshoot, the reference menu swells a few percent past its final width
130 // and relaxes); closing is a faster, non-bouncy suck-back.
131 // Open ≈330ms press→crisp with a soft overshoot (timed against the
132 // reference recording); close is a faster suck-back (~200ms).
133 let grow = cranpose_animation::animate_float_as_state_with_initial(
134 0.0,
135 if expanded { 1.0 } else { 0.0 },
136 if expanded {
137 cranpose_animation::spring(0.58, 380.0)
138 } else {
139 cranpose_animation::spring(1.0, 1100.0)
140 },
141 "menu-grow",
142 );
143 // Content reveal runs on its OWN slower clock: the droplet reaches full
144 // size while the rows are still smudges, and they crisp only around
145 // settle (the reference keeps content illegible until the last beat).
146 // Closing snaps the blur back on fast.
147 let reveal_anim = cranpose_animation::animate_float_as_state_with_initial(
148 0.0,
149 if expanded { 1.0 } else { 0.0 },
150 if expanded {
151 cranpose_animation::spring(1.0, 110.0)
152 } else {
153 cranpose_animation::spring(1.0, 900.0)
154 },
155 "menu-reveal",
156 );
157 // Body-level read: each animation frame recomposes this menu, which
158 // re-registers fresh popup content (see `Popup`), driving the morph.
159 // NOT clamped at 1 — the spring's overshoot is the size overshoot.
160 let appear = grow.get().max(0.0);
161 let reveal = reveal_anim.get().clamp(0.0, 1.0);
162 if !expanded && appear < 0.02 {
163 visible.set(false);
164 return;
165 }
166
167 // The node spans from the anchor's top; ANCHOR_OVERLAP places the card's
168 // top edge so the settled glass swallows the anchor button entirely.
169 let anchor_zone = anchor.height * ANCHOR_OVERLAP;
170 let node_size =
171 remember(|| Rc::new(Cell::new(cranpose_ui_graphics::Size::ZERO))).with(Rc::clone);
172 // Finite-difference morph velocity (per render frame) drives the wobble
173 // and the viscous leading-edge bulge — reactive, not canned.
174 let last_appear = remember(|| Rc::new(Cell::new(f32::NAN))).with(Rc::clone);
175
176 // Right-align the card under the anchor (menus morph out of trailing
177 // buttons), staying on-screen for anchors near the right edge. The host
178 // renders the outside-tap scrim (PopupDismissable). While collapsing the
179 // scrim must not re-fire the caller's dismiss.
180 let scrim_dismiss = Rc::clone(&on_dismiss);
181 let scrim_active = expanded;
182 PopupDismissable(
183 anchor,
184 Point::new(anchor.width - MENU_WIDTH, 0.0),
185 move || {
186 if scrim_active {
187 scrim_dismiss()
188 }
189 },
190 {
191 let items = items.clone();
192 let typography = typography.clone();
193 let on_item = Rc::clone(&on_item);
194 let on_dismiss = Rc::clone(&on_dismiss);
195 let node_size = Rc::clone(&node_size);
196 let last_appear = Rc::clone(&last_appear);
197 move || {
198 // Shapeshift (the WWDC menu-open keyframes): the anchor
199 // bubble inflates into the menu card as ONE droplet — the
200 // anchor is first carved out (crisp button riding the
201 // droplet edge), then swallowed, then melted flat into the
202 // settled edge. Neighbors join the field only while the
203 // growing edge is actually near them, so the settled menu
204 // never halos far-away controls.
205 let anchor_center = (MENU_WIDTH - anchor.width * 0.5, anchor.height * 0.5);
206 let anchor_shape = (
207 anchor_center.0,
208 anchor_center.1,
209 anchor.width,
210 anchor.height,
211 -1.0,
212 );
213 // Node origin in window coords (the popup offset from the
214 // anchor) converts neighbor rects into node-local shapes.
215 let node_origin = (anchor.x + anchor.width - MENU_WIDTH, anchor.y);
216 let neighbor_shapes_for_dyn: Vec<(f32, f32, f32, f32, f32)> = neighbors
217 .iter()
218 .map(|rect| {
219 (
220 rect.x + rect.width * 0.5 - node_origin.0,
221 rect.y + rect.height * 0.5 - node_origin.1,
222 rect.width,
223 rect.height,
224 -1.0,
225 )
226 })
227 .collect();
228 let morph_size = Rc::clone(&node_size);
229 let morph_last_appear = Rc::clone(&last_appear);
230 // Muted vibrancy: the absorbed button must read as a soft
231 // smudge beneath the glass, not a hot saturated orb.
232 let glass = Glass::regular()
233 .shape(LiquidShape::RoundedRect(MENU_RADIUS))
234 .saturation(1.2)
235 .lift(0.34)
236 .no_clip()
237 .shadow(false);
238 let card = Modifier::empty()
239 .report_size(Rc::clone(&node_size))
240 .glass_effect_with(glass, move || {
241 let size = morph_size.get();
242 let menu_h = (size.height - anchor_zone).max(24.0);
243 // Shaped spring: the reference droplet lingers near
244 // the button for the first ~100ms (icon fades, slight
245 // swell) and inflates late — an ease-in on the spring
246 // value reproduces that while keeping the overshoot
247 // past 1 (which IS the size overshoot at settle).
248 let t = if appear < 1.0 {
249 appear.powf(1.6)
250 } else {
251 appear
252 };
253 let start = anchor_shape;
254 // Pillowy settled corners (the reference menu's radius
255 // is ~0.26 of its height — far rounder than a desktop
256 // popup).
257 let settle_radius = (menu_h * 0.26).clamp(MENU_RADIUS, 64.0);
258 let target = (
259 MENU_WIDTH * 0.5,
260 anchor_zone + menu_h * 0.5,
261 MENU_WIDTH,
262 menu_h,
263 settle_radius,
264 );
265 let start_radius = anchor.width.min(anchor.height) * 0.5;
266 let lerp = |a: f32, b: f32| a + (b - a) * t;
267 // Width LAGS height: the reference droplet inflates as
268 // a near-circle first and only stretches into the wide
269 // stadium late in the flight.
270 let t_w = t.powf(1.9);
271 let lerp_w = |a: f32, b: f32| a + (b - a) * t_w;
272 let grown_w = lerp_w(start.2, target.2).max(anchor.width * 0.6);
273 let grown_h = lerp(start.3, target.3).max(anchor.height * 0.6);
274 // The reference droplet stays BLOB-ROUND while it
275 // inflates (mid-flight it is nearly circular — radius
276 // tracking the half-extent) and only squares into the
277 // menu's rounded rect late in the flight.
278 let blob_radius = grown_w.min(grown_h) * 0.5;
279 let squareness = ((t - 0.55) / 0.45).clamp(0.0, 1.0);
280 let radius = if t >= 1.0 {
281 target.4
282 } else {
283 let base = start_radius + (blob_radius - start_radius) * t;
284 base + (target.4 - base) * squareness * squareness
285 };
286 let primary = (
287 lerp(start.0, target.0),
288 lerp(start.1, target.1),
289 grown_w,
290 grown_h,
291 radius,
292 );
293 // Morph velocity (per frame): wobble and the viscous
294 // leading-edge bulge follow the actual motion — fast
295 // growth bubbles hard, the settle calms down, the
296 // close morph bulges back toward the anchor.
297 let prev = morph_last_appear.replace(t);
298 let v = if prev.is_nan() { 0.0 } else { t - prev };
299 let speed = v.abs();
300 // Growth direction from the anchor toward the card
301 // center (node coords, y down).
302 let dir_x = target.0 - start.0;
303 let dir_y = target.1 - start.1;
304 let mut bulge_dir = dir_y.atan2(dir_x);
305 if v < 0.0 {
306 bulge_dir += std::f32::consts::PI;
307 }
308 let mut shapes = Vec::new();
309 // The anchor button rides INSIDE the droplet (the
310 // reference dots fade under the growing glass, they
311 // are never carved out); a melting union bump keeps
312 // the birth edge organic and leaves a flat settled
313 // edge. The close morph walks it backwards.
314 let melt = (1.0 - t).clamp(0.0, 1.0);
315 if melt > 0.01 {
316 shapes.push((
317 anchor_shape.0,
318 anchor_shape.1,
319 anchor_shape.2 * melt.max(0.4),
320 anchor_shape.3 * melt.max(0.4),
321 -1.0,
322 ));
323 }
324 // Neighbors participate only while the droplet's
325 // edge is within glue reach of them MID-FLIGHT —
326 // passing glue, never a settled lump (the settled
327 // reference shows neighbors as smudges under the
328 // glass, not edge geometry).
329 let glue = 22.0 * (1.0 - t.clamp(0.0, 1.0) * 0.65);
330 for neighbor in &neighbor_shapes_for_dyn {
331 let (nx, ny, nw, nh, _) = *neighbor;
332 let half_w = primary.2 * 0.5;
333 let half_h = primary.3 * 0.5;
334 let dx = ((nx - primary.0).abs() - half_w - nw * 0.5).max(0.0);
335 let dy = ((ny - primary.1).abs() - half_h - nh * 0.5).max(0.0);
336 let gap = (dx * dx + dy * dy).sqrt();
337 if gap >= glue * 2.2 {
338 continue;
339 }
340 if reveal < 0.45 {
341 // Metaball phase: the neighbor's glass HOST
342 // joins the field (union halo necks with the
343 // droplet) while the button itself is carved
344 // crisp — the reference keeps the blue filter
345 // a sharp circle with its icon until the
346 // glass truly swallows it.
347 shapes.push((nx, ny, nw + 12.0, nh + 12.0, -1.0));
348 shapes.push((nx, ny, nw - 4.0, nh - 4.0, -2.0));
349 } else if t < 0.85 {
350 // Swallowed: melt to a smudge under the glass.
351 let swallow = 1.0 - ((reveal - 0.45) / 0.35).clamp(0.0, 1.0);
352 if swallow > 0.01 {
353 shapes.push((
354 nx,
355 ny,
356 nw * swallow.max(0.3),
357 nh * swallow.max(0.3),
358 -1.0,
359 ));
360 }
361 }
362 }
363 GlassDynamics {
364 morph: Some(GlassMorph {
365 node_size: (size.width.max(1.0), size.height.max(1.0)),
366 primary,
367 shapes,
368 glue,
369 wobble_amplitude: (speed * 220.0).min(6.5),
370 wobble_phase: t * 8.0,
371 bulge_amplitude: (speed * 340.0).min(10.0),
372 bulge_direction: bulge_dir,
373 }),
374 ..Default::default()
375 }
376 })
377 .width(MENU_WIDTH);
378
379 let has_checks = items.iter().any(|item| item.checked);
380 // Finger/pointer sliding through the menu highlights the row
381 // under it (release selects) — the iOS drag-through-menu.
382 let hovered = remember(|| mutableStateOf(Option::<usize>::None)).with(|s| *s);
383 Column(card, ColumnSpec::default(), {
384 let items = items.clone();
385 let typography = typography.clone();
386 let on_item = Rc::clone(&on_item);
387 let on_dismiss = Rc::clone(&on_dismiss);
388 move || {
389 Box(
390 Modifier::empty().height(anchor_zone),
391 BoxSpec::default(),
392 || {},
393 );
394 // The card's drop shadow belongs to the menu rect
395 // only (the glass node also spans the anchor zone).
396 // Soft and wide — the reference menu shadow is a
397 // whisper, not a hard blob. Content stays ABSENT
398 // through the droplet's first stretch and only
399 // materializes late (the reference droplet is blank
400 // at 25%, smudges at 60%, crisp in the last stretch)
401 // — and never renders outside the droplet.
402 // Content is ABSENT through most of the flight (the
403 // reference droplet is empty glass until the last
404 // stretch) and, while CLOSING, rides the fast glass
405 // clock so it collapses WITH the panel instead of
406 // dissolving in place.
407 let content = if expanded {
408 ((reveal - 0.62) / 0.38).clamp(0.0, 1.0).powf(1.2)
409 } else {
410 ((appear - 0.45) / 0.55).clamp(0.0, 1.0)
411 };
412 let shadow_color = if colors.is_dark {
413 cranpose_ui_graphics::Color::BLACK.with_alpha(0.45 * content)
414 } else {
415 cranpose_ui_graphics::Color::BLACK.with_alpha(0.13 * content)
416 };
417 // Rows materialize from behind the glass: heavily
418 // blurred and ghosted early in the flight, sharp at
419 // settle (and back to blur during the close morph).
420 // They also SCALE with the droplet from the anchor
421 // corner — the content lives on the growing surface,
422 // it doesn't fade in at full size.
423 let content_blur = CONTENT_BLUR * (1.0 - content).max(0.0);
424 let content_scale = 0.55 + 0.45 * content;
425 let rows_wrap = Modifier::empty()
426 .fill_max_width()
427 .graphics_layer(move || GraphicsLayer {
428 alpha: content.powf(1.3),
429 scale_x: content_scale,
430 scale_y: content_scale,
431 transform_origin: cranpose_ui_graphics::TransformOrigin {
432 pivot_fraction_x: 1.0,
433 pivot_fraction_y: 0.0,
434 },
435 render_effect: (content_blur > 0.4)
436 .then(|| RenderEffect::blur(content_blur)),
437 ..Default::default()
438 })
439 .drop_shadow(
440 cranpose_ui_graphics::LayerShape::Rounded(
441 cranpose_ui_graphics::RoundedCornerShape::uniform(MENU_RADIUS),
442 ),
443 move |scope| {
444 scope.radius = 34.0;
445 scope.spread = 0.0;
446 scope.offset.y = 10.0;
447 scope.color = shadow_color;
448 scope.cutout = true;
449 },
450 );
451 Box(rows_wrap, BoxSpec::default(), {
452 let items = items.clone();
453 let typography = typography.clone();
454 let on_item = Rc::clone(&on_item);
455 let on_dismiss = Rc::clone(&on_dismiss);
456 move || {
457 Column(
458 Modifier::empty().fill_max_width(),
459 ColumnSpec::default(),
460 {
461 let items = items.clone();
462 let typography = typography.clone();
463 let on_item = Rc::clone(&on_item);
464 let on_dismiss = Rc::clone(&on_dismiss);
465 move || {
466 for (index, item) in items.iter().enumerate() {
467 if item.section_start && index > 0 {
468 // Whisper-subtle: the
469 // reference surface reads
470 // nearly seamless.
471 let separator = colors
472 .separator
473 .with_alpha(colors.separator.a() * 0.22);
474 Box(
475 Modifier::empty()
476 .fill_max_width()
477 .padding_symmetric(ROW_PADDING_X, 0.0)
478 .height(1.0)
479 .draw_behind(move |scope| {
480 scope.draw_rect(cranpose_ui_graphics::Brush::solid(
481 separator,
482 ));
483 }),
484 BoxSpec::default(),
485 || {},
486 );
487 }
488
489 if item.header {
490 menu_header_row(
491 item,
492 &typography,
493 has_checks,
494 colors,
495 );
496 continue;
497 }
498 menu_item_row(
499 index,
500 item,
501 &typography,
502 has_checks,
503 colors,
504 hovered,
505 Rc::clone(&on_item),
506 Rc::clone(&on_dismiss),
507 );
508 }
509 }
510 },
511 );
512 }
513 });
514 }
515 });
516 }
517 },
518 );
519}
520
521/// Gray non-interactive section header, aligned with the icon column.
522fn menu_header_row(
523 item: &LiquidMenuItem,
524 typography: &crate::theme::LiquidTypography,
525 has_checks: bool,
526 colors: crate::theme::LiquidColors,
527) {
528 let label = item.label.clone();
529 let style = TextStyle {
530 span_style: SpanStyle {
531 color: Some(colors.secondary_label),
532 font_size: TextUnit::Sp(13.0),
533 ..typography.footnote.span_style.clone()
534 },
535 ..typography.footnote.clone()
536 };
537 let indent = ROW_PADDING_X + if has_checks { CHECK_COLUMN } else { 0.0 };
538 let row = Modifier::empty()
539 .fill_max_width()
540 .padding_each(indent, 12.0, ROW_PADDING_X, 2.0);
541 Row(row, RowSpec::default(), move || {
542 Text(label.clone(), Modifier::empty(), style.clone());
543 });
544}
545
546/// One interactive menu row: [check][icon][label].
547#[allow(non_snake_case)]
548#[allow(clippy::too_many_arguments)]
549fn menu_item_row(
550 index: usize,
551 item: &LiquidMenuItem,
552 typography: &crate::theme::LiquidTypography,
553 has_checks: bool,
554 colors: crate::theme::LiquidColors,
555 hovered: cranpose_core::MutableState<Option<usize>>,
556 on_item: Rc<dyn Fn(usize)>,
557 on_dismiss: Rc<dyn Fn()>,
558) {
559 let color = if item.destructive {
560 colors.destructive
561 } else {
562 colors.label
563 };
564 let is_hovered = hovered.get() == Some(index);
565 let highlight = if colors.is_dark {
566 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 72)
567 } else {
568 cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 48)
569 };
570 let row_label = item.label.clone();
571 let row = Modifier::empty()
572 .fill_max_width()
573 .semantics(move |config| {
574 config.is_button = true;
575 config.is_clickable = true;
576 config.content_description = Some(row_label.clone());
577 })
578 .pointer_input(index, {
579 let on_item = Rc::clone(&on_item);
580 let on_dismiss = Rc::clone(&on_dismiss);
581 move |scope: PointerInputScope| {
582 let on_item = Rc::clone(&on_item);
583 let on_dismiss = Rc::clone(&on_dismiss);
584 async move {
585 scope
586 .await_pointer_event_scope(|await_scope| async move {
587 loop {
588 let event = await_scope.await_pointer_event().await;
589 match event.kind {
590 PointerEventKind::Enter | PointerEventKind::Move => {
591 hovered.set(Some(index));
592 }
593 PointerEventKind::Exit if hovered.get() == Some(index) => {
594 hovered.set(None);
595 }
596 PointerEventKind::Down => {
597 hovered.set(Some(index));
598 event.consume();
599 }
600 PointerEventKind::Up => {
601 hovered.set(None);
602 on_item(index);
603 on_dismiss();
604 event.consume();
605 }
606 _ => {}
607 }
608 }
609 })
610 .await;
611 }
612 }
613 })
614 .draw_behind(move |scope| {
615 if is_hovered {
616 scope.draw_round_rect(Brush::solid(highlight), CornerRadii::uniform(14.0));
617 }
618 })
619 .padding_symmetric(ROW_PADDING_X, ROW_PADDING_Y);
620
621 let label = item.label.clone();
622 let icon = item.icon;
623 let checked = item.checked;
624 let typography = typography.clone();
625 Row(
626 row,
627 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
628 move || {
629 let label = label.clone();
630 if has_checks {
631 // Leading checkmark column, reserved on every row so icons
632 // and labels align (the reference "Show" menu).
633 Box(
634 Modifier::empty().width(CHECK_COLUMN),
635 BoxSpec::default(),
636 move || {
637 if checked {
638 crate::icons::Icon(crate::icons::CHECK, 16.0, color);
639 }
640 },
641 );
642 }
643 if let Some(icon) = icon {
644 crate::icons::Icon(icon, ICON_SIZE, color);
645 Box(Modifier::empty().width(ICON_GAP), BoxSpec::default(), || {});
646 }
647 let style = TextStyle {
648 span_style: SpanStyle {
649 color: Some(color),
650 font_weight: Some(FontWeight::NORMAL),
651 ..typography.body.span_style.clone()
652 },
653 ..typography.body.clone()
654 };
655 Text(label, Modifier::empty().weight(1.0), style);
656 },
657 );
658}