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    /// Text shown on the item.
125    pub label: String,
126    /// Runs when the user taps the item.
127    pub action: Rc<dyn Fn()>,
128}
129
130impl TextMenuItem {
131    /// Creates an item that shows `label` and runs `action` when tapped.
132    pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
133        Self {
134            label: label.into(),
135            action: Rc::new(action),
136        }
137    }
138}
139
140impl PartialEq for TextMenuItem {
141    fn eq(&self, other: &Self) -> bool {
142        self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
143    }
144}
145
146impl std::fmt::Debug for TextMenuItem {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("TextMenuItem")
149            .field("label", &self.label)
150            .finish()
151    }
152}
153
154/// Builds the consuming tap gesture for a menu button: it swallows the press,
155/// any moves, and the release. It fires `action` when the finger lifts after
156/// a press that started on this button, or when an already-active continuous
157/// press slides onto this button and lifts. Every event is consumed so the
158/// gesture can never fall through to the text field beneath the overlay.
159/// Keyed by the button label so recomposition reuses the running gesture task.
160pub(crate) fn menu_item_pointer_input(
161    label: &str,
162    action: Rc<dyn Fn()>,
163    continuous_press: Rc<Cell<bool>>,
164) -> Modifier {
165    let key = label.to_string();
166    Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
167        let action = Rc::clone(&action);
168        let continuous_press = Rc::clone(&continuous_press);
169        async move {
170            scope
171                .await_pointer_event_scope(|await_scope| async move {
172                    let mut pressed = false;
173                    loop {
174                        let event = await_scope.await_pointer_event().await;
175                        match event.kind {
176                            PointerEventKind::Down => {
177                                pressed = true;
178                                event.consume();
179                            }
180                            PointerEventKind::Move => {
181                                event.consume();
182                            }
183                            PointerEventKind::Up => {
184                                if pressed || continuous_press.get() {
185                                    action();
186                                }
187                                pressed = false;
188                                event.consume();
189                            }
190                            PointerEventKind::Cancel => {
191                                pressed = false;
192                                event.consume();
193                            }
194                            _ => {}
195                        }
196                    }
197                })
198                .await;
199        }
200    })
201}
202
203/// The chevron disc's press-flash + page gesture: flashes the disc white
204/// while held (the reference press feedback) and advances the page on lift.
205fn disc_pointer_input(
206    pressed: Rc<Cell<bool>>,
207    on_tap: Rc<dyn Fn()>,
208    invalidate: Rc<dyn Fn()>,
209) -> Modifier {
210    Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
211        let pressed = Rc::clone(&pressed);
212        let on_tap = Rc::clone(&on_tap);
213        let invalidate = Rc::clone(&invalidate);
214        async move {
215            scope
216                .await_pointer_event_scope(|await_scope| async move {
217                    let mut down = false;
218                    loop {
219                        let event = await_scope.await_pointer_event().await;
220                        match event.kind {
221                            PointerEventKind::Down => {
222                                down = true;
223                                pressed.set(true);
224                                invalidate();
225                                event.consume();
226                            }
227                            PointerEventKind::Move => {
228                                event.consume();
229                            }
230                            PointerEventKind::Up => {
231                                if down {
232                                    on_tap();
233                                }
234                                down = false;
235                                pressed.set(false);
236                                invalidate();
237                                event.consume();
238                            }
239                            PointerEventKind::Cancel => {
240                                down = false;
241                                pressed.set(false);
242                                invalidate();
243                                event.consume();
244                            }
245                            _ => {}
246                        }
247                    }
248                })
249                .await;
250        }
251    })
252}
253
254/// Measured width of one label cell (padding + text + padding), dp.
255fn item_width(label: &str, style: &TextStyle) -> f32 {
256    measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
257}
258
259/// Page-relative index of the item under a live slide point, walking the
260/// same measured cell widths the Row lays out (6dp of vertical grace above
261/// and below the capsule).
262fn slide_item_at(
263    point: cranpose_ui_graphics::Point,
264    origin_x: f32,
265    origin_y: f32,
266    page_items: &[TextMenuItem],
267    style: &TextStyle,
268) -> Option<usize> {
269    if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
270        return None;
271    }
272    let mut cursor = origin_x;
273    for (index, item) in page_items.iter().enumerate() {
274        if index > 0 {
275            cursor += SEPARATOR_WIDTH;
276        }
277        let width = item_width(&item.label, style);
278        if point.x >= cursor && point.x < cursor + width {
279            return Some(index);
280        }
281        cursor += width;
282    }
283    None
284}
285
286/// Splits `items` into pages that fit `max_width`, appending the chevron
287/// disc's width to any page that is followed by more items. Every page holds
288/// at least one item.
289fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
290    let widths: Vec<f32> = items
291        .iter()
292        .map(|item| item_width(&item.label, style))
293        .collect();
294    let total: f32 =
295        widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
296    if total <= max_width || items.len() <= 1 {
297        return vec![(0..items.len()).collect()];
298    }
299    let disc = MENU_HEIGHT;
300    let mut pages: Vec<Vec<usize>> = Vec::new();
301    let mut page: Vec<usize> = Vec::new();
302    let mut used = disc;
303    for (index, width) in widths.iter().enumerate() {
304        let extra = width
305            + if page.is_empty() {
306                0.0
307            } else {
308                SEPARATOR_WIDTH
309            };
310        if !page.is_empty() && used + extra > max_width {
311            pages.push(std::mem::take(&mut page));
312            used = disc;
313        }
314        used += width
315            + if page.is_empty() {
316                0.0
317            } else {
318                SEPARATOR_WIDTH
319            };
320        page.push(index);
321    }
322    if !page.is_empty() {
323        pages.push(page);
324    }
325    pages
326}
327
328/// Animated visibility state for the menu (dissolve fast, rematerialize
329/// after the measured delay).
330struct MenuMotion {
331    progress: RefCell<Animatable<f32>>,
332    was_visible: Cell<bool>,
333    page: Cell<usize>,
334    disc_pressed: Rc<Cell<bool>>,
335    slide_hover: Cell<Option<usize>>,
336    slide_live: Rc<Cell<bool>>,
337}
338
339/// Where a text edit menu belongs, in window coordinates: centred on
340/// `center_x`, over the line that runs from `line_top` to `line_bottom`.
341#[derive(Clone, Copy, Debug, PartialEq)]
342pub struct MenuAnchor {
343    /// The selection's or the caret's centre.
344    pub center_x: f32,
345    /// The top of the selection's first line, or the caret's line.
346    pub line_top: f32,
347    /// The bottom of that line.
348    pub line_bottom: f32,
349}
350
351impl MenuAnchor {
352    /// The menu's top edge: its bottom rides `MENU_GAP_ABOVE_LINE` above the
353    /// line, or, when that would cross the window's top margin, its top rides
354    /// the same gap below the line.
355    pub fn menu_top(self) -> f32 {
356        let above = self.line_top - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
357        if above >= MENU_SCREEN_MARGIN {
358            above
359        } else {
360            self.line_bottom + MENU_GAP_ABOVE_LINE
361        }
362    }
363}
364
365/// The liquid-glass text edit menu.
366///
367/// * `anchor` — the line it belongs over; the capsule is centred on it,
368///   clamped to the screen margin, and sits above it where the window has
369///   room and below it where it does not.
370/// * `visible` — false while a handle drag is in flight; the menu dissolves
371///   and rematerializes per the measured timings (it stays mounted while
372///   fading).
373/// * `items` — the actions.
374#[composable]
375pub fn LiquidTextMenu(
376    anchor: MenuAnchor,
377    visible: bool,
378    live_point: Option<cranpose_ui_graphics::Point>,
379    items: Vec<TextMenuItem>,
380) {
381    let motion = remember(|| {
382        let runtime = with_current_composer(|composer| composer.runtime_handle());
383        Rc::new(MenuMotion {
384            progress: RefCell::new(Animatable::new(0.0, runtime)),
385            was_visible: Cell::new(false),
386            page: Cell::new(0),
387            disc_pressed: Rc::new(Cell::new(false)),
388            slide_hover: Cell::new(None),
389            slide_live: Rc::new(Cell::new(false)),
390        })
391    })
392    .with(Rc::clone);
393
394    if visible != motion.was_visible.get() {
395        motion.was_visible.set(visible);
396        let mut progress = motion.progress.borrow_mut();
397        if visible {
398            progress.animateTo(
399                1.0,
400                AnimationType::Tween(
401                    AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
402                        .with_delay(MENU_RETURN_DELAY_MS),
403                ),
404            );
405        } else {
406            progress.animateTo(
407                0.0,
408                AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
409            );
410        }
411    }
412    let progress_state = motion.progress.borrow().state();
413    let p = progress_state.value().clamp(0.0, 1.0);
414    if !visible && p <= 0.01 {
415        return;
416    }
417
418    let on_light = local_on_light_surface().current();
419    let style = menu_text_style(on_light);
420    let viewport = local_popup_viewport().current().get();
421    let max_width = if viewport.width > 0.0 {
422        viewport.width - 2.0 * MENU_SCREEN_MARGIN
423    } else {
424        f32::INFINITY
425    };
426    let pages = paginate(&items, &style, max_width);
427    let page_index = motion.page.get().min(pages.len() - 1);
428    let page = &pages[page_index];
429    let has_disc = pages.len() > 1;
430
431    let mut width: f32 = page
432        .iter()
433        .map(|&i| item_width(&items[i].label, &style))
434        .sum();
435    width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
436    if has_disc {
437        width += MENU_HEIGHT;
438    }
439
440    let mut x = anchor.center_x - width * 0.5;
441    if viewport.width > 0.0 {
442        x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
443    }
444    x = x.max(MENU_SCREEN_MARGIN);
445    let y = anchor.menu_top();
446
447    let anchor = Rect {
448        x,
449        y,
450        width: 0.0,
451        height: 0.0,
452    };
453    let density = crate::current_density();
454    let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
455
456    let slide_hover = match live_point {
457        Some(point) => {
458            motion.slide_live.set(true);
459            let hover = slide_item_at(point, x, y, &page_items, &style);
460            motion.slide_hover.set(hover);
461            hover
462        }
463        None => {
464            let hover = motion.slide_hover.take();
465            if motion.slide_live.replace(false) {
466                if let Some(index) = hover {
467                    if let Some(item) = page_items.get(index) {
468                        let action = Rc::clone(&item.action);
469                        SideEffect(move || action());
470                    }
471                }
472            }
473            None
474        }
475    };
476    let disc_pressed = Rc::clone(&motion.disc_pressed);
477    let page_count = pages.len();
478    let motion_for_disc = Rc::clone(&motion);
479    Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
480        let page_items = page_items.clone();
481        let disc_pressed = Rc::clone(&disc_pressed);
482        let motion = Rc::clone(&motion_for_disc);
483        Box(
484            Modifier::empty()
485                .size(Size {
486                    width,
487                    height: MENU_HEIGHT,
488                })
489                .drop_shadow(
490                    LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
491                    move |scope| {
492                        scope.radius = 16.0;
493                        scope.spread = -2.0;
494                        scope.offset.y = 0.0;
495                        scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
496                        scope.cutout = true;
497                    },
498                )
499                .graphics_layer(move || GraphicsLayer {
500                    alpha: p,
501                    backdrop_effect: (p > 0.001).then(|| {
502                        liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
503                    }),
504                    shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
505                    clip: true,
506                    ..Default::default()
507                }),
508            BoxSpec::default(),
509            move || {
510                let page_items = page_items.clone();
511                let disc_pressed = Rc::clone(&disc_pressed);
512                let motion = Rc::clone(&motion);
513                Row(
514                    Modifier::empty().size(Size {
515                        width,
516                        height: MENU_HEIGHT,
517                    }),
518                    RowSpec::default().vertical_alignment(
519                        cranpose_ui_layout::VerticalAlignment::CenterVertically,
520                    ),
521                    move || {
522                        for (index, item) in page_items.iter().enumerate() {
523                            if index > 0 {
524                                Box(
525                                    Modifier::empty()
526                                        .size(Size {
527                                            width: SEPARATOR_WIDTH,
528                                            height: SEPARATOR_HEIGHT,
529                                        })
530                                        .background(separator_color(on_light)),
531                                    BoxSpec::default(),
532                                    || {},
533                                );
534                            }
535                            let slide_hovered = slide_hover == Some(index);
536                            Text(
537                                item.label.clone(),
538                                Modifier::empty()
539                                    .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
540                                    .draw_behind(move |scope| {
541                                        if slide_hovered {
542                                            let hover = if on_light {
543                                                Color(0.0, 0.0, 0.0, 0.08)
544                                            } else {
545                                                Color(1.0, 1.0, 1.0, 0.10)
546                                            };
547                                            scope.draw_round_rect(
548                                                cranpose_ui_graphics::Brush::solid(hover),
549                                                cranpose_ui_graphics::CornerRadii::uniform(10.0),
550                                            );
551                                        }
552                                    })
553                                    .then(menu_item_pointer_input(
554                                        &item.label,
555                                        Rc::clone(&item.action),
556                                        Rc::clone(&motion.slide_live),
557                                    )),
558                                menu_text_style(on_light),
559                            );
560                        }
561                        if page_count > 1 {
562                            let pressed_now = disc_pressed.get();
563                            let motion = Rc::clone(&motion);
564                            let advance: Rc<dyn Fn()> = Rc::new(move || {
565                                motion.page.set((motion.page.get() + 1) % page_count);
566                                crate::request_render_invalidation();
567                            });
568                            let invalidate: Rc<dyn Fn()> =
569                                Rc::new(crate::request_render_invalidation);
570                            Box(
571                                Modifier::empty()
572                                    .size(Size {
573                                        width: MENU_HEIGHT,
574                                        height: MENU_HEIGHT,
575                                    })
576                                    .background(if pressed_now {
577                                        disc_pressed_color(on_light)
578                                    } else {
579                                        disc_color(on_light)
580                                    })
581                                    .rounded_corners(MENU_HEIGHT * 0.5)
582                                    .then(disc_pointer_input(
583                                        Rc::clone(&disc_pressed),
584                                        advance,
585                                        invalidate,
586                                    )),
587                                BoxSpec::default()
588                                    .content_alignment(cranpose_ui_layout::Alignment::CENTER),
589                                move || {
590                                    Text(
591                                        "\u{203A}".to_string(),
592                                        Modifier::empty(),
593                                        menu_text_style(on_light),
594                                    );
595                                },
596                            );
597                        }
598                    },
599                );
600            },
601        );
602    });
603}
604
605/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
606/// selection. `anchor` places it over the selection;
607/// `visible` is false while a handle drag is in flight. `can_copy` hides Copy
608/// and Cut, as a secret's field does. `can_paste` hides the
609/// Paste item when the clipboard is empty. Each action runs against the
610/// focused field; the caller is expected to dismiss the menu.
611#[expect(clippy::too_many_arguments)]
612#[composable]
613pub fn TextSelectionMenu(
614    anchor: MenuAnchor,
615    visible: bool,
616    live_point: Option<cranpose_ui_graphics::Point>,
617    can_copy: bool,
618    can_paste: bool,
619    on_copy: impl Fn() + 'static,
620    on_cut: impl Fn() + 'static,
621    on_paste: impl Fn() + 'static,
622    on_select_all: impl Fn() + 'static,
623) {
624    let mut items = Vec::new();
625    if can_copy {
626        items.push(TextMenuItem::new(crate::UiString::Copy.resolve(), on_copy));
627        items.push(TextMenuItem::new(crate::UiString::Cut.resolve(), on_cut));
628    }
629    append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
630    LiquidTextMenu(anchor, visible, live_point, items);
631}
632
633#[track_caller]
634fn append_edit_actions(
635    items: &mut Vec<TextMenuItem>,
636    can_paste: bool,
637    on_paste: impl Fn() + 'static,
638    on_select_all: impl Fn() + 'static,
639) {
640    if can_paste {
641        items.push(TextMenuItem::new(
642            crate::UiString::Paste.resolve(),
643            on_paste,
644        ));
645    }
646    items.push(TextMenuItem::new(
647        crate::UiString::SelectAll.resolve(),
648        on_select_all,
649    ));
650}
651
652/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
653/// caret. Opened by tapping the cursor handle (there is no selection to
654/// Copy/Cut, so this offers the caret-relevant actions instead).
655///
656/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
657/// hide those items when the field's history has nothing to undo/redo.
658#[expect(clippy::too_many_arguments)]
659#[composable]
660pub fn CaretActionMenu(
661    anchor: MenuAnchor,
662    visible: bool,
663    can_paste: bool,
664    can_undo: bool,
665    can_redo: bool,
666    on_paste: impl Fn() + 'static,
667    on_select_all: impl Fn() + 'static,
668    on_undo: impl Fn() + 'static,
669    on_redo: impl Fn() + 'static,
670) {
671    let mut items = Vec::new();
672    append_edit_actions(&mut items, can_paste, on_paste, on_select_all);
673    if can_undo {
674        items.push(TextMenuItem::new(crate::UiString::Undo.resolve(), on_undo));
675    }
676    if can_redo {
677        items.push(TextMenuItem::new(crate::UiString::Redo.resolve(), on_redo));
678    }
679    LiquidTextMenu(anchor, visible, None, items);
680}
681
682#[cfg(test)]
683#[path = "tests/text_selection_menu_tests.rs"]
684mod tests;