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(
163 label: &str,
164 action: Rc<dyn Fn()>,
165 continuous_press: Rc<Cell<bool>>,
166) -> Modifier {
167 let key = label.to_string();
168 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
169 let action = Rc::clone(&action);
170 let continuous_press = Rc::clone(&continuous_press);
171 async move {
172 scope
173 .await_pointer_event_scope(|await_scope| async move {
174 let mut pressed = false;
179 loop {
180 let event = await_scope.await_pointer_event().await;
181 match event.kind {
182 PointerEventKind::Down => {
183 pressed = true;
184 event.consume();
185 }
186 PointerEventKind::Move => {
187 event.consume();
188 }
189 PointerEventKind::Up => {
190 if pressed || continuous_press.get() {
191 action();
192 }
193 pressed = false;
194 event.consume();
195 }
196 PointerEventKind::Cancel => {
197 pressed = false;
198 event.consume();
199 }
200 _ => {}
201 }
202 }
203 })
204 .await;
205 }
206 })
207}
208
209fn disc_pointer_input(
212 pressed: Rc<Cell<bool>>,
213 on_tap: Rc<dyn Fn()>,
214 invalidate: Rc<dyn Fn()>,
215) -> Modifier {
216 Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
217 let pressed = Rc::clone(&pressed);
218 let on_tap = Rc::clone(&on_tap);
219 let invalidate = Rc::clone(&invalidate);
220 async move {
221 scope
222 .await_pointer_event_scope(|await_scope| async move {
223 let mut down = false;
224 loop {
225 let event = await_scope.await_pointer_event().await;
226 match event.kind {
227 PointerEventKind::Down => {
228 down = true;
229 pressed.set(true);
230 invalidate();
231 event.consume();
232 }
233 PointerEventKind::Move => {
234 event.consume();
235 }
236 PointerEventKind::Up => {
237 if down {
238 on_tap();
239 }
240 down = false;
241 pressed.set(false);
242 invalidate();
243 event.consume();
244 }
245 PointerEventKind::Cancel => {
246 down = false;
247 pressed.set(false);
248 invalidate();
249 event.consume();
250 }
251 _ => {}
252 }
253 }
254 })
255 .await;
256 }
257 })
258}
259
260fn item_width(label: &str, style: &TextStyle) -> f32 {
262 measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
263}
264
265fn slide_item_at(
269 point: cranpose_ui_graphics::Point,
270 origin_x: f32,
271 origin_y: f32,
272 page_items: &[TextMenuItem],
273 style: &TextStyle,
274) -> Option<usize> {
275 if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
276 return None;
277 }
278 let mut cursor = origin_x;
279 for (index, item) in page_items.iter().enumerate() {
280 if index > 0 {
281 cursor += SEPARATOR_WIDTH;
282 }
283 let width = item_width(&item.label, style);
284 if point.x >= cursor && point.x < cursor + width {
285 return Some(index);
286 }
287 cursor += width;
288 }
289 None
290}
291
292fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
296 let widths: Vec<f32> = items
297 .iter()
298 .map(|item| item_width(&item.label, style))
299 .collect();
300 let total: f32 =
301 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
302 if total <= max_width || items.len() <= 1 {
303 return vec![(0..items.len()).collect()];
304 }
305 let disc = MENU_HEIGHT;
308 let mut pages: Vec<Vec<usize>> = Vec::new();
309 let mut page: Vec<usize> = Vec::new();
310 let mut used = disc;
311 for (index, width) in widths.iter().enumerate() {
312 let extra = width
313 + if page.is_empty() {
314 0.0
315 } else {
316 SEPARATOR_WIDTH
317 };
318 if !page.is_empty() && used + extra > max_width {
319 pages.push(std::mem::take(&mut page));
320 used = disc;
321 }
322 used += width
323 + if page.is_empty() {
324 0.0
325 } else {
326 SEPARATOR_WIDTH
327 };
328 page.push(index);
329 }
330 if !page.is_empty() {
331 pages.push(page);
332 }
333 pages
334}
335
336struct MenuMotion {
339 progress: RefCell<Animatable<f32>>,
340 was_visible: Cell<bool>,
341 page: Cell<usize>,
342 disc_pressed: Rc<Cell<bool>>,
343 slide_hover: Cell<Option<usize>>,
346 slide_live: Rc<Cell<bool>>,
347}
348
349#[composable]
360pub fn LiquidTextMenu(
361 center_x: f32,
362 line_top_y: f32,
363 visible: bool,
364 live_point: Option<cranpose_ui_graphics::Point>,
365 items: Vec<TextMenuItem>,
366) {
367 let motion = remember(|| {
368 let runtime = with_current_composer(|composer| composer.runtime_handle());
369 Rc::new(MenuMotion {
370 progress: RefCell::new(Animatable::new(0.0, runtime)),
371 was_visible: Cell::new(false),
372 page: Cell::new(0),
373 disc_pressed: Rc::new(Cell::new(false)),
374 slide_hover: Cell::new(None),
375 slide_live: Rc::new(Cell::new(false)),
376 })
377 })
378 .with(Rc::clone);
379
380 if visible != motion.was_visible.get() {
381 motion.was_visible.set(visible);
382 let mut progress = motion.progress.borrow_mut();
383 if visible {
384 progress.animateTo(
385 1.0,
386 AnimationType::Tween(
387 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
388 .with_delay(MENU_RETURN_DELAY_MS),
389 ),
390 );
391 } else {
392 progress.animateTo(
393 0.0,
394 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
395 );
396 }
397 }
398 let progress_state = motion.progress.borrow().state();
399 let p = progress_state.value().clamp(0.0, 1.0);
400 if !visible && p <= 0.01 {
401 return;
402 }
403
404 let on_light = local_on_light_surface().current();
405 let style = menu_text_style(on_light);
406 let viewport = local_popup_viewport().current().get();
407 let max_width = if viewport.width > 0.0 {
408 viewport.width - 2.0 * MENU_SCREEN_MARGIN
409 } else {
410 f32::INFINITY
411 };
412 let pages = paginate(&items, &style, max_width);
413 let page_index = motion.page.get().min(pages.len() - 1);
414 let page = &pages[page_index];
415 let has_disc = pages.len() > 1;
416
417 let mut width: f32 = page
420 .iter()
421 .map(|&i| item_width(&items[i].label, &style))
422 .sum();
423 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
424 if has_disc {
425 width += MENU_HEIGHT;
426 }
427
428 let mut x = center_x - width * 0.5;
429 if viewport.width > 0.0 {
430 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
431 }
432 x = x.max(MENU_SCREEN_MARGIN);
433 let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
434
435 let anchor = Rect {
436 x,
437 y,
438 width: 0.0,
439 height: 0.0,
440 };
441 let density = crate::current_density();
442 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
443
444 let slide_hover = match live_point {
449 Some(point) => {
450 motion.slide_live.set(true);
451 let hover = slide_item_at(point, x, y, &page_items, &style);
452 motion.slide_hover.set(hover);
453 hover
454 }
455 None => {
456 let hover = motion.slide_hover.take();
457 if motion.slide_live.replace(false) {
458 if let Some(index) = hover {
459 if let Some(item) = page_items.get(index) {
460 let action = Rc::clone(&item.action);
461 SideEffect(move || action());
462 }
463 }
464 }
465 None
466 }
467 };
468 let disc_pressed = Rc::clone(&motion.disc_pressed);
469 let page_count = pages.len();
470 let motion_for_disc = Rc::clone(&motion);
471 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
472 let page_items = page_items.clone();
473 let disc_pressed = Rc::clone(&disc_pressed);
474 let motion = Rc::clone(&motion_for_disc);
475 Box(
476 Modifier::empty()
477 .size(Size {
478 width,
479 height: MENU_HEIGHT,
480 })
481 .drop_shadow(
486 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
487 move |scope| {
488 scope.radius = 16.0;
494 scope.spread = -2.0;
495 scope.offset.y = 0.0;
496 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
497 scope.cutout = true;
498 },
499 )
500 .graphics_layer(move || GraphicsLayer {
501 alpha: p,
502 backdrop_effect: (p > 0.001).then(|| {
508 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
509 }),
510 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
511 clip: true,
512 ..Default::default()
513 }),
514 BoxSpec::default(),
515 move || {
516 let page_items = page_items.clone();
517 let disc_pressed = Rc::clone(&disc_pressed);
518 let motion = Rc::clone(&motion);
519 Row(
520 Modifier::empty().size(Size {
521 width,
522 height: MENU_HEIGHT,
523 }),
524 RowSpec::default().vertical_alignment(
525 cranpose_ui_layout::VerticalAlignment::CenterVertically,
526 ),
527 move || {
528 for (index, item) in page_items.iter().enumerate() {
529 if index > 0 {
530 Box(
531 Modifier::empty()
532 .size(Size {
533 width: SEPARATOR_WIDTH,
534 height: SEPARATOR_HEIGHT,
535 })
536 .background(separator_color(on_light)),
537 BoxSpec::default(),
538 || {},
539 );
540 }
541 let slide_hovered = slide_hover == Some(index);
542 Text(
543 item.label.clone(),
544 Modifier::empty()
545 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
549 .draw_behind(move |scope| {
550 if slide_hovered {
553 let hover = if on_light {
554 Color(0.0, 0.0, 0.0, 0.08)
555 } else {
556 Color(1.0, 1.0, 1.0, 0.10)
557 };
558 scope.draw_round_rect(
559 cranpose_ui_graphics::Brush::solid(hover),
560 cranpose_ui_graphics::CornerRadii::uniform(10.0),
561 );
562 }
563 })
564 .then(menu_item_pointer_input(
565 &item.label,
566 Rc::clone(&item.action),
567 Rc::clone(&motion.slide_live),
568 )),
569 menu_text_style(on_light),
570 );
571 }
572 if page_count > 1 {
573 let pressed_now = disc_pressed.get();
576 let motion = Rc::clone(&motion);
577 let advance: Rc<dyn Fn()> = Rc::new(move || {
578 motion.page.set((motion.page.get() + 1) % page_count);
579 crate::request_render_invalidation();
580 });
581 let invalidate: Rc<dyn Fn()> =
582 Rc::new(crate::request_render_invalidation);
583 Box(
584 Modifier::empty()
585 .size(Size {
586 width: MENU_HEIGHT,
587 height: MENU_HEIGHT,
588 })
589 .background(if pressed_now {
590 disc_pressed_color(on_light)
591 } else {
592 disc_color(on_light)
593 })
594 .rounded_corners(MENU_HEIGHT * 0.5)
595 .then(disc_pointer_input(
596 Rc::clone(&disc_pressed),
597 advance,
598 invalidate,
599 )),
600 BoxSpec::default()
601 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
602 move || {
603 Text(
604 "\u{203A}".to_string(),
605 Modifier::empty(),
606 menu_text_style(on_light),
607 );
608 },
609 );
610 }
611 },
612 );
613 },
614 );
615 });
616}
617
618#[allow(clippy::too_many_arguments)]
624#[composable]
625pub fn TextSelectionMenu(
626 center_x: f32,
627 line_top_y: f32,
628 visible: bool,
629 live_point: Option<cranpose_ui_graphics::Point>,
630 can_paste: bool,
631 on_copy: impl Fn() + 'static,
632 on_cut: impl Fn() + 'static,
633 on_paste: impl Fn() + 'static,
634 on_select_all: impl Fn() + 'static,
635) {
636 let mut items = vec![
637 TextMenuItem::new("Copy", on_copy),
638 TextMenuItem::new("Cut", on_cut),
639 ];
640 if can_paste {
641 items.push(TextMenuItem::new("Paste", on_paste));
642 }
643 items.push(TextMenuItem::new("Select all", on_select_all));
644 LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
645}
646
647#[allow(clippy::too_many_arguments)]
654#[composable]
655pub fn CaretActionMenu(
656 center_x: f32,
657 line_top_y: f32,
658 visible: bool,
659 can_paste: bool,
660 can_undo: bool,
661 can_redo: bool,
662 on_paste: impl Fn() + 'static,
663 on_select_all: impl Fn() + 'static,
664 on_undo: impl Fn() + 'static,
665 on_redo: impl Fn() + 'static,
666) {
667 let mut items = Vec::new();
668 if can_paste {
669 items.push(TextMenuItem::new("Paste", on_paste));
670 }
671 items.push(TextMenuItem::new("Select all", on_select_all));
672 if can_undo {
673 items.push(TextMenuItem::new("Undo", on_undo));
674 }
675 if can_redo {
676 items.push(TextMenuItem::new("Redo", on_redo));
677 }
678 LiquidTextMenu(center_x, line_top_y, visible, None, items);
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
685 use cranpose_foundation::PointerEvent;
686 use cranpose_ui_graphics::Point;
687
688 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
693 let slices = collect_slices_from_modifier(modifier);
694 assert_eq!(
695 slices.pointer_inputs().len(),
696 1,
697 "menu button must install exactly one pointer-input gesture"
698 );
699 let handler = slices.pointer_inputs()[0].clone();
700 (handler, slices)
701 }
702
703 fn down(x: f32, y: f32) -> PointerEvent {
704 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
705 }
706 fn up(x: f32, y: f32) -> PointerEvent {
707 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
708 }
709
710 #[test]
715 fn menu_button_consumes_the_tap_and_runs_the_action() {
716 let _app_context = crate::render_state::app_context_test_scope();
717 let ran = Rc::new(Cell::new(false));
718 let action: Rc<dyn Fn()> = {
719 let ran = Rc::clone(&ran);
720 Rc::new(move || ran.set(true))
721 };
722 let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(false)));
723 let (handler, _slices) = button_handler(&modifier);
724
725 let press = down(5.0, 5.0);
726 handler(press.clone());
727 assert!(
728 press.is_consumed(),
729 "the press must be consumed so it never reaches the field and collapses the selection"
730 );
731 assert!(!ran.get(), "the action fires on release, not on press");
732
733 let release = up(6.0, 6.0);
734 handler(release.clone());
735 assert!(release.is_consumed(), "the release must be consumed too");
736 assert!(
737 ran.get(),
738 "releasing after a press on the button runs the action"
739 );
740 }
741
742 #[test]
745 fn menu_button_release_without_press_is_consumed_but_inert() {
746 let _app_context = crate::render_state::app_context_test_scope();
747 let ran = Rc::new(Cell::new(false));
748 let action: Rc<dyn Fn()> = {
749 let ran = Rc::clone(&ran);
750 Rc::new(move || ran.set(true))
751 };
752 let modifier = menu_item_pointer_input("Cut", action, Rc::new(Cell::new(false)));
753 let (handler, _slices) = button_handler(&modifier);
754
755 let release = up(5.0, 5.0);
756 handler(release.clone());
757 assert!(
758 release.is_consumed(),
759 "a release on the menu is consumed so it never hits the field"
760 );
761 assert!(!ran.get(), "a release with no matching press must not act");
762 }
763
764 #[test]
765 fn menu_button_accepts_release_from_a_continuous_press() {
766 let _app_context = crate::render_state::app_context_test_scope();
767 let ran = Rc::new(Cell::new(false));
768 let action: Rc<dyn Fn()> = {
769 let ran = Rc::clone(&ran);
770 Rc::new(move || ran.set(true))
771 };
772 let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(true)));
773 let (handler, _slices) = button_handler(&modifier);
774
775 let release = up(5.0, 5.0);
776 handler(release.clone());
777
778 assert!(release.is_consumed());
779 assert!(ran.get());
780 }
781
782 #[test]
785 fn menu_geometry_matches_the_reference() {
786 assert_eq!(MENU_HEIGHT, 44.0);
787 assert_eq!(ITEM_PADDING, 20.0);
788 assert_eq!(SEPARATOR_WIDTH, 1.0);
789 assert_eq!(SEPARATOR_HEIGHT, 17.0);
790 assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
791 assert_eq!(MENU_SCREEN_MARGIN, 20.0);
792 }
793
794 #[test]
798 fn pagination_reserves_the_disc_only_when_overflowing() {
799 let _app_context = crate::render_state::app_context_test_scope();
800 let style = menu_text_style(false);
804 let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
805 .iter()
806 .map(|label| TextMenuItem::new(*label, || {}))
807 .collect();
808
809 let one = paginate(&items, &style, f32::INFINITY);
810 assert_eq!(one.len(), 1, "everything fits on one page");
811 assert_eq!(one[0].len(), 4);
812
813 let total: f32 = items
814 .iter()
815 .map(|i| item_width(&i.label, &style))
816 .sum::<f32>()
817 + 3.0 * SEPARATOR_WIDTH;
818 let narrow = paginate(&items, &style, total * 0.55);
819 assert!(narrow.len() > 1, "a narrow window must page");
820 assert!(narrow.iter().all(|page| !page.is_empty()));
821 let all: Vec<usize> = narrow.iter().flatten().copied().collect();
822 assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
823 }
824}