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