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