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