widget.rs raw

//! The retained widget tree: the Widget trait, the arena that owns widgets,
//! and its integration with taffy layout and fragment painting.
//!
//! The tree keeps three parallel structures in sync: the widget arena
//! (behavior and visual state), a taffy tree (layout styles and computed
//! geometry), and per-frame fragments (paint output). Widgets never hold
//! references to each other; all relationships go through [`WidgetId`].

pub mod button;
pub mod checkbox;
pub mod combo_box;
mod compare;
pub mod container;
pub mod dialog;
pub mod dynamic;
pub mod expander;
pub mod grouping;
pub mod image;
pub mod indicator;
pub mod menu;
pub mod overlay;
pub mod scroll_area;
pub mod slider;
pub mod stepper;
pub mod text;
pub mod text_input;
pub mod tooltip;

pub use button::Button;
pub use checkbox::{Checkbox, ListItem, Radio, Switch, Tab};
pub use combo_box::ComboBox;
pub use container::Container;
pub use dialog::{Dialog, DialogScrim, DialogSheet};
pub use expander::Expander;
pub use grouping::{ListBox, TabList};
pub use image::Image;
pub use indicator::{Progress, Separator};
pub use menu::{ContextMenu, Dropdown, Menu, MenuBar, MenuItem, MenuNav, MenuPanel, MenuSeparator};
pub use scroll_area::{ScrollArea, ScrollAxes};
pub use slider::Slider;
pub use stepper::Stepper;
pub use text::{RichText, Text, TextSpan};
pub use text_input::TextInput;
pub use tooltip::TooltipPanel;

use std::any::Any;
use std::cell::RefCell;
use std::ops::{Deref, DerefMut};
use std::rc::Rc;

use guiduck_scene::geom::{Affine, Point, Rect, Size};
use guiduck_scene::{Fragment, FragmentId, FragmentStore};
use slotmap::{SlotMap, new_key_type};
use taffy::{AvailableSpace, TaffyTree};

use crate::content::ContentBuilder;
use crate::dirty::Dirt;
use crate::text::TextContext;

new_key_type! {
    /// Identifier of a widget within a [`WidgetTree`].
    pub struct WidgetId;
}

/// Store a freshly made selection as the primary selection, the Wayland/X11
/// convention that middle-click then pastes. One definition for every widget
/// that has a selection — a text input and a static label alike — so the
/// convention cannot come to mean two things. A collapsed (empty) selection
/// leaves the primary alone.
pub(crate) fn sync_primary_selection(
    clipboard: &mut dyn crate::clipboard::Clipboard,
    selected: Option<&str>,
) {
    if let Some(selected) = selected.filter(|s| !s.is_empty()) {
        clipboard.set_text(crate::clipboard::Selection::Primary, selected);
    }
}

/// Pointer cursor shapes, in the framework's own vocabulary (the platform
/// shell maps them to winit's icons).
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum CursorShape {
    #[default]
    Default,
    /// The hand — interactive controls.
    Pointer,
    /// The I-beam — editable text.
    Text,
}

/// A node in the retained tree.
///
/// Layout style lives in taffy (set via [`WidgetTree::insert`] /
/// [`WidgetTree::set_style`]); widgets carry only their content and visual
/// properties.
pub trait Widget: Any {
    /// Content-based size for leaf widgets (text, images). taffy calls this
    /// only for widgets without children; the default reports no intrinsic
    /// content.
    fn measure(
        &mut self,
        _text: &mut TextContext,
        _known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        taffy::Size::ZERO
    }

    /// Called after layout with the widget's final size and the extent of
    /// its (possibly overflowing) content, before painting. Widgets whose
    /// content depends on their size (text re-wrapping to the assigned
    /// width) finish that work here; scroll containers learn their
    /// scrollable range from `content_size`.
    fn finalize_layout(&mut self, _text: &mut TextContext, _size: Size, _content_size: Size) {}

    /// Translation applied to this widget's children beyond their layout
    /// positions — the scroll seam. The paint walk, hit testing, and
    /// absolute-origin computation all apply this one value, so pixels and
    /// pointer targets can never disagree.
    fn content_offset(&self) -> guiduck_scene::geom::Vec2 {
        guiduck_scene::geom::Vec2::ZERO
    }

    /// Whether children paint clipped to this widget's bounds (scroll
    /// containers).
    fn clips_content(&self) -> bool {
        false
    }

    /// Handle scroll input (wheel/touchpad) hitting this widget or a
    /// descendant. Return true if the widget consumed it (moved); an
    /// unconsumed scroll bubbles to ancestor scrollables.
    fn on_scroll(&mut self, _delta: guiduck_scene::geom::Vec2) -> bool {
        false
    }

    /// The sub-rectangle of this widget (in its own local coordinates) that
    /// should be kept on screen when the widget is focused or edited — a text
    /// input's caret. Widgets with no such point return `None`, and the tree
    /// keeps their whole box in view instead. Read after layout, so the
    /// geometry is current.
    fn reveal_rect(&self) -> Option<Rect> {
        None
    }

    /// If this widget scrolls, bring `target` (a rectangle in window
    /// coordinates) into view by adjusting the scroll offset, and return the
    /// offset change applied. `viewport_top_left` is the widget's own window
    /// origin, supplied by the tree. A non-scrolling widget returns
    /// [`Vec2::ZERO`](guiduck_scene::geom::Vec2) and the reveal passes through
    /// it. The tree re-encodes a widget that reports a nonzero change, so this
    /// need not mark dirt.
    fn scroll_reveal(
        &mut self,
        _viewport_top_left: Point,
        _target: Rect,
    ) -> guiduck_scene::geom::Vec2 {
        guiduck_scene::geom::Vec2::ZERO
    }

    /// Adjust the taffy style at insert time with requirements the widget
    /// itself owns (e.g. `overflow: scroll` for scroll containers), so
    /// callers cannot forget them.
    fn adjust_style(&self, _style: &mut taffy::Style) {}

    /// Emit this widget's own display items. Children are composited by the
    /// tree afterwards, on top.
    fn paint(&mut self, _fragment: &mut Fragment, _size: Size) {}

    /// Emit display items painted *over* the children (scrollbars). Most
    /// widgets have none.
    fn paint_overlay(&mut self, _fragment: &mut Fragment, _size: Size) {}

    /// The widget's accessibility role.
    fn role(&self) -> accesskit::Role {
        accesskit::Role::GenericContainer
    }

    /// Populate accessibility properties beyond role and bounds (labels,
    /// values, states).
    fn accessibility(&self, _node: &mut accesskit::Node) {}

    /// Full accessibility integration for widgets that contribute child
    /// nodes (text runs with selection state). The default delegates to
    /// [`accessibility`](Widget::accessibility); `next_id` allocates ids
    /// from a range reserved for generated nodes, and implementations may
    /// push child nodes into `update`.
    fn accessibility_extended(
        &mut self,
        node: &mut accesskit::Node,
        _update: &mut accesskit::TreeUpdate,
        _next_id: &mut dyn FnMut() -> accesskit::NodeId,
        _origin: Point,
        _text: &mut TextContext,
    ) {
        self.accessibility(node);
    }

    /// An assistive technology acted on a node this widget *generated* through
    /// [`accessibility_extended`](Self::accessibility_extended) rather than on
    /// the widget's own node.
    ///
    /// The tree routes it here because a generated node has no widget id to
    /// look up — and without this, a link a screen reader can see and announce
    /// would be a link it cannot click, which is not accessibility. Widgets
    /// that generate no nodes never see this; ones that do report the result
    /// through [`take_emitted`](Self::take_emitted), like any other input.
    fn accessibility_action(&mut self, _node: accesskit::NodeId, _action: accesskit::Action) {}

    /// Take and reset the dirt accumulated by this widget's setters since
    /// the last call. Widgets with setters keep a [`Dirt`] field their
    /// setters mark; the classification (layout vs. paint) lives with each
    /// setter, next to the property it belongs to.
    fn take_dirt(&mut self) -> Dirt {
        Dirt::CLEAN
    }

    /// Take the semantic events this widget emitted during the last
    /// internal hook (e.g. [`EventData::Changed`] after a user edit). The
    /// tree collects these on the same borrow that collects dirt and
    /// dispatches them to external handlers. Programmatic setters must not
    /// emit — only user input does, or bindings would loop.
    fn take_emitted(&mut self) -> Vec<crate::event::EventData> {
        Vec::new()
    }

    /// Take the widget's pending wake request: "call [`on_timer`] in this
    /// long". One-shot, collected on the same borrow as dirt; a widget that
    /// wants a steady beat (a blinking caret) re-requests from its
    /// `on_timer`. The widget never sees a clock — the tree anchors the
    /// duration when the platform supplies the current time, which is what
    /// keeps timed behavior fully deterministic in tests.
    ///
    /// [`on_timer`]: Self::on_timer
    fn take_wake(&mut self) -> Option<std::time::Duration> {
        None
    }

    /// A wake requested via [`take_wake`](Self::take_wake) has come due.
    fn on_timer(&mut self) {}

    /// Whether this widget distinguishes single from multi-clicks, so a press
    /// on it (or a descendant) should accumulate into a burst and resolve as a
    /// [`CountedClick`] rather than committing on the first release. A
    /// paragraph with links returns true — a link must not be followed on the
    /// first press of a double; a plain label returns false and its clicks are
    /// never held. External `:on-counted-click` handlers opt a path in the same
    /// way, so nothing pays the resolution latency unless it asked to.
    ///
    /// [`CountedClick`]: crate::event::EventKind::CountedClick
    fn handles_counted_click(&self) -> bool {
        false
    }

    /// Whether a click on this widget is *its own* — a button or a checkbox —
    /// so it does not also bubble to an enclosing container's `:on-click`. The
    /// widget's own handlers still run; the click just stops there. Default
    /// false: a plain container is transparent, so a click on a label inside a
    /// clickable card still reaches the card.
    fn consumes_click(&self) -> bool {
        false
    }

