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