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#[composable]
353pub fn LiquidTextMenu(
354 center_x: f32,
355 line_top_y: f32,
356 visible: bool,
357 live_point: Option<cranpose_ui_graphics::Point>,
358 items: Vec<TextMenuItem>,
359) {
360 let motion = remember(|| {
361 let runtime = with_current_composer(|composer| composer.runtime_handle());
362 Rc::new(MenuMotion {
363 progress: RefCell::new(Animatable::new(0.0, runtime)),
364 was_visible: Cell::new(false),
365 page: Cell::new(0),
366 disc_pressed: Rc::new(Cell::new(false)),
367 slide_hover: Cell::new(None),
368 slide_live: Rc::new(Cell::new(false)),
369 })
370 })
371 .with(Rc::clone);
372
373 if visible != motion.was_visible.get() {
374 motion.was_visible.set(visible);
375 let mut progress = motion.progress.borrow_mut();
376 if visible {
377 progress.animateTo(
378 1.0,
379 AnimationType::Tween(
380 AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
381 .with_delay(MENU_RETURN_DELAY_MS),
382 ),
383 );
384 } else {
385 progress.animateTo(
386 0.0,
387 AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
388 );
389 }
390 }
391 let progress_state = motion.progress.borrow().state();
392 let p = progress_state.value().clamp(0.0, 1.0);
393 if !visible && p <= 0.01 {
394 return;
395 }
396
397 let on_light = local_on_light_surface().current();
398 let style = menu_text_style(on_light);
399 let viewport = local_popup_viewport().current().get();
400 let max_width = if viewport.width > 0.0 {
401 viewport.width - 2.0 * MENU_SCREEN_MARGIN
402 } else {
403 f32::INFINITY
404 };
405 let pages = paginate(&items, &style, max_width);
406 let page_index = motion.page.get().min(pages.len() - 1);
407 let page = &pages[page_index];
408 let has_disc = pages.len() > 1;
409
410 let mut width: f32 = page
411 .iter()
412 .map(|&i| item_width(&items[i].label, &style))
413 .sum();
414 width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
415 if has_disc {
416 width += MENU_HEIGHT;
417 }
418
419 let mut x = center_x - width * 0.5;
420 if viewport.width > 0.0 {
421 x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
422 }
423 x = x.max(MENU_SCREEN_MARGIN);
424 let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
425
426 let anchor = Rect {
427 x,
428 y,
429 width: 0.0,
430 height: 0.0,
431 };
432 let density = crate::current_density();
433 let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
434
435 let slide_hover = match live_point {
436 Some(point) => {
437 motion.slide_live.set(true);
438 let hover = slide_item_at(point, x, y, &page_items, &style);
439 motion.slide_hover.set(hover);
440 hover
441 }
442 None => {
443 let hover = motion.slide_hover.take();
444 if motion.slide_live.replace(false) {
445 if let Some(index) = hover {
446 if let Some(item) = page_items.get(index) {
447 let action = Rc::clone(&item.action);
448 SideEffect(move || action());
449 }
450 }
451 }
452 None
453 }
454 };
455 let disc_pressed = Rc::clone(&motion.disc_pressed);
456 let page_count = pages.len();
457 let motion_for_disc = Rc::clone(&motion);
458 Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
459 let page_items = page_items.clone();
460 let disc_pressed = Rc::clone(&disc_pressed);
461 let motion = Rc::clone(&motion_for_disc);
462 Box(
463 Modifier::empty()
464 .size(Size {
465 width,
466 height: MENU_HEIGHT,
467 })
468 .drop_shadow(
469 LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
470 move |scope| {
471 scope.radius = 16.0;
472 scope.spread = -2.0;
473 scope.offset.y = 0.0;
474 scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
475 scope.cutout = true;
476 },
477 )
478 .graphics_layer(move || GraphicsLayer {
479 alpha: p,
480 backdrop_effect: (p > 0.001).then(|| {
481 liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
482 }),
483 shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
484 clip: true,
485 ..Default::default()
486 }),
487 BoxSpec::default(),
488 move || {
489 let page_items = page_items.clone();
490 let disc_pressed = Rc::clone(&disc_pressed);
491 let motion = Rc::clone(&motion);
492 Row(
493 Modifier::empty().size(Size {
494 width,
495 height: MENU_HEIGHT,
496 }),
497 RowSpec::default().vertical_alignment(
498 cranpose_ui_layout::VerticalAlignment::CenterVertically,
499 ),
500 move || {
501 for (index, item) in page_items.iter().enumerate() {
502 if index > 0 {
503 Box(
504 Modifier::empty()
505 .size(Size {
506 width: SEPARATOR_WIDTH,
507 height: SEPARATOR_HEIGHT,
508 })
509 .background(separator_color(on_light)),
510 BoxSpec::default(),
511 || {},
512 );
513 }
514 let slide_hovered = slide_hover == Some(index);
515 Text(
516 item.label.clone(),
517 Modifier::empty()
518 .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
519 .draw_behind(move |scope| {
520 if slide_hovered {
521 let hover = if on_light {
522 Color(0.0, 0.0, 0.0, 0.08)
523 } else {
524 Color(1.0, 1.0, 1.0, 0.10)
525 };
526 scope.draw_round_rect(
527 cranpose_ui_graphics::Brush::solid(hover),
528 cranpose_ui_graphics::CornerRadii::uniform(10.0),
529 );
530 }
531 })
532 .then(menu_item_pointer_input(
533 &item.label,
534 Rc::clone(&item.action),
535 Rc::clone(&motion.slide_live),
536 )),
537 menu_text_style(on_light),
538 );
539 }
540 if page_count > 1 {
541 let pressed_now = disc_pressed.get();
542 let motion = Rc::clone(&motion);
543 let advance: Rc<dyn Fn()> = Rc::new(move || {
544 motion.page.set((motion.page.get() + 1) % page_count);
545 crate::request_render_invalidation();
546 });
547 let invalidate: Rc<dyn Fn()> =
548 Rc::new(crate::request_render_invalidation);
549 Box(
550 Modifier::empty()
551 .size(Size {
552 width: MENU_HEIGHT,
553 height: MENU_HEIGHT,
554 })
555 .background(if pressed_now {
556 disc_pressed_color(on_light)
557 } else {
558 disc_color(on_light)
559 })
560 .rounded_corners(MENU_HEIGHT * 0.5)
561 .then(disc_pointer_input(
562 Rc::clone(&disc_pressed),
563 advance,
564 invalidate,
565 )),
566 BoxSpec::default()
567 .content_alignment(cranpose_ui_layout::Alignment::CENTER),
568 move || {
569 Text(
570 "\u{203A}".to_string(),
571 Modifier::empty(),
572 menu_text_style(on_light),
573 );
574 },
575 );
576 }
577 },
578 );
579 },
580 );
581 });
582}
583
584#[expect(clippy::too_many_arguments)]
590#[composable]
591pub fn TextSelectionMenu(
592 center_x: f32,
593 line_top_y: f32,
594 visible: bool,
595 live_point: Option<cranpose_ui_graphics::Point>,
596 can_paste: bool,
597 on_copy: impl Fn() + 'static,
598 on_cut: impl Fn() + 'static,
599 on_paste: impl Fn() + 'static,
600 on_select_all: impl Fn() + 'static,
601) {
602 let mut items = vec![
603 TextMenuItem::new("Copy", on_copy),
604 TextMenuItem::new("Cut", on_cut),
605 ];
606 if can_paste {
607 items.push(TextMenuItem::new("Paste", on_paste));
608 }
609 items.push(TextMenuItem::new("Select all", on_select_all));
610 LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
611}
612
613#[expect(clippy::too_many_arguments)]
620#[composable]
621pub fn CaretActionMenu(
622 center_x: f32,
623 line_top_y: f32,
624 visible: bool,
625 can_paste: bool,
626 can_undo: bool,
627 can_redo: bool,
628 on_paste: impl Fn() + 'static,
629 on_select_all: impl Fn() + 'static,
630 on_undo: impl Fn() + 'static,
631 on_redo: impl Fn() + 'static,
632) {
633 let mut items = Vec::new();
634 if can_paste {
635 items.push(TextMenuItem::new("Paste", on_paste));
636 }
637 items.push(TextMenuItem::new("Select all", on_select_all));
638 if can_undo {
639 items.push(TextMenuItem::new("Undo", on_undo));
640 }
641 if can_redo {
642 items.push(TextMenuItem::new("Redo", on_redo));
643 }
644 LiquidTextMenu(center_x, line_top_y, visible, None, items);
645}
646
647#[cfg(test)]
648#[path = "tests/text_selection_menu_tests.rs"]
649mod tests;