menu.rs raw

//! The menu family: a bar of titles, the popups they open, and the rows
//! inside them.
//!
//! Five widgets, one shape. A `menu-bar` holds `menu` titles; a `menu` is a
//! title (in a bar) or a submenu row (in a menu), and owns the *deferred
//! content* of the popup it opens; a `menu-panel` is that popup, built by the
//! tree when the menu opens; `menu-item` and `menu-separator` are the rows.
//! The compiler enforces that shape (a descriptor's `required_parents`), which
//! is what lets a menu know at construction whether it is a title or a
//! submenu row rather than sniffing its parent at runtime.
//!
//! **Nothing here opens an overlay.** A widget cannot reach the tree, and
//! menus need the tree's overlay stack, its focus, and its structural
//! knowledge. So these widgets *report* — a clicked title emits
//! [`EventData::MenuOpen`], a panel's arrow key emits
//! [`EventData::MenuNav`] — and the tree acts. It is the same division a
//! button draws when its Enter becomes `EventData::Activated`.
//!
//! Highlight is not focus. The panel holds focus while it is open and moves a
//! highlighted row within itself, so arrowing through a menu costs no focus
//! traffic and leaves the focus to be restored when the menu closes. A row's
//! highlight is its own state, so it selects between token variants
//! (`background` vs `background-highlighted`) exactly as a checkbox selects
//! between `box-fill` and `box-fill-checked` — there is no `:highlighted`
//! theme selector, for the same reason there is no `:checked` one.

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size};
use guiduck_scene::paint::{Brush, Color};
use parley::{Alignment, AlignmentOptions, Layout, StyleProperty};
use taffy::AvailableSpace;

use std::cell::Cell;
use std::rc::Rc;

use super::overlay::{AnchorSide, OverlayOptions};
use super::{CursorShape, Widget, WidgetId, WidgetTree};
use crate::content::ContentBuilder;
use crate::dirty::Dirt;
use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent};
use crate::graphic::{Graphic, GraphicPaint};
use crate::signals::Scope;
use crate::style::{ComputedStyle, StyleReader};
use crate::text::TextContext;

/// Which way a keystroke moves the highlight, or steps through the stack.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MenuNav {
    Previous,
    Next,
    First,
    Last,
    /// Enter: activate the highlighted row.
    Activate,
    /// Right: open the highlighted row's submenu, or step to the next title.
    Deeper,
    /// Left: close this level, or step to the previous title.
    Shallower,
}

/// Inner padding of a menu row and of a bar title.
const ROW_PAD_X: f64 = 12.0;
const ROW_PAD_Y: f64 = 5.0;
/// Gap between a row's label and its accelerator text.
const ACCEL_GAP: f64 = 24.0;
/// Side of the submenu mark's square, and its gap from the accel column.
const MARK_SIZE: f64 = 9.0;
const MARK_GAP: f64 = 8.0;
/// A separator's line and the air around it.
const SEPARATOR_PAD_Y: f64 = 3.0;

/// A shaped run of text, rebuilt when its content, metrics, or brush change.
struct TextRun {
    text: String,
    layout: Option<Layout<Brush>>,
}

impl TextRun {
    fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            layout: None,
        }
    }

    fn set(&mut self, text: impl Into<String>) -> bool {
        let text = text.into();
        if self.text == text {
            return false;
        }
        self.text = text;
        self.layout = None;
        true
    }

    fn invalidate(&mut self) {
        self.layout = None;
    }

    fn shape(
        &mut self,
        text_cx: &mut TextContext,
        font_size: f32,
        family: Option<&str>,
        brush: &Brush,
    ) -> &Layout<Brush> {
        if self.layout.is_none() {
            let mut builder =
                text_cx
                    .layout_cx
                    .ranged_builder(&mut text_cx.font_cx, &self.text, 1.0, true);
            if let Some(family) = family {
                builder.push_default(StyleProperty::FontFamily(crate::text::font_family(family)));
            }
            builder.push_default(StyleProperty::FontSize(font_size));
            builder.push_default(StyleProperty::Brush(brush.clone()));
            let mut layout = builder.build(&self.text);
            layout.break_all_lines(None);
            layout.align(Alignment::Start, AlignmentOptions::default());
            self.layout = Some(layout);
        }
        self.layout.as_ref().expect("just shaped")
    }

    fn width(&self) -> f32 {
        self.layout.as_ref().map_or(0.0, Layout::width)
    }

    fn height(&self) -> f32 {
        self.layout.as_ref().map_or(0.0, Layout::height)
    }
}

// --- menu-separator -------------------------------------------------------

/// The appearance tokens a separator reads from the theme.
struct SeparatorTokens {
    color: Brush,
    thickness: f64,
}

impl SeparatorTokens {
    fn blank() -> Self {
        Self {
            color: Color::TRANSPARENT.into(),
            thickness: 0.0,
        }
    }

    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.color, "color");
        reader.number(&mut self.thickness, "thickness");
        reader.changed()
    }
}

impl Default for SeparatorTokens {
    fn default() -> Self {
        let mut tokens = Self::blank();
        tokens.read(&super::fallback_style("menu-separator"));
        tokens
    }
}

/// A divider between groups of rows.
pub struct MenuSeparator {
    tokens: SeparatorTokens,
    dirt: Dirt,
}

impl MenuSeparator {
    pub fn new() -> Self {
        Self {
            tokens: SeparatorTokens::default(),
            dirt: Dirt::CLEAN,
        }
    }
}

impl Default for MenuSeparator {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for MenuSeparator {
    fn measure(
        &mut self,
        _text: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        taffy::Size {
            width: known.width.unwrap_or(0.0),
            height: (self.tokens.thickness + SEPARATOR_PAD_Y * 2.0) as f32,
        }
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let y = (size.height - self.tokens.thickness) / 2.0;
        fragment.fill(
            Rect::new(0.0, y, size.width, y + self.tokens.thickness),
            self.tokens.color.clone(),
        );
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::Splitter
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn type_name(&self) -> &'static str {
        "menu-separator"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.dirt.mark_layout();
        }
    }
}

// --- shared row appearance ------------------------------------------------

