Skip to main content

cranpose_ui/widgets/
text_selection_menu.rs

1//! The floating text edit menu: a liquid-glass capsule of actions above the
2//! selection / caret, matched against the reference recording
3//! (`example/target/text-selection/`).
4//!
5//! Geometry and material (all measured): a 44 dp glass capsule — weak
6//! backdrop blur (content behind stays readable through the body), a whisper
7//! of dark tint, a top rim highlight — holding white ~15 sp labels separated
8//! by 1 dp hairlines (17 dp tall, centered). When the actions don't fit the
9//! window, the overflow lives behind a trailing chevron `›` sitting in a
10//! lighter glass disc that fills the capsule's end cap (pressing it flashes
11//! the disc white and pages the items).
12//!
13//! Placement: centered over the selection, clamped to a 20 dp screen margin;
14//! the capsule's bottom rides 15 dp above the selection's first line.
15//!
16//! Motion (from the 120 fps reference): the menu dissolves in ~70 ms the
17//! moment a handle drag starts, and rematerializes in place ~250 ms after
18//! release with a ~140 ms fade (no scale, no slide).
19
20use 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
43/// Capsule height (dp) — the measured 133 px @3x.
44pub const MENU_HEIGHT: f32 = 44.0;
45/// Minimum gap between the capsule and the window edges.
46const MENU_SCREEN_MARGIN: f32 = 20.0;
47/// Gap between the capsule bottom and the selection's first line top.
48const MENU_GAP_ABOVE_LINE: f32 = 15.0;
49/// Horizontal padding on each side of an item label.
50const ITEM_PADDING: f32 = 20.0;
51/// Hairline separator between labels: 1×17 dp.
52const SEPARATOR_WIDTH: f32 = 1.0;
53const SEPARATOR_HEIGHT: f32 = 17.0;
54const MENU_FONT_SP: f32 = 15.0;
55
56/// CompositionLocal marking the surface the floating glass sits over as
57/// LIGHT. Liquid glass is transparent, so its labels and hairlines must
58/// flip to dark ink over a light backdrop to stay readable — the same
59/// content over a dark app keeps its near-white ink. Defaults to `false`
60/// (dark surface), preserving the existing look; a light-themed screen
61/// provides `true`.
62pub 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
75/// Label ink: near-white over a dark surface, near-black over a light one.
76fn 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
84/// Hairline separator: a whisper of the ink polarity.
85fn 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
93/// The chevron disc glass fill and its pressed flash follow the polarity.
94fn 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}
109/// Backdrop blur behind the capsule, dp.
110/// The materialize smudge radius; the effect resolves it to ~a fifth as the
111/// menu sharpens (the settled reference pill keeps backdrop text readable).
112const MENU_BLUR_DP: f32 = 15.0;
113
114/// Motion (measured): ~70 ms dissolve, ~140 ms materialize arriving ~250 ms
115/// after the release.
116const 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/// One tappable action.
128#[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
157/// Builds the consuming tap gesture for a menu button: it swallows the press,
158/// any moves, and the release. It fires `action` when the finger lifts after
159/// a press that started on this button, or when an already-active continuous
160/// press slides onto this button and lifts. Every event is consumed so the
161/// gesture can never fall through to the text field beneath the overlay.
162/// Keyed by the button label so recomposition reuses the running gesture task.
163pub(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
206/// The chevron disc's press-flash + page gesture: flashes the disc white
207/// while held (the reference press feedback) and advances the page on lift.
208fn 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
257/// Measured width of one label cell (padding + text + padding), dp.
258fn item_width(label: &str, style: &TextStyle) -> f32 {
259    measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
260}
261
262/// Page-relative index of the item under a live slide point, walking the
263/// same measured cell widths the Row lays out (6dp of vertical grace above
264/// and below the capsule).
265fn 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
289/// Splits `items` into pages that fit `max_width`, appending the chevron
290/// disc's width to any page that is followed by more items. Every page holds
291/// at least one item.
292fn 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
331/// Animated visibility state for the menu (dissolve fast, rematerialize
332/// after the measured delay).
333struct 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/// The liquid-glass text edit menu.
343///
344/// * `center_x` — window-space x to center the capsule on (the selection /
345///   caret center); clamped to the screen margin.
346/// * `line_top_y` — window-space top of the selection's first line; the
347///   capsule bottom rides `MENU_GAP_ABOVE_LINE` above it.
348/// * `visible` — false while a handle drag is in flight; the menu dissolves
349///   and rematerializes per the measured timings (it stays mounted while
350///   fading).
351/// * `items` — the actions.
352#[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/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
585/// selection. `center_x` / `line_top_y` anchor it over the selection;
586/// `visible` is false while a handle drag is in flight. `can_paste` hides the
587/// Paste item when the clipboard is empty. Each action runs against the
588/// focused field; the caller is expected to dismiss the menu.
589#[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/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
614/// caret. Opened by tapping the cursor handle (there is no selection to
615/// Copy/Cut, so this offers the caret-relevant actions instead).
616///
617/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
618/// hide those items when the field's history has nothing to undo/redo.
619#[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;