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