/// The appearance tokens a row (a `menu-item`, or a `menu` acting as a title
/// or submenu row) reads from the theme.
struct RowTokens {
    background: Brush,
    background_highlighted: Brush,
    color: Brush,
    color_highlighted: Brush,
    accel_color: Brush,
    corner_radius: f64,
    submenu_mark: Graphic,
}

impl RowTokens {
    fn blank() -> Self {
        Self {
            background: Color::TRANSPARENT.into(),
            background_highlighted: Color::TRANSPARENT.into(),
            color: Color::TRANSPARENT.into(),
            color_highlighted: Color::TRANSPARENT.into(),
            accel_color: Color::TRANSPARENT.into(),
            corner_radius: 0.0,
            submenu_mark: Graphic::Path(Default::default()),
        }
    }

    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.background_highlighted, "background-highlighted");
        reader.brush(&mut self.color, "color");
        reader.brush(&mut self.color_highlighted, "color-highlighted");
        reader.brush(&mut self.accel_color, "accel-color");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.graphic(&mut self.submenu_mark, "submenu-mark");
        reader.changed()
    }

    fn for_kind(kind: &'static str) -> Self {
        let mut tokens = Self::blank();
        tokens.read(&super::fallback_style(kind));
        tokens
    }

    /// The background and label brushes for the row's own highlight state —
    /// widget-internal state selecting between token variants, the checkbox
    /// pattern.
    fn painted(&self, highlighted: bool) -> (&Brush, &Brush) {
        if highlighted {
            (&self.background_highlighted, &self.color_highlighted)
        } else {
            (&self.background, &self.color)
        }
    }
}

/// Paint a row's background, label, accelerator, and submenu mark.
struct RowPaint<'a> {
    tokens: &'a RowTokens,
    highlighted: bool,
    label: &'a TextRun,
    accel: Option<&'a TextRun>,
    submenu: bool,
}

impl RowPaint<'_> {
    fn paint(&self, fragment: &mut Fragment, size: Size) {
        let (background, _) = self.tokens.painted(self.highlighted);
        let bounds = Rect::from_origin_size((0.0, 0.0), size);
        if !super::is_transparent(background) {
            fragment.fill(
                bounds.to_rounded_rect(self.tokens.corner_radius),
                background.clone(),
            );
        }
        if let Some(layout) = &self.label.layout {
            let origin = Point::new(ROW_PAD_X, (size.height - f64::from(layout.height())) / 2.0);
            crate::text::append_layout(fragment, layout, origin);
        }
        // The accelerator sits against the right edge — the convention that
        // makes a column of them readable.
        let mut right = size.width - ROW_PAD_X;
        if self.submenu {
            let top = (size.height - MARK_SIZE) / 2.0;
            let mark =
                Rect::from_origin_size((right - MARK_SIZE, top), Size::new(MARK_SIZE, MARK_SIZE));
            let (_, label_color) = self.tokens.painted(self.highlighted);
            self.tokens.submenu_mark.paint_into(
                fragment,
                mark,
                &GraphicPaint::Fill(label_color.clone()),
            );
            right -= MARK_SIZE + MARK_GAP;
        }
        if let Some(accel) = self.accel
            && let Some(layout) = &accel.layout
        {
            let origin = Point::new(
                right - f64::from(layout.width()),
                (size.height - f64::from(layout.height())) / 2.0,
            );
            crate::text::append_layout(fragment, layout, origin);
        }
    }

    /// The row's natural size: label, accelerator column, submenu mark.
    fn measure(&self) -> taffy::Size<f32> {
        let mut width = f64::from(self.label.width()) + ROW_PAD_X * 2.0;
        if let Some(accel) = self.accel {
            width += ACCEL_GAP + f64::from(accel.width());
        }
        if self.submenu {
            width += MARK_GAP + MARK_SIZE;
        }
        let height = f64::from(self.label.height()).max(MARK_SIZE) + ROW_PAD_Y * 2.0;
        taffy::Size {
            width: width.ceil() as f32,
            height: height.ceil() as f32,
        }
    }
}

// --- menu-item ------------------------------------------------------------

/// One activatable row of a menu.
pub struct MenuItem {
    label: TextRun,
    accel: Option<TextRun>,
    highlighted: bool,
    font_size: f32,
    family: Option<String>,
    tokens: RowTokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl Default for MenuItem {
    fn default() -> Self {
        Self {
            label: TextRun::new(""),
            accel: None,
            highlighted: false,
            font_size: 14.0,
            family: None,
            tokens: RowTokens::for_kind("menu-item"),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }
}

impl MenuItem {
    pub fn new(label: impl Into<String>) -> Self {
        let mut item = Self::default();
        item.set_label(label);
        item
    }

    /// The accelerator's rendered text. The `Keystroke` it also stands for is
    /// registered by the tree, not held here.
    pub fn set_accel_text(&mut self, text: impl Into<String>) {
        match &mut self.accel {
            Some(accel) => {
                if accel.set(text) {
                    self.dirt.mark_layout();
                }
            }
            None => {
                self.accel = Some(TextRun::new(text));
                self.dirt.mark_layout();
            }
        }
    }

    pub fn accel_text(mut self, text: impl Into<String>) -> Self {
        self.set_accel_text(text);
        self
    }

    pub fn family(mut self, family: impl Into<String>) -> Self {
        self.set_family(family);
        self
    }

    pub fn font_size(mut self, size: f32) -> Self {
        self.set_font_size(size);
        self
    }

    pub fn set_label(&mut self, label: impl Into<String>) {
        if self.label.set(label) {
            self.dirt.mark_layout();
        }
    }

    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size != size {
            self.font_size = size;
            self.label.invalidate();
            if let Some(accel) = &mut self.accel {
                accel.invalidate();
            }
            self.dirt.mark_layout();
        }
    }

    pub fn set_family(&mut self, family: impl Into<String>) {
        let family = Some(family.into());
        if self.family != family {
            self.family = family;
            self.label.invalidate();
            if let Some(accel) = &mut self.accel {
                accel.invalidate();
            }
            self.dirt.mark_layout();
        }
    }

    pub fn label(&self) -> &str {
        &self.label.text
    }

    pub fn is_highlighted(&self) -> bool {
        self.highlighted
    }

    /// Highlight or unhighlight the row. Driven by the tree, which owns which
    /// row of an open menu is current.
    pub fn set_highlighted(&mut self, highlighted: bool) {
        if self.highlighted != highlighted {
            self.highlighted = highlighted;
            // The label's brush changes with the highlight, so its shaped run
            // is stale — same width, so paint-only.
            self.label.invalidate();
            self.dirt.mark_paint();
        }
    }

    /// Report that this row was chosen. The tree turns it into the
    /// `:on-select` dispatch and closes the menu stack.
    pub fn select(&mut self) {
        self.emitted.push(EventData::Selected);
    }

    fn row(&self) -> RowPaint<'_> {
        RowPaint {
            tokens: &self.tokens,
            highlighted: self.highlighted,
            label: &self.label,
            accel: self.accel.as_ref(),
            submenu: false,
        }
    }
}

