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};
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 paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
215 let widths: Vec<f32> = items
216 .iter()
217 .map(|item| item_width(&item.label, style))
218 .collect();
219 let total: f32 =
220 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
221 if total <= max_width || items.len() <= 1 {
222 return vec![(0..items.len()).collect()];
223 }
224 let disc = MENU_HEIGHT;
227 let mut pages: Vec<Vec<usize>> = Vec::new();
228 let mut page: Vec<usize> = Vec::new();
229 let mut used = disc;
230 for (index, width) in widths.iter().enumerate() {
231 let extra = width
232 + if page.is_empty() {
233 0.0
234 } else {
235 SEPARATOR_WIDTH
236 };
237 if !page.is_empty() && used + extra > max_width {
238 pages.push(std::mem::take(&mut page));
239 used = disc;
240 }
241 used += width
242 + if page.is_empty() {
243 0.0
244 } else {
245 SEPARATOR_WIDTH
246 };
247 page.push(index);
248 }
249 if !page.is_empty() {
250 pages.push(page);
251 }
252 pages
253}
254
255struct MenuMotion {
258 progress: RefCell<Animatable<f32>>,
259 was_visible: Cell<bool>,
260 page: Cell<usize>,
261 disc_pressed: Rc<Cell<bool>>,
262}
263
264#[composable]
275pub fn LiquidTextMenu(center_x: f32, line_top_y: f32, visible: bool, items: Vec<TextMenuItem>) {
276 let motion = remember(|| {
277 let runtime = with_current_composer(|composer| composer.runtime_handle());
278 Rc::new(MenuMotion {
279 progress: RefCell::new(Animatable::new(0.0, runtime)),
280 was_visible: Cell::new(false),
281 page: Cell::new(0),
282 disc_pressed: Rc::new(Cell::new(false)),
283 })
284 })
285 .with(Rc::clone);
286
287 if visible != motion.was_visible.get() {
288 motion.was_visible.set(visible);
289 let mut progress = motion.progress.borrow_mut();
290 if visible {
291 progress.animateTo(
292 1.0,
293 AnimationType::Tween(
294 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
295 .with_delay(MENU_RETURN_DELAY_MS),
296 ),
297 );
298 } else {
299 progress.animateTo(
300 0.0,
301 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
302 );
303 }
304 }
305 let progress_state = motion.progress.borrow().state();
306 let p = progress_state.value().clamp(0.0, 1.0);
307 if !visible && p <= 0.01 {
308 return;
309 }
310
311 let style = menu_text_style();
312 let viewport = local_popup_viewport().current().get();
313 let max_width = if viewport.width > 0.0 {
314 viewport.width - 2.0 * MENU_SCREEN_MARGIN
315 } else {
316 f32::INFINITY
317 };
318 let pages = paginate(&items, &style, max_width);
319 let page_index = motion.page.get().min(pages.len() - 1);
320 let page = &pages[page_index];
321 let has_disc = pages.len() > 1;
322
323 let mut width: f32 = page
326 .iter()
327 .map(|&i| item_width(&items[i].label, &style))
328 .sum();
329 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
330 if has_disc {
331 width += MENU_HEIGHT;
332 }
333
334 let mut x = center_x - width * 0.5;
335 if viewport.width > 0.0 {
336 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
337 }
338 x = x.max(MENU_SCREEN_MARGIN);
339 let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
340
341 let anchor = Rect {
342 x,
343 y,
344 width: 0.0,
345 height: 0.0,
346 };
347 let density = crate::current_density();
348 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
349 let disc_pressed = Rc::clone(&motion.disc_pressed);
350 let page_count = pages.len();
351 let motion_for_disc = Rc::clone(&motion);
352 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
353 let page_items = page_items.clone();
354 let disc_pressed = Rc::clone(&disc_pressed);
355 let motion = Rc::clone(&motion_for_disc);
356 Box(
357 Modifier::empty()
358 .size(Size {
359 width,
360 height: MENU_HEIGHT,
361 })
362 .drop_shadow(
367 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
368 move |scope| {
369 scope.radius = 16.0;
375 scope.spread = -2.0;
376 scope.offset.y = 0.0;
377 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
378 scope.cutout = true;
379 },
380 )
381 .graphics_layer(move || GraphicsLayer {
382 alpha: p,
383 backdrop_effect: (p > 0.001).then(|| {
389 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
390 }),
391 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
392 clip: true,
393 ..Default::default()
394 }),
395 BoxSpec::default(),
396 move || {
397 let page_items = page_items.clone();
398 let disc_pressed = Rc::clone(&disc_pressed);
399 let motion = Rc::clone(&motion);
400 Row(
401 Modifier::empty().size(Size {
402 width,
403 height: MENU_HEIGHT,
404 }),
405 RowSpec::default().vertical_alignment(
406 cranpose_ui_layout::VerticalAlignment::CenterVertically,
407 ),
408 move || {
409 for (index, item) in page_items.iter().enumerate() {
410 if index > 0 {
411 Box(
412 Modifier::empty()
413 .size(Size {
414 width: SEPARATOR_WIDTH,
415 height: SEPARATOR_HEIGHT,
416 })
417 .background(SEPARATOR_COLOR),
418 BoxSpec::default(),
419 || {},
420 );
421 }
422 Text(
423 item.label.clone(),
424 Modifier::empty()
425 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
429 .then(menu_item_pointer_input(
430 &item.label,
431 Rc::clone(&item.action),
432 )),
433 menu_text_style(),
434 );
435 }
436 if page_count > 1 {
437 let pressed_now = disc_pressed.get();
440 let motion = Rc::clone(&motion);
441 let advance: Rc<dyn Fn()> = Rc::new(move || {
442 motion.page.set((motion.page.get() + 1) % page_count);
443 crate::request_render_invalidation();
444 });
445 let invalidate: Rc<dyn Fn()> =
446 Rc::new(crate::request_render_invalidation);
447 Box(
448 Modifier::empty()
449 .size(Size {
450 width: MENU_HEIGHT,
451 height: MENU_HEIGHT,
452 })
453 .background(if pressed_now {
454 DISC_PRESSED_COLOR
455 } else {
456 DISC_COLOR
457 })
458 .rounded_corners(MENU_HEIGHT * 0.5)
459 .then(disc_pointer_input(
460 Rc::clone(&disc_pressed),
461 advance,
462 invalidate,
463 )),
464 BoxSpec::default()
465 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
466 || {
467 Text(
468 "\u{203A}".to_string(),
469 Modifier::empty(),
470 menu_text_style(),
471 );
472 },
473 );
474 }
475 },
476 );
477 },
478 );
479 });
480}
481
482#[allow(clippy::too_many_arguments)]
488#[composable]
489pub fn TextSelectionMenu(
490 center_x: f32,
491 line_top_y: f32,
492 visible: bool,
493 can_paste: bool,
494 on_copy: impl Fn() + 'static,
495 on_cut: impl Fn() + 'static,
496 on_paste: impl Fn() + 'static,
497 on_select_all: impl Fn() + 'static,
498) {
499 let mut items = vec![
500 TextMenuItem::new("Copy", on_copy),
501 TextMenuItem::new("Cut", on_cut),
502 ];
503 if can_paste {
504 items.push(TextMenuItem::new("Paste", on_paste));
505 }
506 items.push(TextMenuItem::new("Select all", on_select_all));
507 LiquidTextMenu(center_x, line_top_y, visible, items);
508}
509
510#[allow(clippy::too_many_arguments)]
517#[composable]
518pub fn CaretActionMenu(
519 center_x: f32,
520 line_top_y: f32,
521 visible: bool,
522 can_paste: bool,
523 can_undo: bool,
524 can_redo: bool,
525 on_paste: impl Fn() + 'static,
526 on_select_all: impl Fn() + 'static,
527 on_undo: impl Fn() + 'static,
528 on_redo: impl Fn() + 'static,
529) {
530 let mut items = Vec::new();
531 if can_paste {
532 items.push(TextMenuItem::new("Paste", on_paste));
533 }
534 items.push(TextMenuItem::new("Select all", on_select_all));
535 if can_undo {
536 items.push(TextMenuItem::new("Undo", on_undo));
537 }
538 if can_redo {
539 items.push(TextMenuItem::new("Redo", on_redo));
540 }
541 LiquidTextMenu(center_x, line_top_y, visible, items);
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
548 use cranpose_foundation::PointerEvent;
549 use cranpose_ui_graphics::Point;
550
551 fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
556 let slices = collect_slices_from_modifier(modifier);
557 assert_eq!(
558 slices.pointer_inputs().len(),
559 1,
560 "menu button must install exactly one pointer-input gesture"
561 );
562 let handler = slices.pointer_inputs()[0].clone();
563 (handler, slices)
564 }
565
566 fn down(x: f32, y: f32) -> PointerEvent {
567 PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
568 }
569 fn up(x: f32, y: f32) -> PointerEvent {
570 PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
571 }
572
573 #[test]
578 fn menu_button_consumes_the_tap_and_runs_the_action() {
579 let _app_context = crate::render_state::app_context_test_scope();
580 let ran = Rc::new(Cell::new(false));
581 let action: Rc<dyn Fn()> = {
582 let ran = Rc::clone(&ran);
583 Rc::new(move || ran.set(true))
584 };
585 let modifier = menu_item_pointer_input("Copy", action);
586 let (handler, _slices) = button_handler(&modifier);
587
588 let press = down(5.0, 5.0);
589 handler(press.clone());
590 assert!(
591 press.is_consumed(),
592 "the press must be consumed so it never reaches the field and collapses the selection"
593 );
594 assert!(!ran.get(), "the action fires on release, not on press");
595
596 let release = up(6.0, 6.0);
597 handler(release.clone());
598 assert!(release.is_consumed(), "the release must be consumed too");
599 assert!(
600 ran.get(),
601 "releasing after a press on the button runs the action"
602 );
603 }
604
605 #[test]
608 fn menu_button_release_without_press_is_consumed_but_inert() {
609 let _app_context = crate::render_state::app_context_test_scope();
610 let ran = Rc::new(Cell::new(false));
611 let action: Rc<dyn Fn()> = {
612 let ran = Rc::clone(&ran);
613 Rc::new(move || ran.set(true))
614 };
615 let modifier = menu_item_pointer_input("Cut", action);
616 let (handler, _slices) = button_handler(&modifier);
617
618 let release = up(5.0, 5.0);
619 handler(release.clone());
620 assert!(
621 release.is_consumed(),
622 "a release on the menu is consumed so it never hits the field"
623 );
624 assert!(!ran.get(), "a release with no matching press must not act");
625 }
626
627 #[test]
630 fn menu_geometry_matches_the_reference() {
631 assert_eq!(MENU_HEIGHT, 44.0);
632 assert_eq!(ITEM_PADDING, 20.0);
633 assert_eq!(SEPARATOR_WIDTH, 1.0);
634 assert_eq!(SEPARATOR_HEIGHT, 17.0);
635 assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
636 assert_eq!(MENU_SCREEN_MARGIN, 20.0);
637 }
638
639 #[test]
643 fn pagination_reserves_the_disc_only_when_overflowing() {
644 let _app_context = crate::render_state::app_context_test_scope();
645 let style = menu_text_style();
646 let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
647 .iter()
648 .map(|label| TextMenuItem::new(*label, || {}))
649 .collect();
650
651 let one = paginate(&items, &style, f32::INFINITY);
652 assert_eq!(one.len(), 1, "everything fits on one page");
653 assert_eq!(one[0].len(), 4);
654
655 let total: f32 = items
656 .iter()
657 .map(|i| item_width(&i.label, &style))
658 .sum::<f32>()
659 + 3.0 * SEPARATOR_WIDTH;
660 let narrow = paginate(&items, &style, total * 0.55);
661 assert!(narrow.len() > 1, "a narrow window must page");
662 assert!(narrow.iter().all(|page| !page.is_empty()));
663 let all: Vec<usize> = narrow.iter().flatten().copied().collect();
664 assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
665 }
666}