    /// How this widget's popup opens, if it hosts one.
    ///
    /// A menu, a dropdown, and a combo box all open the same panel; they
    /// differ only in where it goes and how wide it is. Asking the widget is
    /// what keeps the tree from carrying a list of which types can do this —
    /// and is what lets an application's own widget host a popup, which was
    /// the deferred half of making content-deferral universal.
    fn popup(&self) -> Option<menu::Popup> {
        None
    }

    /// Whether this widget builds its declared children on demand rather than
    /// at mount — a menu, dropdown, dialog, or context menu, whose content
    /// lives in a popup that exists only while it is open. The tree consults
    /// this when mounting a node's children (through
    /// [`realize_content`](WidgetTree::realize_content)): false, the default,
    /// mounts them inline; true hands them to [`set_content`](Self::set_content)
    /// to build when the widget opens.
    ///
    /// It is a property of the widget, not of its place in a manifest, so an
    /// application widget that hosts a popup opts in here exactly as a builtin
    /// does — deferral is a universal capability, invisible to the `.gdw`.
    fn defers_content(&self) -> bool {
        false
    }

    /// Receive the builder for this widget's deferred children. The tree calls
    /// this in place of mounting them inline, but only when
    /// [`defers_content`](Self::defers_content) is true; the widget stores the
    /// builder and runs it when it opens. The default drops it, which is why a
    /// widget that does not defer need not implement it.
    fn set_content(&mut self, _content: ContentBuilder) {}

    /// The pointer cursor to show while this widget is hovered, with `local`
    /// the pointer's position in the widget's own coordinates.
    ///
    /// The position is a parameter rather than something the widget remembers
    /// from `on_pointer`, because for a widget whose cursor varies *within* it
    /// — a paragraph with links in it — remembering would mean tracking hover
    /// a second time, in a second place, off a move the widget is not even
    /// delivered unless it captured the pointer. A cursor is a pure function of
    /// where the pointer is; saying so here is what keeps it from disagreeing
    /// with the pixels.
    fn cursor(&self, _local: Point) -> CursorShape {
        CursorShape::Default
    }

    /// The widget's type name for theme rule matching (`container`, `text`).
    fn type_name(&self) -> &'static str {
        "widget"
    }

    /// Whether the widget participates in focus (click-to-focus and Tab
    /// traversal).
    fn focusable(&self) -> bool {
        false
    }

    /// Whether Tab traversal stops here, and whether the focus invariant may
    /// land here.
    ///
    /// Defaults to [`focusable`](Self::focusable), because for a control the
    /// two questions have one answer. They come apart for a container that
    /// takes focus so it can be *operated* — a scroll area, which the
    /// keyboard should scroll once you click it, but which has no business
    /// interrupting a Tab walk between the controls inside it. The web makes
    /// the same split (a scroll container is click-focusable, not tabbable).
    fn tab_stop(&self) -> bool {
        self.focusable()
    }

    /// Focus arrived or left.
    fn on_focus_changed(&mut self, _focused: bool) {}

    /// Handle a key while focused. Return true to consume it (unconsumed
    /// Tab performs focus traversal; other unconsumed keys bubble to the
    /// shell).
    fn on_key(
        &mut self,
        _key: &crate::event::KeyInput,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        false
    }

    /// Handle an input-method event while focused.
    fn on_ime(&mut self, _ime: &crate::event::ImeInput, _text: &mut TextContext) {}

    /// Handle pasted text while focused: the content of a clipboard read
    /// that answered `Pending`, arriving after the gesture that asked for
    /// it. Widgets that paste do the same thing here as on the `Ready`
    /// path; everything else ignores it.
    fn on_paste(&mut self, _pasted: &str, _text: &mut TextContext) {}

    /// Internal pointer behavior (cursor placement, drag selection, primary
    /// selection), with coordinates local to the widget. Return true to
    /// consume the event and — for presses — capture subsequent moves until
    /// release.
    fn on_pointer(
        &mut self,
        _kind: crate::event::EventKind,
        _event: &crate::event::PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        false
    }

    /// Where the input method should place candidate windows, in widget-local
    /// coordinates. Only meaningful for focused text-editing widgets.
    fn ime_cursor_area(&self) -> Option<Rect> {
        None
    }

    /// Apply a computed style by calling the widget's own setters for the
    /// properties present. Setters compare-and-set, so re-applying an
    /// unchanged style marks no dirt. Properties absent from the style are
    /// left untouched (theme validation's base-coverage rule guarantees
    /// state exits always have a base value to return to).
    fn apply_style(&mut self, _style: &crate::style::ComputedStyle) {}
}

impl dyn Widget {
    /// Downcast a widget to its concrete type.
    pub fn downcast_ref<T: Widget>(&self) -> Option<&T> {
        (self as &dyn Any).downcast_ref()
    }

    /// Downcast a widget to its concrete type, mutably.
    pub fn downcast_mut<T: Widget>(&mut self) -> Option<&mut T> {
        (self as &mut dyn Any).downcast_mut()
    }
}

struct WidgetNode {
    widget: Box<dyn Widget>,
    parent: Option<WidgetId>,
    children: Vec<WidgetId>,
    taffy_id: taffy::NodeId,
    handlers: Vec<crate::event::HandlerEntry>,
    classes: Vec<String>,
    /// `:tooltip "text"` — shown after the pointer rests here. Universal, so
    /// it lives on the node rather than in any widget.
    tooltip: Option<String>,
    /// The widget's own enabled flag; a widget is *effectively* disabled
    /// when it or any ancestor is disabled.
    enabled: bool,
    /// The widget's retained fragment: allocated at insert, re-encoded in
    /// place when the widget is paint-dirty, freed at removal.
    fragment: FragmentId,
    /// The rectangle of the last completed layout, for detecting which
    /// widgets a relayout actually moved or resized.
    last_layout: Rect,
}

/// An erased widget setter: applies one value to one widget, recovering both
/// concrete types inside the closure so that neither appears in the
/// signature. This is what lets a binding be installed by code that cannot
/// name the widget it drives — [`WidgetTree::bind_dyn`] takes one, and
/// generated code bakes tables of them (a thunk per bindable property), the
/// way a component spec bakes its child factories.
///
/// A thunk whose widget or value does not have the type it expects applies
/// nothing, which is the erased spelling of the type mismatch that makes
/// [`Commands::mutate`] a no-op.
pub type SetterThunk = Rc<dyn Fn(&mut dyn Widget, Box<dyn Any>)>;

/// A deferred mutation of the tree, queued by effects and event handlers
/// (which cannot borrow the tree) and applied at the start of the next frame.
pub(crate) type Command = Box<dyn FnOnce(&mut WidgetTree)>;

/// Handle for queueing deferred tree mutations. Clones share one queue;
/// effects and event handlers capture a clone.
#[derive(Clone, Default)]
pub struct Commands {
    queue: Rc<RefCell<Vec<Command>>>,
}

impl Commands {
    pub fn push(&self, f: impl FnOnce(&mut WidgetTree) + 'static) {
        self.queue.borrow_mut().push(Box::new(f));
    }

    /// Queue a typed mutation of one widget. A vanished widget or a type
    /// mismatch makes the command a no-op.
    pub fn mutate<T: Widget>(&self, id: WidgetId, f: impl FnOnce(&mut T) + 'static) {
        self.mutate_dyn(id, move |widget| {
            if let Some(widget) = widget.downcast_mut::<T>() {
                f(widget);
            }
        });
    }

    /// Queue a mutation of one widget whose concrete type the caller cannot
    /// name — the erased form of [`mutate`](Self::mutate), which is a thin
    /// wrapper that moves the downcast inside `f`. A vanished widget makes
    /// the command a no-op; the mutation collects dirt through the same
    /// borrow guard as every other path.
    pub fn mutate_dyn(&self, id: WidgetId, f: impl FnOnce(&mut dyn Widget) + 'static) {
        self.push(move |tree| {
            if let Some(mut widget) = tree.widget_dyn_mut(id) {
                f(&mut *widget);
            }
        });
    }

    pub fn is_empty(&self) -> bool {
        self.queue.borrow().is_empty()
    }

    fn take(&self) -> Vec<Command> {
        std::mem::take(&mut self.queue.borrow_mut())
    }
}

/// The retained tree of widgets with layout and paint passes.
pub struct WidgetTree {
    nodes: SlotMap<WidgetId, WidgetNode>,
    /// The retained scene: one fragment per widget, stable across frames.
    fragments: FragmentStore,
    taffy: TaffyTree<WidgetId>,
    text: TextContext,
    root: Option<WidgetId>,
    /// Overlay layers above the base root, bottom to top.
    pub(crate) overlays: Vec<overlay::Overlay>,
    /// The composed frame: the base root plus each overlay at its position.
    /// This is what `render_frame` returns; rebuilt only when layers change.
    frame_fragment: FragmentId,
    pub(crate) frame_dirty: bool,
    commands: Commands,
    tooltip: tooltip::TooltipState,
    /// The dialogs currently on screen, reconciled each frame against their
    /// `:open` bindings.
    dialogs: Vec<dialog::OpenDialog>,
    /// The open menu levels, innermost last. Focus-like state: which popups
    /// are up, and which row of each the keyboard is on. It lives here rather
    /// than in the widgets because every question it answers — is this a
    /// sibling or a submenu, what are this panel's rows — is structural.
    menu_stack: Vec<menu::OpenMenu>,
    pub(crate) dirt: Vec<(WidgetId, Dirt)>,
    /// The viewport of the last completed layout, if any.
    laid_out: Option<Size>,
    pub(crate) pointer: crate::event::PointerState,
    /// The application's theme, layered over the always-present default.
    app_theme: Option<crate::style::Theme>,
    /// The resolved theme every widget styles against: the app theme over the
    /// default (or just the default). Recomputed whenever the app theme
    /// changes, so the style pass never rebuilds it per widget.
    effective_theme: crate::style::Theme,
    pub(crate) style_dirty: Vec<WidgetId>,
    focus: Option<WidgetId>,
    /// A widget to bring into view on the next frame, once layout has settled:
    /// a newly focused control, or a text input whose caret just moved. The
    /// frame scrolls its ancestors so its [`reveal_rect`](Widget::reveal_rect)
    /// (or whole box) is visible.
    pending_reveal: Option<WidgetId>,
    /// Keyboard shortcuts, consulted only after the focused widget declines
    /// a key. An owner means the shortcut belongs to that widget and goes
    /// when it does — a menu's accelerator; `None` means it belongs to the
    /// application and lasts as long as the tree.
    pub(crate) shortcuts: Vec<(Option<WidgetId>, crate::event::Keystroke, Box<dyn FnMut()>)>,
    /// The sources this tree takes its structure from — one per live mount.
    pub(crate) live: Vec<Box<dyn crate::live::LiveReload>>,
    /// Widget wake requests not yet anchored to a clock (the tree has no
    /// clock of its own — the platform supplies `now`).
    wake_requests: Vec<(WidgetId, std::time::Duration)>,
    /// Anchored wake deadlines, one per widget (the latest request wins).
    deadlines: Vec<(WidgetId, std::time::Instant)>,
    /// Next id for generated accessibility nodes (text runs); the range
    /// below 2^32 is disjoint from widget ids.
    a11y_counter: u64,
    /// Which widget generated each non-widget accessibility node, rebuilt with
    /// the tree it describes. It is what lets an action on a generated node —
    /// clicking a link inside a paragraph — reach the widget that owns it,
    /// since a generated node has no widget id to decode.
    a11y_owners: Vec<(accesskit::NodeId, WidgetId)>,
}

