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