impl Widget for MenuItem {
    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        _known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let (_, label_color) = self.tokens.painted(self.highlighted);
        let label_color = label_color.clone();
        let accel_color = self.tokens.accel_color.clone();
        let (size, family) = (self.font_size, self.family.clone());
        self.label
            .shape(text_cx, size, family.as_deref(), &label_color);
        if let Some(accel) = &mut self.accel {
            accel.shape(text_cx, size, family.as_deref(), &accel_color);
        }
        self.row().measure()
    }

    fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) {
        self.measure(
            text_cx,
            taffy::Size::NONE,
            taffy::Size {
                width: AvailableSpace::MaxContent,
                height: AvailableSpace::MaxContent,
            },
        );
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        self.row().paint(fragment, size);
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::MenuItem
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_label(self.label.text.clone());
        if let Some(accel) = &self.accel {
            node.set_keyboard_shortcut(accel.text.clone());
        }
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn cursor(&self, _local: Point) -> CursorShape {
        CursorShape::Pointer
    }

    fn type_name(&self) -> &'static str {
        "menu-item"
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        _event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        // Hovering a row makes it current, but that is the tree's doing (see
        // `set_hover_chain`): enter and leave never reach a widget's own
        // hooks.
        if kind == EventKind::Click {
            self.select();
            true
        } else {
            false
        }
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.label.invalidate();
            if let Some(accel) = &mut self.accel {
                accel.invalidate();
            }
            self.dirt.mark_paint();
        }
        if let Some(size) = style.number("font-size") {
            self.set_font_size(size as f32);
        }
        if let Some(family) = style.string("font-family") {
            self.set_family(family.to_owned());
        }
    }
}

// --- menu -----------------------------------------------------------------

/// A menu title (in a bar) or a submenu row (in a menu), and the deferred
/// content of the popup it opens.
pub struct Menu {
    label: TextRun,
    submenu: bool,
    highlighted: bool,
    open: bool,
    content: ContentBuilder,
    font_size: f32,
    family: Option<String>,
    tokens: RowTokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl Default for Menu {
    fn default() -> Self {
        Self {
            label: TextRun::new(""),
            submenu: false,
            highlighted: false,
            open: false,
            content: ContentBuilder::default(),
            font_size: 14.0,
            family: None,
            tokens: RowTokens::for_kind("menu"),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }
}

impl Menu {
    pub fn new(label: impl Into<String>) -> Self {
        let mut menu = Self::default();
        menu.set_label(label);
        menu
    }

    /// Whether this menu is a submenu row (inside another menu) rather than a
    /// bar title. Both consumers know it from the parent IR node's kind, so
    /// it arrives here as a fact rather than as something to sniff — which is
    /// exactly what the compiler's structural rule guarantees.
    pub fn set_submenu(&mut self, submenu: bool) {
        if self.submenu != submenu {
            self.submenu = submenu;
            self.dirt.mark_layout();
        }
    }

    pub fn submenu(mut self, submenu: bool) -> Self {
        self.set_submenu(submenu);
        self
    }

    /// Use a specific font family instead of the collection's default.
    pub fn family(mut self, family: impl Into<String>) -> Self {
        self.set_family(family);
        self
    }

    pub fn font_size(mut self, size: f32) -> Self {
        self.set_font_size(size);
        self
    }

    pub fn content(mut self, content: ContentBuilder) -> Self {
        self.set_content(content);
        self
    }

    pub fn label(&self) -> &str {
        &self.label.text
    }

    pub fn is_submenu(&self) -> bool {
        self.submenu
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn content_builder(&self) -> ContentBuilder {
        self.content.clone()
    }

    /// Told by the tree, which owns the menu stack.
    pub fn set_open(&mut self, open: bool) {
        if self.open != open {
            self.open = open;
            self.dirt.mark_paint();
        }
    }

    pub fn is_highlighted(&self) -> bool {
        self.highlighted
    }

    pub fn set_highlighted(&mut self, highlighted: bool) {
        if self.highlighted != highlighted {
            self.highlighted = highlighted;
            self.label.invalidate();
            self.dirt.mark_paint();
        }
    }

    pub fn set_label(&mut self, label: impl Into<String>) {
        if self.label.set(label) {
            self.dirt.mark_layout();
        }
    }

    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size != size {
            self.font_size = size;
            self.label.invalidate();
            self.dirt.mark_layout();
        }
    }

    pub fn set_family(&mut self, family: impl Into<String>) {
        let family = Some(family.into());
        if self.family != family {
            self.family = family;
            self.label.invalidate();
            self.dirt.mark_layout();
        }
    }

    /// Ask the tree to open this menu's popup.
    pub fn request_open(&mut self) {
        self.emitted.push(EventData::MenuOpen);
    }

    fn row(&self) -> RowPaint<'_> {
        RowPaint {
            tokens: &self.tokens,
            // An open menu's title stays lit while its popup is up, so the
            // bar shows where you are.
            highlighted: self.highlighted || self.open,
            label: &self.label,
            accel: None,
            submenu: self.submenu,
        }
    }
}

impl Widget for Menu {
    fn popup(&self) -> Option<Popup> {
        Some(Popup {
            content: self.content.clone(),
            beside: self.is_submenu(),
            match_width: false,
        })
    }