impl WidgetTree {
    /// A tree using the platform's fonts.
    pub fn new() -> Self {
        Self::with_text_context(TextContext::new())
    }

    /// A tree with an explicit text context (e.g. [`TextContext::hermetic`]
    /// for reproducible tests).
    pub fn with_text_context(text: TextContext) -> Self {
        let mut fragments = FragmentStore::new();
        let frame_fragment = fragments.create();
        Self {
            nodes: SlotMap::with_key(),
            fragments,
            taffy: TaffyTree::new(),
            text,
            root: None,
            overlays: Vec::new(),
            frame_fragment,
            frame_dirty: false,
            commands: Commands::default(),
            tooltip: tooltip::TooltipState::default(),
            dialogs: Vec::new(),
            menu_stack: Vec::new(),
            dirt: Vec::new(),
            laid_out: None,
            pointer: crate::event::PointerState::default(),
            app_theme: None,
            effective_theme: crate::style::default_theme().clone(),
            style_dirty: Vec::new(),
            focus: None,
            pending_reveal: None,
            shortcuts: Vec::new(),
            live: Vec::new(),
            wake_requests: Vec::new(),
            deadlines: Vec::new(),
            a11y_counter: 1,
            a11y_owners: Vec::new(),
        }
    }

    /// Insert a widget. With `parent: None` the widget becomes the root,
    /// which must not already exist; otherwise it is appended to the parent's
    /// children.
    pub fn insert(
        &mut self,
        widget: impl Widget,
        style: taffy::Style,
        parent: Option<WidgetId>,
    ) -> WidgetId {
        self.insert_boxed(Box::new(widget), style, parent)
    }

    /// Insert a widget whose concrete type the caller cannot name — the
    /// erased form of [`insert`](Self::insert), which is a thin wrapper that
    /// boxes first. The interpreter builds app-registered widgets through a
    /// factory thunk, so a `Box<dyn Widget>` is all it ever holds.
    pub fn insert_boxed(
        &mut self,
        widget: Box<dyn Widget>,
        mut style: taffy::Style,
        parent: Option<WidgetId>,
    ) -> WidgetId {
        if parent.is_none() {
            // The root *is* the window, so it fills the viewport whatever it
            // asked for. Any other size would be answering a question nothing
            // posed — there is no surrounding box for the root to take a share
            // of — and a shrink-to-content root is actively wrong twice over:
            // it draws the application into a corner, and every `100%`
            // beneath it resolves against that corner instead of the window.
            style.size = taffy::Size {
                width: taffy::prelude::percent(1.0_f32),
                height: taffy::prelude::percent(1.0_f32),
            };
        }
        let id = self.create_node_boxed(widget, style);
        match parent {
            Some(parent_id) => {
                let parent_taffy = self.nodes[parent_id].taffy_id;
                let taffy_id = self.nodes[id].taffy_id;
                self.nodes[id].parent = Some(parent_id);
                self.nodes[parent_id].children.push(id);
                self.taffy
                    .add_child(parent_taffy, taffy_id)
                    .expect("both nodes exist");
            }
            None => {
                assert!(self.root.is_none(), "tree already has a root");
                self.root = Some(id);
                self.frame_dirty = true;
            }
        }
        // A structural change always needs relayout (hot reload inserts into
        // an already-laid-out tree), and the parent's fragment must pick up
        // the new child reference even if nothing else moves.
        self.dirt.push((id, Dirt::LAYOUT));
        if let Some(parent_id) = parent {
            self.dirt.push((parent_id, Dirt::PAINT));
        }
        self.style_dirty.push(id);
        id
    }

    /// Insert a widget at a specific position among the parent's children
    /// (an [`insert`](Self::insert) appends). The seam dynamic regions
    /// (`if`/`for` in `.gdc`) mount rows and branches through.
    pub fn insert_at(
        &mut self,
        widget: impl Widget,
        style: taffy::Style,
        parent: WidgetId,
        index: usize,
    ) -> WidgetId {
        let id = self.create_node(widget, style);
        let parent_taffy = self.nodes[parent].taffy_id;
        let taffy_id = self.nodes[id].taffy_id;
        self.nodes[id].parent = Some(parent);
        self.nodes[parent].children.insert(index, id);
        self.taffy
            .insert_child_at_index(parent_taffy, index, taffy_id)
            .expect("both nodes exist and the index is in range");
        self.dirt.push((id, Dirt::LAYOUT));
        self.dirt.push((parent, Dirt::PAINT));
        self.style_dirty.push(id);
        id
    }

    /// Move a child to a new position among its siblings (keyed-list
    /// reorder). The subtree stays mounted; only the order changes.
    pub fn move_child(&mut self, parent: WidgetId, child: WidgetId, index: usize) {
        let current = self.nodes[parent]
            .children
            .iter()
            .position(|c| *c == child)
            .expect("child belongs to parent");
        if current == index {
            return;
        }
        self.nodes[parent].children.remove(current);
        self.nodes[parent].children.insert(index, child);
        let parent_taffy = self.nodes[parent].taffy_id;
        let child_taffy = self.nodes[child].taffy_id;
        self.taffy
            .remove_child(parent_taffy, child_taffy)
            .expect("both nodes exist");
        self.taffy
            .insert_child_at_index(parent_taffy, index, child_taffy)
            .expect("both nodes exist and the index is in range");
        self.dirt.push((parent, Dirt::LAYOUT));
    }

    /// The position of `child` among `parent`'s children.
    pub fn child_index(&self, parent: WidgetId, child: WidgetId) -> Option<usize> {
        self.nodes
            .get(parent)?
            .children
            .iter()
            .position(|c| *c == child)
    }

    /// Create a widget node with no attachment — the shared setup for
    /// [`insert`](Self::insert) and overlay roots.
    pub(crate) fn create_node(&mut self, widget: impl Widget, style: taffy::Style) -> WidgetId {
        self.create_node_boxed(Box::new(widget), style)
    }

    /// [`create_node`](Self::create_node) with the widget already boxed.
    pub(crate) fn create_node_boxed(
        &mut self,
        mut widget: Box<dyn Widget>,
        mut style: taffy::Style,
    ) -> WidgetId {
        widget.adjust_style(&mut style);
        // A widget is built by construction plus setter calls, and setters mark
        // dirt — but dirt marked before the widget was in the tree names
        // nothing the tree can act on, and every caller here marks the new node
        // `LAYOUT`-dirty, which subsumes it. Dropping it is what lets "build it,
        // then set its properties" leave exactly the state a do-everything
        // constructor left.
        let _ = widget.take_dirt();
        let taffy_id = self
            .taffy
            .new_leaf(style)
            .expect("taffy node creation is infallible");
        let fragment = self.fragments.create();
        let id = self.nodes.insert(WidgetNode {
            widget,
            parent: None,
            children: Vec::new(),
            taffy_id,
            handlers: Vec::new(),
            classes: Vec::new(),
            tooltip: None,
            enabled: true,
            fragment,
            last_layout: Rect::ZERO,
        });
        // taffy consults node context only for leaves; every widget gets its
        // id attached so content measurement works wherever it applies.
        self.taffy
            .set_node_context(taffy_id, Some(id))
            .expect("node was just created");
        id
    }

    /// Remove a widget and its whole subtree. The root may be removed and a
    /// new one inserted afterwards (this is how hot reload swaps a mounted
    /// component). Removing an overlay's root closes the overlay.
    pub fn remove(&mut self, id: WidgetId) {
        if self.is_overlay(id) {
            self.close_overlay(id);
            return;
        }
        self.remove_subtree(id);
    }

    /// The removal body, without the overlay-root indirection (which would
    /// recurse — `close_overlay` calls this).
    pub(crate) fn remove_subtree(&mut self, id: WidgetId) {
        let Some(node) = self.nodes.get(id) else {
            return;
        };
        let parent = node.parent;
        self.remove_recursive(id);
        match parent {
            Some(parent_id) => {
                if let Some(parent_node) = self.nodes.get_mut(parent_id) {
                    parent_node.children.retain(|c| *c != id);
                    self.dirt.push((parent_id, Dirt::LAYOUT));
                }
            }
            None => {
                if self.root == Some(id) {
                    self.root = None;
                    self.frame_dirty = true;
                }
            }
        }
    }

    fn remove_recursive(&mut self, id: WidgetId) {
        let Some(node) = self.nodes.remove(id) else {
            return;
        };
        for child in node.children {
            self.remove_recursive(child);
        }
        // Removing the taffy node also detaches it from its taffy parent.
        let _ = self.taffy.remove(node.taffy_id);
        self.fragments.remove(node.fragment);
        // A shortcut this widget owns goes with it. Without this a dev
        // reload would leave the previous subtree's accelerators registered,
        // and since the first match wins they would shadow the new ones —
        // the key you deleted from the file still firing the handler the file
        // no longer names.
        self.shortcuts.retain(|(owner, ..)| *owner != Some(id));
    }

