1#![allow(non_snake_case)]
21
22use std::{
23 cell::{Cell, RefCell},
24 rc::Rc,
25};
26
27use cranpose_animation::{Animatable, AnimationSpec, AnimationType, Easing};
28use cranpose_core::{remember, with_current_composer, SideEffect};
29use cranpose_foundation::PointerEventKind;
30use cranpose_ui_graphics::{
31 liquid_menu_glass_effect, GraphicsLayer, LayerShape, Point, Rect, RoundedCornerShape, Size,
32};
33
34use crate::{
35 composable,
36 modifier::{Color, Modifier},
37 text::{measure_text, AnnotatedString, TextStyle, TextUnit},
38 widgets::{
39 box_widget::{Box, BoxSpec},
40 popup::{local_popup_viewport, Popup},
41 Row, RowSpec, Text,
42 },
43 PointerInputScope,
44};
45
46pub const MENU_HEIGHT: f32 = 44.0;
48const MENU_SCREEN_MARGIN: f32 = 20.0;
50const MENU_GAP_ABOVE_LINE: f32 = 15.0;
52const ITEM_PADDING: f32 = 20.0;
54const SEPARATOR_WIDTH: f32 = 1.0;
56const SEPARATOR_HEIGHT: f32 = 17.0;
57const MENU_FONT_SP: f32 = 15.0;
58
59pub fn local_on_light_surface() -> cranpose_core::CompositionLocal<bool> {
66 use std::cell::RefCell;
67 thread_local! {
68 static LOCAL: RefCell<Option<cranpose_core::CompositionLocal<bool>>> =
69 const { RefCell::new(None) };
70 }
71 LOCAL.with(|cell| {
72 cell.borrow_mut()
73 .get_or_insert_with(|| cranpose_core::compositionLocalOf(|| false))
74 .clone()
75 })
76}
77
78fn menu_fg(on_light: bool) -> Color {
80 if on_light {
81 Color(0.08, 0.08, 0.10, 1.0)
82 } else {
83 Color(0.96, 0.96, 0.98, 1.0)
84 }
85}
86
87fn separator_color(on_light: bool) -> Color {
89 if on_light {
90 Color(0.0, 0.0, 0.0, 0.10)
91 } else {
92 Color(1.0, 1.0, 1.0, 0.07)
93 }
94}
95
96fn disc_color(on_light: bool) -> Color {
98 if on_light {
99 Color(0.0, 0.0, 0.0, 0.10)
100 } else {
101 Color(1.0, 1.0, 1.0, 0.19)
102 }
103}
104
105fn disc_pressed_color(on_light: bool) -> Color {
106 if on_light {
107 Color(0.0, 0.0, 0.0, 0.55)
108 } else {
109 Color(1.0, 1.0, 1.0, 0.9)
110 }
111}
112const MENU_BLUR_DP: f32 = 15.0;
116
117const MENU_DISSOLVE_MS: u64 = 70;
120const MENU_MATERIALIZE_MS: u64 = 140;
121const MENU_RETURN_DELAY_MS: u64 = 250;
122
123fn menu_text_style(on_light: bool) -> TextStyle {
124 let mut style = TextStyle::default();
125 style.span_style.color = Some(menu_fg(on_light));
126 style.span_style.font_size = TextUnit::Sp(MENU_FONT_SP);
127 style
128}
129
130#[derive(Clone)]
132pub struct TextMenuItem {
133 pub label: String,
134 pub action: Rc<dyn Fn()>,
135}
136
137impl TextMenuItem {
138 pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
139 Self {
140 label: label.into(),
141 action: Rc::new(action),
142 }
143 }
144}
145
146impl PartialEq for TextMenuItem {
147 fn eq(&self, other: &Self) -> bool {
148 self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
152 }
153}
154
155impl std::fmt::Debug for TextMenuItem {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("TextMenuItem")
158 .field("label", &self.label)
159 .finish()
160 }
161}
162
163pub(crate) fn menu_item_pointer_input(
170 label: &str,
171 action: Rc<dyn Fn()>,
172 continuous_press: Rc<Cell<bool>>,
173) -> Modifier {
174 let key = label.to_string();
175 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
176 let action = Rc::clone(&action);
177 let continuous_press = Rc::clone(&continuous_press);
178 async move {
179 scope
180 .await_pointer_event_scope(|await_scope| async move {
181 let mut pressed = false;
186 loop {
187 let event = await_scope.await_pointer_event().await;
188 match event.kind {
189 PointerEventKind::Down => {
190 pressed = true;
191 event.consume();
192 }
193 PointerEventKind::Move => {
194 event.consume();
195 }
196 PointerEventKind::Up => {
197 if pressed || continuous_press.get() {
198 action();
199 }
200 pressed = false;
201 event.consume();
202 }
203 PointerEventKind::Cancel => {
204 pressed = false;
205 event.consume();
206 }
207 _ => {}
208 }
209 }
210 })
211 .await;
212 }
213 })
214}
215
216fn disc_pointer_input(
219 pressed: Rc<Cell<bool>>,
220 on_tap: Rc<dyn Fn()>,
221 invalidate: Rc<dyn Fn()>,
222) -> Modifier {
223 Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
224 let pressed = Rc::clone(&pressed);
225 let on_tap = Rc::clone(&on_tap);
226 let invalidate = Rc::clone(&invalidate);
227 async move {
228 scope
229 .await_pointer_event_scope(|await_scope| async move {
230 let mut down = false;
231 loop {
232 let event = await_scope.await_pointer_event().await;
233 match event.kind {
234 PointerEventKind::Down => {
235 down = true;
236 pressed.set(true);
237 invalidate();
238 event.consume();
239 }
240 PointerEventKind::Move => {
241 event.consume();
242 }
243 PointerEventKind::Up => {
244 if down {
245 on_tap();
246 }
247 down = false;
248 pressed.set(false);
249 invalidate();
250 event.consume();
251 }
252 PointerEventKind::Cancel => {
253 down = false;
254 pressed.set(false);
255 invalidate();
256 event.consume();
257 }
258 _ => {}
259 }
260 }
261 })
262 .await;
263 }
264 })
265}
266
267fn item_width(label: &str, style: &TextStyle) -> f32 {
269 measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
270}
271
272fn slide_item_at(
276 point: cranpose_ui_graphics::Point,
277 origin_x: f32,
278 origin_y: f32,
279 page_items: &[TextMenuItem],
280 style: &TextStyle,
281) -> Option<usize> {
282 if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
283 return None;
284 }
285 let mut cursor = origin_x;
286 for (index, item) in page_items.iter().enumerate() {
287 if index > 0 {
288 cursor += SEPARATOR_WIDTH;
289 }
290 let width = item_width(&item.label, style);
291 if point.x >= cursor && point.x < cursor + width {
292 return Some(index);
293 }
294 cursor += width;
295 }
296 None
297}
298
299fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
303 let widths: Vec<f32> = items
304 .iter()
305 .map(|item| item_width(&item.label, style))
306 .collect();
307 let total: f32 =
308 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
309 if total <= max_width || items.len() <= 1 {
310 return vec![(0..items.len()).collect()];
311 }
312 let disc = MENU_HEIGHT;
315 let mut pages: Vec<Vec<usize>> = Vec::new();
316 let mut page: Vec<usize> = Vec::new();
317 let mut used = disc;
318 for (index, width) in widths.iter().enumerate() {
319 let extra = width
320 + if page.is_empty() {
321 0.0
322 } else {
323 SEPARATOR_WIDTH
324 };
325 if !page.is_empty() && used + extra > max_width {
326 pages.push(std::mem::take(&mut page));
327 used = disc;
328 }
329 used += width
330 + if page.is_empty() {
331 0.0
332 } else {
333 SEPARATOR_WIDTH
334 };
335 page.push(index);
336 }
337 if !page.is_empty() {
338 pages.push(page);
339 }
340 pages
341}
342
343struct MenuMotion {
346 progress: RefCell<Animatable<f32>>,
347 was_visible: Cell<bool>,
348 page: Cell<usize>,
349 disc_pressed: Rc<Cell<bool>>,
350 slide_hover: Cell<Option<usize>>,
353 slide_live: Rc<Cell<bool>>,
354}
355
356#[composable]
367pub fn LiquidTextMenu(
368 center_x: f32,
369 line_top_y: f32,
370 visible: bool,
371 live_point: Option<cranpose_ui_graphics::Point>,
372 items: Vec<TextMenuItem>,
373) {
374 let motion = remember(|| {
375 let runtime = with_current_composer(|composer| composer.runtime_handle());
376 Rc::new(MenuMotion {
377 progress: RefCell::new(Animatable::new(0.0, runtime)),
378 was_visible: Cell::new(false),
379 page: Cell::new(0),
380 disc_pressed: Rc::new(Cell::new(false)),
381 slide_hover: Cell::new(None),
382 slide_live: Rc::new(Cell::new(false)),
383 })
384 })
385 .with(Rc::clone);
386
387 if visible != motion.was_visible.get() {
388 motion.was_visible.set(visible);
389 let mut progress = motion.progress.borrow_mut();
390 if visible {
391 progress.animateTo(
392 1.0,
393 AnimationType::Tween(
394 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
395 .with_delay(MENU_RETURN_DELAY_MS),
396 ),
397 );
398 } else {
399 progress.animateTo(
400 0.0,
401 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
402 );
403 }
404 }
405 let progress_state = motion.progress.borrow().state();
406 let p = progress_state.value().clamp(0.0, 1.0);
407 if !visible && p <= 0.01 {
408 return;
409 }
410
411 let on_light = local_on_light_surface().current();
412 let style = menu_text_style(on_light);
413 let viewport = local_popup_viewport().current().get();
414 let max_width = if viewport.width > 0.0 {
415 viewport.width - 2.0 * MENU_SCREEN_MARGIN
416 } else {
417 f32::INFINITY
418 };
419 let pages = paginate(&items, &style, max_width);
420 let page_index = motion.page.get().min(pages.len() - 1);
421 let page = &pages[page_index];
422 let has_disc = pages.len() > 1;
423
424 let mut width: f32 = page
427 .iter()
428 .map(|&i| item_width(&items[i].label, &style))
429 .sum();
430 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
431 if has_disc {
432 width += MENU_HEIGHT;
433 }
434
435 let mut x = center_x - width * 0.5;
436 if viewport.width > 0.0 {
437 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
438 }
439 x = x.max(MENU_SCREEN_MARGIN);
440 let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
441
442 let anchor = Rect {
443 x,
444 y,
445 width: 0.0,
446 height: 0.0,
447 };
448 let density = crate::current_density();
449 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
450
451 let slide_hover = match live_point {
456 Some(point) => {
457 motion.slide_live.set(true);
458 let hover = slide_item_at(point, x, y, &page_items, &style);
459 motion.slide_hover.set(hover);
460 hover
461 }
462 None => {
463 let hover = motion.slide_hover.take();
464 if motion.slide_live.replace(false) {
465 if let Some(index) = hover {
466 if let Some(item) = page_items.get(index) {
467 let action = Rc::clone(&item.action);
468 SideEffect(move || action());
469 }
470 }
471 }
472 None
473 }
474 };
475 let disc_pressed = Rc::clone(&motion.disc_pressed);
476 let page_count = pages.len();
477 let motion_for_disc = Rc::clone(&motion);
478 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
479 let page_items = page_items.clone();
480 let disc_pressed = Rc::clone(&disc_pressed);
481 let motion = Rc::clone(&motion_for_disc);
482 Box(
483 Modifier::empty()
484 .size(Size {
485 width,
486 height: MENU_HEIGHT,
487 })
488 .drop_shadow(
493 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
494 move |scope| {
495 scope.radius = 16.0;
501 scope.spread = -2.0;
502 scope.offset.y = 0.0;
503 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
504 scope.cutout = true;
505 },
506 )
507 .graphics_layer(move || GraphicsLayer {
508 alpha: p,
509 backdrop_effect: (p > 0.001).then(|| {
515 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
516 }),
517 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
518 clip: true,
519 ..Default::default()
520 }),
521 BoxSpec::default(),
522 move || {
523 let page_items = page_items.clone();
524 let disc_pressed = Rc::clone(&disc_pressed);
525 let motion = Rc::clone(&motion);
526 Row(
527 Modifier::empty().size(Size {
528 width,
529 height: MENU_HEIGHT,
530 }),
531 RowSpec::default().vertical_alignment(
532 cranpose_ui_layout::VerticalAlignment::CenterVertically,
533 ),
534 move || {
535 for (index, item) in page_items.iter().enumerate() {
536 if index > 0 {
537 Box(
538 Modifier::empty()
539 .size(Size {
540 width: SEPARATOR_WIDTH,
541 height: SEPARATOR_HEIGHT,
542 })
543 .background(separator_color(on_light)),
544 BoxSpec::default(),
545 || {},
546 );
547 }
548 let slide_hovered = slide_hover == Some(index);
549 Text(
550 item.label.clone(),
551 Modifier::empty()
552 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
556 .draw_behind(move |scope| {
557 if slide_hovered {
560 let hover = if on_light {
561 Color(0.0, 0.0, 0.0, 0.08)
562 } else {
563 Color(1.0, 1.0, 1.0, 0.10)
564 };
565 scope.draw_round_rect(
566 cranpose_ui_graphics::Brush::solid(hover),
567 cranpose_ui_graphics::CornerRadii::uniform(10.0),
568 );
569 }
570 })
571 .then(menu_item_pointer_input(
572 &item.label,
573 Rc::clone(&item.action),
574 Rc::clone(&motion.slide_live),
575 )),
576 menu_text_style(on_light),
577 );
578 }
579 if page_count > 1 {
580 let pressed_now = disc_pressed.get();
583 let motion = Rc::clone(&motion);
584 let advance: Rc<dyn Fn()> = Rc::new(move || {
585 motion.page.set((motion.page.get() + 1) % page_count);
586 crate::request_render_invalidation();
587 });
588 let invalidate: Rc<dyn Fn()> =
589 Rc::new(crate::request_render_invalidation);
590 Box(
591 Modifier::empty()
592 .size(Size {
593 width: MENU_HEIGHT,
594 height: MENU_HEIGHT,
595 })
596 .background(if pressed_now {
597 disc_pressed_color(on_light)
598 } else {
599 disc_color(on_light)
600 })
601 .rounded_corners(MENU_HEIGHT * 0.5)
602 .then(disc_pointer_input(
603 Rc::clone(&disc_pressed),
604 advance,
605 invalidate,
606 )),
607 BoxSpec::default()
608 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
609 move || {
610 Text(
611 "\u{203A}".to_string(),
612 Modifier::empty(),
613 menu_text_style(on_light),
614 );
615 },
616 );
617 }
618 },
619 );
620 },
621 );
622 });
623}
624
625#[allow(clippy::too_many_arguments)]
631#[composable]
632pub fn TextSelectionMenu(
633 center_x: f32,
634 line_top_y: f32,
635 visible: bool,
636 live_point: Option<cranpose_ui_graphics::Point>,
637 can_paste: bool,
638 on_copy: impl Fn() + 'static,
639 on_cut: impl Fn() + 'static,
640 on_paste: impl Fn() + 'static,
641 on_select_all: impl Fn() + 'static,
642) {
643 let mut items = vec![
644 TextMenuItem::new("Copy", on_copy),
645 TextMenuItem::new("Cut", on_cut),
646 ];
647 if can_paste {
648 items.push(TextMenuItem::new("Paste", on_paste));
649 }
650 items.push(TextMenuItem::new("Select all", on_select_all));
651 LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
652}
653
654#[allow(clippy::too_many_arguments)]
661#[composable]
662pub fn CaretActionMenu(
663 center_x: f32,
664 line_top_y: f32,
665 visible: bool,
666 can_paste: bool,
667 can_undo: bool,
668 can_redo: bool,
669 on_paste: impl Fn() + 'static,
670 on_select_all: impl Fn() + 'static,
671 on_undo: impl Fn() + 'static,
672 on_redo: impl Fn() + 'static,
673) {
674 let mut items = Vec::new();
675 if can_paste {
676 items.push(TextMenuItem::new("Paste", on_paste));
677 }
678 items.push(TextMenuItem::new("Select all", on_select_all));
679 if can_undo {
680 items.push(TextMenuItem::new("Undo", on_undo));
681 }
682 if can_redo {
683 items.push(TextMenuItem::new("Redo", on_redo));
684 }
685 LiquidTextMenu(center_x, line_top_y, visible, None, items);
686}
687
688#[cfg(test)]
689mod tests {
690 use cranpose_foundation::PointerEvent;
691 use cranpose_ui_graphics::Point;
692
693 use super::*;
694 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
695
696 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
701 let slices = collect_slices_from_modifier(modifier);
702 assert_eq!(
703 slices.pointer_inputs().len(),
704 1,
705 "menu button must install exactly one pointer-input gesture"
706 );
707 let handler = slices.pointer_inputs()[0].clone();
708 (handler, slices)
709 }
710
711 fn down(x: f32, y: f32) -> PointerEvent {
712 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
713 }
714 fn up(x: f32, y: f32) -> PointerEvent {
715 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
716 }
717
718 #[test]
723 fn menu_button_consumes_the_tap_and_runs_the_action() {
724 let _app_context = crate::render_state::app_context_test_scope();
725 let ran = Rc::new(Cell::new(false));
726 let action: Rc<dyn Fn()> = {
727 let ran = Rc::clone(&ran);
728 Rc::new(move || ran.set(true))
729 };
730 let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(false)));
731 let (handler, _slices) = button_handler(&modifier);
732
733 let press = down(5.0, 5.0);
734 handler(press.clone());
735 assert!(
736 press.is_consumed(),
737 "the press must be consumed so it never reaches the field and collapses the selection"
738 );
739 assert!(!ran.get(), "the action fires on release, not on press");
740
741 let release = up(6.0, 6.0);
742 handler(release.clone());
743 assert!(release.is_consumed(), "the release must be consumed too");
744 assert!(
745 ran.get(),
746 "releasing after a press on the button runs the action"
747 );
748 }
749
750 #[test]
753 fn menu_button_release_without_press_is_consumed_but_inert() {
754 let _app_context = crate::render_state::app_context_test_scope();
755 let ran = Rc::new(Cell::new(false));
756 let action: Rc<dyn Fn()> = {
757 let ran = Rc::clone(&ran);
758 Rc::new(move || ran.set(true))
759 };
760 let modifier = menu_item_pointer_input("Cut", action, Rc::new(Cell::new(false)));
761 let (handler, _slices) = button_handler(&modifier);
762
763 let release = up(5.0, 5.0);
764 handler(release.clone());
765 assert!(
766 release.is_consumed(),
767 "a release on the menu is consumed so it never hits the field"
768 );
769 assert!(!ran.get(), "a release with no matching press must not act");
770 }
771
772 #[test]
773 fn menu_button_accepts_release_from_a_continuous_press() {
774 let _app_context = crate::render_state::app_context_test_scope();
775 let ran = Rc::new(Cell::new(false));
776 let action: Rc<dyn Fn()> = {
777 let ran = Rc::clone(&ran);
778 Rc::new(move || ran.set(true))
779 };
780 let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(true)));
781 let (handler, _slices) = button_handler(&modifier);
782
783 let release = up(5.0, 5.0);
784 handler(release.clone());
785
786 assert!(release.is_consumed());
787 assert!(ran.get());
788 }
789
790 #[test]
793 fn menu_geometry_matches_the_reference() {
794 assert_eq!(MENU_HEIGHT, 44.0);
795 assert_eq!(ITEM_PADDING, 20.0);
796 assert_eq!(SEPARATOR_WIDTH, 1.0);
797 assert_eq!(SEPARATOR_HEIGHT, 17.0);
798 assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
799 assert_eq!(MENU_SCREEN_MARGIN, 20.0);
800 }
801
802 #[test]
806 fn pagination_reserves_the_disc_only_when_overflowing() {
807 let _app_context = crate::render_state::app_context_test_scope();
808 let style = menu_text_style(false);
812 let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
813 .iter()
814 .map(|label| TextMenuItem::new(*label, || {}))
815 .collect();
816
817 let one = paginate(&items, &style, f32::INFINITY);
818 assert_eq!(one.len(), 1, "everything fits on one page");
819 assert_eq!(one[0].len(), 4);
820
821 let total: f32 = items
822 .iter()
823 .map(|i| item_width(&i.label, &style))
824 .sum::<f32>()
825 + 3.0 * SEPARATOR_WIDTH;
826 let narrow = paginate(&items, &style, total * 0.55);
827 assert!(narrow.len() > 1, "a narrow window must page");
828 assert!(narrow.iter().all(|page| !page.is_empty()));
829 let all: Vec<usize> = narrow.iter().flatten().copied().collect();
830 assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
831 }
832}