    fn defers_content(&self) -> bool {
        true
    }

    /// The popup's content, built each time it opens.
    fn set_content(&mut self, content: ContentBuilder) {
        self.content = content;
    }

    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        _known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let (_, label_color) = self.tokens.painted(self.highlighted || self.open);
        let label_color = label_color.clone();
        let (size, family) = (self.font_size, self.family.clone());
        self.label
            .shape(text_cx, size, family.as_deref(), &label_color);
        self.row().measure()
    }

    fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) {
        self.measure(
            text_cx,
            taffy::Size::NONE,
            taffy::Size {
                width: AvailableSpace::MaxContent,
                height: AvailableSpace::MaxContent,
            },
        );
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        self.row().paint(fragment, size);
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::MenuItem
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_label(self.label.text.clone());
        node.set_expanded(self.open);
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn cursor(&self, _local: Point) -> CursorShape {
        CursorShape::Pointer
    }

    fn type_name(&self) -> &'static str {
        "menu"
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        _event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if kind == EventKind::Click {
            self.request_open();
            true
        } else {
            false
        }
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.label.invalidate();
            self.dirt.mark_paint();
        }
        if let Some(size) = style.number("font-size") {
            self.set_font_size(size as f32);
        }
        if let Some(family) = style.string("font-family") {
            self.set_family(family.to_owned());
        }
    }
}

// --- menu-bar -------------------------------------------------------------

/// The appearance tokens a bar reads from the theme.
struct BarTokens {
    background: Brush,
}

impl BarTokens {
    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.changed()
    }
}

impl Default for BarTokens {
    fn default() -> Self {
        let mut tokens = Self {
            background: Color::TRANSPARENT.into(),
        };
        tokens.read(&super::fallback_style("menu-bar"));
        tokens
    }
}

/// A row of menu titles.
pub struct MenuBar {
    tokens: BarTokens,
    dirt: Dirt,
}

impl MenuBar {
    pub fn new() -> Self {
        Self {
            tokens: BarTokens::default(),
            dirt: Dirt::CLEAN,
        }
    }
}

impl Default for MenuBar {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for MenuBar {
    fn adjust_style(&self, style: &mut taffy::Style) {
        // A bar is a row of titles; the caller cannot forget that.
        style.display = taffy::Display::Flex;
        style.flex_direction = taffy::FlexDirection::Row;
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        fragment.fill(
            Rect::from_origin_size((0.0, 0.0), size),
            self.tokens.background.clone(),
        );
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::MenuBar
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn type_name(&self) -> &'static str {
        "menu-bar"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.dirt.mark_paint();
        }
    }
}

// --- menu-panel -----------------------------------------------------------

/// The appearance tokens a popup panel reads from the theme.
struct PanelTokens {
    background: Brush,
    border_color: Brush,
    border_width: f64,
    corner_radius: f64,
}

impl PanelTokens {
    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.border_color, "border-color");
        reader.number(&mut self.border_width, "border-width");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.changed()
    }
}

impl Default for PanelTokens {
    fn default() -> Self {
        let mut tokens = Self {
            background: Color::TRANSPARENT.into(),
            border_color: Color::TRANSPARENT.into(),
            border_width: 0.0,
            corner_radius: 0.0,
        };
        tokens.read(&super::fallback_style("menu-panel"));
        tokens
    }
}

/// The popup a menu opens: the rows' home, and the keyboard's.
///
/// It holds focus while it is up — so the highlight can rove without any
/// focus traffic, and the overlay restores the previous focus when it closes —
/// and reports navigation keys rather than acting on them, because moving a
/// highlight needs its rows, which only the tree can see.
pub struct MenuPanel {
    tokens: PanelTokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl MenuPanel {
    pub fn new() -> Self {
        Self {
            tokens: PanelTokens::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }
}

impl Default for MenuPanel {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for MenuPanel {
    fn adjust_style(&self, style: &mut taffy::Style) {
        style.display = taffy::Display::Flex;
        style.flex_direction = taffy::FlexDirection::Column;
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let t = &self.tokens;
        let bounds = Rect::from_origin_size((0.0, 0.0), size);
        fragment.fill(
            bounds.to_rounded_rect(t.corner_radius),
            t.background.clone(),
        );
        if t.border_width > 0.0 {
            let inset = bounds.inset(-t.border_width / 2.0);
            fragment.stroke(
                inset.to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)),
                t.border_color.clone(),
                guiduck_scene::geom::Stroke::new(t.border_width),
            );
        }
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::Menu
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn type_name(&self) -> &'static str {
        "menu-panel"
    }

    fn focusable(&self) -> bool {
        true
    }

    fn on_key(
        &mut self,
        key: &KeyInput,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if !key.pressed {
            return false;
        }
        let nav = match &key.key {
            Key::Up => MenuNav::Previous,
            Key::Down => MenuNav::Next,
            Key::Home => MenuNav::First,
            Key::End => MenuNav::Last,
            Key::Enter => MenuNav::Activate,
            Key::Right => MenuNav::Deeper,
            Key::Left => MenuNav::Shallower,
            // Escape is the overlay's business: it closes the topmost
            // dismissable layer, which is exactly "one level".
            _ => return false,
        };
        self.emitted.push(EventData::MenuNav(nav));
        true
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.dirt.mark_paint();
        }
    }
}

// --- the tree's half ------------------------------------------------------

/// One open level of the menu stack.
pub(crate) struct OpenMenu {
    /// The `menu` whose popup this is.
    menu: WidgetId,
    /// The overlay layer holding it.
    overlay: WidgetId,
    /// The `menu-panel` inside that layer.
    panel: WidgetId,
    /// The row the keyboard is currently on, if any.
    highlight: Option<WidgetId>,
    /// The reactive scope the content was built in. Every effect a menu's
    /// content creates lives here, so closing the menu disposes exactly what
    /// opening it created: teardown is scope disposal.
    scope: Option<Scope>,
}