    pub fn root(&self) -> Option<WidgetId> {
        self.root
    }

    /// The base root — `root()` under a name that stays honest beside
    /// overlay roots.
    pub(crate) fn base_root(&self) -> Option<WidgetId> {
        self.root
    }

    pub(crate) fn node_exists(&self, id: WidgetId) -> bool {
        self.nodes.contains_key(id)
    }

    /// The widget's parent, or `None` for the root.
    pub fn parent(&self, id: WidgetId) -> Option<WidgetId> {
        self.nodes.get(id)?.parent
    }

    /// The widget's children, in layout order.
    pub fn children(&self, id: WidgetId) -> &[WidgetId] {
        self.nodes
            .get(id)
            .map(|n| n.children.as_slice())
            .unwrap_or(&[])
    }

    /// Replace a widget's layout style.
    pub fn set_style(&mut self, id: WidgetId, style: taffy::Style) {
        let taffy_id = self.nodes[id].taffy_id;
        self.taffy.set_style(taffy_id, style).expect("node exists");
    }

    /// Modify a widget's layout style in place — the seam for *patching*
    /// (e.g. a parent overriding an instance root's flex-item properties)
    /// without clobbering what `adjust_style` or earlier callers set.
    pub fn update_style(&mut self, id: WidgetId, f: impl FnOnce(&mut taffy::Style)) {
        let taffy_id = self.nodes[id].taffy_id;
        let mut style = self.taffy.style(taffy_id).expect("node exists").clone();
        f(&mut style);
        self.taffy.set_style(taffy_id, style).expect("node exists");
        self.dirt.push((id, Dirt::LAYOUT));
    }

    /// Access a widget by id and concrete type.
    pub fn widget<T: Widget>(&self, id: WidgetId) -> Option<&T> {
        self.nodes.get(id)?.widget.downcast_ref()
    }

    /// Borrow a widget mutably by id and concrete type. When the borrow
    /// ends, any dirt the widget's setters marked is collected for the next
    /// frame.
    pub fn widget_mut<T: Widget>(&mut self, id: WidgetId) -> Option<WidgetMut<'_, T>> {
        let dirt = &mut self.dirt;
        let widget = self.nodes.get_mut(id)?.widget.downcast_mut()?;
        Some(WidgetMut { id, widget, dirt })
    }

    /// Borrow a widget mutably without naming its concrete type: the path
    /// for code that must drive a widget it has no compile-time knowledge of
    /// (the hot-reload interpreter, and the setter thunks generated for
    /// app-registered widgets). Dirt is collected when the borrow ends
    /// exactly as it is for [`widget_mut`](Self::widget_mut) — the guard is
    /// the same one.
    pub fn widget_dyn_mut(&mut self, id: WidgetId) -> Option<WidgetMut<'_, dyn Widget>> {
        let dirt = &mut self.dirt;
        let widget: &mut dyn Widget = self.nodes.get_mut(id)?.widget.as_mut();
        Some(WidgetMut { id, widget, dirt })
    }

    /// Whether the widget at `id` defers its content (builds its children on
    /// open rather than at mount). A missing id defers nothing.
    pub fn defers_content(&self, id: WidgetId) -> bool {
        self.nodes
            .get(id)
            .is_some_and(|node| node.widget.defers_content())
    }

    /// Mount a node's declared children. Every widget with children is handed
    /// its content builder here, and the widget decides — through
    /// [`Widget::defers_content`] — when it runs: a widget that mounts inline
    /// builds it now, into itself; one that defers (a menu, dropdown, dialog)
    /// stores it and builds it when it opens. One rule for both, so a builtin
    /// popup and an application one that hosts content are mounted by the same
    /// code.
    pub fn realize_content(&mut self, id: WidgetId, content: ContentBuilder) {
        if self.defers_content(id) {
            if let Some(mut widget) = self.widget_dyn_mut(id) {
                widget.set_content(content);
            }
        } else {
            content.build(self, id);
        }
    }

    /// The shared text resources.
    pub fn text_context_mut(&mut self) -> &mut TextContext {
        &mut self.text
    }

    pub(crate) fn handlers_mut(
        &mut self,
        id: WidgetId,
    ) -> Option<&mut Vec<crate::event::HandlerEntry>> {
        self.nodes.get_mut(id).map(|n| &mut n.handlers)
    }

    /// Compute layout for the whole tree within a viewport of the given
    /// logical size, then let widgets finalize their content at their
    /// assigned sizes.
    pub fn compute_layout(&mut self, viewport: Size) {
        let roots: Vec<WidgetId> = self
            .root
            .into_iter()
            .chain(self.overlays.iter().map(|o| o.root))
            .collect();
        for root in roots {
            let root_taffy = self.nodes[root].taffy_id;
            let nodes = &mut self.nodes;
            let text = &mut self.text;
            self.taffy
                .compute_layout_with_measure(
                    root_taffy,
                    taffy::Size {
                        width: AvailableSpace::Definite(viewport.width as f32),
                        height: AvailableSpace::Definite(viewport.height as f32),
                    },
                    |known, available, _taffy_id, ctx, _style| match ctx {
                        Some(id) => match nodes.get_mut(*id) {
                            Some(node) => node.widget.measure(text, known, available),
                            None => taffy::Size::ZERO,
                        },
                        None => taffy::Size::ZERO,
                    },
                )
                .expect("root node exists");

            self.finalize_recursive(root);
        }
    }

    fn finalize_recursive(&mut self, id: WidgetId) {
        let size = self.layout(id).size();
        let content_size = self.content_size(id);
        let node = &mut self.nodes[id];
        node.widget
            .finalize_layout(&mut self.text, size, content_size);
        // Finalizing can change what a widget draws — a scroll area clamps its
        // offset to content that just shrank out from under it. That dirt is
        // raised after the frame's set was taken, so it is collected here and
        // folded into this frame's re-encode set; left in the widget it would
        // wait for a frame nobody is going to schedule.
        let dirt = node.widget.take_dirt();
        if !dirt.is_clean() {
            self.dirt.push((id, dirt));
        }
        let node = &mut self.nodes[id];
        let children = node.children.clone();
        for child in children {
            self.finalize_recursive(child);
        }
    }

    /// The extent of a widget's laid-out content, which can exceed its own
    /// size when children overflow (the scrollable range's source).
    pub fn content_size(&self, id: WidgetId) -> Size {
        let layout = self
            .taffy
            .layout(self.nodes[id].taffy_id)
            .expect("node exists");
        Size::new(
            layout.content_size.width as f64,
            layout.content_size.height as f64,
        )
    }

    /// The widget's scroll shift for its children, [`Vec2::ZERO`] for
    /// non-scrolling widgets.
    pub(crate) fn content_offset(&self, id: WidgetId) -> guiduck_scene::geom::Vec2 {
        self.nodes
            .get(id)
            .map(|n| n.widget.content_offset())
            .unwrap_or_default()
    }

    /// The widget's laid-out rectangle in its parent's coordinate space.
    /// Valid after [`compute_layout`](Self::compute_layout).
    pub fn layout(&self, id: WidgetId) -> Rect {
        let layout = self
            .taffy
            .layout(self.nodes[id].taffy_id)
            .expect("node exists");
        Rect::new(
            layout.location.x as f64,
            layout.location.y as f64,
            (layout.location.x + layout.size.width) as f64,
            (layout.location.y + layout.size.height) as f64,
        )
    }

    /// Paint the whole tree from scratch into an external `store`, ignoring
    /// the retained fragments. This is the correctness oracle: the retained
    /// scene must always be structurally identical to what this produces
    /// (a differential test holds the two together).
    pub fn paint_fresh(&mut self, store: &mut FragmentStore) -> Option<FragmentId> {
        if self.root.is_none() && self.overlays.is_empty() {
            return None;
        }
        let mut frame = Fragment::new();
        if let Some(root) = self.root {
            let fragment = self.paint_fresh_recursive(root, store);
            frame.child(Affine::IDENTITY, fragment);
        }
        for index in 0..self.overlays.len() {
            let (root, position) = {
                let overlay = &self.overlays[index];
                (overlay.root, overlay.position)
            };
            let fragment = self.paint_fresh_recursive(root, store);
            frame.child(Affine::translate(position.to_vec2()), fragment);
        }
        Some(store.insert(frame))
    }

    fn paint_fresh_recursive(&mut self, id: WidgetId, store: &mut FragmentStore) -> FragmentId {
        let mut fragment = Fragment::new();
        self.encode_into(id, &mut fragment, &mut |tree: &mut Self, child| {
            tree.paint_fresh_recursive(child, store)
        });
        store.insert(fragment)
    }

    /// Re-encode one widget's retained fragment in place: its own painting,
    /// child references by stable id, overlay. Children's fragments are not
    /// touched — that is the point.
    fn encode_node(&mut self, id: WidgetId) {
        let mut fragment = Fragment::new();
        self.encode_into(id, &mut fragment, &mut |tree: &mut Self, child| {
            tree.nodes[child].fragment
        });
        let slot = self.nodes[id].fragment;
        if let Some(retained) = self.fragments.get_mut(slot) {
            *retained = fragment;
        }
    }

    /// One widget's display items, with children resolved by `child_ref` —
    /// the single encoding of a widget's paint order, shared by the
    /// retained path and the from-scratch oracle. Scroll containers shift
    /// children by their content offset and clip them to their bounds; the
    /// Child items sit inside the clip because children are inline in paint
    /// order.
    fn encode_into(
        &mut self,
        id: WidgetId,
        fragment: &mut Fragment,
        child_ref: &mut dyn FnMut(&mut Self, WidgetId) -> FragmentId,
    ) {
        let size = self.layout(id).size();
        self.nodes[id].widget.paint(fragment, size);
        let children = self.nodes[id].children.clone();
        let content_offset = self.nodes[id].widget.content_offset();
        let clips = self.nodes[id].widget.clips_content();
        if clips {
            fragment.push_clip(Rect::from_origin_size((0.0, 0.0), size));
        }
        for child in children {
            let origin = self.layout(child).origin() + content_offset;
            let child_fragment = child_ref(self, child);
            fragment.child(Affine::translate(origin.to_vec2()), child_fragment);
        }
        if clips {
            fragment.pop_clip();
        }
        self.nodes[id].widget.paint_overlay(fragment, size);
    }

    /// The retained scene. Valid after [`render_frame`](Self::render_frame);
    /// backends read it, tests compare it.
    pub fn fragments(&self) -> &FragmentStore {
        &self.fragments
    }

    /// Build the accessibility tree: every widget contributes a node with
    /// its role and absolute bounds, wrapped in a window root. Text-editing
    /// widgets add text-run child nodes (with selection state) through
    /// [`Widget::accessibility_extended`].
    pub fn accessibility_tree(&mut self) -> accesskit::TreeUpdate {
        const WINDOW: accesskit::NodeId = accesskit::NodeId(u64::MAX);
        let mut update = accesskit::TreeUpdate {
            nodes: Vec::with_capacity(self.nodes.len() + 1),
            tree: Some(accesskit::Tree::new(WINDOW)),
            tree_id: accesskit::TreeId::ROOT,
            focus: self.focus.map(access_id).unwrap_or(WINDOW),
        };
        let mut window = accesskit::Node::new(accesskit::Role::Window);
        // Generated node ids (text runs) live below the slotmap key range
        // (whose ffi encoding is always >= 2^32); parley keeps run ids
        // stable across updates, so the counter persists on the tree.
        let mut counter = self.a11y_counter;
        // The ownership of generated nodes is rebuilt with the tree that
        // states them, so it can never outlive what it describes.
        self.a11y_owners.clear();
        let mut children = Vec::new();
        if let Some(root) = self.root {
            children.push(access_id(root));
            self.access_recursive(root, Point::ORIGIN, &mut update, &mut counter);
        }
        for index in 0..self.overlays.len() {
            let (root, position, modal) = {
                let overlay = &self.overlays[index];
                (overlay.root, overlay.position, overlay.modal)
            };
            children.push(access_id(root));
            self.access_recursive(root, position, &mut update, &mut counter);
            if modal
                && let Some((_, node)) = update
                    .nodes
                    .iter_mut()
                    .find(|(id, _)| *id == access_id(root))
            {
                node.set_role(accesskit::Role::Dialog);
                node.set_modal();
            }
        }
        window.set_children(children);
        self.a11y_counter = counter;
        update.nodes.push((WINDOW, window));
        update
    }

    /// Perform an accessibility action requested by an assistive technology.
    pub fn accessibility_action(&mut self, request: &accesskit::ActionRequest) {
        use slotmap::KeyData;
        // A node a widget generated is not a widget: it belongs to one, and
        // only that widget knows what acting on it means. This is checked
        // first because the generated range and the widget-id range are
        // disjoint, so a hit here settles the question.
        if let Some(owner) = self
            .a11y_owners
            .iter()
            .find(|(node_id, _)| *node_id == request.target_node)
            .map(|(_, owner)| *owner)
        {
            let (node, action) = (request.target_node, request.action);
            self.with_widget_and_text(owner, |widget, _| widget.accessibility_action(node, action));
            return;
        }
        let id = WidgetId::from(KeyData::from_ffi(request.target_node.0));
        if !self.nodes.contains_key(id) {
            return;
        }
        match request.action {
            accesskit::Action::Click => {
                // Synthesize a click at the widget's center, through the
                // ordinary dispatch path (capture, bubble, handlers).
                let origin = self.absolute_origin(id);
                let size = self.layout(id).size();
                let center = Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
                self.dispatch_click_to(id, center);
            }
            accesskit::Action::Focus => self.set_focus(Some(id)),
            _ => {}
        }
    }

    fn access_recursive(
        &mut self,
        id: WidgetId,
        parent_origin: Point,
        update: &mut accesskit::TreeUpdate,
        counter: &mut u64,
    ) {
        let Some(node) = self.nodes.get(id) else {
            return;
        };
        let local = self.layout(id);
        let origin = parent_origin + local.origin().to_vec2();
        let clickable = node
            .handlers
            .iter()
            .any(|h| h.kind == crate::event::EventKind::Click);
        // A generic container wired for clicks is, semantically, a button.
        let role = match node.widget.role() {
            accesskit::Role::GenericContainer if clickable => accesskit::Role::Button,
            role => role,
        };
        let children = node.children.clone();
        let mut access = accesskit::Node::new(role);
        access.set_bounds(accesskit::Rect::new(
            origin.x,
            origin.y,
            origin.x + local.width(),
            origin.y + local.height(),
        ));
        if clickable {
            access.add_action(accesskit::Action::Click);
        }
        if self.effectively_disabled(id) {
            access.set_disabled();
        }
        // Whatever the extended hook pushes into the update is this widget's
        // doing, so claiming exactly the window it wrote records the ownership
        // without the widget having to declare it — and without depending on
        // `next_id` being called, which a hook reusing an id it minted on an
        // earlier pass (as parley's does) never is.
        let generated_from = update.nodes.len();
        {
            let node = self.nodes.get_mut(id).expect("checked above");
            if node.widget.focusable() {
                access.add_action(accesskit::Action::Focus);
            }
            let mut next_id = || {
                let id = accesskit::NodeId(*counter);
                *counter += 1;
                id
            };
            node.widget.accessibility_extended(
                &mut access,
                update,
                &mut next_id,
                origin,
                &mut self.text,
            );
        }
        let generated: Vec<accesskit::NodeId> = update.nodes[generated_from..]
            .iter()
            .map(|(node_id, _)| *node_id)
            .collect();
        self.a11y_owners
            .extend(generated.into_iter().map(|node_id| (node_id, id)));
        // Extended hooks may have attached generated children (text runs);
        // widget children follow them.
        let mut merged = access.children().to_vec();
        merged.extend(children.iter().map(|c| access_id(*c)));
        access.set_children(merged);
        update.nodes.push((access_id(id), access));
        for child in children {
            self.access_recursive(child, origin, update, counter);
        }
    }
}

