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 SEPARATOR_COLOR: Color = Color(1.0, 1.0, 1.0, 0.07);
51const MENU_FG: Color = Color(0.96, 0.96, 0.98, 1.0);
53const MENU_FONT_SP: f32 = 15.0;
54const DISC_COLOR: Color = Color(1.0, 1.0, 1.0, 0.19);
56const DISC_PRESSED_COLOR: Color = Color(1.0, 1.0, 1.0, 0.9);
57const MENU_BLUR_DP: f32 = 15.0;
61
62const MENU_DISSOLVE_MS: u64 = 70;
65const MENU_MATERIALIZE_MS: u64 = 140;
66const MENU_RETURN_DELAY_MS: u64 = 250;
67
68fn menu_text_style() -> TextStyle {
69 let mut style = TextStyle::default();
70 style.span_style.color = Some(MENU_FG);
71 style.span_style.font_size = TextUnit::Sp(MENU_FONT_SP);
72 style
73}
74
75#[derive(Clone)]
77pub struct TextMenuItem {
78 pub label: String,
79 pub action: Rc<dyn Fn()>,
80}
81
82impl TextMenuItem {
83 pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
84 Self {
85 label: label.into(),
86 action: Rc::new(action),
87 }
88 }
89}
90
91impl PartialEq for TextMenuItem {
92 fn eq(&self, other: &Self) -> bool {
93 self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
97 }
98}
99
100impl std::fmt::Debug for TextMenuItem {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("TextMenuItem")
103 .field("label", &self.label)
104 .finish()
105 }
106}
107
108pub(crate) fn menu_item_pointer_input(label: &str, action: Rc<dyn Fn()>) -> Modifier {
114 let key = label.to_string();
115 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
116 let action = Rc::clone(&action);
117 async move {
118 scope
119 .await_pointer_event_scope(|await_scope| async move {
120 let mut pressed = false;
125 loop {
126 let event = await_scope.await_pointer_event().await;
127 match event.kind {
128 PointerEventKind::Down => {
129 pressed = true;
130 event.consume();
131 }
132 PointerEventKind::Move => {
133 event.consume();
134 }
135 PointerEventKind::Up => {
136 if pressed {
137 action();
138 }
139 pressed = false;
140 event.consume();
141 }
142 PointerEventKind::Cancel => {
143 pressed = false;
144 event.consume();
145 }
146 _ => {}
147 }
148 }
149 })
150 .await;
151 }
152 })
153}
154
155fn disc_pointer_input(
158 pressed: Rc<Cell<bool>>,
159 on_tap: Rc<dyn Fn()>,
160 invalidate: Rc<dyn Fn()>,
161) -> Modifier {
162 Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
163 let pressed = Rc::clone(&pressed);
164 let on_tap = Rc::clone(&on_tap);
165 let invalidate = Rc::clone(&invalidate);
166 async move {
167 scope
168 .await_pointer_event_scope(|await_scope| async move {
169 let mut down = false;
170 loop {
171 let event = await_scope.await_pointer_event().await;
172 match event.kind {
173 PointerEventKind::Down => {
174 down = true;
175 pressed.set(true);
176 invalidate();
177 event.consume();
178 }
179 PointerEventKind::Move => {
180 event.consume();
181 }
182 PointerEventKind::Up => {
183 if down {
184 on_tap();
185 }
186 down = false;
187 pressed.set(false);
188 invalidate();
189 event.consume();
190 }
191 PointerEventKind::Cancel => {
192 down = false;
193 pressed.set(false);
194 invalidate();
195 event.consume();
196 }
197 _ => {}
198 }
199 }
200 })
201 .await;
202 }
203 })
204}
205
206fn item_width(label: &str, style: &TextStyle) -> f32 {
208 measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
209}
210
211fn slide_item_at(
215 point: cranpose_ui_graphics::Point,
216 origin_x: f32,
217 origin_y: f32,
218 page_items: &[TextMenuItem],
219 style: &TextStyle,
220) -> Option<usize> {
221 if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
222 return None;
223 }
224 let mut cursor = origin_x;
225 for (index, item) in page_items.iter().enumerate() {
226 if index > 0 {
227 cursor += SEPARATOR_WIDTH;
228 }
229 let width = item_width(&item.label, style);
230 if point.x >= cursor && point.x < cursor + width {
231 return Some(index);
232 }
233 cursor += width;
234 }
235 None
236}
237
238fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
242 let widths: Vec<f32> = items
243 .iter()
244 .map(|item| item_width(&item.label, style))
245 .collect();
246 let total: f32 =
247 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
248 if total <= max_width || items.len() <= 1 {
249 return vec![(0..items.len()).collect()];
250 }
251 let disc = MENU_HEIGHT;
254 let mut pages: Vec<Vec<usize>> = Vec::new();
255 let mut page: Vec<usize> = Vec::new();
256 let mut used = disc;
257 for (index, width) in widths.iter().enumerate() {
258 let extra = width
259 + if page.is_empty() {
260 0.0
261 } else {
262 SEPARATOR_WIDTH
263 };
264 if !page.is_empty() && used + extra > max_width {
265 pages.push(std::mem::take(&mut page));
266 used = disc;
267 }
268 used += width
269 + if page.is_empty() {
270 0.0
271 } else {
272 SEPARATOR_WIDTH
273 };
274 page.push(index);
275 }
276 if !page.is_empty() {
277 pages.push(page);
278 }
279 pages
280}
281
282struct MenuMotion {
285 progress: RefCell<Animatable<f32>>,
286 was_visible: Cell<bool>,
287 page: Cell<usize>,
288 disc_pressed: Rc<Cell<bool>>,
289 slide_hover: Cell<Option<usize>>,
292 slide_live: Cell<bool>,
293}
294
295#[composable]
306pub fn LiquidTextMenu(
307 center_x: f32,
308 line_top_y: f32,
309 visible: bool,
310 live_point: Option<cranpose_ui_graphics::Point>,
311 items: Vec<TextMenuItem>,
312) {
313 let motion = remember(|| {
314 let runtime = with_current_composer(|composer| composer.runtime_handle());
315 Rc::new(MenuMotion {
316 progress: RefCell::new(Animatable::new(0.0, runtime)),
317 was_visible: Cell::new(false),
318 page: Cell::new(0),
319 disc_pressed: Rc::new(Cell::new(false)),
320 slide_hover: Cell::new(None),
321 slide_live: Cell::new(false),
322 })
323 })
324 .with(Rc::clone);
325
326 if visible != motion.was_visible.get() {
327 motion.was_visible.set(visible);
328 let mut progress = motion.progress.borrow_mut();
329 if visible {
330 progress.animateTo(
331 1.0,
332 AnimationType::Tween(
333 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
334 .with_delay(MENU_RETURN_DELAY_MS),
335 ),
336 );
337 } else {
338 progress.animateTo(
339 0.0,
340 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
341 );
342 }
343 }
344 let progress_state = motion.progress.borrow().state();
345 let p = progress_state.value().clamp(0.0, 1.0);
346 if !visible && p <= 0.01 {
347 return;
348 }
349
350 let style = menu_text_style();
351 let viewport = local_popup_viewport().current().get();
352 let max_width = if viewport.width > 0.0 {
353 viewport.width - 2.0 * MENU_SCREEN_MARGIN
354 } else {
355 f32::INFINITY
356 };
357 let pages = paginate(&items, &style, max_width);
358 let page_index = motion.page.get().min(pages.len() - 1);
359 let page = &pages[page_index];
360 let has_disc = pages.len() > 1;
361
362 let mut width: f32 = page
365 .iter()
366 .map(|&i| item_width(&items[i].label, &style))
367 .sum();
368 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
369 if has_disc {
370 width += MENU_HEIGHT;
371 }
372
373 let mut x = center_x - width * 0.5;
374 if viewport.width > 0.0 {
375 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
376 }
377 x = x.max(MENU_SCREEN_MARGIN);
378 let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
379
380 let anchor = Rect {
381 x,
382 y,
383 width: 0.0,
384 height: 0.0,
385 };
386 let density = crate::current_density();
387 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
388
389 let slide_hover = match live_point {
394 Some(point) => {
395 motion.slide_live.set(true);
396 let hover = slide_item_at(point, x, y, &page_items, &style);
397 motion.slide_hover.set(hover);
398 hover
399 }
400 None => {
401 let hover = motion.slide_hover.take();
402 if motion.slide_live.replace(false) {
403 if let Some(index) = hover {
404 if let Some(item) = page_items.get(index) {
405 let action = Rc::clone(&item.action);
406 SideEffect(move || action());
407 }
408 }
409 }
410 None
411 }
412 };
413 let disc_pressed = Rc::clone(&motion.disc_pressed);
414 let page_count = pages.len();
415 let motion_for_disc = Rc::clone(&motion);
416 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
417 let page_items = page_items.clone();
418 let disc_pressed = Rc::clone(&disc_pressed);
419 let motion = Rc::clone(&motion_for_disc);
420 Box(
421 Modifier::empty()
422 .size(Size {
423 width,
424 height: MENU_HEIGHT,
425 })
426 .drop_shadow(
431 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
432 move |scope| {
433 scope.radius = 16.0;
439 scope.spread = -2.0;
440 scope.offset.y = 0.0;
441 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
442 scope.cutout = true;
443 },
444 )
445 .graphics_layer(move || GraphicsLayer {
446 alpha: p,
447 backdrop_effect: (p > 0.001).then(|| {
453 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
454 }),
455 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
456 clip: true,
457 ..Default::default()
458 }),
459 BoxSpec::default(),
460 move || {
461 let page_items = page_items.clone();
462 let disc_pressed = Rc::clone(&disc_pressed);
463 let motion = Rc::clone(&motion);
464 Row(
465 Modifier::empty().size(Size {
466 width,
467 height: MENU_HEIGHT,
468 }),
469 RowSpec::default().vertical_alignment(
470 cranpose_ui_layout::VerticalAlignment::CenterVertically,
471 ),
472 move || {
473 for (index, item) in page_items.iter().enumerate() {
474 if index > 0 {
475 Box(
476 Modifier::empty()
477 .size(Size {
478 width: SEPARATOR_WIDTH,
479 height: SEPARATOR_HEIGHT,
480 })
481 .background(SEPARATOR_COLOR),
482 BoxSpec::default(),
483 || {},
484 );
485 }
486 let slide_hovered = slide_hover == Some(index);
487 Text(
488 item.label.clone(),
489 Modifier::empty()
490 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
494 .draw_behind(move |scope| {
495 if slide_hovered {
498 scope.draw_round_rect(
499 cranpose_ui_graphics::Brush::solid(Color(
500 1.0, 1.0, 1.0, 0.10,
501 )),
502 cranpose_ui_graphics::CornerRadii::uniform(10.0),
503 );
504 }
505 })
506 .then(menu_item_pointer_input(
507 &item.label,
508 Rc::clone(&item.action),
509 )),
510 menu_text_style(),
511 );
512 }
513 if page_count > 1 {
514 let pressed_now = disc_pressed.get();
517 let motion = Rc::clone(&motion);
518 let advance: Rc<dyn Fn()> = Rc::new(move || {
519 motion.page.set((motion.page.get() + 1) % page_count);
520 crate::request_render_invalidation();
521 });
522 let invalidate: Rc<dyn Fn()> =
523 Rc::new(crate::request_render_invalidation);
524 Box(
525 Modifier::empty()
526 .size(Size {
527 width: MENU_HEIGHT,
528 height: MENU_HEIGHT,
529 })
530 .background(if pressed_now {
531 DISC_PRESSED_COLOR
532 } else {
533 DISC_COLOR
534 })
535 .rounded_corners(MENU_HEIGHT * 0.5)
536 .then(disc_pointer_input(
537 Rc::clone(&disc_pressed),
538 advance,
539 invalidate,
540 )),
541 BoxSpec::default()
542 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
543 || {
544 Text(
545 "\u{203A}".to_string(),
546 Modifier::empty(),
547 menu_text_style(),
548 );
549 },
550 );
551 }
552 },
553 );
554 },
555 );
556 });
557}
558
559#[allow(clippy::too_many_arguments)]
565#[composable]
566pub fn TextSelectionMenu(
567 center_x: f32,
568 line_top_y: f32,
569 visible: bool,
570 live_point: Option<cranpose_ui_graphics::Point>,
571 can_paste: bool,
572 on_copy: impl Fn() + 'static,
573 on_cut: impl Fn() + 'static,
574 on_paste: impl Fn() + 'static,
575 on_select_all: impl Fn() + 'static,
576) {
577 let mut items = vec![
578 TextMenuItem::new("Copy", on_copy),
579 TextMenuItem::new("Cut", on_cut),
580 ];
581 if can_paste {
582 items.push(TextMenuItem::new("Paste", on_paste));
583 }
584 items.push(TextMenuItem::new("Select all", on_select_all));
585 LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
586}
587
588#[allow(clippy::too_many_arguments)]
595#[composable]
596pub fn CaretActionMenu(
597 center_x: f32,
598 line_top_y: f32,
599 visible: bool,
600 can_paste: bool,
601 can_undo: bool,
602 can_redo: bool,
603 on_paste: impl Fn() + 'static,
604 on_select_all: impl Fn() + 'static,
605 on_undo: impl Fn() + 'static,
606 on_redo: impl Fn() + 'static,
607) {
608 let mut items = Vec::new();
609 if can_paste {
610 items.push(TextMenuItem::new("Paste", on_paste));
611 }
612 items.push(TextMenuItem::new("Select all", on_select_all));
613 if can_undo {
614 items.push(TextMenuItem::new("Undo", on_undo));
615 }
616 if can_redo {
617 items.push(TextMenuItem::new("Redo", on_redo));
618 }
619 LiquidTextMenu(center_x, line_top_y, visible, None, items);
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
626 use cranpose_foundation::PointerEvent;
627 use cranpose_ui_graphics::Point;
628
629 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
634 let slices = collect_slices_from_modifier(modifier);
635 assert_eq!(
636 slices.pointer_inputs().len(),
637 1,
638 "menu button must install exactly one pointer-input gesture"
639 );
640 let handler = slices.pointer_inputs()[0].clone();
641 (handler, slices)
642 }
643
644 fn down(x: f32, y: f32) -> PointerEvent {
645 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
646 }
647 fn up(x: f32, y: f32) -> PointerEvent {
648 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
649 }
650
651 #[test]
656 fn menu_button_consumes_the_tap_and_runs_the_action() {
657 let _app_context = crate::render_state::app_context_test_scope();
658 let ran = Rc::new(Cell::new(false));
659 let action: Rc<dyn Fn()> = {
660 let ran = Rc::clone(&ran);
661 Rc::new(move || ran.set(true))
662 };
663 let modifier = menu_item_pointer_input("Copy", action);
664 let (handler, _slices) = button_handler(&modifier);
665
666 let press = down(5.0, 5.0);
667 handler(press.clone());
668 assert!(
669 press.is_consumed(),
670 "the press must be consumed so it never reaches the field and collapses the selection"
671 );
672 assert!(!ran.get(), "the action fires on release, not on press");
673
674 let release = up(6.0, 6.0);
675 handler(release.clone());
676 assert!(release.is_consumed(), "the release must be consumed too");
677 assert!(
678 ran.get(),
679 "releasing after a press on the button runs the action"
680 );
681 }
682
683 #[test]
686 fn menu_button_release_without_press_is_consumed_but_inert() {
687 let _app_context = crate::render_state::app_context_test_scope();
688 let ran = Rc::new(Cell::new(false));
689 let action: Rc<dyn Fn()> = {
690 let ran = Rc::clone(&ran);
691 Rc::new(move || ran.set(true))
692 };
693 let modifier = menu_item_pointer_input("Cut", action);
694 let (handler, _slices) = button_handler(&modifier);
695
696 let release = up(5.0, 5.0);
697 handler(release.clone());
698 assert!(
699 release.is_consumed(),
700 "a release on the menu is consumed so it never hits the field"
701 );
702 assert!(!ran.get(), "a release with no matching press must not act");
703 }
704
705 #[test]
708 fn menu_geometry_matches_the_reference() {
709 assert_eq!(MENU_HEIGHT, 44.0);
710 assert_eq!(ITEM_PADDING, 20.0);
711 assert_eq!(SEPARATOR_WIDTH, 1.0);
712 assert_eq!(SEPARATOR_HEIGHT, 17.0);
713 assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
714 assert_eq!(MENU_SCREEN_MARGIN, 20.0);
715 }
716
717 #[test]
721 fn pagination_reserves_the_disc_only_when_overflowing() {
722 let _app_context = crate::render_state::app_context_test_scope();
723 let style = menu_text_style();
724 let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
725 .iter()
726 .map(|label| TextMenuItem::new(*label, || {}))
727 .collect();
728
729 let one = paginate(&items, &style, f32::INFINITY);
730 assert_eq!(one.len(), 1, "everything fits on one page");
731 assert_eq!(one[0].len(), 4);
732
733 let total: f32 = items
734 .iter()
735 .map(|i| item_width(&i.label, &style))
736 .sum::<f32>()
737 + 3.0 * SEPARATOR_WIDTH;
738 let narrow = paginate(&items, &style, total * 0.55);
739 assert!(narrow.len() > 1, "a narrow window must page");
740 assert!(narrow.iter().all(|page| !page.is_empty()));
741 let all: Vec<usize> = narrow.iter().flatten().copied().collect();
742 assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
743 }
744}