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
20#![allow(non_snake_case)]
21
22use std::{
23    cell::{Cell, RefCell},
24    rc::Rc,
25};
26
27use cranpose_animation::{Animatable, AnimationSpec, AnimationType, Easing};
28use cranpose_core::{remember, with_current_composer, SideEffect};
29use cranpose_foundation::PointerEventKind;
30use cranpose_ui_graphics::{
31    liquid_menu_glass_effect, GraphicsLayer, LayerShape, Point, Rect, RoundedCornerShape, Size,
32};
33
34use crate::{
35    composable,
36    modifier::{Color, Modifier},
37    text::{measure_text, AnnotatedString, TextStyle, TextUnit},
38    widgets::{
39        box_widget::{Box, BoxSpec},
40        popup::{local_popup_viewport, Popup},
41        Row, RowSpec, Text,
42    },
43    PointerInputScope,
44};
45
46/// Capsule height (dp) — the measured 133 px @3x.
47pub const MENU_HEIGHT: f32 = 44.0;
48/// Minimum gap between the capsule and the window edges.
49const MENU_SCREEN_MARGIN: f32 = 20.0;
50/// Gap between the capsule bottom and the selection's first line top.
51const MENU_GAP_ABOVE_LINE: f32 = 15.0;
52/// Horizontal padding on each side of an item label.
53const ITEM_PADDING: f32 = 20.0;
54/// Hairline separator between labels: 1×17 dp.
55const SEPARATOR_WIDTH: f32 = 1.0;
56const SEPARATOR_HEIGHT: f32 = 17.0;
57const MENU_FONT_SP: f32 = 15.0;
58
59/// CompositionLocal marking the surface the floating glass sits over as
60/// LIGHT. Liquid glass is transparent, so its labels and hairlines must
61/// flip to dark ink over a light backdrop to stay readable — the same
62/// content over a dark app keeps its near-white ink. Defaults to `false`
63/// (dark surface), preserving the existing look; a light-themed screen
64/// provides `true`.
65pub fn local_on_light_surface() -> cranpose_core::CompositionLocal<bool> {
66    use std::cell::RefCell;
67    thread_local! {
68        static LOCAL: RefCell<Option<cranpose_core::CompositionLocal<bool>>> =
69            const { RefCell::new(None) };
70    }
71    LOCAL.with(|cell| {
72        cell.borrow_mut()
73            .get_or_insert_with(|| cranpose_core::compositionLocalOf(|| false))
74            .clone()
75    })
76}
77
78/// Label ink: near-white over a dark surface, near-black over a light one.
79fn menu_fg(on_light: bool) -> Color {
80    if on_light {
81        Color(0.08, 0.08, 0.10, 1.0)
82    } else {
83        Color(0.96, 0.96, 0.98, 1.0)
84    }
85}
86
87/// Hairline separator: a whisper of the ink polarity.
88fn separator_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.07)
93    }
94}
95
96/// The chevron disc glass fill and its pressed flash follow the polarity.
97fn disc_color(on_light: bool) -> Color {
98    if on_light {
99        Color(0.0, 0.0, 0.0, 0.10)
100    } else {
101        Color(1.0, 1.0, 1.0, 0.19)
102    }
103}
104
105fn disc_pressed_color(on_light: bool) -> Color {
106    if on_light {
107        Color(0.0, 0.0, 0.0, 0.55)
108    } else {
109        Color(1.0, 1.0, 1.0, 0.9)
110    }
111}
112/// Backdrop blur behind the capsule, dp.
113/// The materialize smudge radius; the effect resolves it to ~a fifth as the
114/// menu sharpens (the settled reference pill keeps backdrop text readable).
115const MENU_BLUR_DP: f32 = 15.0;
116
117/// Motion (measured): ~70 ms dissolve, ~140 ms materialize arriving ~250 ms
118/// after the release.
119const MENU_DISSOLVE_MS: u64 = 70;
120const MENU_MATERIALIZE_MS: u64 = 140;
121const MENU_RETURN_DELAY_MS: u64 = 250;
122
123fn menu_text_style(on_light: bool) -> TextStyle {
124    let mut style = TextStyle::default();
125    style.span_style.color = Some(menu_fg(on_light));
126    style.span_style.font_size = TextUnit::Sp(MENU_FONT_SP);
127    style
128}
129
130/// One tappable action.
131#[derive(Clone)]
132pub struct TextMenuItem {
133    pub label: String,
134    pub action: Rc<dyn Fn()>,
135}
136
137impl TextMenuItem {
138    pub fn new(label: impl Into<String>, action: impl Fn() + 'static) -> Self {
139        Self {
140            label: label.into(),
141            action: Rc::new(action),
142        }
143    }
144}
145
146impl PartialEq for TextMenuItem {
147    fn eq(&self, other: &Self) -> bool {
148        // Composable memoization identity: the label plus the action's
149        // allocation. Freshly captured closures compare unequal, which is
150        // correct - they may close over new state.
151        self.label == other.label && Rc::ptr_eq(&self.action, &other.action)
152    }
153}
154
155impl std::fmt::Debug for TextMenuItem {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("TextMenuItem")
158            .field("label", &self.label)
159            .finish()
160    }
161}
162
163/// Builds the consuming tap gesture for a menu button: it swallows the press,
164/// any moves, and the release. It fires `action` when the finger lifts after
165/// a press that started on this button, or when an already-active continuous
166/// press slides onto this button and lifts. Every event is consumed so the
167/// gesture can never fall through to the text field beneath the overlay.
168/// Keyed by the button label so recomposition reuses the running gesture task.
169pub(crate) fn menu_item_pointer_input(
170    label: &str,
171    action: Rc<dyn Fn()>,
172    continuous_press: Rc<Cell<bool>>,
173) -> Modifier {
174    let key = label.to_string();
175    Modifier::empty().pointer_input(key, move |scope: PointerInputScope| {
176        let action = Rc::clone(&action);
177        let continuous_press = Rc::clone(&continuous_press);
178        async move {
179            scope
180                .await_pointer_event_scope(|await_scope| async move {
181                    // Only a release that follows a press *on this button* runs
182                    // the action; a stray release without a press is ignored. The
183                    // Down capture keeps the whole gesture on the button, so the
184                    // field never sees it.
185                    let mut pressed = false;
186                    loop {
187                        let event = await_scope.await_pointer_event().await;
188                        match event.kind {
189                            PointerEventKind::Down => {
190                                pressed = true;
191                                event.consume();
192                            }
193                            PointerEventKind::Move => {
194                                event.consume();
195                            }
196                            PointerEventKind::Up => {
197                                if pressed || continuous_press.get() {
198                                    action();
199                                }
200                                pressed = false;
201                                event.consume();
202                            }
203                            PointerEventKind::Cancel => {
204                                pressed = false;
205                                event.consume();
206                            }
207                            _ => {}
208                        }
209                    }
210                })
211                .await;
212        }
213    })
214}
215
216/// The chevron disc's press-flash + page gesture: flashes the disc white
217/// while held (the reference press feedback) and advances the page on lift.
218fn disc_pointer_input(
219    pressed: Rc<Cell<bool>>,
220    on_tap: Rc<dyn Fn()>,
221    invalidate: Rc<dyn Fn()>,
222) -> Modifier {
223    Modifier::empty().pointer_input("menu-overflow-disc", move |scope: PointerInputScope| {
224        let pressed = Rc::clone(&pressed);
225        let on_tap = Rc::clone(&on_tap);
226        let invalidate = Rc::clone(&invalidate);
227        async move {
228            scope
229                .await_pointer_event_scope(|await_scope| async move {
230                    let mut down = false;
231                    loop {
232                        let event = await_scope.await_pointer_event().await;
233                        match event.kind {
234                            PointerEventKind::Down => {
235                                down = true;
236                                pressed.set(true);
237                                invalidate();
238                                event.consume();
239                            }
240                            PointerEventKind::Move => {
241                                event.consume();
242                            }
243                            PointerEventKind::Up => {
244                                if down {
245                                    on_tap();
246                                }
247                                down = false;
248                                pressed.set(false);
249                                invalidate();
250                                event.consume();
251                            }
252                            PointerEventKind::Cancel => {
253                                down = false;
254                                pressed.set(false);
255                                invalidate();
256                                event.consume();
257                            }
258                            _ => {}
259                        }
260                    }
261                })
262                .await;
263        }
264    })
265}
266
267/// Measured width of one label cell (padding + text + padding), dp.
268fn item_width(label: &str, style: &TextStyle) -> f32 {
269    measure_text(&AnnotatedString::from(label), style).width + 2.0 * ITEM_PADDING
270}
271
272/// Page-relative index of the item under a live slide point, walking the
273/// same measured cell widths the Row lays out (6dp of vertical grace above
274/// and below the capsule).
275fn slide_item_at(
276    point: cranpose_ui_graphics::Point,
277    origin_x: f32,
278    origin_y: f32,
279    page_items: &[TextMenuItem],
280    style: &TextStyle,
281) -> Option<usize> {
282    if point.y < origin_y - 6.0 || point.y > origin_y + MENU_HEIGHT + 6.0 {
283        return None;
284    }
285    let mut cursor = origin_x;
286    for (index, item) in page_items.iter().enumerate() {
287        if index > 0 {
288            cursor += SEPARATOR_WIDTH;
289        }
290        let width = item_width(&item.label, style);
291        if point.x >= cursor && point.x < cursor + width {
292            return Some(index);
293        }
294        cursor += width;
295    }
296    None
297}
298
299/// Splits `items` into pages that fit `max_width`, appending the chevron
300/// disc's width to any page that is followed by more items. Every page holds
301/// at least one item.
302fn paginate(items: &[TextMenuItem], style: &TextStyle, max_width: f32) -> Vec<Vec<usize>> {
303    let widths: Vec<f32> = items
304        .iter()
305        .map(|item| item_width(&item.label, style))
306        .collect();
307    let total: f32 =
308        widths.iter().sum::<f32>() + SEPARATOR_WIDTH * items.len().saturating_sub(1) as f32;
309    if total <= max_width || items.len() <= 1 {
310        return vec![(0..items.len()).collect()];
311    }
312    // Overflow: every page reserves the disc (the chevron also pages BACK
313    // from the last page, wrapping — it is always present once paging).
314    let disc = MENU_HEIGHT;
315    let mut pages: Vec<Vec<usize>> = Vec::new();
316    let mut page: Vec<usize> = Vec::new();
317    let mut used = disc;
318    for (index, width) in widths.iter().enumerate() {
319        let extra = width
320            + if page.is_empty() {
321                0.0
322            } else {
323                SEPARATOR_WIDTH
324            };
325        if !page.is_empty() && used + extra > max_width {
326            pages.push(std::mem::take(&mut page));
327            used = disc;
328        }
329        used += width
330            + if page.is_empty() {
331                0.0
332            } else {
333                SEPARATOR_WIDTH
334            };
335        page.push(index);
336    }
337    if !page.is_empty() {
338        pages.push(page);
339    }
340    pages
341}
342
343/// Animated visibility state for the menu (dissolve fast, rematerialize
344/// after the measured delay).
345struct MenuMotion {
346    progress: RefCell<Animatable<f32>>,
347    was_visible: Cell<bool>,
348    page: Cell<usize>,
349    disc_pressed: Rc<Cell<bool>>,
350    /// Page-relative item index a live slide gesture hovers, and whether a
351    /// slide was in flight last frame (its release fires the hovered item).
352    slide_hover: Cell<Option<usize>>,
353    slide_live: Rc<Cell<bool>>,
354}
355
356/// The liquid-glass text edit menu.
357///
358/// * `center_x` — window-space x to center the capsule on (the selection /
359///   caret center); clamped to the screen margin.
360/// * `line_top_y` — window-space top of the selection's first line; the
361///   capsule bottom rides `MENU_GAP_ABOVE_LINE` above it.
362/// * `visible` — false while a handle drag is in flight; the menu dissolves
363///   and rematerializes per the measured timings (it stays mounted while
364///   fading).
365/// * `items` — the actions.
366#[composable]
367pub fn LiquidTextMenu(
368    center_x: f32,
369    line_top_y: f32,
370    visible: bool,
371    live_point: Option<cranpose_ui_graphics::Point>,
372    items: Vec<TextMenuItem>,
373) {
374    let motion = remember(|| {
375        let runtime = with_current_composer(|composer| composer.runtime_handle());
376        Rc::new(MenuMotion {
377            progress: RefCell::new(Animatable::new(0.0, runtime)),
378            was_visible: Cell::new(false),
379            page: Cell::new(0),
380            disc_pressed: Rc::new(Cell::new(false)),
381            slide_hover: Cell::new(None),
382            slide_live: Rc::new(Cell::new(false)),
383        })
384    })
385    .with(Rc::clone);
386
387    if visible != motion.was_visible.get() {
388        motion.was_visible.set(visible);
389        let mut progress = motion.progress.borrow_mut();
390        if visible {
391            progress.animateTo(
392                1.0,
393                AnimationType::Tween(
394                    AnimationSpec::tween(MENU_MATERIALIZE_MS, Easing::EaseOut)
395                        .with_delay(MENU_RETURN_DELAY_MS),
396                ),
397            );
398        } else {
399            progress.animateTo(
400                0.0,
401                AnimationType::Tween(AnimationSpec::tween(MENU_DISSOLVE_MS, Easing::LinearEasing)),
402            );
403        }
404    }
405    let progress_state = motion.progress.borrow().state();
406    let p = progress_state.value().clamp(0.0, 1.0);
407    if !visible && p <= 0.01 {
408        return;
409    }
410
411    let on_light = local_on_light_surface().current();
412    let style = menu_text_style(on_light);
413    let viewport = local_popup_viewport().current().get();
414    let max_width = if viewport.width > 0.0 {
415        viewport.width - 2.0 * MENU_SCREEN_MARGIN
416    } else {
417        f32::INFINITY
418    };
419    let pages = paginate(&items, &style, max_width);
420    let page_index = motion.page.get().min(pages.len() - 1);
421    let page = &pages[page_index];
422    let has_disc = pages.len() > 1;
423
424    // Capsule width from the measured labels (the same measurer the labels
425    // lay out with), for centering + clamping.
426    let mut width: f32 = page
427        .iter()
428        .map(|&i| item_width(&items[i].label, &style))
429        .sum();
430    width += SEPARATOR_WIDTH * page.len().saturating_sub(1) as f32;
431    if has_disc {
432        width += MENU_HEIGHT;
433    }
434
435    let mut x = center_x - width * 0.5;
436    if viewport.width > 0.0 {
437        x = x.min(viewport.width - MENU_SCREEN_MARGIN - width);
438    }
439    x = x.max(MENU_SCREEN_MARGIN);
440    let y = line_top_y - MENU_GAP_ABOVE_LINE - MENU_HEIGHT;
441
442    let anchor = Rect {
443        x,
444        y,
445        width: 0.0,
446        height: 0.0,
447    };
448    let density = crate::current_density();
449    let page_items: Vec<TextMenuItem> = page.iter().map(|&i| items[i].clone()).collect();
450
451    // Slide-to-fire: a still-down gesture (long-press claimed) feeds live
452    // window positions; the hovered item highlights, and the gesture's
453    // release fires it — no separate Down required (the reference menu
454    // selects under a continuous press).
455    let slide_hover = match live_point {
456        Some(point) => {
457            motion.slide_live.set(true);
458            let hover = slide_item_at(point, x, y, &page_items, &style);
459            motion.slide_hover.set(hover);
460            hover
461        }
462        None => {
463            let hover = motion.slide_hover.take();
464            if motion.slide_live.replace(false) {
465                if let Some(index) = hover {
466                    if let Some(item) = page_items.get(index) {
467                        let action = Rc::clone(&item.action);
468                        SideEffect(move || action());
469                    }
470                }
471            }
472            None
473        }
474    };
475    let disc_pressed = Rc::clone(&motion.disc_pressed);
476    let page_count = pages.len();
477    let motion_for_disc = Rc::clone(&motion);
478    Popup(anchor, Point { x: 0.0, y: 0.0 }, move || {
479        let page_items = page_items.clone();
480        let disc_pressed = Rc::clone(&disc_pressed);
481        let motion = Rc::clone(&motion_for_disc);
482        Box(
483            Modifier::empty()
484                .size(Size {
485                    width,
486                    height: MENU_HEIGHT,
487                })
488                // The reference capsule floats on a soft elevation shadow
489                // (~a 45px halo). Knocked out of its own silhouette — glass
490                // samples the backdrop behind itself and must not refract
491                // its own shadow.
492                .drop_shadow(
493                    LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
494                    move |scope| {
495                        // A transient BRIGHT bloom while the pill
496                        // materializes (the reference's disc flash / glow),
497                        // gone at rest — the settled reference shows a flat
498                        // baseline right up to the rim, and a dark band is
499                        // invisible on the dark card anyway.
500                        scope.radius = 16.0;
501                        scope.spread = -2.0;
502                        scope.offset.y = 0.0;
503                        scope.color = Color(1.0, 1.0, 1.0, 0.12 * p * (1.0 - p));
504                        scope.cutout = true;
505                    },
506                )
507                .graphics_layer(move || GraphicsLayer {
508                    alpha: p,
509                    // No material during the return-delay window (p≈0): a
510                    // composed backdrop blur ignores layer alpha and would
511                    // smear the content behind the pill a quarter-second
512                    // before anything fades in — the reference shows nothing
513                    // until the fade starts.
514                    backdrop_effect: (p > 0.001).then(|| {
515                        liquid_menu_glass_effect((width, MENU_HEIGHT), MENU_BLUR_DP * density, p)
516                    }),
517                    shape: LayerShape::Rounded(RoundedCornerShape::uniform(1.0e6)),
518                    clip: true,
519                    ..Default::default()
520                }),
521            BoxSpec::default(),
522            move || {
523                let page_items = page_items.clone();
524                let disc_pressed = Rc::clone(&disc_pressed);
525                let motion = Rc::clone(&motion);
526                Row(
527                    Modifier::empty().size(Size {
528                        width,
529                        height: MENU_HEIGHT,
530                    }),
531                    RowSpec::default().vertical_alignment(
532                        cranpose_ui_layout::VerticalAlignment::CenterVertically,
533                    ),
534                    move || {
535                        for (index, item) in page_items.iter().enumerate() {
536                            if index > 0 {
537                                Box(
538                                    Modifier::empty()
539                                        .size(Size {
540                                            width: SEPARATOR_WIDTH,
541                                            height: SEPARATOR_HEIGHT,
542                                        })
543                                        .background(separator_color(on_light)),
544                                    BoxSpec::default(),
545                                    || {},
546                                );
547                            }
548                            let slide_hovered = slide_hover == Some(index);
549                            Text(
550                                item.label.clone(),
551                                Modifier::empty()
552                                    // The glyphs sit low in this stack's line
553                                    // box; the reference centers them to a
554                                    // pixel, so bias 2dp onto the bottom.
555                                    .padding_each(ITEM_PADDING, 0.0, ITEM_PADDING, 2.0)
556                                    .draw_behind(move |scope| {
557                                        // Slide hover: the reference lights the
558                                        // item under the sliding finger.
559                                        if slide_hovered {
560                                            let hover = if on_light {
561                                                Color(0.0, 0.0, 0.0, 0.08)
562                                            } else {
563                                                Color(1.0, 1.0, 1.0, 0.10)
564                                            };
565                                            scope.draw_round_rect(
566                                                cranpose_ui_graphics::Brush::solid(hover),
567                                                cranpose_ui_graphics::CornerRadii::uniform(10.0),
568                                            );
569                                        }
570                                    })
571                                    .then(menu_item_pointer_input(
572                                        &item.label,
573                                        Rc::clone(&item.action),
574                                        Rc::clone(&motion.slide_live),
575                                    )),
576                                menu_text_style(on_light),
577                            );
578                        }
579                        if page_count > 1 {
580                            // The lighter glass disc filling the end cap, with
581                            // the paging chevron; flashes white while pressed.
582                            let pressed_now = disc_pressed.get();
583                            let motion = Rc::clone(&motion);
584                            let advance: Rc<dyn Fn()> = Rc::new(move || {
585                                motion.page.set((motion.page.get() + 1) % page_count);
586                                crate::request_render_invalidation();
587                            });
588                            let invalidate: Rc<dyn Fn()> =
589                                Rc::new(crate::request_render_invalidation);
590                            Box(
591                                Modifier::empty()
592                                    .size(Size {
593                                        width: MENU_HEIGHT,
594                                        height: MENU_HEIGHT,
595                                    })
596                                    .background(if pressed_now {
597                                        disc_pressed_color(on_light)
598                                    } else {
599                                        disc_color(on_light)
600                                    })
601                                    .rounded_corners(MENU_HEIGHT * 0.5)
602                                    .then(disc_pointer_input(
603                                        Rc::clone(&disc_pressed),
604                                        advance,
605                                        invalidate,
606                                    )),
607                                BoxSpec::default()
608                                    .content_alignment(cranpose_ui_layout::Alignment::CENTER),
609                                move || {
610                                    Text(
611                                        "\u{203A}".to_string(),
612                                        Modifier::empty(),
613                                        menu_text_style(on_light),
614                                    );
615                                },
616                            );
617                        }
618                    },
619                );
620            },
621        );
622    });
623}
624
625/// A floating Copy / Cut / Paste / Select-all menu shown just above the text
626/// selection. `center_x` / `line_top_y` anchor it over the selection;
627/// `visible` is false while a handle drag is in flight. `can_paste` hides the
628/// Paste item when the clipboard is empty. Each action runs against the
629/// focused field; the caller is expected to dismiss the menu.
630#[allow(clippy::too_many_arguments)]
631#[composable]
632pub fn TextSelectionMenu(
633    center_x: f32,
634    line_top_y: f32,
635    visible: bool,
636    live_point: Option<cranpose_ui_graphics::Point>,
637    can_paste: bool,
638    on_copy: impl Fn() + 'static,
639    on_cut: impl Fn() + 'static,
640    on_paste: impl Fn() + 'static,
641    on_select_all: impl Fn() + 'static,
642) {
643    let mut items = vec![
644        TextMenuItem::new("Copy", on_copy),
645        TextMenuItem::new("Cut", on_cut),
646    ];
647    if can_paste {
648        items.push(TextMenuItem::new("Paste", on_paste));
649    }
650    items.push(TextMenuItem::new("Select all", on_select_all));
651    LiquidTextMenu(center_x, line_top_y, visible, live_point, items);
652}
653
654/// A floating Paste / Select all / Undo / Redo menu shown near the collapsed
655/// caret. Opened by tapping the cursor handle (there is no selection to
656/// Copy/Cut, so this offers the caret-relevant actions instead).
657///
658/// `can_paste` hides Paste when the clipboard is empty; `can_undo`/`can_redo`
659/// hide those items when the field's history has nothing to undo/redo.
660#[allow(clippy::too_many_arguments)]
661#[composable]
662pub fn CaretActionMenu(
663    center_x: f32,
664    line_top_y: f32,
665    visible: bool,
666    can_paste: bool,
667    can_undo: bool,
668    can_redo: bool,
669    on_paste: impl Fn() + 'static,
670    on_select_all: impl Fn() + 'static,
671    on_undo: impl Fn() + 'static,
672    on_redo: impl Fn() + 'static,
673) {
674    let mut items = Vec::new();
675    if can_paste {
676        items.push(TextMenuItem::new("Paste", on_paste));
677    }
678    items.push(TextMenuItem::new("Select all", on_select_all));
679    if can_undo {
680        items.push(TextMenuItem::new("Undo", on_undo));
681    }
682    if can_redo {
683        items.push(TextMenuItem::new("Redo", on_redo));
684    }
685    LiquidTextMenu(center_x, line_top_y, visible, None, items);
686}
687
688#[cfg(test)]
689mod tests {
690    use cranpose_foundation::PointerEvent;
691    use cranpose_ui_graphics::Point;
692
693    use super::*;
694    use crate::modifier::{collect_slices_from_modifier, ModifierNodeSlices};
695
696    /// Collects the button's live pointer-input handler. Returns the owning
697    /// [`ModifierNodeSlices`] too: it keeps the attached node (and its running
698    /// coroutine) alive — dropping it would cancel the gesture and swallow the
699    /// events.
700    fn button_handler(modifier: &Modifier) -> (Rc<dyn Fn(PointerEvent)>, ModifierNodeSlices) {
701        let slices = collect_slices_from_modifier(modifier);
702        assert_eq!(
703            slices.pointer_inputs().len(),
704            1,
705            "menu button must install exactly one pointer-input gesture"
706        );
707        let handler = slices.pointer_inputs()[0].clone();
708        (handler, slices)
709    }
710
711    fn down(x: f32, y: f32) -> PointerEvent {
712        PointerEvent::new(PointerEventKind::Down, Point { x, y }, Point { x, y })
713    }
714    fn up(x: f32, y: f32) -> PointerEvent {
715        PointerEvent::new(PointerEventKind::Up, Point { x, y }, Point { x, y })
716    }
717
718    /// Bug 7: a tap (press then release) on a menu button consumes BOTH the
719    /// press and the release — so the tap can never fall through to the text
720    /// field below (which would collapse the selection) — and runs the action on
721    /// release.
722    #[test]
723    fn menu_button_consumes_the_tap_and_runs_the_action() {
724        let _app_context = crate::render_state::app_context_test_scope();
725        let ran = Rc::new(Cell::new(false));
726        let action: Rc<dyn Fn()> = {
727            let ran = Rc::clone(&ran);
728            Rc::new(move || ran.set(true))
729        };
730        let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(false)));
731        let (handler, _slices) = button_handler(&modifier);
732
733        let press = down(5.0, 5.0);
734        handler(press.clone());
735        assert!(
736            press.is_consumed(),
737            "the press must be consumed so it never reaches the field and collapses the selection"
738        );
739        assert!(!ran.get(), "the action fires on release, not on press");
740
741        let release = up(6.0, 6.0);
742        handler(release.clone());
743        assert!(release.is_consumed(), "the release must be consumed too");
744        assert!(
745            ran.get(),
746            "releasing after a press on the button runs the action"
747        );
748    }
749
750    /// A stray release with no preceding press on this button is still consumed
751    /// (never reaches the field) but does not run the action.
752    #[test]
753    fn menu_button_release_without_press_is_consumed_but_inert() {
754        let _app_context = crate::render_state::app_context_test_scope();
755        let ran = Rc::new(Cell::new(false));
756        let action: Rc<dyn Fn()> = {
757            let ran = Rc::clone(&ran);
758            Rc::new(move || ran.set(true))
759        };
760        let modifier = menu_item_pointer_input("Cut", action, Rc::new(Cell::new(false)));
761        let (handler, _slices) = button_handler(&modifier);
762
763        let release = up(5.0, 5.0);
764        handler(release.clone());
765        assert!(
766            release.is_consumed(),
767            "a release on the menu is consumed so it never hits the field"
768        );
769        assert!(!ran.get(), "a release with no matching press must not act");
770    }
771
772    #[test]
773    fn menu_button_accepts_release_from_a_continuous_press() {
774        let _app_context = crate::render_state::app_context_test_scope();
775        let ran = Rc::new(Cell::new(false));
776        let action: Rc<dyn Fn()> = {
777            let ran = Rc::clone(&ran);
778            Rc::new(move || ran.set(true))
779        };
780        let modifier = menu_item_pointer_input("Copy", action, Rc::new(Cell::new(true)));
781        let (handler, _slices) = button_handler(&modifier);
782
783        let release = up(5.0, 5.0);
784        handler(release.clone());
785
786        assert!(release.is_consumed());
787        assert!(ran.get());
788    }
789
790    /// The measured layout constants: a 44 dp capsule with 20 dp label
791    /// padding and 1×17 dp separators.
792    #[test]
793    fn menu_geometry_matches_the_reference() {
794        assert_eq!(MENU_HEIGHT, 44.0);
795        assert_eq!(ITEM_PADDING, 20.0);
796        assert_eq!(SEPARATOR_WIDTH, 1.0);
797        assert_eq!(SEPARATOR_HEIGHT, 17.0);
798        assert_eq!(MENU_GAP_ABOVE_LINE, 15.0);
799        assert_eq!(MENU_SCREEN_MARGIN, 20.0);
800    }
801
802    /// Pagination: everything fits on one page when there is room; a narrow
803    /// window splits into pages, each reserving the chevron disc, and every
804    /// page keeps at least one item.
805    #[test]
806    fn pagination_reserves_the_disc_only_when_overflowing() {
807        let _app_context = crate::render_state::app_context_test_scope();
808        // Pagination is polarity-independent (ink color never enters
809        // item_width); reading the CompositionLocal here needs an active
810        // composer that a bare unit test does not have.
811        let style = menu_text_style(false);
812        let items: Vec<TextMenuItem> = ["Copy", "Cut", "Paste", "Select all"]
813            .iter()
814            .map(|label| TextMenuItem::new(*label, || {}))
815            .collect();
816
817        let one = paginate(&items, &style, f32::INFINITY);
818        assert_eq!(one.len(), 1, "everything fits on one page");
819        assert_eq!(one[0].len(), 4);
820
821        let total: f32 = items
822            .iter()
823            .map(|i| item_width(&i.label, &style))
824            .sum::<f32>()
825            + 3.0 * SEPARATOR_WIDTH;
826        let narrow = paginate(&items, &style, total * 0.55);
827        assert!(narrow.len() > 1, "a narrow window must page");
828        assert!(narrow.iter().all(|page| !page.is_empty()));
829        let all: Vec<usize> = narrow.iter().flatten().copied().collect();
830        assert_eq!(all, vec![0, 1, 2, 3], "pages cover every item in order");
831    }
832}