impl WidgetTree {
    /// The shared command queue, for effects and handlers that outlive a
    /// borrow of the tree.
    /// Every `dialog` in the tree.
    pub(crate) fn dialog_ids(&self) -> Vec<WidgetId> {
        self.nodes
            .iter()
            .filter(|(id, _)| self.widget::<dialog::Dialog>(*id).is_some())
            .map(|(id, _)| id)
            .collect()
    }

    pub fn commands(&self) -> Commands {
        self.commands.clone()
    }

    /// Set (or clear) the application theme. It is layered over the
    /// always-present default theme (the app's rules win); the resolved
    /// theme is recomputed here so the style pass never rebuilds it. All
    /// widgets restyle on the next frame.
    pub fn set_theme(&mut self, theme: Option<crate::style::Theme>) {
        self.effective_theme = match &theme {
            Some(app) => app.clone().over(crate::style::default_theme()),
            None => crate::style::default_theme().clone(),
        };
        self.app_theme = theme;
        self.style_dirty.extend(self.nodes.keys());
    }

    /// The application theme, if one was set (the default theme underneath is
    /// always present but not returned here).
    pub fn theme(&self) -> Option<&crate::style::Theme> {
        self.app_theme.as_ref()
    }

    /// Replace a widget's style classes (space-separated in `.gdc`,
    /// individual strings here).
    pub fn set_classes(&mut self, id: WidgetId, classes: impl IntoIterator<Item = String>) {
        if let Some(node) = self.nodes.get_mut(id) {
            node.classes = classes.into_iter().collect();
            self.style_dirty.push(id);
        }
    }

    /// Append classes to a widget's list (a parent adding to an instance
    /// root's own classes, say) rather than replacing it.
    pub fn add_classes(&mut self, id: WidgetId, classes: impl IntoIterator<Item = String>) {
        if let Some(node) = self.nodes.get_mut(id) {
            node.classes.extend(classes);
            self.style_dirty.push(id);
        }
    }

    pub fn classes(&self, id: WidgetId) -> &[String] {
        self.nodes
            .get(id)
            .map(|n| n.classes.as_slice())
            .unwrap_or(&[])
    }

    /// Set a widget's enabled flag. Disabling makes the whole subtree
    /// *effectively* disabled: inert to pointer events (while still
    /// occluding what is beneath), unfocusable, and matching `:disabled`
    /// theme rules.
    pub fn set_enabled(&mut self, id: WidgetId, enabled: bool) {
        let Some(node) = self.nodes.get_mut(id) else {
            return;
        };
        if node.enabled == enabled {
            return;
        }
        node.enabled = enabled;
        // The effective state changed for the whole subtree; restyle it
        // (visual-state delivery runs with or without a theme).
        let mut stack = vec![id];
        while let Some(current) = stack.pop() {
            self.style_dirty.push(current);
            stack.extend(self.children(current));
        }
        // If the focused widget just became disabled, the focus invariant
        // moves focus on the next frame; events check live state meanwhile.
    }

    /// The widget's own enabled flag (ancestors not considered).
    pub fn is_enabled(&self, id: WidgetId) -> bool {
        self.nodes.get(id).is_none_or(|n| n.enabled)
    }

    /// Whether the widget or any of its ancestors is disabled.
    pub fn effectively_disabled(&self, id: WidgetId) -> bool {
        let mut current = Some(id);
        while let Some(c) = current {
            if !self.is_enabled(c) {
                return true;
            }
            current = self.parent(c);
        }
        false
    }

    /// The nearest enabled widget at or above `id` — where pointer events
    /// for a disabled target actually go (a disabled control occludes, but
    /// its enabled container still hovers and clicks, exactly as if the
    /// pointer were on the container's padding).
    pub(crate) fn enabled_target(&self, id: WidgetId) -> Option<WidgetId> {
        let mut current = Some(id);
        while let Some(c) = current {
            if !self.effectively_disabled(c) {
                return Some(c);
            }
            current = self.parent(c);
        }
        None
    }

