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