/// How a widget's popup opens.
///
/// Returned by [`Widget::popup`](super::Widget::popup); the tree reads it
/// rather than asking what kind of widget this is.
pub struct Popup {
    /// The content to build into the panel when it opens.
    pub content: ContentBuilder,
    /// Out to the side rather than below — a submenu row rather than a title.
    pub beside: bool,
    /// Make the panel as wide as the opener.
    pub match_width: bool,
}

impl WidgetTree {
    /// Open a menu's popup: the tree's half of [`EventData::MenuOpen`].
    ///
    /// Clicking an open menu closes it, which is what a bar title should do.
    /// Otherwise every level that is not an ancestor of this one closes
    /// first, so opening a sibling replaces rather than stacks.
    pub(crate) fn open_menu(&mut self, menu: WidgetId) {
        if self.menu_stack.iter().any(|open| open.menu == menu) {
            self.close_menus_from(menu);
            return;
        }
        // A menu opened from inside another menu's panel keeps that panel up;
        // anything else is a sibling and goes.
        let depth = self
            .menu_stack
            .iter()
            .position(|open| self.is_within(menu, open.panel))
            .map_or(0, |index| index + 1);
        self.close_menu_levels(depth);

        // The widget says how its popup opens; the tree does not carry a list
        // of which kinds have one.
        let Some(spec) = self.with_widget(menu, |widget| widget.popup()).flatten() else {
            return;
        };
        let (submenu, content) = (spec.beside, spec.content);
        // A field's list matches the field, so the popup reads as the field
        // opening rather than as a menu appearing near it.
        let field_width = spec.match_width.then(|| self.layout(menu).width());
        // A bar title drops its popup below itself; a submenu row sends it out
        // to the side. Placement flips and clamps against the viewport.
        let side = if submenu {
            AnchorSide::RightOf
        } else {
            AnchorSide::Below
        };
        // However this layer closes — Escape, an outside click, a selection —
        // it comes back through `menu_closed`, so there is one teardown path.
        // The id it needs is the overlay's, which does not exist yet, so the
        // callback reads it from a cell the open fills in.
        let commands = self.commands();
        let closing: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
        let mut options = OverlayOptions::popup(menu, side);
        options.dismiss_on_outside_click = true;
        options.dismiss_on_escape = true;
        options.on_close = Some({
            let closing = Rc::clone(&closing);
            Box::new(move || {
                if let Some(overlay) = closing.get() {
                    commands.push(move |tree| tree.menu_closed(overlay));
                }
            })
        });

        let overlay = self.open_overlay(taffy::Style::default(), options);
        closing.set(Some(overlay));
        let panel_style = match field_width {
            Some(width) => taffy::Style {
                size: taffy::Size {
                    width: taffy::prelude::length(width as f32),
                    height: taffy::prelude::auto(),
                },
                ..Default::default()
            },
            None => taffy::Style::default(),
        };
        let panel = self.insert(MenuPanel::new(), panel_style, Some(overlay));

        // Everything the content creates — widgets, effects, subscriptions —
        // is created now and belongs to this scope. Nothing existed while the
        // menu was closed, and nothing survives its closing.
        let scope = Scope::new();
        scope.run(|| content.build(self, panel));

        self.menu_stack.push(OpenMenu {
            menu,
            overlay,
            panel,
            highlight: None,
            scope: Some(scope),
        });
        self.set_menu_open(menu, true);
        // The panel holds focus while it is up, so the highlight can rove
        // without focus traffic; the overlay restores the previous focus.
        self.set_focus(Some(panel));
    }
    /// Close every level from `depth` up, innermost first.
    ///
    /// The stack is popped *here*, synchronously, rather than left to the
    /// overlay's `on_close`: that callback cannot touch the tree, so it can
    /// only queue a command, and a loop waiting for a queued pop would spin
    /// forever. Disposing the scope here is what makes closing exactly undo
    /// opening — every effect the content created goes with it.
    fn close_menu_levels(&mut self, depth: usize) {
        while self.menu_stack.len() > depth {
            let open = self.menu_stack.pop().expect("checked non-empty");
            self.set_menu_open(open.menu, false);
            if let Some(scope) = open.scope {
                scope.dispose();
            }
            // Already gone when the close came *from* the overlay (Escape, an
            // outside click); still here when we are the ones closing it.
            if self.is_overlay(open.overlay) {
                self.close_overlay(open.overlay);
            }
        }
    }

    /// A menu's overlay closed on its own — dismissed by Escape or by an
    /// outside click. Drop its level, and any above it.
    pub(crate) fn menu_closed(&mut self, overlay: WidgetId) {
        if let Some(index) = self
            .menu_stack
            .iter()
            .position(|open| open.overlay == overlay)
        {
            self.close_menu_levels(index);
        }
    }

    /// Close `menu`'s level and everything above it.
    fn close_menus_from(&mut self, menu: WidgetId) {
        if let Some(index) = self.menu_stack.iter().position(|open| open.menu == menu) {
            self.close_menu_levels(index);
        }
    }

    /// Close the whole stack — a row was chosen, and the command is done.
    pub(crate) fn close_all_menus(&mut self) {
        self.close_menu_levels(0);
    }
}

/// The rows of an open panel, in order: what the highlight moves through.
///
/// Separators are not rows — you cannot land on one — which is why this asks
/// the widgets rather than taking every child.
fn panel_rows(tree: &WidgetTree, panel: WidgetId) -> Vec<WidgetId> {
    let mut rows = Vec::new();
    let mut stack: Vec<WidgetId> = tree.children(panel).to_vec();
    stack.reverse();
    while let Some(id) = stack.pop() {
        // A row is one of the two kinds you can land on. Asked by type, not
        // by name: a separator is not a row, and neither is the scaffolding a
        // `for` region puts around its rows.
        if tree.widget::<MenuItem>(id).is_some() || tree.widget::<Menu>(id).is_some() {
            rows.push(id);
            continue;
        }
        // A `for` region wraps its rows in markers, so the rows of a
        // data-driven menu are found by looking through the structure rather
        // than only at the panel's direct children.
        let mut children = tree.children(id).to_vec();
        children.reverse();
        stack.extend(children);
    }
    rows
}