    /// The widget's current interaction state, derived from pointer
    /// tracking: hovered if it is on the hover chain, active if it is the
    /// pressed widget or one of its ancestors, disabled if it or an
    /// ancestor is disabled.
    pub fn interaction_state(&self, id: WidgetId) -> crate::style::InteractionState {
        let hover = self.pointer.hover_chain.contains(&id);
        let mut active = false;
        let mut current = self.pointer.pressed;
        while let Some(c) = current {
            if c == id {
                active = true;
                break;
            }
            current = self.parent(c);
        }
        crate::style::InteractionState {
            hover,
            active,
            focus: self.focus == Some(id),
            disabled: self.effectively_disabled(id),
        }
    }

    /// The cursor for the current hover target (the disabled-retargeting
    /// in hover tracking already makes disabled controls answer Default).
    ///
    /// The widget is asked in its own coordinates, so one that varies its
    /// cursor internally — a paragraph with links in it — answers about the
    /// place the pointer actually is.
    pub fn cursor_shape(&self) -> CursorShape {
        let Some(id) = self.pointer.hover_chain.last().copied() else {
            return CursorShape::default();
        };
        let Some(position) = self.pointer.position else {
            return CursorShape::default();
        };
        let local = position - self.absolute_origin(id).to_vec2();
        self.with_widget(id, |w| w.cursor(local))
            .unwrap_or_default()
    }

    /// The focused widget, if any.
    pub fn focus(&self) -> Option<WidgetId> {
        self.focus
    }

    /// Move focus, delivering blur/focus to the affected widgets.
    pub fn set_focus(&mut self, target: Option<WidgetId>) {
        let target = target.filter(|id| {
            self.nodes
                .get(*id)
                .is_some_and(|node| node.widget.focusable())
                && !self.effectively_disabled(*id)
        });
        if self.focus == target {
            return;
        }
        let lost = self.focus.take();
        if let Some(old) = lost {
            self.with_widget_and_text(old, |widget, _| widget.on_focus_changed(false));
            self.style_dirty.push(old);
        }
        if let Some(new) = target {
            self.with_widget_and_text(new, |widget, _| widget.on_focus_changed(true));
            self.style_dirty.push(new);
            // Bring the newly focused control into view — a Tab into a control
            // scrolled out of a long form has the same need a moved caret does.
            self.pending_reveal = Some(new);
        }
        self.focus = target;
        // Report *after* `self.focus` is settled, so a handler that asks sees
        // the new truth rather than the transition it is being told about.
        if let Some(old) = lost {
            self.dispatch_direct(
                old,
                crate::event::EventKind::FocusChange,
                crate::event::EventData::FocusChanged(false),
            );
        }
        if let Some(new) = target {
            self.dispatch_direct(
                new,
                crate::event::EventKind::FocusChange,
                crate::event::EventData::FocusChanged(true),
            );
        }
    }

    /// Drive one widget's focus from an application binding — the endpoint of
    /// `:focused`.
    ///
    /// Focus is exclusive and the tree owns it, so this is deliberately not
    /// symmetric: `true` takes focus, and `false` gives it up *only if this
    /// widget is the one holding it*. That asymmetry is what makes the
    /// property order-independent. When focus moves between two widgets driven
    /// by one piece of state, both bindings fire — one falling, one rising —
    /// and nothing says which effect runs first; because a `false` on a widget
    /// that is already unfocused does nothing, either order lands in the same
    /// place. (The same shape as `:checked` across a radio group, for the same
    /// reason: exclusivity is a fact about the app's data.)
    pub fn set_focused(&mut self, id: WidgetId, focused: bool) {
        if focused {
            self.set_focus(Some(id));
        } else if self.focus == Some(id) {
            self.set_focus(None);
        }
    }

    /// Move focus to the next (or previous) focusable widget in tree order,
    /// cycling.
    pub fn focus_step(&mut self, backwards: bool) {
        let mut order = Vec::new();
        if let Some(scope) = self.focus_scope_root() {
            self.collect_focusable(scope, &mut order);
        }
        if order.is_empty() {
            self.set_focus(None);
            return;
        }
        let next = match self
            .focus
            .and_then(|f| order.iter().position(|id| *id == f))
        {
            Some(index) => {
                let len = order.len();
                if backwards {
                    order[(index + len - 1) % len]
                } else {
                    order[(index + 1) % len]
                }
            }
            None if backwards => *order.last().expect("nonempty"),
            None => order[0],
        };
        self.set_focus(Some(next));
    }

    fn collect_focusable(&self, id: WidgetId, out: &mut Vec<WidgetId>) {
        if let Some(node) = self.nodes.get(id) {
            // A disabled widget takes its whole subtree out of traversal.
            if !node.enabled {
                return;
            }
            // Tab order, and the invariant's landing places: a widget that is
            // only click-focusable is neither.
            if node.widget.tab_stop() {
                out.push(id);
            }
            for child in &node.children {
                self.collect_focusable(*child, out);
            }
        }
    }

    /// Enforce the focus invariant: whenever the tree contains a focusable
    /// widget, one of them has focus. Runs once per frame after commands are
    /// drained, so startup, widget removal, and hot reload all converge on
    /// a valid focus without per-mutation special cases. Without it,
    /// keyboard and IME input silently goes nowhere — which real-desktop
    /// testing showed is genuinely confusing (the IME's own UI keeps
    /// working while the field stays blank).
    fn ensure_focus(&mut self) {
        if self.focus.is_some_and(|f| {
            self.nodes.contains_key(f) && self.focus_admissible(f) && !self.effectively_disabled(f)
        }) {
            return;
        }
        // Focus on a removed widget clears silently (no blur to deliver);
        // focus beneath a just-opened modal blurs normally through
        // set_focus below.
        if self.focus.is_some_and(|f| !self.nodes.contains_key(f)) {
            self.focus = None;
        }
        let mut order = Vec::new();
        if let Some(scope) = self.focus_scope_root() {
            self.collect_focusable(scope, &mut order);
        }
        match order.first() {
            Some(first) => self.set_focus(Some(*first)),
            // A modal with no focusables (a confirmation box of plain
            // click targets) must not leak keys to widgets beneath it.
            None => self.set_focus(None),
        }
    }

    /// Scroll ancestors so a pending target is visible, and report which
    /// scroll containers moved so the frame can re-encode them. Runs after
    /// layout, when both the target's geometry and every ancestor's are
    /// settled.
    ///
    /// The walk is in window coordinates: [`absolute_origin`](Self::absolute_origin)
    /// already folds in every ancestor scroll offset, so translating the
    /// target rectangle by the applied offset change after each container
    /// keeps it correct through nested scrolling.
    fn resolve_reveal(&mut self) -> Vec<WidgetId> {
        let Some(target) = self.pending_reveal.take() else {
            return Vec::new();
        };
        if !self.nodes.contains_key(target) {
            return Vec::new();
        }
        let local = self.nodes[target]
            .widget
            .reveal_rect()
            .unwrap_or_else(|| Rect::from_origin_size(Point::ORIGIN, self.layout(target).size()));
        let origin = self.absolute_origin(target);
        let mut rect = Rect::from_origin_size(local.origin() + origin.to_vec2(), local.size());

        let mut moved = Vec::new();
        let mut current = target;
        while let Some(parent) = self.parent(current) {
            let viewport = self.absolute_origin(parent);
            let applied = self.nodes[parent].widget.scroll_reveal(viewport, rect);
            if applied != guiduck_scene::geom::Vec2::ZERO {
                // The container scrolled: its content — the target with it —
                // shifted by the opposite of the offset change. Track that so
                // an outer container reveals the target's new position.
                rect = Rect::from_origin_size(rect.origin() - applied, rect.size());
                moved.push(parent);
            }
            current = parent;
        }
        moved
    }

    /// Register an application-level keyboard shortcut. The routing order
    /// is fixed: the focused widget sees every key first (so a text input's
    /// Ctrl+C beats an app accelerator, and a bare-letter shortcut cannot
    /// steal typing), then overlay dismissal (Escape), then shortcuts, then
    /// Tab traversal.
    pub fn on_shortcut(
        &mut self,
        keystroke: crate::event::Keystroke,
        handler: impl FnMut() + 'static,
    ) {
        self.shortcuts.push((None, keystroke, Box::new(handler)));
    }

    /// Dispatch a key: focused widget first, then its `:on-key` wires, then
    /// overlay dismissal, then shortcuts, then Tab traversal. Returns true if
    /// the tree used the key.
    pub fn dispatch_key(
        &mut self,
        key: &crate::event::KeyInput,
        clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if !key.pressed {
            return false;
        }
        if let Some(id) = self.focus {
            let consumed = self
                .with_widget_and_text(id, |widget, text| widget.on_key(key, text, clipboard))
                .unwrap_or(false);
            if consumed {
                // A consumed key may have moved the caret; keep it in view.
                self.pending_reveal = Some(id);
                return true;
            }
        }
        // The focused widget declined, so the key is offered to the
        // application: that widget's own `:on-key` wire first, then its
        // ancestors'. This sits *after* the widget's own handling — a text
        // input's Ctrl+C is still the text input's — and *before* the overlay
        // and shortcut layers, because a wire written on the focused widget is
        // more specific than either. A handler consumes the key by consuming
        // the event (`cx.consume()`).
        //
        // With nothing focused — a tree with no focusable widget at all, since
        // the focus invariant covers every other case — the offer starts at
        // the root, so a key wire on a container is never silently dead.
        //
        // A key with no canonical spelling is not offered: there would be
        // nothing for a handler to compare against.
        if let Some(target) = self.focus.or_else(|| self.root())
            && let Some(spelling) = key.spelling()
            && self.dispatch_bubbling(
                target,
                crate::event::EventKind::Key,
                crate::event::EventData::Key(spelling),
            )
        {
            return true;
        }
        // Escape on a dialog *reports*; it does not dismiss. Closing the
        // overlay here would leave `:open` saying true while the sheet was
        // gone, and the app's state is the truth. This runs before the
        // overlay walk so a dialog's Escape never reaches it.
        if key.key == crate::event::Key::Escape && self.request_close_topmost_dialog() {
            return true;
        }
        if key.key == crate::event::Key::Escape && self.dismiss_for_escape() {
            return true;
        }
        for (_, keystroke, handler) in &mut self.shortcuts {
            if keystroke.matches(key) {
                handler();
                return true;
            }
        }
        if key.key == crate::event::Key::Tab {
            self.focus_step(key.modifiers.shift);
            return true;
        }
        false
    }

