1use std::{
21 cell::{Cell, RefCell},
22 rc::Rc,
23};
24
25use cranpose_animation::{Animatable, AnimationSpec, AnimationType, Easing};
26use cranpose_core::{SideEffect, remember, with_current_composer};
27use cranpose_foundation::PointerEventKind;
28use cranpose_ui_graphics::{
29 GraphicsLayer, LayerShape, Point, Rect, RoundedCornerShape, Size, liquid_menu_glass_effect,
30};
31
32use crate::{
33 PointerInputScope, composable,
34 modifier::{Color, Modifier},
35 text::{AnnotatedString, TextStyle, TextUnit, measure_text},
36 widgets::{
37 Row, RowSpec, Text,
38 box_widget::{Box, BoxSpec},
39 popup::{Popup, local_popup_viewport},
40 },
41};
42
43pub const MENU_HEIGHT: f32 = 44.0;
45const MENU_SCREEN_MARGIN: f32 = 20.0;
47const MENU_GAP_ABOVE_LINE: f32 = 15.0;
49const ITEM_PADDING: f32 = 20.0;
51const SEPARATOR_WIDTH: f32 = 1.0;
53const SEPARATOR_HEIGHT: f32 = 17.0;
54const MENU_FONT_SP: f32 = 15.0;
55
56pub fn local_on_light_surface() -> cranpose_core::CompositionLocal<bool> {
63 crate::environment_locals::cached_local(
64 |locals| &locals.on_light_surface,
65 || cranpose_core::compositionLocalOf(|| false),
66 )
67}
68
69fn menu_fg(on_light: bool) -> Color {
71 if on_light {
72 Color(0.08, 0.08, 0.10, 1.0)
73 } else {
74 Color(0.96, 0.96, 0.98, 1.0)
75 }
76}
77
78fn separator_color(on_light: bool) -> Color {
80 if on_light {
81 Color(0.0, 0.0, 0.0, 0.10)
82 } else {
83 Color(1.0, 1.0, 1.0, 0.07)
84 }
85}
86
87fn disc_color(on_light: bool) -> Color {
89 if on_light {
90 Color(0.0, 0.0, 0.0, 0.10)
91 } else {
92 Color(1.0, 1.0, 1.0, 0.19)
93 }
94}
95
96fn disc_pressed_color(on_light: bool) -> Color {
97 if on_light {
98 Color(0.0, 0.0, 0.0, 0.55)
99 } else {
100 Color(1.0, 1.0, 1.0, 0.9)
101 }
102}
103const MENU_BLUR_DP: f32 = 15.0;
107
108const MENU_DISSOLVE_MS: u64 = 70;
111const MENU_MATERIALIZE_MS: u64 = 140;
112const MENU_RETURN_DELAY_MS: u64 = 250;
113
114fn menu_text_style(on_light: bool) -> TextStyle {
115 let mut style = TextStyle::default();
116 style.span_style.color = Some(menu_fg(on_light));
117 style.span_style.font_size = TextUnit::Sp(MENU_FONT_SP);
118 style
119}
120
121#[derive(Clone)]
123pub struct TextMenuItem {
124 pub label: String,
126 pub action: Rc<dyn Fn()>,
128}
129
130impl TextMenuItem {
131 pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
133 Self {
134 label: label.into(),
135 action: Rc::new(action),
136 }
137 }
138}
139
140impl PartialEq for TextMenuItem {
141 fn eq(&self, other: &Self) -> bool {
142 self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
143 }
144}
145
146impl std::fmt::Debug for TextMenuItem {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("TextMenuItem")
149 .field("label", &self.label)
150 .finish()
151 }
152}
153
154pub(crate) fn menu_item_pointer_input(
161 label: &str,
162 action: Rc<dyn Fn()>,
163 continuous_press: Rc<Cell<bool>>,
164) -> Modifier {
165 let key = label.to_string();
166 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
167 let action = Rc::clone(&action);
168 let continuous_press = Rc::clone(&continuous_press);
169 async move {
170 scope
171 .await_pointer_event_scope(|await_scope| async move {
172 let mut pressed = false;
173 loop {
174 let event = await_scope.await_pointer_event().await;
175 match event.kind {
176 PointerEventKind::Down => {
177 pressed = true;
178 event.consume();
179 }
180 PointerEventKind::Move => {
181 event.consume();
182 }
183 PointerEventKind::Up => {
184 if pressed || continuous_press.get() {
185 action();
186 }
187 pressed = false;
188 event.consume();
189 }
190 PointerEventKind::Cancel => {
191 pressed = false;
192 event.consume();
193 }
194 _ => {}
195 }
196 }
197 })
198 .await;
199 }
200 })
201}
202
203fn disc_pointer_input(
206 pressed: Rc<Cell<bool>>,
207 on_tap: Rc<dyn Fn()>,
208 invalidate: Rc<dyn Fn()>,
209) -> Modifier {
210 Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
211 let pressed = Rc::clone(&pressed);
212 let on_tap = Rc::clone(&on_tap);
213 let invalidate = Rc::clone(&invalidate);
214 async move {
215 scope
216 .await_pointer_event_scope(|await_scope| async move {
217 let mut down = false;
218 loop {
219 let event = await_scope.await_pointer_event().await;
220 match event.kind {
221 PointerEventKind::Down => {
222 down = true;
223 pressed.set(true);
224 invalidate();
225 event.consume();
226 }
227 PointerEventKind::Move => {
228 event.consume();
229 }
230 PointerEventKind::Up => {
231 if down {
232 on_tap();
233 }
234 down = false;
235 pressed.set(false);
236 invalidate();
237 event.consume();
238 }
239 PointerEventKind::Cancel => {
240 down = false;
241 pressed.set(false);
242 invalidate();
243 event.consume();
244 }
245 _ => {}
246 }
247 }
248 })
249 .await;
250 }
251 })
252}
253
254fn item_width(label: &str, style: &TextStyle) -> f32 {
256 measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
257}
258
259fn slide_item_at(
263 point: cranpose_ui_graphics::Point,
264 origin_x: f32,
265 origin_y: f32,
266 page_items: &[TextMenuItem],
267 style: &TextStyle,
268) -> Option<usize> {
269 if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
270 return None;
271 }
272 let mut cursor = origin_x;
273 for (index, item) in page_items.iter().enumerate() {
274 if index > 0 {
275 cursor += SEPARATOR_WIDTH;
276 }
277 let width = item_width(&item.label, style);
278 if point.x >= cursor && point.x < cursor + width {
279 return Some(index);
280 }
281 cursor += width;
282 }
283 None
284}
285
286fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
290 let widths: Vec<f32> = items
291 .iter()
292 .map(|item| item_width(&item.label, style))
293 .collect();
294 let total: f32 =
295 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
296 if total <= max_width || items.len() <= 1 {
297 return vec![(0..items.len()).collect()];
298 }
299 let disc = MENU_HEIGHT;
300 let mut pages: Vec<Vec<usize>> = Vec::new();
301 let mut page: Vec<usize> = Vec::new();
302 let mut used = disc;
303 for (index, width) in widths.iter().enumerate() {
304 let extra = width
305 + if page.is_empty() {
306 0.0
307 } else {
308 SEPARATOR_WIDTH
309 };
310 if !page.is_empty() && used + extra > max_width {
311 pages.push(std::mem::take(&mut page));
312 used = disc;
313 }
314 used += width
315 + if page.is_empty() {
316 0.0
317 } else {
318 SEPARATOR_WIDTH
319 };
320 page.push(index);
321 }
322 if !page.is_empty() {
323 pages.push(page);
324 }
325 pages
326}
327
328struct MenuMotion {
331 progress: RefCell<Animatable<f32>>,
332 was_visible: Cell<bool>,
333 page: Cell<usize>,
334 disc_pressed: Rc<Cell<bool>>,
335 slide_hover: Cell<Option<usize>>,
336 slide_live: Rc<Cell<bool>>,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq)]
342pub struct MenuAnchor {
343 pub center_x: f32,
345 pub line_top: f32,
347 pub line_bottom: f32,
349}
350
351impl MenuAnchor {
352 pub fn menu_top(self) -> f32 {
356 let above = self.line_top - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
357 if above >= MENU_SCREEN_MARGIN {
358 above
359 } else {
360 self.line_bottom + MENU_GAP_ABOVE_LINE
361 }
362 }
363}
364
365#[composable]
375pub fn LiquidTextMenu(
376 anchor: MenuAnchor,
377 visible: bool,
378 live_point: Option<cranpose_ui_graphics::Point>,
379 items: Vec<TextMenuItem>,
380) {
381 let motion = remember(|| {
382 let runtime = with_current_composer(|composer| composer.runtime_handle());
383 Rc::new(MenuMotion {
384 progress: RefCell::new(Animatable::new(0.0, runtime)),
385 was_visible: Cell::new(false),
386 page: Cell::new(0),
387 disc_pressed: Rc::new(Cell::new(false)),
388 slide_hover: Cell::new(None),
389 slide_live: Rc::new(Cell::new(false)),
390 })
391 })
392 .with(Rc::clone);
393
394 if visible != motion.was_visible.get() {
395 motion.was_visible.set(visible);
396 let mut progress = motion.progress.borrow_mut();
397 if visible {
398 progress.animateTo(
399 1.0,
400 AnimationType::Tween(
401 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
402 .with_delay(MENU_RETURN_DELAY_MS),
403 ),
404 );
405 } else {
406 progress.animateTo(
407 0.0,
408 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
409 );
410 }
411 }
412 let progress_state = motion.progress.borrow().state();
413 let p = progress_state.value().clamp(0.0, 1.0);
414 if !visible && p <= 0.01 {
415 return;
416 }
417
418 let on_light = local_on_light_surface().current();
419 let style = menu_text_style(on_light);
420 let viewport = local_popup_viewport().current().get();
421 let max_width = if viewport.width > 0.0 {
422 viewport.width - 2.0 * MENU_SCREEN_MARGIN
423 } else {
424 f32::INFINITY
425 };
426 let pages = paginate(&items, &style, max_width);
427 let page_index = motion.page.get().min(pages.len() - 1);
428 let page = &pages[page_index];
429 let has_disc = pages.len() > 1;
430
431 let mut width: f32 = page
432 .iter()
433 .map(|&i| item_width(&items[i].label, &style))
434 .sum();
435 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
436 if has_disc {
437 width += MENU_HEIGHT;
438 }
439
440 let mut x = anchor.center_x - width * 0.5;
441 if viewport.width > 0.0 {
442 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
443 }
444 x = x.max(MENU_SCREEN_MARGIN);
445 let y = anchor.menu_top();
446
447 let anchor = Rect {
448 x,
449 y,
450 width: 0.0,
451 height: 0.0,
452 };
453 let density = crate::current_density();
454 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
455
456 let slide_hover = match live_point {
457 Some(point) => {
458 motion.slide_live.set(true);
459 let hover = slide_item_at(point, x, y, &page_items, &style);
460 motion.slide_hover.set(hover);
461 hover
462 }
463 None => {
464 let hover = motion.slide_hover.take();
465 if motion.slide_live.replace(false) {
466 if let Some(index) = hover {
467 if let Some(item) = page_items.get(index) {
468 let action = Rc::clone(&item.action);
469 SideEffect(move || action());
470 }
471 }
472 }
473 None
474 }
475 };
476 let disc_pressed = Rc::clone(&motion.disc_pressed);
477 let page_count = pages.len();
478 let motion_for_disc = Rc::clone(&motion);
479 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
480 let page_items = page_items.clone();
481 let disc_pressed = Rc::clone(&disc_pressed);
482 let motion = Rc::clone(&motion_for_disc);
483 Box(
484 Modifier::empty()
485 .size(Size {
486 width,
487 height: MENU_HEIGHT,
488 })
489 .drop_shadow(
490 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
491 move |scope| {
492 scope.radius = 16.0;
493 scope.spread = -2.0;
494 scope.offset.y = 0.0;
495 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
496 scope.cutout = true;
497 },
498 )
499 .graphics_layer(move || GraphicsLayer {
500 alpha: p,
501 backdrop_effect: (p > 0.001).then(|| {
502 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
503 }),
504 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
505 clip: true,
506 ..Default::default()
507 }),
508 BoxSpec::default(),
509 move || {
510 let page_items = page_items.clone();
511 let disc_pressed = Rc::clone(&disc_pressed);
512 let motion = Rc::clone(&motion);
513 Row(
514 Modifier::empty().size(Size {
515 width,
516 height: MENU_HEIGHT,
517 }),
518 RowSpec::default().vertical_alignment(
519 cranpose_ui_layout::VerticalAlignment::CenterVertically,
520 ),
521 move || {
522 for (index, item) in page_items.iter().enumerate() {
523 if index > 0 {
524 Box(
525 Modifier::empty()
526 .size(Size {
527 width: SEPARATOR_WIDTH,
528 height: SEPARATOR_HEIGHT,
529 })
530 .background(separator_color(on_light)),
531 BoxSpec::default(),
532 || {},
533 );
534 }
535 let slide_hovered = slide_hover == Some(index);
536 Text(
537 item.label.clone(),
538 Modifier::empty()
539 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
540 .draw_behind(move |scope| {
541 if slide_hovered {
542 let hover = if on_light {
543 Color(0.0, 0.0, 0.0, 0.08)
544 } else {
545 Color(1.0, 1.0, 1.0, 0.10)
546 };
547 scope.draw_round_rect(
548 cranpose_ui_graphics::Brush::solid(hover),
549 cranpose_ui_graphics::CornerRadii::uniform(10.0),
550 );
551 }
552 })
553 .then(menu_item_pointer_input(
554 &item.label,
555 Rc::clone(&item.action),
556 Rc::clone(&motion.slide_live),
557 )),
558 menu_text_style(on_light),
559 );
560 }
561 if page_count > 1 {
562 let pressed_now = disc_pressed.get();
563 let motion = Rc::clone(&motion);
564 let advance: Rc<dyn Fn()> = Rc::new(move || {
565 motion.page.set((motion.page.get() + 1) % page_count);
566 crate::request_render_invalidation();
567 });
568 let invalidate: Rc<dyn Fn()> =
569 Rc::new(crate::request_render_invalidation);
570 Box(
571 Modifier::empty()
572 .size(Size {
573 width: MENU_HEIGHT,
574 height: MENU_HEIGHT,
575 })
576 .background(if pressed_now {
577 disc_pressed_color(on_light)
578 } else {
579 disc_color(on_light)
580 })
581 .rounded_corners(MENU_HEIGHT * 0.5)
582 .then(disc_pointer_input(
583 Rc::clone(&disc_pressed),
584 advance,
585 invalidate,
586 )),
587 BoxSpec::default()
588 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
589 move || {
590 Text(
591 "\u{203A}".to_string(),
592 Modifier::empty(),
593 menu_text_style(on_light),
594 );
595 },
596 );
597 }
598 },
599 );
600 },
601 );
602 });
603}
604
605#[expect(clippy::too_many_arguments)]
612#[composable]
613pub fn TextSelectionMenu(
614 anchor: MenuAnchor,
615 visible: bool,
616 live_point: Option<cranpose_ui_graphics::Point>,
617 can_copy: bool,
618 can_paste: bool,
619 on_copy: impl Fn() + 'static,
620 on_cut: impl Fn() + 'static,
621 on_paste: impl Fn() + 'static,
622 on_select_all: impl Fn() + 'static,
623) {
624 let mut items = Vec::new();
625 if can_copy {
626 items.push(TextMenuItem::new(crate::UiString::Copy.resolve(), on_copy));
627 items.push(TextMenuItem::new(crate::UiString::Cut.resolve(), on_cut));
628 }
629 append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
630 LiquidTextMenu(anchor, visible, live_point, items);
631}
632
633#[track_caller]
634fn append_edit_actions(
635 items: &mut Vec<TextMenuItem>,
636 can_paste: bool,
637 on_paste: impl Fn() + 'static,
638 on_select_all: impl Fn() + 'static,
639) {
640 if can_paste {
641 items.push(TextMenuItem::new(
642 crate::UiString::Paste.resolve(),
643 on_paste,
644 ));
645 }
646 items.push(TextMenuItem::new(
647 crate::UiString::SelectAll.resolve(),
648 on_select_all,
649 ));
650}
651
652#[expect(clippy::too_many_arguments)]
659#[composable]
660pub fn CaretActionMenu(
661 anchor: MenuAnchor,
662 visible: bool,
663 can_paste: bool,
664 can_undo: bool,
665 can_redo: bool,
666 on_paste: impl Fn() + 'static,
667 on_select_all: impl Fn() + 'static,
668 on_undo: impl Fn() + 'static,
669 on_redo: impl Fn() + 'static,
670) {
671 let mut items = Vec::new();
672 append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
673 if can_undo {
674 items.push(TextMenuItem::new(crate::UiString::Undo.resolve(), on_undo));
675 }
676 if can_redo {
677 items.push(TextMenuItem::new(crate::UiString::Redo.resolve(), on_redo));
678 }
679 LiquidTextMenu(anchor, visible, None, items);
680}
681
682#[cfg(test)]
683#[path = "tests/text_selection_menu_tests.rs"]
684mod tests;