impl WidgetTree {
    /// Move the highlight, or act on it: the tree's half of
    /// [`EventData::MenuNav`].
    pub(crate) fn menu_nav(&mut self, panel: WidgetId, nav: MenuNav) {
        let Some(level) = self.menu_stack.iter().position(|open| open.panel == panel) else {
            return;
        };
        let rows = panel_rows(self, panel);
        if rows.is_empty() {
            return;
        }
        let current = self.menu_stack[level]
            .highlight
            .and_then(|id| rows.iter().position(|row| *row == id));

        let next = match nav {
            MenuNav::Previous => Some(match current {
                Some(0) | None => rows.len() - 1,
                Some(index) => index - 1,
            }),
            MenuNav::Next => Some(match current {
                Some(index) if index + 1 < rows.len() => index + 1,
                _ => 0,
            }),
            MenuNav::First => Some(0),
            MenuNav::Last => Some(rows.len() - 1),
            MenuNav::Activate => {
                if let Some(row) = self.menu_stack[level].highlight {
                    self.activate_menu_row(row);
                }
                return;
            }
            MenuNav::Deeper => {
                // Right opens the highlighted submenu; on a plain row it does
                // nothing, since there is nowhere deeper to go.
                if let Some(row) = self.menu_stack[level].highlight
                    && self.widget::<Menu>(row).is_some()
                {
                    self.open_menu(row);
                }
                return;
            }
            MenuNav::Shallower => {
                // Left closes this level and returns to the row that opened
                // it, which is where the eye already is.
                let menu = self.menu_stack[level].menu;
                self.close_menus_from(menu);
                return;
            }
        };
        if let Some(index) = next {
            self.set_menu_highlight(level, Some(rows[index]));
        }
    }

    /// A row asked to become the current one (the pointer entered it): the
    /// tree's half of [`EventData::MenuHighlight`].
    pub(crate) fn highlight_menu_row(&mut self, row: WidgetId) {
        let Some(level) = self
            .menu_stack
            .iter()
            .position(|open| self.is_within(row, open.panel))
        else {
            return;
        };
        // Pointing at a row of an outer menu abandons any submenu it opened.
        self.close_menu_levels(level + 1);
        self.set_menu_highlight(level, Some(row));
    }

    /// Move one level's highlight, telling the rows that gained and lost it.
    fn set_menu_highlight(&mut self, level: usize, row: Option<WidgetId>) {
        let previous = self.menu_stack[level].highlight;
        if previous == row {
            return;
        }
        self.menu_stack[level].highlight = row;
        for (id, highlighted) in [(previous, false), (row, true)] {
            let Some(id) = id else { continue };
            if let Some(mut widget) = self.widget_mut::<MenuItem>(id) {
                widget.set_highlighted(highlighted);
            } else if let Some(mut widget) = self.widget_mut::<Menu>(id) {
                widget.set_highlighted(highlighted);
            }
        }
    }

    /// Choose a row, whichever way it was chosen. A submenu row opens rather
    /// than selects — there is nothing to report about opening a menu.
    pub(crate) fn activate_menu_row(&mut self, row: WidgetId) {
        if self.widget::<Menu>(row).is_some() {
            self.open_menu(row);
        } else if self.widget::<MenuItem>(row).is_some() {
            self.select_menu_row(row);
        }
    }

    /// Fire a row's `:on-select` and close the stack.
    ///
    /// The one selection path: a pointer click reports `EventData::Selected`
    /// and lands here, and so do Enter and an accelerator.
    ///
    /// The wire fires now; the close is queued. Both halves of that matter.
    /// The wire must run before the rows are gone, and the close must not run
    /// *during* a dispatch that is still walking those rows — a pointer Click
    /// arrives mid-traversal, and pulling the widgets out from under it would
    /// leave the walk holding dead ids. The command queue is the seam for
    /// exactly this: it runs when the frame's dispatch has unwound.
    pub(crate) fn select_menu_row(&mut self, row: WidgetId) {
        self.dispatch_semantic(
            row,
            crate::event::EventKind::Select,
            crate::event::EventData::Selected,
        );
        self.commands().push(|tree| tree.close_all_menus());
    }

    /// Tell whatever opened a popup that it is (or is no longer) open — a
    /// `menu` title lights up, a `dropdown` field turns its arrow.
    fn set_menu_open(&mut self, id: WidgetId, open: bool) {
        if let Some(mut widget) = self.widget_mut::<Menu>(id) {
            widget.set_open(open);
        } else if let Some(mut widget) = self.widget_mut::<Dropdown>(id) {
            widget.set_open(open);
        } else if let Some(mut widget) = self.widget_mut::<super::ComboBox>(id) {
            widget.set_menu_open(open);
        }
    }

    /// Whether any menu is open — the shell's cue that a click outside is a
    /// dismissal rather than ordinary input.
    pub fn menus_open(&self) -> bool {
        !self.menu_stack.is_empty()
    }
}

#[cfg(test)]
mod tests;

/// The runtime keystroke an accelerator declaration stands for.
///
/// The grammar lives in the compiler, which is where a typo becomes a
/// diagnostic — so this parses the *same* string with the *same* parser
/// rather than keeping a second opinion about what `"Ctrl+S"` means. It
/// cannot fail in practice: validation already accepted it.
fn keystroke_for(accel: &str) -> Option<crate::event::Keystroke> {
    use guiduck_component_core::accel::{AccelKey, parse_accel};
    let parsed = parse_accel(accel, guiduck_component_core::sexpr::Span::new(0, 0)).ok()?;
    Some(crate::event::Keystroke {
        key: match parsed.key {
            AccelKey::Character(c) => Key::Character(c),
            AccelKey::Enter => Key::Enter,
            AccelKey::Delete => Key::Delete,
            AccelKey::Home => Key::Home,
            AccelKey::End => Key::End,
            AccelKey::Left => Key::Left,
            AccelKey::Right => Key::Right,
            AccelKey::Up => Key::Up,
            AccelKey::Down => Key::Down,
        },
        modifiers: crate::event::Modifiers {
            ctrl: parsed.ctrl,
            shift: parsed.shift,
            alt: parsed.alt,
            logo: parsed.logo,
        },
    })
}