    /// Forward pasted text to the focused widget: the delivery half of a
    /// [`Clipboard::request_text`](crate::clipboard::Clipboard::request_text)
    /// that answered `Pending`. Like IME input, the text goes to whatever
    /// is focused when it arrives — the platform fetch takes long enough
    /// that "the widget that asked" and "the widget being edited" can only
    /// be told apart by a user who moved focus meanwhile, and input
    /// follows focus.
    pub fn dispatch_paste(&mut self, pasted: &str) {
        if let Some(id) = self.focus {
            self.with_widget_and_text(id, |widget, text| widget.on_paste(pasted, text));
            // The paste moved the caret; keep it in view.
            self.pending_reveal = Some(id);
        }
    }

    /// Forward an input-method event to the focused widget.
    pub fn dispatch_ime(&mut self, ime: &crate::event::ImeInput) {
        if let Some(id) = self.focus {
            self.with_widget_and_text(id, |widget, text| widget.on_ime(ime, text));
            // A commit or preedit moves the caret; keep it in view.
            self.pending_reveal = Some(id);
        }
    }

    /// Where the input method should place its candidate window, in logical
    /// window coordinates.
    pub fn ime_cursor_area(&self) -> Option<Rect> {
        let id = self.focus?;
        let local = self.nodes.get(id)?.widget.ime_cursor_area()?;
        let origin = self.absolute_origin(id);
        Some(Rect::new(
            local.x0 + origin.x,
            local.y0 + origin.y,
            local.x1 + origin.x,
            local.y1 + origin.y,
        ))
    }

    /// Read-only access to one widget as a trait object.
    pub(crate) fn with_widget<R>(
        &self,
        id: WidgetId,
        f: impl FnOnce(&dyn Widget) -> R,
    ) -> Option<R> {
        self.nodes.get(id).map(|node| f(node.widget.as_ref()))
    }

    /// Run `f` with mutable access to one widget and the shared text
    /// context, collecting any dirt the widget marked. The single access
    /// path for internal widget hooks (keys, IME, pointer, focus).
    pub(crate) fn with_widget_and_text<R>(
        &mut self,
        id: WidgetId,
        f: impl FnOnce(&mut dyn Widget, &mut TextContext) -> R,
    ) -> Option<R> {
        let node = self.nodes.get_mut(id)?;
        let result = f(node.widget.as_mut(), &mut self.text);
        let dirt = node.widget.take_dirt();
        let emitted = node.widget.take_emitted();
        if let Some(wake) = node.widget.take_wake() {
            self.wake_requests.push((id, wake));
        }
        if !dirt.is_clean() {
            self.dirt.push((id, dirt));
        }
        // The widget borrow has ended; emitted semantic events dispatch to
        // external handlers through the ordinary capture/bubble path.
        for data in emitted {
            let kind = match &data {
                crate::event::EventData::Changed(_) => crate::event::EventKind::Changed,
                crate::event::EventData::Toggled(_) => crate::event::EventKind::Toggled,
                crate::event::EventData::ValueChanged(_) => crate::event::EventKind::ValueChanged,
                crate::event::EventData::Scrolled { .. } => crate::event::EventKind::Scrolled,
                // A link span reports its target and the tree dispatches it,
                // like any other value a widget reports. Nothing here resolves
                // the target: what it names is the application's business, and
                // the wire is the whole of the framework's part.
                crate::event::EventData::Link(_) => crate::event::EventKind::Link,
                // Keyboard activation converts into a synthesized click at
                // the widget's center — the path a11y Click already takes —
                // so keyboard and pointer share the `:on-click` wire.
                crate::event::EventData::Activated => {
                    let origin = self.absolute_origin(id);
                    let size = self.layout(id).size();
                    let center =
                        Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
                    self.dispatch_click_to(id, center);
                    continue;
                }
                // The menu family reports; the tree acts. Opening a popup
                // needs the overlay stack, moving a highlight needs the
                // panel's rows, and closing a stack needs the stack — none
                // of which a widget can reach from inside its own borrow.
                crate::event::EventData::MenuOpen => {
                    self.open_menu(id);
                    continue;
                }
                crate::event::EventData::MenuNav(nav) => {
                    self.menu_nav(id, *nav);
                    continue;
                }
                // A chosen row is one wire however it was chosen — pointer,
                // Enter, or accelerator — as `Activated` is for a button.
                crate::event::EventData::Selected => {
                    self.select_menu_row(id);
                    continue;
                }
                // A dialog's close request reaches handlers through the
                // ordinary path; the tree does not act on it, because acting
                // is the app's job — that is what controlled means.
                crate::event::EventData::CloseRequested => crate::event::EventKind::Close,
                // A manifest-declared event reaches handlers like any other
                // semantic one; which event it is rides in the payload,
                // because the kind enum is the closed vocabulary's.
                crate::event::EventData::User { .. } => crate::event::EventKind::User,
                // Widgets emit semantic events only; pointer and file-drop
                // events always originate from the shell.
                //
                // Focus and unconsumed keys are the tree's to report, not a
                // widget's: a widget cannot know it lost focus to someone else,
                // and by the time a key is unconsumed the widget has already
                // had it. Both dispatch from where that is known — `set_focus`
                // and `dispatch_key` — so neither can arrive here.
                crate::event::EventData::Pointer(_)
                | crate::event::EventData::FileDrop(_)
                | crate::event::EventData::FocusChanged(_)
                | crate::event::EventData::Key(_) => {
                    continue;
                }
            };
            self.dispatch_semantic(id, kind, data);
        }
        Some(result)
    }

    /// Re-resolve and apply computed styles for widgets whose style inputs
    /// changed. Style application goes through ordinary setters, so the
    /// resulting dirt is collected exactly like any other mutation.
    fn style_pass(&mut self) {
        let mut dirty = std::mem::take(&mut self.style_dirty);
        dirty.sort_unstable();
        dirty.dedup();
        for id in dirty {
            if !self.nodes.contains_key(id) {
                continue;
            }
            let state = self.interaction_state(id);
            // Every widget resolves against the always-present effective
            // theme (the app theme over the default). Controls read their
            // whole appearance — including state-driven chrome — from the
            // computed style the theme's state selectors produce.
            let node = &self.nodes[id];
            let computed =
                self.effective_theme
                    .resolve(node.widget.type_name(), &node.classes, state);
            if computed.is_empty() {
                continue;
            }
            let node = self.nodes.get_mut(id).expect("checked above");
            node.widget.apply_style(&computed);
            let dirt = node.widget.take_dirt();
            if !dirt.is_clean() {
                self.dirt.push((id, dirt));
            }
        }
    }

