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    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/// Where a text edit menu belongs, in window coordinates: centred on
343/// `center_x`, over the line that runs from `line_top` to `line_bottom`.
344#[derive(Clone, Copy, Debug, PartialEq)]
345pub struct MenuAnchor {
346    /// The selection's or the caret's centre.
347    pub center_x: f32,
348    /// The top of the selection's first line, or the caret's line.
349    pub line_top: f32,
350    /// The bottom of that line.
351    pub line_bottom: f32,
352}
353
354impl MenuAnchor {
355    /// The menu's top edge: its bottom rides `MENU_GAP_ABOVE_LINE` above the
356    /// line, or, when that would cross the window's top margin, its top rides
357    /// the same gap below the line.
358    pub fn menu_top(self) -> f32 {
359        let above = self.line_top - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
360        if above >= MENU_SCREEN_MARGIN {
361            above
362        } else {
363            self.line_bottom + MENU_GAP_ABOVE_LINE
364        }
365    }
366}
367
368/// The liquid-glass text edit menu.
369///
370/// * `anchor` — the line it belongs over; the capsule is centred on it,
371///   clamped to the screen margin, and sits above it where the window has
372///   room and below it where it does not.
373/// * `visible` — false while a handle drag is in flight; the menu dissolves
374///   and rematerializes per the measured timings (it stays mounted while
375///   fading).
376/// * `items` — the actions.
377#[composable]
378pub fn LiquidTextMenu(
379    anchor: MenuAnchor,
380    visible: bool,
381    live_point: Option<cranpose_ui_graphics::Point>,
382    items: Vec<TextMenuItem>,
383) {
384    let motion = remember(|| {
385        let runtime = with_current_composer(|composer| composer.runtime_handle());
386        Rc::new(MenuMotion {
387            progress: RefCell::new(Animatable::new(0.0, runtime)),
388            was_visible: Cell::new(false),
389            page: Cell::new(0),
390            disc_pressed: Rc::new(Cell::new(false)),
391            slide_hover: Cell::new(None),
392            slide_live: Rc::new(Cell::new(false)),
393        })
394    })
395    .with(Rc::clone);
396
397    if visible != motion.was_visible.get() {
398        motion.was_visible.set(visible);
399        let mut progress = motion.progress.borrow_mut();
400        if visible {
401            progress.animateTo(
402                1.0,
403                AnimationType::Tween(
404                    AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
405                        .with_delay(MENU_RETURN_DELAY_MS),
406                ),
407            );
408        } else {
409            progress.animateTo(
410                0.0,
411                AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
412            );
413        }
414    }
415    let progress_state = motion.progress.borrow().state();
416    let p = progress_state.value().clamp(0.0, 1.0);
417    if !visible && p <= 0.01 {
418        return;
419    }
420
421    let on_light = local_on_light_surface().current();
422    let style = menu_text_style(on_light);
423    let viewport = local_popup_viewport().current().get();
424    let max_width = if viewport.width > 0.0 {
425        viewport.width - 2.0 * MENU_SCREEN_MARGIN
426    } else {
427        f32::INFINITY
428    };
429    let pages = paginate(&items, &style, max_width);
430    let page_index = motion.page.get().min(pages.len() - 1);
431    let page = &pages[page_index];
432    let has_disc = pages.len() > 1;
433
434    let mut width: f32 = page
435        .iter()
436        .map(|&i| item_width(&items[i].label, &style))
437        .sum();
438    width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
439    if has_disc {
440        width += MENU_HEIGHT;
441    }
442
443    let mut x = anchor.center_x - width * 0.5;
444    if viewport.width > 0.0 {
445        x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
446    }
447    x = x.max(MENU_SCREEN_MARGIN);
448    let y = anchor.menu_top();
449
450    let anchor = Rect {
451        x,
452        y,
453        width: 0.0,
454        height: 0.0,
455    };
456    let density = crate::current_density();
457    let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
458
459    let slide_hover = match live_point {
460        Some(point) => {
461            motion.slide_live.set(true);
462            let hover = slide_item_at(point, x, y, &page_items, &style);
463            motion.slide_hover.set(hover);
464            hover
465        }
466        None => {
467            let hover = motion.slide_hover.take();
468            if motion.slide_live.replace(false) {
469                if let Some(index) = hover {
470                    if let Some(item) = page_items.get(index) {
471                        let action = Rc::clone(&item.action);
472                        SideEffect(move || action());
473                    }
474                }
475            }
476            None
477        }
478    };
479    let disc_pressed = Rc::clone(&motion.disc_pressed);
480    let page_count = pages.len();
481    let motion_for_disc = Rc::clone(&motion);
482    Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
483        let page_items = page_items.clone();
484        let disc_pressed = Rc::clone(&disc_pressed);
485        let motion = Rc::clone(&motion_for_disc);
486        Box(
487            Modifier::empty()
488                .size(Size {
489                    width,
490                    height: MENU_HEIGHT,
491                })
492                .drop_shadow(
493                    LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
494                    move |scope| {
495                        scope.radius = 16.0;
496                        scope.spread = -2.0;
497                        scope.offset.y = 0.0;
498                        scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
499                        scope.cutout = true;
500                    },
501                )
502                .graphics_layer(move || GraphicsLayer {
503                    alpha: p,
504                    backdrop_effect: (p > 0.001).then(|| {
505                        liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
506                    }),
507                    shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
508                    clip: true,
509                    ..Default::default()
510                }),
511            BoxSpec::default(),
512            move || {
513                let page_items = page_items.clone();
514                let disc_pressed = Rc::clone(&disc_pressed);
515                let motion = Rc::clone(&motion);
516                Row(
517                    Modifier::empty().size(Size {
518                        width,
519                        height: MENU_HEIGHT,
520                    }),
521                    RowSpec::default().vertical_alignment(
522                        cranpose_ui_layout::VerticalAlignment::CenterVertically,
523                    ),
524                    move || {
525                        for (index, item) in page_items.iter().enumerate() {
526                            if index > 0 {
527                                Box(
528                                    Modifier::empty()
529                                        .size(Size {
530                                            width: SEPARATOR_WIDTH,
531                                            height: SEPARATOR_HEIGHT,
532                                        })
533                                        .background(separator_color(on_light)),
534                                    BoxSpec::default(),
535                                    || {},
536                                );
537                            }
538                            let slide_hovered = slide_hover == Some(index);
539                            Text(
540                                item.label.clone(),
541                                Modifier::empty()
542                                    .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
543                                    .draw_behind(move |scope| {
544                                        if slide_hovered {
545                                            let hover = if on_light {
546                                                Color(0.0, 0.0, 0.0, 0.08)
547                                            } else {
548                                                Color(1.0, 1.0, 1.0, 0.10)
549                                            };
550                                            scope.draw_round_rect(
551                                                cranpose_ui_graphics::Brush::solid(hover),
552                                                cranpose_ui_graphics::CornerRadii::uniform(10.0),
553                                            );
554                                        }
555                                    })
556                                    .then(menu_item_pointer_input(
557                                        &item.label,
558                                        Rc::clone(&item.action),
559                                        Rc::clone(&motion.slide_live),
560                                    )),
561                                menu_text_style(on_light),
562                            );
563                        }
564                        if page_count > 1 {
565                            let pressed_now = disc_pressed.get();
566                            let motion = Rc::clone(&motion);
567                            let advance: Rc<dyn Fn()> = Rc::new(move || {
568                                motion.page.set((motion.page.get() + 1) % page_count);
569                                crate::request_render_invalidation();
570                            });
571                            let invalidate: Rc<dyn Fn()> =
572                                Rc::new(crate::request_render_invalidation);
573                            Box(
574                                Modifier::empty()
575                                    .size(Size {
576                                        width: MENU_HEIGHT,
577                                        height: MENU_HEIGHT,
578                                    })
579                                    .background(if pressed_now {
580                                        disc_pressed_color(on_light)
581                                    } else {
582                                        disc_color(on_light)
583                                    })
584                                    .rounded_corners(MENU_HEIGHT * 0.5)
585                                    .then(disc_pointer_input(
586                                        Rc::clone(&disc_pressed),
587                                        advance,
588                                        invalidate,
589                                    )),
590                                BoxSpec::default()
591                                    .content_alignment(cranpose_ui_layout::Alignment::CENTER),
592                                move || {
593                                    Text(
594                                        "\u{203A}".to_string(),
595                                        Modifier::empty(),
596                                        menu_text_style(on_light),
597                                    );
598                                },
599                            );
600                        }
601                    },
602                );
603            },
604        );
605    });
606}
607
608/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
609/// selection. `anchor` places it over the selection;
610/// `visible` is false while a handle drag is in flight. `can_paste` hides the
611/// Paste item when the clipboard is empty. Each action runs against the
612/// focused field; the caller is expected to dismiss the menu.
613#[expect(clippy::too_many_arguments)]
614#[composable]
615pub fn TextSelectionMenu(
616    anchor: MenuAnchor,
617    visible: bool,
618    live_point: Option<cranpose_ui_graphics::Point>,
619    can_paste: bool,
620    on_copy: impl Fn() + 'static,
621    on_cut: impl Fn() + 'static,
622    on_paste: impl Fn() + 'static,
623    on_select_all: impl Fn() + 'static,
624) {
625    let mut items = vec![
626        TextMenuItem::new("Copy", on_copy),
627        TextMenuItem::new("Cut", on_cut),
628    ];
629    if can_paste {
630        items.push(TextMenuItem::new("Paste", on_paste));
631    }
632    items.push(TextMenuItem::new("Select all", on_select_all));
633    LiquidTextMenu(anchor, visible, live_point, items);
634}
635
636/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
637/// caret. Opened by tapping the cursor handle (there is no selection to
638/// Copy/Cut, so this offers the caret-relevant actions instead).
639///
640/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
641/// hide those items when the field's history has nothing to undo/redo.
642#[expect(clippy::too_many_arguments)]
643#[composable]
644pub fn CaretActionMenu(
645    anchor: MenuAnchor,
646    visible: bool,
647    can_paste: bool,
648    can_undo: bool,
649    can_redo: bool,
650    on_paste: impl Fn() + 'static,
651    on_select_all: impl Fn() + 'static,
652    on_undo: impl Fn() + 'static,
653    on_redo: impl Fn() + 'static,
654) {
655    let mut items = Vec::new();
656    if can_paste {
657        items.push(TextMenuItem::new("Paste", on_paste));
658    }
659    items.push(TextMenuItem::new("Select all", on_select_all));
660    if can_undo {
661        items.push(TextMenuItem::new("Undo", on_undo));
662    }
663    if can_redo {
664        items.push(TextMenuItem::new("Redo", on_redo));
665    }
666    LiquidTextMenu(anchor, visible, None, items);
667}
668
669#[cfg(test)]
670#[path = "tests/text_selection_menu_tests.rs"]
671mod tests;