impl WidgetTree {
    /// Register a menu item's accelerator.
    ///
    /// `run` is the item's own `:on-select` work, not a dispatch at the row:
    /// an accelerator's whole point is firing while its menu is *closed*, and
    /// a closed menu's rows do not exist. So the two routes share the handler
    /// rather than the widget — which is also why an accelerator may not sit
    /// inside `if` or `for`, where the row is per-item and there is nothing to
    /// register at mount.
    ///
    /// The key-routing order already does the right thing: the focused widget
    /// sees the key first, so a text input's Ctrl+C beats an accelerator.
    /// `owner` is the widget the accelerator belongs to — the menu that
    /// declared it. When that widget goes, so does the accelerator: a dev
    /// reload rebuilds the subtree, and a registration outliving its menu
    /// would shadow the one that replaced it.
    pub fn on_menu_accel(&mut self, owner: WidgetId, accel: &str, mut run: impl FnMut() + 'static) {
        let Some(keystroke) = keystroke_for(accel) else {
            return;
        };
        let commands = self.commands();
        self.shortcuts.push((
            Some(owner),
            keystroke,
            Box::new(move || {
                // An accelerator can fire with a menu open (you can reach for
                // Ctrl+S mid-browse), so the stack goes, exactly as choosing the
                // row would take it.
                commands.push(|tree| tree.close_all_menus());
                run();
            }),
        ));
    }
}

// --- context-menu ---------------------------------------------------------

/// Rows that open at the pointer when the enclosing widget is right-pressed.
///
/// It draws nothing and takes no space: it is an *attachment point* that
/// happens to live in the tree, which is what lets it be written where the
/// thing it belongs to is written. Its rows are deferred exactly as a menu's
/// are, so a context menu on every row of a long list costs nothing until one
/// is opened.
#[derive(Default)]
pub struct ContextMenu {
    content: ContentBuilder,
}

impl ContextMenu {
    pub fn new(content: ContentBuilder) -> Self {
        Self { content }
    }

    pub fn content_builder(&self) -> ContentBuilder {
        self.content.clone()
    }
}

impl Widget for ContextMenu {
    fn defers_content(&self) -> bool {
        true
    }

    /// The popup's rows, built each time it opens.
    fn set_content(&mut self, content: ContentBuilder) {
        self.content = content;
    }

    fn adjust_style(&self, style: &mut taffy::Style) {
        // Present in the tree, absent from the layout — the caller cannot
        // forget, because there is nothing here to lay out.
        style.display = taffy::Display::None;
    }

    fn type_name(&self) -> &'static str {
        "context-menu"
    }
}

impl WidgetTree {
    /// The context menu attached at or above `target`, if any.
    ///
    /// Innermost wins: a row's own menu beats the list's, which is what
    /// nesting them is for.
    fn context_menu_for(&self, target: WidgetId) -> Option<WidgetId> {
        let mut current = Some(target);
        while let Some(id) = current {
            if let Some(found) = self
                .children(id)
                .iter()
                .find(|child| self.widget::<ContextMenu>(**child).is_some())
            {
                return Some(*found);
            }
            current = self.parent(id);
        }
        None
    }

    /// Open the context menu attached at or above `target`, at `at`. Reports
    /// whether one was there — an unclaimed right-press is not ours.
    pub(crate) fn open_context_menu(&mut self, target: WidgetId, at: Point) -> bool {
        let Some(menu) = self.context_menu_for(target) else {
            return false;
        };
        // A right-press elsewhere abandons whatever was open, exactly as a
        // left-press outside would.
        self.close_all_menus();
        let Some(content) = self
            .widget::<ContextMenu>(menu)
            .map(|widget| widget.content_builder())
        else {
            return false;
        };

        let commands = self.commands();
        let closing: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
        let mut options = OverlayOptions::popup_at(at);
        options.dismiss_on_outside_click = true;
        options.dismiss_on_escape = true;
        options.on_close = Some({
            let closing = Rc::clone(&closing);
            Box::new(move || {
                if let Some(overlay) = closing.get() {
                    commands.push(move |tree| tree.menu_closed(overlay));
                }
            })
        });

        let overlay = self.open_overlay(taffy::Style::default(), options);
        closing.set(Some(overlay));
        let panel = self.insert(MenuPanel::new(), taffy::Style::default(), Some(overlay));
        let scope = Scope::new();
        scope.run(|| content.build(self, panel));

        // It joins the same stack as a bar menu's popup: one set of rules for
        // dismissal, navigation, and selection, whatever opened it.
        self.menu_stack.push(OpenMenu {
            menu,
            overlay,
            panel,
            highlight: None,
            scope: Some(scope),
        });
        self.set_focus(Some(panel));
        true
    }
}

// --- dropdown -------------------------------------------------------------

/// The appearance tokens a dropdown reads from the theme.
struct DropdownTokens {
    background: Brush,
    color: Brush,
    border: Brush,
    border_width: f64,
    corner_radius: f64,
    focus_ring_color: Brush,
    focus_ring_width: f64,
    arrow_mark: Graphic,
    arrow_color: Brush,
}

impl DropdownTokens {
    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.color, "color");
        reader.brush(&mut self.border, "border-color");
        reader.number(&mut self.border_width, "border-width");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.brush(&mut self.focus_ring_color, "focus-ring-color");
        reader.number(&mut self.focus_ring_width, "focus-ring-width");
        reader.graphic(&mut self.arrow_mark, "arrow-mark");
        reader.brush(&mut self.arrow_color, "arrow-color");
        reader.changed()
    }
}

impl Default for DropdownTokens {
    fn default() -> Self {
        let mut tokens = Self {
            background: Color::TRANSPARENT.into(),
            color: Color::TRANSPARENT.into(),
            border: Color::TRANSPARENT.into(),
            border_width: 0.0,
            corner_radius: 0.0,
            focus_ring_color: Color::TRANSPARENT.into(),
            focus_ring_width: 0.0,
            arrow_mark: Graphic::Path(Default::default()),
            arrow_color: Color::TRANSPARENT.into(),
        };
        tokens.read(&super::fallback_style("dropdown"));
        tokens
    }
}

