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,
125 pub action: Rc<dyn Fn()>,
126}
127
128impl TextMenuItem {
129 pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
130 Self {
131 label: label.into(),
132 action: Rc::new(action),
133 }
134 }
135}
136
137impl PartialEq for TextMenuItem {
138 fn eq(&self, other: &Self) -> bool {
139 self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
140 }
141}
142
143impl std::fmt::Debug for TextMenuItem {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("TextMenuItem")
146 .field("label", &self.label)
147 .finish()
148 }
149}
150
151pub(crate) fn menu_item_pointer_input(
158 label: &str,
159 action: Rc<dyn Fn()>,
160 continuous_press: Rc<Cell<bool>>,
161) -> Modifier {
162 let key = label.to_string();
163 Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
164 let action = Rc::clone(&action);
165 let continuous_press = Rc::clone(&continuous_press);
166 async move {
167 scope
168 .await_pointer_event_scope(|await_scope| async move {
169 let mut pressed = false;
170 loop {
171 let event = await_scope.await_pointer_event().await;
172 match event.kind {
173 PointerEventKind::Down => {
174 pressed = true;
175 event.consume();
176 }
177 PointerEventKind::Move => {
178 event.consume();
179 }
180 PointerEventKind::Up => {
181 if pressed || continuous_press.get() {
182 action();
183 }
184 pressed = false;
185 event.consume();
186 }
187 PointerEventKind::Cancel => {
188 pressed = false;
189 event.consume();
190 }
191 _ => {}
192 }
193 }
194 })
195 .await;
196 }
197 })
198}
199
200fn disc_pointer_input(
203 pressed: Rc<Cell<bool>>,
204 on_tap: Rc<dyn Fn()>,
205 invalidate: Rc<dyn Fn()>,
206) -> Modifier {
207 Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
208 let pressed = Rc::clone(&pressed);
209 let on_tap = Rc::clone(&on_tap);
210 let invalidate = Rc::clone(&invalidate);
211 async move {
212 scope
213 .await_pointer_event_scope(|await_scope| async move {
214 let mut down = false;
215 loop {
216 let event = await_scope.await_pointer_event().await;
217 match event.kind {
218 PointerEventKind::Down => {
219 down = true;
220 pressed.set(true);
221 invalidate();
222 event.consume();
223 }
224 PointerEventKind::Move => {
225 event.consume();
226 }
227 PointerEventKind::Up => {
228 if down {
229 on_tap();
230 }
231 down = false;
232 pressed.set(false);
233 invalidate();
234 event.consume();
235 }
236 PointerEventKind::Cancel => {
237 down = false;
238 pressed.set(false);
239 invalidate();
240 event.consume();
241 }
242 _ => {}
243 }
244 }
245 })
246 .await;
247 }
248 })
249}
250
251fn item_width(label: &str, style: &TextStyle) -> f32 {
253 measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
254}
255
256fn slide_item_at(
260 point: cranpose_ui_graphics::Point,
261 origin_x: f32,
262 origin_y: f32,
263 page_items: &[TextMenuItem],
264 style: &TextStyle,
265) -> Option<usize> {
266 if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
267 return None;
268 }
269 let mut cursor = origin_x;
270 for (index, item) in page_items.iter().enumerate() {
271 if index > 0 {
272 cursor += SEPARATOR_WIDTH;
273 }
274 let width = item_width(&item.label, style);
275 if point.x >= cursor && point.x < cursor + width {
276 return Some(index);
277 }
278 cursor += width;
279 }
280 None
281}
282
283fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
287 let widths: Vec<f32> = items
288 .iter()
289 .map(|item| item_width(&item.label, style))
290 .collect();
291 let total: f32 =
292 widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
293 if total <= max_width || items.len() <= 1 {
294 return vec![(0..items.len()).collect()];
295 }
296 let disc = MENU_HEIGHT;
297 let mut pages: Vec<Vec<usize>> = Vec::new();
298 let mut page: Vec<usize> = Vec::new();
299 let mut used = disc;
300 for (index, width) in widths.iter().enumerate() {
301 let extra = width
302 + if page.is_empty() {
303 0.0
304 } else {
305 SEPARATOR_WIDTH
306 };
307 if !page.is_empty() && used + extra > max_width {
308 pages.push(std::mem::take(&mut page));
309 used = disc;
310 }
311 used += width
312 + if page.is_empty() {
313 0.0
314 } else {
315 SEPARATOR_WIDTH
316 };
317 page.push(index);
318 }
319 if !page.is_empty() {
320 pages.push(page);
321 }
322 pages
323}
324
325struct MenuMotion {
328 progress: RefCell<Animatable<f32>>,
329 was_visible: Cell<bool>,
330 page: Cell<usize>,
331 disc_pressed: Rc<Cell<bool>>,
332 slide_hover: Cell<Option<usize>>,
333 slide_live: Rc<Cell<bool>>,
334}
335
336#[derive(Clone, Copy, Debug, PartialEq)]
339pub struct MenuAnchor {
340 pub center_x: f32,
342 pub line_top: f32,
344 pub line_bottom: f32,
346}
347
348impl MenuAnchor {
349 pub fn menu_top(self) -> f32 {
353 let above = self.line_top - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
354 if above >= MENU_SCREEN_MARGIN {
355 above
356 } else {
357 self.line_bottom + MENU_GAP_ABOVE_LINE
358 }
359 }
360}
361
362#[composable]
372pub fn LiquidTextMenu(
373 anchor: MenuAnchor,
374 visible: bool,
375 live_point: Option<cranpose_ui_graphics::Point>,
376 items: Vec<TextMenuItem>,
377) {
378 let motion = remember(|| {
379 let runtime = with_current_composer(|composer| composer.runtime_handle());
380 Rc::new(MenuMotion {
381 progress: RefCell::new(Animatable::new(0.0, runtime)),
382 was_visible: Cell::new(false),
383 page: Cell::new(0),
384 disc_pressed: Rc::new(Cell::new(false)),
385 slide_hover: Cell::new(None),
386 slide_live: Rc::new(Cell::new(false)),
387 })
388 })
389 .with(Rc::clone);
390
391 if visible != motion.was_visible.get() {
392 motion.was_visible.set(visible);
393 let mut progress = motion.progress.borrow_mut();
394 if visible {
395 progress.animateTo(
396 1.0,
397 AnimationType::Tween(
398 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
399 .with_delay(MENU_RETURN_DELAY_MS),
400 ),
401 );
402 } else {
403 progress.animateTo(
404 0.0,
405 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
406 );
407 }
408 }
409 let progress_state = motion.progress.borrow().state();
410 let p = progress_state.value().clamp(0.0, 1.0);
411 if !visible && p <= 0.01 {
412 return;
413 }
414
415 let on_light = local_on_light_surface().current();
416 let style = menu_text_style(on_light);
417 let viewport = local_popup_viewport().current().get();
418 let max_width = if viewport.width > 0.0 {
419 viewport.width - 2.0 * MENU_SCREEN_MARGIN
420 } else {
421 f32::INFINITY
422 };
423 let pages = paginate(&items, &style, max_width);
424 let page_index = motion.page.get().min(pages.len() - 1);
425 let page = &pages[page_index];
426 let has_disc = pages.len() > 1;
427
428 let mut width: f32 = page
429 .iter()
430 .map(|&i| item_width(&items[i].label, &style))
431 .sum();
432 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
433 if has_disc {
434 width += MENU_HEIGHT;
435 }
436
437 let mut x = anchor.center_x - width * 0.5;
438 if viewport.width > 0.0 {
439 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
440 }
441 x = x.max(MENU_SCREEN_MARGIN);
442 let y = anchor.menu_top();
443
444 let anchor = Rect {
445 x,
446 y,
447 width: 0.0,
448 height: 0.0,
449 };
450 let density = crate::current_density();
451 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
452
453 let slide_hover = match live_point {
454 Some(point) => {
455 motion.slide_live.set(true);
456 let hover = slide_item_at(point, x, y, &page_items, &style);
457 motion.slide_hover.set(hover);
458 hover
459 }
460 None => {
461 let hover = motion.slide_hover.take();
462 if motion.slide_live.replace(false) {
463 if let Some(index) = hover {
464 if let Some(item) = page_items.get(index) {
465 let action = Rc::clone(&item.action);
466 SideEffect(move || action());
467 }
468 }
469 }
470 None
471 }
472 };
473 let disc_pressed = Rc::clone(&motion.disc_pressed);
474 let page_count = pages.len();
475 let motion_for_disc = Rc::clone(&motion);
476 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
477 let page_items = page_items.clone();
478 let disc_pressed = Rc::clone(&disc_pressed);
479 let motion = Rc::clone(&motion_for_disc);
480 Box(
481 Modifier::empty()
482 .size(Size {
483 width,
484 height: MENU_HEIGHT,
485 })
486 .drop_shadow(
487 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
488 move |scope| {
489 scope.radius = 16.0;
490 scope.spread = -2.0;
491 scope.offset.y = 0.0;
492 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
493 scope.cutout = true;
494 },
495 )
496 .graphics_layer(move || GraphicsLayer {
497 alpha: p,
498 backdrop_effect: (p > 0.001).then(|| {
499 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
500 }),
501 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
502 clip: true,
503 ..Default::default()
504 }),
505 BoxSpec::default(),
506 move || {
507 let page_items = page_items.clone();
508 let disc_pressed = Rc::clone(&disc_pressed);
509 let motion = Rc::clone(&motion);
510 Row(
511 Modifier::empty().size(Size {
512 width,
513 height: MENU_HEIGHT,
514 }),
515 RowSpec::default().vertical_alignment(
516 cranpose_ui_layout::VerticalAlignment::CenterVertically,
517 ),
518 move || {
519 for (index, item) in page_items.iter().enumerate() {
520 if index > 0 {
521 Box(
522 Modifier::empty()
523 .size(Size {
524 width: SEPARATOR_WIDTH,
525 height: SEPARATOR_HEIGHT,
526 })
527 .background(separator_color(on_light)),
528 BoxSpec::default(),
529 || {},
530 );
531 }
532 let slide_hovered = slide_hover == Some(index);
533 Text(
534 item.label.clone(),
535 Modifier::empty()
536 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
537 .draw_behind(move |scope| {
538 if slide_hovered {
539 let hover = if on_light {
540 Color(0.0, 0.0, 0.0, 0.08)
541 } else {
542 Color(1.0, 1.0, 1.0, 0.10)
543 };
544 scope.draw_round_rect(
545 cranpose_ui_graphics::Brush::solid(hover),
546 cranpose_ui_graphics::CornerRadii::uniform(10.0),
547 );
548 }
549 })
550 .then(menu_item_pointer_input(
551 &item.label,
552 Rc::clone(&item.action),
553 Rc::clone(&motion.slide_live),
554 )),
555 menu_text_style(on_light),
556 );
557 }
558 if page_count > 1 {
559 let pressed_now = disc_pressed.get();
560 let motion = Rc::clone(&motion);
561 let advance: Rc<dyn Fn()> = Rc::new(move || {
562 motion.page.set((motion.page.get() + 1) % page_count);
563 crate::request_render_invalidation();
564 });
565 let invalidate: Rc<dyn Fn()> =
566 Rc::new(crate::request_render_invalidation);
567 Box(
568 Modifier::empty()
569 .size(Size {
570 width: MENU_HEIGHT,
571 height: MENU_HEIGHT,
572 })
573 .background(if pressed_now {
574 disc_pressed_color(on_light)
575 } else {
576 disc_color(on_light)
577 })
578 .rounded_corners(MENU_HEIGHT * 0.5)
579 .then(disc_pointer_input(
580 Rc::clone(&disc_pressed),
581 advance,
582 invalidate,
583 )),
584 BoxSpec::default()
585 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
586 move || {
587 Text(
588 "\u{203A}".to_string(),
589 Modifier::empty(),
590 menu_text_style(on_light),
591 );
592 },
593 );
594 }
595 },
596 );
597 },
598 );
599 });
600}
601
602#[expect(clippy::too_many_arguments)]
608#[composable]
609pub fn TextSelectionMenu(
610 anchor: MenuAnchor,
611 visible: bool,
612 live_point: Option<cranpose_ui_graphics::Point>,
613 can_paste: bool,
614 on_copy: impl Fn() + 'static,
615 on_cut: impl Fn() + 'static,
616 on_paste: impl Fn() + 'static,
617 on_select_all: impl Fn() + 'static,
618) {
619 let mut items = vec![
620 TextMenuItem::new(crate::UiString::Copy.resolve(), on_copy),
621 TextMenuItem::new(crate::UiString::Cut.resolve(), on_cut),
622 ];
623 append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
624 LiquidTextMenu(anchor, visible, live_point, items);
625}
626
627#[track_caller]
628fn append_edit_actions(
629 items: &mut Vec<TextMenuItem>,
630 can_paste: bool,
631 on_paste: impl Fn() + 'static,
632 on_select_all: impl Fn() + 'static,
633) {
634 if can_paste {
635 items.push(TextMenuItem::new(
636 crate::UiString::Paste.resolve(),
637 on_paste,
638 ));
639 }
640 items.push(TextMenuItem::new(
641 crate::UiString::SelectAll.resolve(),
642 on_select_all,
643 ));
644}
645
646#[expect(clippy::too_many_arguments)]
653#[composable]
654pub fn CaretActionMenu(
655 anchor: MenuAnchor,
656 visible: bool,
657 can_paste: bool,
658 can_undo: bool,
659 can_redo: bool,
660 on_paste: impl Fn() + 'static,
661 on_select_all: impl Fn() + 'static,
662 on_undo: impl Fn() + 'static,
663 on_redo: impl Fn() + 'static,
664) {
665 let mut items = Vec::new();
666 append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
667 if can_undo {
668 items.push(TextMenuItem::new(crate::UiString::Undo.resolve(), on_undo));
669 }
670 if can_redo {
671 items.push(TextMenuItem::new(crate::UiString::Redo.resolve(), on_redo));
672 }
673 LiquidTextMenu(anchor, visible, None, items);
674}
675
676#[cfg(test)]
677#[path = "tests/text_selection_menu_tests.rs"]
678mod tests;