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