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#[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
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#[derive(Clone, Copy, Debug, PartialEq)]
207pub struct LiquidMenuSpec {
208 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#[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 pub fn is_pressed(&self) -> bool {
312 self.inner.snapshot.get().active
313 }
314
315 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#[composable]
416#[track_caller]
417pub fn rememberLiquidMenuGesture() -> LiquidMenuGesture {
418 remember(LiquidMenuGesture::new).with(Clone::clone)
419}
420
421#[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
750pub 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#[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#[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#[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
1643fn 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;