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