Skip to main content

cranpose_liquid/widgets/
menu.rs

1//! A popup menu that morphs out of its anchor: the glass bubble springs from
2//! the anchor corner while its items fade in (the WWDC "Show" menu).
3
4use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
5use crate::theme::{liquid_colors, liquid_typography};
6use cranpose_core::{mutableStateOf, remember};
7use cranpose_macros::composable;
8use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle, TextUnit};
9use cranpose_ui::widgets::{
10    Box, BoxSpec, Column, ColumnSpec, PopupDismissable, Row, RowSpec, Text,
11};
12use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope};
13use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer, Point, Rect, RenderEffect};
14use cranpose_ui_layout::VerticalAlignment;
15use std::cell::Cell;
16use std::rc::Rc;
17
18/// One menu entry.
19#[derive(Clone, Debug, PartialEq)]
20pub struct LiquidMenuItem {
21    pub label: String,
22    /// Optional leading icon (24×24 path data).
23    pub icon: Option<&'static str>,
24    /// Draws the leading checkmark (selected state).
25    pub checked: bool,
26    /// Destructive styling.
27    pub destructive: bool,
28    /// Starts a new visual section (full-width hairline above).
29    pub section_start: bool,
30    /// A non-interactive gray section header ("Show").
31    pub header: bool,
32}
33
34impl LiquidMenuItem {
35    pub fn new(label: impl Into<String>) -> Self {
36        Self {
37            label: label.into(),
38            icon: None,
39            checked: false,
40            destructive: false,
41            section_start: false,
42            header: false,
43        }
44    }
45
46    /// A gray, non-interactive section header row.
47    pub fn header(label: impl Into<String>) -> Self {
48        Self {
49            header: true,
50            ..Self::new(label)
51        }
52    }
53
54    pub fn icon(mut self, icon: &'static str) -> Self {
55        self.icon = Some(icon);
56        self
57    }
58
59    pub fn checked(mut self, checked: bool) -> Self {
60        self.checked = checked;
61        self
62    }
63
64    pub fn destructive(mut self) -> Self {
65        self.destructive = true;
66        self
67    }
68
69    pub fn section_start(mut self) -> Self {
70        self.section_start = true;
71        self
72    }
73}
74
75const MENU_WIDTH: f32 = 250.0;
76const MENU_RADIUS: f32 = 40.0;
77/// How far the card's top edge sits below the anchor's top: the settled menu
78/// swallows the anchor button ENTIRELY (the reference "…" disappears under
79/// the glass, reading as a smudge; only mid-flight does its bump ride the
80/// droplet edge).
81const ANCHOR_OVERLAP: f32 = 0.0;
82const ROW_PADDING_X: f32 = 16.0;
83const ROW_PADDING_Y: f32 = 12.0;
84/// Width reserved for the leading checkmark column when any item is checkable.
85const CHECK_COLUMN: f32 = 24.0;
86const ICON_SIZE: f32 = 20.0;
87const ICON_GAP: f32 = 12.0;
88/// Content materialization blur (dp) at the start of the morph — rows fade in
89/// from behind the glass, sharpening over the flight (reference frames 6-9).
90const CONTENT_BLUR: f32 = 10.0;
91
92/// A glass popup menu anchored to `anchor` (window coordinates of the button
93/// that opened it). `neighbors` are the window-coord rects of other glass
94/// controls near the anchor (the rest of the nav cluster): the growing
95/// bubble SDF-glues to them in passing, exactly like the reference
96/// keyframes. While `expanded`, taps outside dismiss via `on_dismiss`; item
97/// taps call `on_item` with the index then dismiss.
98///
99/// Layout follows the iOS menu: an optional leading checkmark column (present
100/// on every row once any item is checkable, so labels align), then the icon
101/// column, then the label. Sections split with full-width hairlines; headers
102/// are gray non-interactive rows.
103#[composable]
104#[allow(non_snake_case)]
105pub fn LiquidMenu(
106    expanded: bool,
107    anchor: Rect,
108    neighbors: Vec<Rect>,
109    items: Vec<LiquidMenuItem>,
110    on_item: impl Fn(usize) + 'static,
111    on_dismiss: impl Fn() + 'static,
112) {
113    // The menu outlives `expanded` by one collapse animation: dismissing
114    // deflates the droplet back into the anchor (the reference close morph)
115    // before the popup unmounts.
116    let visible = remember(|| mutableStateOf(false)).with(|s| *s);
117    if expanded && !visible.get() {
118        visible.set(true);
119    }
120    if !expanded && !visible.get() {
121        return;
122    }
123    let colors = liquid_colors();
124    let typography = liquid_typography();
125    let on_item: Rc<dyn Fn(usize)> = Rc::new(on_item);
126    let on_dismiss: Rc<dyn Fn()> = Rc::new(on_dismiss);
127
128    // The droplet spring: opening uses the bouncy morph spring (visible size
129    // overshoot, the reference menu swells a few percent past its final width
130    // and relaxes); closing is a faster, non-bouncy suck-back.
131    // Open ≈330ms press→crisp with a soft overshoot (timed against the
132    // reference recording); close is a faster suck-back (~200ms).
133    let grow = cranpose_animation::animate_float_as_state_with_initial(
134        0.0,
135        if expanded { 1.0 } else { 0.0 },
136        if expanded {
137            cranpose_animation::spring(0.58, 380.0)
138        } else {
139            cranpose_animation::spring(1.0, 1100.0)
140        },
141        "menu-grow",
142    );
143    // Content reveal runs on its OWN slower clock: the droplet reaches full
144    // size while the rows are still smudges, and they crisp only around
145    // settle (the reference keeps content illegible until the last beat).
146    // Closing snaps the blur back on fast.
147    let reveal_anim = cranpose_animation::animate_float_as_state_with_initial(
148        0.0,
149        if expanded { 1.0 } else { 0.0 },
150        if expanded {
151            cranpose_animation::spring(1.0, 110.0)
152        } else {
153            cranpose_animation::spring(1.0, 900.0)
154        },
155        "menu-reveal",
156    );
157    // Body-level read: each animation frame recomposes this menu, which
158    // re-registers fresh popup content (see `Popup`), driving the morph.
159    // NOT clamped at 1 — the spring's overshoot is the size overshoot.
160    let appear = grow.get().max(0.0);
161    let reveal = reveal_anim.get().clamp(0.0, 1.0);
162    if !expanded && appear < 0.02 {
163        visible.set(false);
164        return;
165    }
166
167    // The node spans from the anchor's top; ANCHOR_OVERLAP places the card's
168    // top edge so the settled glass swallows the anchor button entirely.
169    let anchor_zone = anchor.height * ANCHOR_OVERLAP;
170    let node_size =
171        remember(|| Rc::new(Cell::new(cranpose_ui_graphics::Size::ZERO))).with(Rc::clone);
172    // Finite-difference morph velocity (per render frame) drives the wobble
173    // and the viscous leading-edge bulge — reactive, not canned.
174    let last_appear = remember(|| Rc::new(Cell::new(f32::NAN))).with(Rc::clone);
175
176    // Right-align the card under the anchor (menus morph out of trailing
177    // buttons), staying on-screen for anchors near the right edge. The host
178    // renders the outside-tap scrim (PopupDismissable). While collapsing the
179    // scrim must not re-fire the caller's dismiss.
180    let scrim_dismiss = Rc::clone(&on_dismiss);
181    let scrim_active = expanded;
182    PopupDismissable(
183        anchor,
184        Point::new(anchor.width - MENU_WIDTH, 0.0),
185        move || {
186            if scrim_active {
187                scrim_dismiss()
188            }
189        },
190        {
191            let items = items.clone();
192            let typography = typography.clone();
193            let on_item = Rc::clone(&on_item);
194            let on_dismiss = Rc::clone(&on_dismiss);
195            let node_size = Rc::clone(&node_size);
196            let last_appear = Rc::clone(&last_appear);
197            move || {
198                // Shapeshift (the WWDC menu-open keyframes): the anchor
199                // bubble inflates into the menu card as ONE droplet — the
200                // anchor is first carved out (crisp button riding the
201                // droplet edge), then swallowed, then melted flat into the
202                // settled edge. Neighbors join the field only while the
203                // growing edge is actually near them, so the settled menu
204                // never halos far-away controls.
205                let anchor_center = (MENU_WIDTH - anchor.width * 0.5, anchor.height * 0.5);
206                let anchor_shape = (
207                    anchor_center.0,
208                    anchor_center.1,
209                    anchor.width,
210                    anchor.height,
211                    -1.0,
212                );
213                // Node origin in window coords (the popup offset from the
214                // anchor) converts neighbor rects into node-local shapes.
215                let node_origin = (anchor.x + anchor.width - MENU_WIDTH, anchor.y);
216                let neighbor_shapes_for_dyn: Vec<(f32, f32, f32, f32, f32)> = neighbors
217                    .iter()
218                    .map(|rect| {
219                        (
220                            rect.x + rect.width * 0.5 - node_origin.0,
221                            rect.y + rect.height * 0.5 - node_origin.1,
222                            rect.width,
223                            rect.height,
224                            -1.0,
225                        )
226                    })
227                    .collect();
228                let morph_size = Rc::clone(&node_size);
229                let morph_last_appear = Rc::clone(&last_appear);
230                // Muted vibrancy: the absorbed button must read as a soft
231                // smudge beneath the glass, not a hot saturated orb.
232                let glass = Glass::regular()
233                    .shape(LiquidShape::RoundedRect(MENU_RADIUS))
234                    .saturation(1.2)
235                    .lift(0.34)
236                    .no_clip()
237                    .shadow(false);
238                let card = Modifier::empty()
239                    .report_size(Rc::clone(&node_size))
240                    .glass_effect_with(glass, move || {
241                        let size = morph_size.get();
242                        let menu_h = (size.height - anchor_zone).max(24.0);
243                        // Shaped spring: the reference droplet lingers near
244                        // the button for the first ~100ms (icon fades, slight
245                        // swell) and inflates late — an ease-in on the spring
246                        // value reproduces that while keeping the overshoot
247                        // past 1 (which IS the size overshoot at settle).
248                        let t = if appear < 1.0 {
249                            appear.powf(1.6)
250                        } else {
251                            appear
252                        };
253                        let start = anchor_shape;
254                        // Pillowy settled corners (the reference menu's radius
255                        // is ~0.26 of its height — far rounder than a desktop
256                        // popup).
257                        let settle_radius = (menu_h * 0.26).clamp(MENU_RADIUS, 64.0);
258                        let target = (
259                            MENU_WIDTH * 0.5,
260                            anchor_zone + menu_h * 0.5,
261                            MENU_WIDTH,
262                            menu_h,
263                            settle_radius,
264                        );
265                        let start_radius = anchor.width.min(anchor.height) * 0.5;
266                        let lerp = |a: f32, b: f32| a + (b - a) * t;
267                        // Width LAGS height: the reference droplet inflates as
268                        // a near-circle first and only stretches into the wide
269                        // stadium late in the flight.
270                        let t_w = t.powf(1.9);
271                        let lerp_w = |a: f32, b: f32| a + (b - a) * t_w;
272                        let grown_w = lerp_w(start.2, target.2).max(anchor.width * 0.6);
273                        let grown_h = lerp(start.3, target.3).max(anchor.height * 0.6);
274                        // The reference droplet stays BLOB-ROUND while it
275                        // inflates (mid-flight it is nearly circular — radius
276                        // tracking the half-extent) and only squares into the
277                        // menu's rounded rect late in the flight.
278                        let blob_radius = grown_w.min(grown_h) * 0.5;
279                        let squareness = ((t - 0.55) / 0.45).clamp(0.0, 1.0);
280                        let radius = if t >= 1.0 {
281                            target.4
282                        } else {
283                            let base = start_radius + (blob_radius - start_radius) * t;
284                            base + (target.4 - base) * squareness * squareness
285                        };
286                        let primary = (
287                            lerp(start.0, target.0),
288                            lerp(start.1, target.1),
289                            grown_w,
290                            grown_h,
291                            radius,
292                        );
293                        // Morph velocity (per frame): wobble and the viscous
294                        // leading-edge bulge follow the actual motion — fast
295                        // growth bubbles hard, the settle calms down, the
296                        // close morph bulges back toward the anchor.
297                        let prev = morph_last_appear.replace(t);
298                        let v = if prev.is_nan() { 0.0 } else { t - prev };
299                        let speed = v.abs();
300                        // Growth direction from the anchor toward the card
301                        // center (node coords, y down).
302                        let dir_x = target.0 - start.0;
303                        let dir_y = target.1 - start.1;
304                        let mut bulge_dir = dir_y.atan2(dir_x);
305                        if v < 0.0 {
306                            bulge_dir += std::f32::consts::PI;
307                        }
308                        let mut shapes = Vec::new();
309                        // The anchor button rides INSIDE the droplet (the
310                        // reference dots fade under the growing glass, they
311                        // are never carved out); a melting union bump keeps
312                        // the birth edge organic and leaves a flat settled
313                        // edge. The close morph walks it backwards.
314                        let melt = (1.0 - t).clamp(0.0, 1.0);
315                        if melt > 0.01 {
316                            shapes.push((
317                                anchor_shape.0,
318                                anchor_shape.1,
319                                anchor_shape.2 * melt.max(0.4),
320                                anchor_shape.3 * melt.max(0.4),
321                                -1.0,
322                            ));
323                        }
324                        // Neighbors participate only while the droplet's
325                        // edge is within glue reach of them MID-FLIGHT —
326                        // passing glue, never a settled lump (the settled
327                        // reference shows neighbors as smudges under the
328                        // glass, not edge geometry).
329                        let glue = 22.0 * (1.0 - t.clamp(0.0, 1.0) * 0.65);
330                        for neighbor in &neighbor_shapes_for_dyn {
331                            let (nx, ny, nw, nh, _) = *neighbor;
332                            let half_w = primary.2 * 0.5;
333                            let half_h = primary.3 * 0.5;
334                            let dx = ((nx - primary.0).abs() - half_w - nw * 0.5).max(0.0);
335                            let dy = ((ny - primary.1).abs() - half_h - nh * 0.5).max(0.0);
336                            let gap = (dx * dx + dy * dy).sqrt();
337                            if gap >= glue * 2.2 {
338                                continue;
339                            }
340                            if reveal < 0.45 {
341                                // Metaball phase: the neighbor's glass HOST
342                                // joins the field (union halo necks with the
343                                // droplet) while the button itself is carved
344                                // crisp — the reference keeps the blue filter
345                                // a sharp circle with its icon until the
346                                // glass truly swallows it.
347                                shapes.push((nx, ny, nw + 12.0, nh + 12.0, -1.0));
348                                shapes.push((nx, ny, nw - 4.0, nh - 4.0, -2.0));
349                            } else if t < 0.85 {
350                                // Swallowed: melt to a smudge under the glass.
351                                let swallow = 1.0 - ((reveal - 0.45) / 0.35).clamp(0.0, 1.0);
352                                if swallow > 0.01 {
353                                    shapes.push((
354                                        nx,
355                                        ny,
356                                        nw * swallow.max(0.3),
357                                        nh * swallow.max(0.3),
358                                        -1.0,
359                                    ));
360                                }
361                            }
362                        }
363                        GlassDynamics {
364                            morph: Some(GlassMorph {
365                                node_size: (size.width.max(1.0), size.height.max(1.0)),
366                                primary,
367                                shapes,
368                                glue,
369                                wobble_amplitude: (speed * 220.0).min(6.5),
370                                wobble_phase: t * 8.0,
371                                bulge_amplitude: (speed * 340.0).min(10.0),
372                                bulge_direction: bulge_dir,
373                            }),
374                            ..Default::default()
375                        }
376                    })
377                    .width(MENU_WIDTH);
378
379                let has_checks = items.iter().any(|item| item.checked);
380                // Finger/pointer sliding through the menu highlights the row
381                // under it (release selects) — the iOS drag-through-menu.
382                let hovered = remember(|| mutableStateOf(Option::<usize>::None)).with(|s| *s);
383                Column(card, ColumnSpec::default(), {
384                    let items = items.clone();
385                    let typography = typography.clone();
386                    let on_item = Rc::clone(&on_item);
387                    let on_dismiss = Rc::clone(&on_dismiss);
388                    move || {
389                        Box(
390                            Modifier::empty().height(anchor_zone),
391                            BoxSpec::default(),
392                            || {},
393                        );
394                        // The card's drop shadow belongs to the menu rect
395                        // only (the glass node also spans the anchor zone).
396                        // Soft and wide — the reference menu shadow is a
397                        // whisper, not a hard blob. Content stays ABSENT
398                        // through the droplet's first stretch and only
399                        // materializes late (the reference droplet is blank
400                        // at 25%, smudges at 60%, crisp in the last stretch)
401                        // — and never renders outside the droplet.
402                        // Content is ABSENT through most of the flight (the
403                        // reference droplet is empty glass until the last
404                        // stretch) and, while CLOSING, rides the fast glass
405                        // clock so it collapses WITH the panel instead of
406                        // dissolving in place.
407                        let content = if expanded {
408                            ((reveal - 0.62) / 0.38).clamp(0.0, 1.0).powf(1.2)
409                        } else {
410                            ((appear - 0.45) / 0.55).clamp(0.0, 1.0)
411                        };
412                        let shadow_color = if colors.is_dark {
413                            cranpose_ui_graphics::Color::BLACK.with_alpha(0.45 * content)
414                        } else {
415                            cranpose_ui_graphics::Color::BLACK.with_alpha(0.13 * content)
416                        };
417                        // Rows materialize from behind the glass: heavily
418                        // blurred and ghosted early in the flight, sharp at
419                        // settle (and back to blur during the close morph).
420                        // They also SCALE with the droplet from the anchor
421                        // corner — the content lives on the growing surface,
422                        // it doesn't fade in at full size.
423                        let content_blur = CONTENT_BLUR * (1.0 - content).max(0.0);
424                        let content_scale = 0.55 + 0.45 * content;
425                        let rows_wrap = Modifier::empty()
426                            .fill_max_width()
427                            .graphics_layer(move || GraphicsLayer {
428                                alpha: content.powf(1.3),
429                                scale_x: content_scale,
430                                scale_y: content_scale,
431                                transform_origin: cranpose_ui_graphics::TransformOrigin {
432                                    pivot_fraction_x: 1.0,
433                                    pivot_fraction_y: 0.0,
434                                },
435                                render_effect: (content_blur > 0.4)
436                                    .then(|| RenderEffect::blur(content_blur)),
437                                ..Default::default()
438                            })
439                            .drop_shadow(
440                                cranpose_ui_graphics::LayerShape::Rounded(
441                                    cranpose_ui_graphics::RoundedCornerShape::uniform(MENU_RADIUS),
442                                ),
443                                move |scope| {
444                                    scope.radius = 34.0;
445                                    scope.spread = 0.0;
446                                    scope.offset.y = 10.0;
447                                    scope.color = shadow_color;
448                                    scope.cutout = true;
449                                },
450                            );
451                        Box(rows_wrap, BoxSpec::default(), {
452                            let items = items.clone();
453                            let typography = typography.clone();
454                            let on_item = Rc::clone(&on_item);
455                            let on_dismiss = Rc::clone(&on_dismiss);
456                            move || {
457                                Column(
458                                    Modifier::empty().fill_max_width(),
459                                    ColumnSpec::default(),
460                                    {
461                                        let items = items.clone();
462                                        let typography = typography.clone();
463                                        let on_item = Rc::clone(&on_item);
464                                        let on_dismiss = Rc::clone(&on_dismiss);
465                                        move || {
466                                            for (index, item) in items.iter().enumerate() {
467                                                if item.section_start && index > 0 {
468                                                    // Whisper-subtle: the
469                                                    // reference surface reads
470                                                    // nearly seamless.
471                                                    let separator = colors
472                                                        .separator
473                                                        .with_alpha(colors.separator.a() * 0.22);
474                                                    Box(
475                                    Modifier::empty()
476                                        .fill_max_width()
477                                        .padding_symmetric(ROW_PADDING_X, 0.0)
478                                        .height(1.0)
479                                        .draw_behind(move |scope| {
480                                            scope.draw_rect(cranpose_ui_graphics::Brush::solid(
481                                                separator,
482                                            ));
483                                        }),
484                                    BoxSpec::default(),
485                                    || {},
486                                );
487                                                }
488
489                                                if item.header {
490                                                    menu_header_row(
491                                                        item,
492                                                        &typography,
493                                                        has_checks,
494                                                        colors,
495                                                    );
496                                                    continue;
497                                                }
498                                                menu_item_row(
499                                                    index,
500                                                    item,
501                                                    &typography,
502                                                    has_checks,
503                                                    colors,
504                                                    hovered,
505                                                    Rc::clone(&on_item),
506                                                    Rc::clone(&on_dismiss),
507                                                );
508                                            }
509                                        }
510                                    },
511                                );
512                            }
513                        });
514                    }
515                });
516            }
517        },
518    );
519}
520
521/// Gray non-interactive section header, aligned with the icon column.
522fn menu_header_row(
523    item: &LiquidMenuItem,
524    typography: &crate::theme::LiquidTypography,
525    has_checks: bool,
526    colors: crate::theme::LiquidColors,
527) {
528    let label = item.label.clone();
529    let style = TextStyle {
530        span_style: SpanStyle {
531            color: Some(colors.secondary_label),
532            font_size: TextUnit::Sp(13.0),
533            ..typography.footnote.span_style.clone()
534        },
535        ..typography.footnote.clone()
536    };
537    let indent = ROW_PADDING_X + if has_checks { CHECK_COLUMN } else { 0.0 };
538    let row = Modifier::empty()
539        .fill_max_width()
540        .padding_each(indent, 12.0, ROW_PADDING_X, 2.0);
541    Row(row, RowSpec::default(), move || {
542        Text(label.clone(), Modifier::empty(), style.clone());
543    });
544}
545
546/// One interactive menu row: [check][icon][label].
547#[allow(non_snake_case)]
548#[allow(clippy::too_many_arguments)]
549fn menu_item_row(
550    index: usize,
551    item: &LiquidMenuItem,
552    typography: &crate::theme::LiquidTypography,
553    has_checks: bool,
554    colors: crate::theme::LiquidColors,
555    hovered: cranpose_core::MutableState<Option<usize>>,
556    on_item: Rc<dyn Fn(usize)>,
557    on_dismiss: Rc<dyn Fn()>,
558) {
559    let color = if item.destructive {
560        colors.destructive
561    } else {
562        colors.label
563    };
564    let is_hovered = hovered.get() == Some(index);
565    let highlight = if colors.is_dark {
566        cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 72)
567    } else {
568        cranpose_ui_graphics::Color::from_rgba_u8(120, 120, 128, 48)
569    };
570    let row_label = item.label.clone();
571    let row = Modifier::empty()
572        .fill_max_width()
573        .semantics(move |config| {
574            config.is_button = true;
575            config.is_clickable = true;
576            config.content_description = Some(row_label.clone());
577        })
578        .pointer_input(index, {
579            let on_item = Rc::clone(&on_item);
580            let on_dismiss = Rc::clone(&on_dismiss);
581            move |scope: PointerInputScope| {
582                let on_item = Rc::clone(&on_item);
583                let on_dismiss = Rc::clone(&on_dismiss);
584                async move {
585                    scope
586                        .await_pointer_event_scope(|await_scope| async move {
587                            loop {
588                                let event = await_scope.await_pointer_event().await;
589                                match event.kind {
590                                    PointerEventKind::Enter | PointerEventKind::Move => {
591                                        hovered.set(Some(index));
592                                    }
593                                    PointerEventKind::Exit if hovered.get() == Some(index) => {
594                                        hovered.set(None);
595                                    }
596                                    PointerEventKind::Down => {
597                                        hovered.set(Some(index));
598                                        event.consume();
599                                    }
600                                    PointerEventKind::Up => {
601                                        hovered.set(None);
602                                        on_item(index);
603                                        on_dismiss();
604                                        event.consume();
605                                    }
606                                    _ => {}
607                                }
608                            }
609                        })
610                        .await;
611                }
612            }
613        })
614        .draw_behind(move |scope| {
615            if is_hovered {
616                scope.draw_round_rect(Brush::solid(highlight), CornerRadii::uniform(14.0));
617            }
618        })
619        .padding_symmetric(ROW_PADDING_X, ROW_PADDING_Y);
620
621    let label = item.label.clone();
622    let icon = item.icon;
623    let checked = item.checked;
624    let typography = typography.clone();
625    Row(
626        row,
627        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
628        move || {
629            let label = label.clone();
630            if has_checks {
631                // Leading checkmark column, reserved on every row so icons
632                // and labels align (the reference "Show" menu).
633                Box(
634                    Modifier::empty().width(CHECK_COLUMN),
635                    BoxSpec::default(),
636                    move || {
637                        if checked {
638                            crate::icons::Icon(crate::icons::CHECK, 16.0, color);
639                        }
640                    },
641                );
642            }
643            if let Some(icon) = icon {
644                crate::icons::Icon(icon, ICON_SIZE, color);
645                Box(Modifier::empty().width(ICON_GAP), BoxSpec::default(), || {});
646            }
647            let style = TextStyle {
648                span_style: SpanStyle {
649                    color: Some(color),
650                    font_weight: Some(FontWeight::NORMAL),
651                    ..typography.body.span_style.clone()
652                },
653                ..typography.body.clone()
654            };
655            Text(label, Modifier::empty().weight(1.0), style);
656        },
657    );
658}