/// A field that opens a list.
///
/// Structurally this *is* a [`Menu`]: deferred rows, one popup, the same
/// stack, highlight, dismissal, and selection. What it adds is a control's
/// manners — it is focusable and opens from the keyboard, it draws like a
/// field, and its popup matches its own width instead of hugging its rows.
/// That short list is the whole difference, which is the point: the fourth
/// customer of deferred content needed a widget, not machinery.
///
/// It holds no list and no selection. `:label` is the chosen option's text,
/// bindable, computed by the app from its own data — `for` is the model.
pub struct Dropdown {
    label: TextRun,
    open: bool,
    content: ContentBuilder,
    font_size: f32,
    family: Option<String>,
    tokens: DropdownTokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl Default for Dropdown {
    fn default() -> Self {
        Self {
            label: TextRun::new(""),
            open: false,
            content: ContentBuilder::default(),
            font_size: 14.0,
            family: None,
            tokens: DropdownTokens::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }
}

impl Dropdown {
    pub fn new(label: impl Into<String>) -> Self {
        let mut dropdown = Self::default();
        dropdown.set_label(label);
        dropdown
    }

    pub fn content(mut self, content: ContentBuilder) -> Self {
        self.set_content(content);
        self
    }

    pub fn family(mut self, family: impl Into<String>) -> Self {
        self.set_family(family);
        self
    }

    pub fn font_size(mut self, size: f32) -> Self {
        self.set_font_size(size);
        self
    }

    pub fn label(&self) -> &str {
        &self.label.text
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn content_builder(&self) -> ContentBuilder {
        self.content.clone()
    }

    pub fn set_open(&mut self, open: bool) {
        if self.open != open {
            self.open = open;
            self.dirt.mark_paint();
        }
    }

    pub fn set_label(&mut self, label: impl Into<String>) {
        if self.label.set(label) {
            self.dirt.mark_layout();
        }
    }

    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size != size {
            self.font_size = size;
            self.label.invalidate();
            self.dirt.mark_layout();
        }
    }

    pub fn set_family(&mut self, family: impl Into<String>) {
        let family = Some(family.into());
        if self.family != family {
            self.family = family;
            self.label.invalidate();
            self.dirt.mark_layout();
        }
    }
}

impl Widget for Dropdown {
    fn popup(&self) -> Option<Popup> {
        Some(Popup {
            content: self.content.clone(),
            beside: false,
            match_width: true,
        })
    }

    fn defers_content(&self) -> bool {
        true
    }

    /// The list's rows, built each time it opens.
    fn set_content(&mut self, content: ContentBuilder) {
        self.content = content;
    }

    fn measure(
        &mut self,
        text_cx: &mut TextContext,
        _known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let color = self.tokens.color.clone();
        let (size, family) = (self.font_size, self.family.clone());
        self.label.shape(text_cx, size, family.as_deref(), &color);
        taffy::Size {
            width: (f64::from(self.label.width()) + ROW_PAD_X * 2.0 + MARK_GAP + MARK_SIZE).ceil()
                as f32,
            height: (f64::from(self.label.height()).max(MARK_SIZE) + ROW_PAD_Y * 2.0).ceil() as f32,
        }
    }

    fn finalize_layout(&mut self, text_cx: &mut TextContext, _size: Size, _content: Size) {
        self.measure(
            text_cx,
            taffy::Size::NONE,
            taffy::Size {
                width: AvailableSpace::MaxContent,
                height: AvailableSpace::MaxContent,
            },
        );
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let t = &self.tokens;
        let bounds = Rect::from_origin_size((0.0, 0.0), size);
        fragment.fill(
            bounds.to_rounded_rect(t.corner_radius),
            t.background.clone(),
        );
        if t.border_width > 0.0 {
            let inset = bounds.inset(-t.border_width / 2.0);
            fragment.stroke(
                inset.to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)),
                t.border.clone(),
                guiduck_scene::geom::Stroke::new(t.border_width),
            );
        }
        // The `:focus` rule makes the ring opaque; the widget never asks
        // whether it is focused.
        if !super::is_transparent(&t.focus_ring_color) {
            let ring = bounds.inset(-t.focus_ring_width / 2.0 - 1.0);
            fragment.stroke(
                ring.to_rounded_rect((t.corner_radius + 1.0).max(0.0)),
                t.focus_ring_color.clone(),
                guiduck_scene::geom::Stroke::new(t.focus_ring_width),
            );
        }
        if let Some(layout) = &self.label.layout {
            let origin = Point::new(ROW_PAD_X, (size.height - f64::from(layout.height())) / 2.0);
            crate::text::append_layout(fragment, layout, origin);
        }
        let mark = Rect::from_origin_size(
            (
                size.width - ROW_PAD_X - MARK_SIZE,
                (size.height - MARK_SIZE) / 2.0,
            ),
            Size::new(MARK_SIZE, MARK_SIZE),
        );
        t.arrow_mark
            .paint_into(fragment, mark, &GraphicPaint::Fill(t.arrow_color.clone()));
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::ComboBox
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_value(self.label.text.clone());
        node.set_expanded(self.open);
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn cursor(&self, _local: Point) -> CursorShape {
        CursorShape::Pointer
    }

    fn type_name(&self) -> &'static str {
        "dropdown"
    }

    fn focusable(&self) -> bool {
        true
    }

    fn on_key(
        &mut self,
        key: &KeyInput,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if !key.pressed {
            return false;
        }
        // The three ways a closed field is opened from the keyboard.
        match &key.key {
            Key::Enter | Key::Down => {}
            Key::Character(text) if text == " " => {}
            _ => return false,
        }
        self.emitted.push(EventData::MenuOpen);
        true
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        _event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if kind == EventKind::Click {
            self.emitted.push(EventData::MenuOpen);
            true
        } else {
            false
        }
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.label.invalidate();
            self.dirt.mark_paint();
        }
        if let Some(size) = style.number("font-size") {
            self.set_font_size(size as f32);
        }
        if let Some(family) = style.string("font-family") {
            self.set_family(family.to_owned());
        }
    }
}