    /// Bind a widget property to reactive state: `compute` runs in an
    /// effect (auto-tracking the signals it reads) and its result is applied
    /// to the widget through `apply` — typically a setter, which classifies
    /// the resulting dirt. The initial application happens on the next
    /// frame.
    ///
    /// A caller that cannot name `T` — a hot-reload interpreter, or code
    /// generated for an app-registered widget — wants
    /// [`bind_dyn`](Self::bind_dyn), which erases both types instead.
    pub fn bind<T: Widget, V: 'static>(
        &mut self,
        id: WidgetId,
        mut compute: impl FnMut() -> V + 'static,
        apply: impl Fn(&mut T, V) + 'static,
    ) {
        let commands = self.commands.clone();
        let apply = Rc::new(apply);
        // The Effect handle is intentionally dropped: the effect stays alive,
        // owned by the current reactive scope.
        guiduck_signals::Effect::new(move || {
            let value = compute();
            let apply = apply.clone();
            commands.mutate::<T>(id, move |widget| apply(widget, value));
        });
    }

    /// Bind a widget property to reactive state without naming the widget's
    /// type: the erased form of [`bind`](Self::bind), for code that is
    /// compiled before the widget it drives exists (the hot-reload
    /// interpreter) or that is generated for an app-registered widget.
    ///
    /// `compute` runs in an effect exactly as `bind`'s does, but yields its
    /// value erased; `apply` is a [`SetterThunk`], which recovers both the
    /// widget's type and the value's inside the closure. The resulting
    /// mutation travels the same command queue and the same borrow guard, so
    /// the dirt a setter marks is classified and collected identically.
    pub fn bind_dyn(
        &mut self,
        id: WidgetId,
        mut compute: impl FnMut() -> Box<dyn Any> + 'static,
        apply: SetterThunk,
    ) {
        let commands = self.commands.clone();
        // As in `bind`, the Effect handle is intentionally dropped: the
        // effect stays alive, owned by the current reactive scope.
        guiduck_signals::Effect::new(move || {
            let value = compute();
            let apply = apply.clone();
            commands.mutate_dyn(id, move |widget| apply(widget, value));
        });
    }

    fn drain_commands(&mut self) {
        loop {
            let batch = self.commands.take();
            if batch.is_empty() {
                break;
            }
            for command in batch {
                command(self);
            }
        }
    }

    /// Anchor pending wake requests against `now` and return the earliest
    /// deadline, if any. The platform shell calls this after each frame to
    /// decide between waiting indefinitely (no deadline — the zero-idle
    /// guarantee) and waiting until the deadline; tests fabricate `now`.
    pub fn next_wake(&mut self, now: std::time::Instant) -> Option<std::time::Instant> {
        for (id, duration) in self.wake_requests.drain(..) {
            let deadline = now + duration;
            // The latest request from a widget replaces its earlier one
            // (a reset blink phase supersedes the pending toggle).
            self.deadlines.retain(|(existing, _)| *existing != id);
            self.deadlines.push((id, deadline));
        }
        self.deadlines
            .retain(|(id, _)| self.nodes.contains_key(*id));
        // The tooltip's deadline is the tree's own, not a widget's: hovering
        // is not something a widget is told about (enter and leave reach only
        // external handlers), and a tooltip is the tree's policy anyway. It
        // is anchored here, where the platform supplies `now` — the widget
        // rule applied to the tree: say "in this long", never read a clock.
        if self.tooltip.armed.is_some() && self.tooltip.overlay.is_none() {
            self.tooltip.due.get_or_insert(now + tooltip::TOOLTIP_DELAY);
        } else {
            self.tooltip.due = None;
        }
        self.deadlines
            .iter()
            .map(|(_, at)| *at)
            .chain(self.tooltip.due)
            .chain(self.counted_click_deadline())
            .min()
    }

    /// Deliver [`Widget::on_timer`] to every widget whose deadline has
    /// passed. Dirt (and follow-up wake requests) are collected exactly as
    /// for any other widget hook; the caller renders a frame afterwards if
    /// [`needs_frame`](Self::needs_frame) says so.
    pub fn tick(&mut self, now: std::time::Instant) {
        let due: Vec<WidgetId> = self
            .deadlines
            .iter()
            .filter(|(_, at)| *at <= now)
            .map(|(id, _)| *id)
            .collect();
        self.deadlines.retain(|(_, at)| *at > now);
        for id in due {
            self.with_widget_and_text(id, |widget, _| widget.on_timer());
        }
        if self.tooltip.due.is_some_and(|at| at <= now) {
            self.tooltip.due = None;
            self.open_tooltip();
        }
        // A click burst whose window has closed settles now, with no clipboard
        // (only Down-driven widgets — the text input — read it, and none of
        // those handle CountedClick).
        if self.counted_click_due(now) {
            self.resolve_counted_click(&mut crate::clipboard::NoClipboard);
        }
    }

    /// Whether anything requires rendering a new frame: pending effects,
    /// queued commands, collected dirt, pending restyles, or no completed
    /// layout yet.
    pub fn needs_frame(&self) -> bool {
        guiduck_signals::has_pending_effects()
            || !self.commands.is_empty()
            || !self.dirt.is_empty()
            || !self.style_dirty.is_empty()
            || self.laid_out.is_none()
            || self.frame_dirty
    }

    /// Produce a frame: flush reactive effects, apply queued mutations,
    /// re-lay-out if anything layout-affecting changed (or the viewport
    /// did), and paint.
    ///
    /// This is the single frame pipeline; the platform shell calls it per
    /// redraw, and headless tests call it directly.
    pub fn render_frame(&mut self, viewport: Size) -> Option<FragmentId> {
        guiduck_signals::flush_effects();
        self.drain_commands();
        // A dialog's `:open` is a binding, and a binding's effect lands in
        // the command queue — so the flag is only true once those have
        // settled. Reconciling here, after the drain, is what lets `:open`
        // be the single truth rather than a setter that must remember to act.
        self.sync_dialogs();
        self.ensure_focus();
        // Styles resolve after commands (which may have changed the theme or
        // classes) and before layout, since style can affect text metrics.
        self.style_pass();

        let dirt = std::mem::take(&mut self.dirt);
        let needs_layout = self.laid_out != Some(viewport) || dirt.iter().any(|(_, d)| d.layout);
        if needs_layout {
            for (id, d) in &dirt {
                if d.layout
                    && let Some(node) = self.nodes.get(*id)
                {
                    self.taffy
                        .mark_dirty(node.taffy_id)
                        .expect("widget's taffy node exists");
                }
            }
            self.compute_layout(viewport);
            self.laid_out = Some(viewport);
        } else {
            // Paint-only changes keep their geometry, but the widget still
            // gets to refresh size-dependent content (e.g. a text layout
            // rebuilt with a new brush) before painting.
            for (id, _) in &dirt {
                if self.nodes.contains_key(*id) {
                    let size = self.layout(*id).size();
                    let content_size = self.content_size(*id);
                    if let Some(node) = self.nodes.get_mut(*id) {
                        node.widget
                            .finalize_layout(&mut self.text, size, content_size);
                    }
                }
            }
        }

        // Dirt raised *by* finalizing (a scroll area clamping its offset to
        // content that shrank). It arrived after the frame's set was taken, so
        // it is folded in here rather than left for a later frame: the change
        // is already in the geometry this frame paints from, and no frame is
        // scheduled to carry it. Consuming it is what keeps an idle
        // application at zero frames.
        let late: Vec<WidgetId> = std::mem::take(&mut self.dirt)
            .into_iter()
            .map(|(id, _)| id)
            .filter(|id| self.nodes.contains_key(*id))
            .collect();

        // Bring a just-focused widget, or a moved caret, into view by
        // scrolling its ancestors. Resolved here, after layout, because it
        // reads settled geometry; the scroll containers it moves are
        // re-encoded this frame alongside the dirty set.
        let revealed = self.resolve_reveal();

        // The re-encode set: paint-dirty widgets, plus — on layout frames —
        // whatever the relayout actually moved or resized. A resized widget
        // repaints itself; a moved widget's *parent* re-encodes, because
        // child transforms live in the parent's fragment. Everything else
        // keeps last frame's fragment untouched.
        let mut encode: Vec<WidgetId> = dirt
            .iter()
            .map(|(id, _)| *id)
            .filter(|id| self.nodes.contains_key(*id))
            .chain(late)
            .chain(revealed)
            .collect();
        if needs_layout {
            let ids: Vec<WidgetId> = self.nodes.keys().collect();
            for id in ids {
                let rect = self.layout(id);
                let node = &mut self.nodes[id];
                let previous = node.last_layout;
                node.last_layout = rect;
                if rect == previous {
                    continue;
                }
                if rect.size() != previous.size() {
                    encode.push(id);
                }
                if rect.origin() != previous.origin()
                    && let Some(parent) = self.nodes[id].parent
                {
                    encode.push(parent);
                }
            }
        }
        encode.sort_unstable();
        encode.dedup();
        for id in encode {
            if self.nodes.contains_key(id) {
                self.encode_node(id);
            }
        }

        // Overlay positions resolve against the settled layout (anchor
        // rectangles are only now known), then the frame composes the base
        // root and each overlay at its position. The frame fragment is
        // rewritten only when layers appear, vanish, or move.
        self.position_overlays(viewport);
        if self.root.is_none() && self.overlays.is_empty() {
            return None;
        }
        if self.frame_dirty {
            let mut frame = Fragment::new();
            if let Some(root) = self.root {
                frame.child(Affine::IDENTITY, self.nodes[root].fragment);
            }
            for overlay in &self.overlays {
                frame.child(
                    Affine::translate(overlay.position.to_vec2()),
                    self.nodes[overlay.root].fragment,
                );
            }
            if let Some(retained) = self.fragments.get_mut(self.frame_fragment) {
                *retained = frame;
            }
            self.frame_dirty = false;
        }
        Some(self.frame_fragment)
    }
}

/// The dirt hand-off a [`WidgetMut`] performs when its borrow ends, spelled
/// as its own trait so that one guard — and so one copy of the rule that
/// governs dirt collection — serves both a statically typed borrow and an
/// erased one. The guard cannot simply ask for [`Widget`], because `dyn
/// Widget` does not implement it.
pub trait WidgetDirt {
    /// Take the dirt the borrowed widget's setters marked. Both sides
    /// forward to [`Widget::take_dirt`].
    fn take_widget_dirt(&mut self) -> Dirt;
}

impl<T: Widget> WidgetDirt for T {
    fn take_widget_dirt(&mut self) -> Dirt {
        self.take_dirt()
    }
}

impl WidgetDirt for dyn Widget {
    fn take_widget_dirt(&mut self) -> Dirt {
        self.take_dirt()
    }
}

/// A mutable borrow of a widget that reports the dirt its setters marked
/// back to the tree when dropped. `T` is the concrete widget type for a
/// borrow through [`WidgetTree::widget_mut`], or `dyn Widget` for one
/// through [`WidgetTree::widget_dyn_mut`].
pub struct WidgetMut<'a, T: ?Sized + WidgetDirt> {
    id: WidgetId,
    widget: &'a mut T,
    dirt: &'a mut Vec<(WidgetId, Dirt)>,
}

impl<T: ?Sized + WidgetDirt> Deref for WidgetMut<'_, T> {
    type Target = T;

    fn deref(&self) -> &T {
        self.widget
    }
}

impl<T: ?Sized + WidgetDirt> DerefMut for WidgetMut<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        self.widget
    }
}

impl<T: ?Sized + WidgetDirt> Drop for WidgetMut<'_, T> {
    fn drop(&mut self) {
        let dirt = self.widget.take_widget_dirt();
        if !dirt.is_clean() {
            self.dirt.push((self.id, dirt));
        }
    }
}

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

/// A std control's appearance tokens as the built-in fallback theme defines
/// them, in the resting interaction state.
///
/// Controls initialize their tokens from this rather than each carrying a
/// palette of literals in code, which keeps one definition per token: the
/// fallback theme states it, the default theme overrides it, an app theme
/// overrides that. The values here are never painted in practice — the style
/// pass runs before a widget's first paint and the default theme covers every
/// token — but they mean a control is fully dressed from the moment it is
/// constructed, with no ordering to get right.
pub(crate) fn fallback_style(widget_type: &str) -> crate::style::ComputedStyle {
    crate::style::fallback_theme().resolve(
        widget_type,
        &[],
        crate::style::InteractionState::default(),
    )
}

/// Whether a brush is a fully transparent solid color — the signal a theme
/// uses to hide a token (a focus ring whose color the base rule leaves
/// transparent and the `:focus` rule makes opaque). Non-solid brushes are
/// treated as visible.
pub(crate) fn is_transparent(brush: &guiduck_scene::paint::Brush) -> bool {
    matches!(brush, guiduck_scene::paint::Brush::Solid(c) if c.components[3] <= 0.0)
}

/// The accesskit id for a widget: slotmap keys are unique u64s, and the
/// window root uses `u64::MAX`, which no slotmap key takes.
fn access_id(id: WidgetId) -> accesskit::NodeId {
    use slotmap::Key;
    accesskit::NodeId(id.data().as_ffi())
}

#[cfg(test)]
mod tests;