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. `LiquidTheme` provides
60/// it from its scheme; without a theme it is `false` (a dark surface), and a
61/// light screen provides `true`.
62pub 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
69/// Label ink: near-white over a dark surface, near-black over a light one.
70fn 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
78/// Hairline separator: a whisper of the ink polarity.
79fn 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
87/// The chevron disc glass fill and its pressed flash follow the polarity.
88fn 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}
103/// Backdrop blur behind the capsule, dp.
104/// The materialize smudge radius; the effect resolves it to ~a fifth as the
105/// menu sharpens (the settled reference pill keeps backdrop text readable).
106const MENU_BLUR_DP: f32 = 15.0;
107
108/// Motion (measured): ~70 ms dissolve, ~140 ms materialize arriving ~250 ms
109/// after the release.
110const 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/// One tappable action.
122#[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
151/// Builds the consuming tap gesture for a menu button: it swallows the press,
152/// any moves, and the release. It fires `action` when the finger lifts after
153/// a press that started on this button, or when an already-active continuous
154/// press slides onto this button and lifts. Every event is consumed so the
155/// gesture can never fall through to the text field beneath the overlay.
156/// Keyed by the button label so recomposition reuses the running gesture task.
157pub(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
200/// The chevron disc's press-flash + page gesture: flashes the disc white
201/// while held (the reference press feedback) and advances the page on lift.
202fn 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
251/// Measured width of one label cell (padding + text + padding), dp.
252fn item_width(label: &str, style: &TextStyle) -> f32 {
253    measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
254}
255
256/// Page-relative index of the item under a live slide point, walking the
257/// same measured cell widths the Row lays out (6dp of vertical grace above
258/// and below the capsule).
259fn 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
283/// Splits `items` into pages that fit `max_width`, appending the chevron
284/// disc's width to any page that is followed by more items. Every page holds
285/// at least one item.
286fn 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
325/// Animated visibility state for the menu (dissolve fast, rematerialize
326/// after the measured delay).
327struct 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/// Where a text edit menu belongs, in window coordinates: centred on
337/// `center_x`, over the line that runs from `line_top` to `line_bottom`.
338#[derive(Clone, Copy, Debug, PartialEq)]
339pub struct MenuAnchor {
340    /// The selection's or the caret's centre.
341    pub center_x: f32,
342    /// The top of the selection's first line, or the caret's line.
343    pub line_top: f32,
344    /// The bottom of that line.
345    pub line_bottom: f32,
346}
347
348impl MenuAnchor {
349    /// The menu's top edge: its bottom rides `MENU_GAP_ABOVE_LINE` above the
350    /// line, or, when that would cross the window's top margin, its top rides
351    /// the same gap below the line.
352    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/// The liquid-glass text edit menu.
363///
364/// * `anchor` — the line it belongs over; the capsule is centred on it,
365///   clamped to the screen margin, and sits above it where the window has
366///   room and below it where it does not.
367/// * `visible` — false while a handle drag is in flight; the menu dissolves
368///   and rematerializes per the measured timings (it stays mounted while
369///   fading).
370/// * `items` — the actions.
371#[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/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
603/// selection. `anchor` places it over the selection;
604/// `visible` is false while a handle drag is in flight. `can_paste` hides the
605/// Paste item when the clipboard is empty. Each action runs against the
606/// focused field; the caller is expected to dismiss the menu.
607#[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/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
647/// caret. Opened by tapping the cursor handle (there is no selection to
648/// Copy/Cut, so this offers the caret-relevant actions instead).
649///
650/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
651/// hide those items when the field's history has nothing to undo/redo.
652#[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;