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