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