Skip to main content

lumen_core/
render_world.rs

1//! Render world definitions and the extract pipeline.
2//!
3//! ## Worlds
4//!
5//! - **Main world** (`App::world`): UI state, layout, scripts. Tick stages: `Input -> CommandDrain -> Systems -> LayoutSync -> A11ySync`.
6//! - **Render world** (`App::render_world`): per-frame extracted draw data, GPU resource caches, renderer state. Tick stages: `Prepare -> Render`.
7//!
8//! ## Extract step
9//!
10//! After the main schedule and before the render schedule, the registered chain of [`ExtractFn`] entries runs.
11//! Each function takes `(&mut World, &mut World)` and copies or upserts draw data from the main world into the render world.
12//!
13//! ## Drawable model
14//!
15//! Each render-world entity represents one drawable. Adding a primitive consists of: one `Extracted*` component, one extract fn, and one render system.
16
17use crate::app::App;
18use crate::components::{
19    CARET_WIDTH_PX, CaretBlink, CaretWidth, Color, Display, EchoMode, Fill, FlexDirection,
20    FlexJustify, ImeState, Opacity, PASSWORD_MASK_CHAR, PasswordCharacter, Style, TextAlign,
21    TextBlockOrigin, TextContent, TextInput, TextInputPaint, TextInputScroll, TextStyle, Transform,
22    Visible, Visuals, resolve_line_height, text_baseline_in_line, text_block_top,
23};
24use crate::input::{Focused, ScrollOffset};
25use crate::node_ir::transform_extracted_to_nodes;
26use crate::tick::TickStage;
27use bevy_ecs::prelude::*;
28use bevy_ecs::query::Or;
29use glam::Vec2;
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::{Arc, Mutex};
33
34/// Function pointer for an extract step. Stateless; closures are disallowed, so per-extract state lives in render-world resources.
35///
36/// Takes `&mut` on the main world to enable [`World::query`], which caches component-id resolution on first call.
37pub type ExtractFn = fn(&mut World, &mut World);
38
39/// Marks a render world whose extract pipeline is installed, so a second
40/// backend asking for it finds it already there.
41#[derive(Resource, Default, Debug)]
42pub struct ExtractPipeline;
43
44/// Install the pipeline that turns the main world into a paintable scene: the
45/// built-in extractors, the dirty roll-up that decides whether a tick extracts
46/// at all, and the `Prepare` systems that cull what was extracted and fold the
47/// rest into the retained node tree.
48///
49/// A render backend calls this when it installs itself. An app with no render
50/// backend, such as the browser runtime or a server render, never does, and so
51/// carries none of it: the page's own engine lays out and paints there.
52///
53/// The built-in chain goes ahead of whatever a plugin already appended with
54/// [`App::add_extract_fn`], so [`stash_hidden_entities`] still runs first and
55/// primes the hierarchy memos for the rest. Installing twice is a no-op, which
56/// is what lets an offscreen renderer sit beside a window in one app without
57/// doubling the chain.
58pub fn install_extract_pipeline(app: &mut App) {
59    if app.render_world.contains_resource::<ExtractPipeline>() {
60        return;
61    }
62    app.render_world.insert_resource(ExtractPipeline);
63    let chain: [ExtractFn; 8] = [
64        // Runs first so it primes the shared hierarchy memos and so
65        // `HiddenExtracts` is fresh for the `cull_hidden` guard.
66        stash_hidden_entities,
67        extract_shadows,
68        extract_rects,
69        extract_borders,
70        extract_text,
71        extract_clips,
72        extract_opacity_groups,
73        extract_scrollbars,
74    ];
75    app.extract_fns.splice(0..0, chain);
76    // Fold render-relevant `Changed<T>` filters into `FrameDirty` in the last
77    // main-world stage before extract.
78    app.add_systems(TickStage::A11ySync, roll_up_frame_dirty);
79    // Drop extracted entities outside the viewport, then any whose main entity
80    // is hidden by a `Visible(false)` on itself or an ancestor, before the
81    // survivors are folded into the retained tree.
82    app.add_render_systems(RenderStage::Prepare, cull_offscreen);
83    app.add_render_systems(RenderStage::Prepare, cull_hidden);
84    app.add_render_systems(
85        RenderStage::Prepare,
86        transform_extracted_to_nodes
87            .after(cull_offscreen)
88            .after(cull_hidden),
89    );
90}
91
92/// Schedule label for the render schedule.
93#[derive(bevy_ecs::schedule::ScheduleLabel, Clone, Copy, Debug, Hash, PartialEq, Eq)]
94pub struct Render;
95
96/// Ordered stages inside the [`Render`] schedule.
97#[derive(SystemSet, Clone, Copy, Debug, Hash, PartialEq, Eq)]
98pub enum RenderStage {
99    /// Build GPU buffers, vello scenes, and cache lookups for the upcoming submit.
100    Prepare,
101    /// Submit GPU draw work.
102    Render,
103}
104
105/// Window viewport, inserted as a `Resource` into both the main and render worlds.
106/// The window plugin writes both copies on every resize.
107#[derive(Resource, Clone, Debug)]
108pub struct Viewport {
109    /// Logical-pixel window size. The window backend divides the raw
110    /// `PhysicalSize` it receives from winit by [`scale_factor`] before
111    /// writing here, so layout consumers see the same coordinate space
112    /// CSS / pointer events do.
113    ///
114    /// [`scale_factor`]: Self::scale_factor
115    pub size: Vec2,
116    /// Background clear color.
117    pub clear: Color,
118    /// Current window scale factor (logical -> physical multiplier). Written
119    /// by the window backend on startup, `Resized`, and `ScaleFactorChanged`.
120    /// Defaults to `1.0` so headless / pre-window code paths can multiply
121    /// through unconditionally.
122    pub scale_factor: f32,
123    /// HiDPI factor of the current monitor (e.g. `1.0`, `1.5`, `2.0`); written by the window backend on startup and `MonitorChanged`. `None` while monitor info is unavailable.
124    pub monitor_scale: Option<f32>,
125    /// Physical-pixel size of the current monitor; written by the window backend.
126    pub monitor_size: Option<Vec2>,
127    /// Monitor name reported by winit (for example `"DELL U2719D"`).
128    pub monitor_name: Option<String>,
129}
130
131impl Default for Viewport {
132    fn default() -> Self {
133        Self {
134            size: Vec2::new(800.0, 600.0),
135            clear: Color::rgba(0.0, 0.0, 0.0, 0.0),
136            scale_factor: 1.0,
137            monitor_scale: None,
138            monitor_size: None,
139            monitor_name: None,
140        }
141    }
142}
143
144/// Main-world resource shrinking the layout viewport of every normal root
145/// while a panel is docked to a window edge (browser-devtools semantics: the
146/// page reflows into the remaining space instead of being covered). Roots in
147/// the top paint band ([`OverlayLayer`]) keep the full viewport - the docked
148/// panel itself is one, so it can lay out along the edge it occupies.
149///
150/// Written by whoever docks the panel (the devtools overlay); read by the
151/// layout backend. Absent or all-zero means no dock.
152#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
153pub struct DockInsets {
154    /// Logical pixels taken from the right window edge.
155    pub right: f32,
156    /// Logical pixels taken from the bottom window edge.
157    pub bottom: f32,
158}
159
160/// Per-frame flag indicating that the upcoming frame requires a fresh GPU encode.
161///
162/// - Set by [`roll_up_frame_dirty`] from `Changed<T>` filters on render-relevant components: [`Transform`], [`Visuals`], [`TextStyle`], [`TextContent`], [`TextInput`], [`TextInputScroll`], [`Opacity`], [`Visible`], [`Viewport`], [`crate::components::LumenClasses`], [`ScrollOffset`], plus the [`crate::property_store::PropertyStore`] notify queue (any property write since the previous tick), plus child-set mutations (newly added [`Visible`] / removed `ChildOf`).
163/// - Cleared by the window backend after submitting the frame.
164/// - When unset, window backends skip GPU encode and submit in `RedrawRequested`.
165///
166/// The flag over-approximates. A renderer refines it with the retained-scene
167/// diff (`lumen_paint::scene_has_damage`), which tells whether the painted
168/// tree really changed.
169#[derive(Resource, Debug)]
170pub struct FrameDirty {
171    /// `true` when the upcoming frame needs a fresh GPU encode.
172    pub dirty: bool,
173}
174
175impl Default for FrameDirty {
176    fn default() -> Self {
177        // Start dirty so the initial frame paints.
178        Self { dirty: true }
179    }
180}
181
182/// Per-tick flag raised by animation drivers (hover / press tweens,
183/// opacity transitions, scroll inertia) while a value is still in motion.
184///
185/// Read by the window backend right after `App::tick()` to self-schedule a
186/// follow-up frame (`RedrawScheduler.pending = true`) so an in-flight
187/// animation keeps advancing without waiting for an unrelated OS event.
188/// Without this, the first tween frame paints and then the loop parks -
189/// the animation freezes mid-way until the next mouse move.
190///
191/// Any other driver that finishes a tick with work still in flight raises it
192/// the same way, for the same reason: a parked loop runs no system, so work
193/// that completes off the frame (a background decode, a reply a later tick
194/// has to deliver) needs the next tick scheduled or it lands nowhere.
195///
196/// Idle-quiescence contract: the flag is stored in an [`AtomicBool`] so
197/// several parallel animation systems can raise it via `&Res` without
198/// serialising. [`reset_animations_active`] clears it at the *start* of
199/// every tick (`TickStage::Input`, which is chained before
200/// `TickStage::Systems` where the drivers run), and each driver re-raises
201/// it *only while it still has work left* (progress strictly short of its
202/// target, non-zero velocity, an element still without the content it
203/// named). A driver keys its claim on something in the world rather than on
204/// its own bookkeeping, so the claim dies with that thing even when the work
205/// behind it never finishes. The moment every driver settles, none raises
206/// it, the flag stays `false`, and the scheduler parks - so there is no
207/// permanent vsync spin.
208#[derive(Resource, Debug, Default)]
209pub struct AnimationsActive(std::sync::atomic::AtomicBool);
210
211impl AnimationsActive {
212    /// Raise the flag: at least one animation still has motion this tick.
213    /// Interior-mutable so animation systems can take a shared `Res`.
214    #[inline]
215    pub fn request(&self) {
216        self.0.store(true, std::sync::atomic::Ordering::Relaxed);
217    }
218
219    /// Read the current flag without clearing it. The window backend calls
220    /// this after the tick to decide whether to re-arm the redraw.
221    #[inline]
222    pub fn get(&self) -> bool {
223        self.0.load(std::sync::atomic::Ordering::Relaxed)
224    }
225
226    /// Clear the flag. Called by [`reset_animations_active`] at tick start.
227    #[inline]
228    pub fn clear(&self) {
229        self.0.store(false, std::sync::atomic::Ordering::Relaxed);
230    }
231}
232
233/// Clears [`AnimationsActive`] at the top of every tick (registered in
234/// [`crate::tick::TickStage::Input`], which is chained before the
235/// `Systems` stage where animation drivers run). Recomputing the flag
236/// from scratch each tick is what guarantees idle-quiescence: a tick with
237/// no live animation leaves it `false`.
238pub fn reset_animations_active(flag: Res<AnimationsActive>) {
239    flag.clear();
240}
241
242/// Axis-aligned rectangle in logical pixel coordinates.
243#[derive(Clone, Copy, Debug, Default, PartialEq)]
244pub struct Rect {
245    /// Top-left corner.
246    pub origin: Vec2,
247    /// Width x height.
248    pub size: Vec2,
249}
250
251impl Rect {
252    /// Constructs a rect from origin and size.
253    pub const fn new(origin: Vec2, size: Vec2) -> Self {
254        Self { origin, size }
255    }
256}
257
258/// Per-extract-phase memo of the parent-derived maps that every extract fn
259/// recomputes identically within a single frame.
260///
261/// The default extract pass runs six extract fns back-to-back
262/// ([`extract_shadows`], [`extract_rects`], [`extract_borders`],
263/// [`extract_text`], [`extract_clips`], [`extract_scrollbars`]) - plus the
264/// image/SVG extractors registered by `lumen-assets` - and each one
265/// independently rebuilds the same hierarchy-derived structures via
266/// [`build_parent_map`], [`hidden_entities`], [`parent_scroll_offsets`],
267/// [`opacity_model`], and [`parent_scroll_clip_rects`]. Those depend
268/// only on the main-world hierarchy / `ScrollOffset` / `Opacity` /
269/// `Visible` / clip components, none of which mutate between extractors of
270/// the same phase (extract fns only read the main world and write the
271/// render world). Recomputing them six times is pure redundant traversal +
272/// allocation on every dirty frame - the steady-state cost of any
273/// animation, scroll, or state change.
274///
275/// [`crate::app::App::tick`] wraps the extract-fn loop with
276/// [`Self::begin_phase`] / [`Self::end_phase`]. The first extractor of a
277/// phase computes each map and stores a copy here; the rest clone it back
278/// (an O(n) memcpy instead of the query + DFS + sort rebuild). Reuse is
279/// gated on [`Self::active`], so Systems-stage callers that share these
280/// helpers - hit-testing hover, which runs *before* the `<if>` / `<for>`
281/// reconcilers have finalised the tree - never observe a cached, stale
282/// hierarchy: with `active == false` they always recompute. `begin_phase`
283/// clears every slot, so no data survives across phases either.
284///
285/// Mirrors the "compute the frame's scene-graph context once" pattern in
286/// retained-scene toolkits (Qt Quick's `QSGRenderContext`, GTK4's snapshot
287/// pass): shared per-frame derivations are built once and threaded through
288/// the emitters, not rederived per draw-list.
289#[derive(Resource, Default)]
290pub struct ExtractContextCache {
291    /// `true` only while [`crate::app::App::tick`] is running the extract
292    /// fns. Consulted before any reuse so out-of-phase callers (hover
293    /// hit-testing in `TickStage::Systems`) always recompute.
294    active: bool,
295    /// Memoised `(child -> parent-entity, entity -> paint-order)` pair.
296    parent_map: Option<(HashMap<Entity, Entity>, HashMap<Entity, u32>)>,
297    /// Memoised hidden-subtree set.
298    hidden: Option<std::collections::HashSet<Entity>>,
299    /// Memoised cumulative ancestor scroll offsets.
300    scroll: Option<HashMap<Entity, Vec2>>,
301    /// Memoised per-entity draw alphas and opacity groups.
302    opacities: Option<OpacityModel>,
303    /// Memoised nearest-clip rect per entity.
304    clip: Option<HashMap<Entity, (Vec2, Vec2)>>,
305}
306
307impl ExtractContextCache {
308    /// Open the extract phase: enable reuse and drop any slots left from a
309    /// prior phase so the first extractor recomputes against the current
310    /// (post-reconcile, post-layout) hierarchy.
311    pub fn begin_phase(&mut self) {
312        self.active = true;
313        self.parent_map = None;
314        self.hidden = None;
315        self.scroll = None;
316        self.opacities = None;
317        self.clip = None;
318    }
319
320    /// Close the extract phase: disable reuse and release the memoised
321    /// maps so they cannot be observed by out-of-phase callers or pin
322    /// memory between frames.
323    pub fn end_phase(&mut self) {
324        self.active = false;
325        self.parent_map = None;
326        self.hidden = None;
327        self.scroll = None;
328        self.opacities = None;
329        self.clip = None;
330    }
331}
332
333/// [`SystemSet`] label for render-world extract systems registered via [`crate::app::App::add_extract_systems`].
334///
335/// The default extract pass (the legacy [`Vec<ExtractFn>`]) runs in registration order before this set; downstream crates
336/// that need `Changed<T>` extract semantics install their systems into this set on the [`ExtractSchedule`].
337#[derive(SystemSet, Clone, Copy, Debug, Hash, PartialEq, Eq)]
338pub enum ExtractSet {
339    /// Extract draw data from already-extracted render-world entities.
340    Extract,
341}
342
343/// Schedule label for the dedicated extract schedule on the render world.
344///
345/// Distinct from [`Render`] so extract systems and render systems can be reasoned about independently. Wave 2 migrates
346/// the existing [`ExtractFn`] entries onto this schedule.
347#[derive(bevy_ecs::schedule::ScheduleLabel, Clone, Copy, Debug, Hash, PartialEq, Eq)]
348pub struct ExtractSchedule;
349
350/// Rolls up render-relevant change signals into [`FrameDirty`].
351/// Runs each tick in [`crate::tick::TickStage::A11ySync`]; the window backend reads the flag right before encoding and clears it after submission.
352///
353/// Sources, in order of preference:
354/// 1. [`crate::property_store::PropertyStore::dirty_peek`] - the typed notify queue. Any global / entity property write since the last tick flips dirty. This is the long-term replacement for the per-component `Changed<T>` filters; wave 1 reads the queue so newly-installed bindable components (typed signals, theme, custom properties) flip dirty without needing a Query column here.
355/// 2. `Changed<T>` filters on the legacy render-relevant components ([`Transform`], [`Visuals`], [`TextStyle`], [`TextContent`], [`TextInput`] (caret / selection moves), [`TextInputScroll`], [`Opacity`], [`Visible`], [`crate::components::LumenClasses`]). Kept until the wave 2 migration moves these onto [`crate::property_store::PropertyStore`] notify.
356/// 3. [`Viewport`] resource change.
357/// 4. Child-set mutations: `Added<Visible>` (newly-spawned / newly-hidden entities - fixes the `FrameDirty ignores child-set mutations` audit bug in `renderer.md:86`) and `RemovedComponents<ChildOf>` (despawn / reparent - same bug).
358///
359/// `dirty_peek` is non-destructive; downstream observer systems remain free to `drain_dirty` later in the tick.
360///
361/// Set `LUMEN_TRACE_FRAME_DIRTY=1` to log which source raised the flag each
362/// tick (stderr) - the tool for hunting "app never idles" regressions.
363#[allow(clippy::type_complexity, clippy::too_many_arguments)]
364pub fn roll_up_frame_dirty(
365    mut fd: ResMut<FrameDirty>,
366    viewport: Res<Viewport>,
367    property_store: Option<Res<crate::property_store::PropertyStore>>,
368    component_changed: Query<
369        (),
370        Or<(
371            bevy_ecs::query::Changed<Transform>,
372            bevy_ecs::query::Changed<Visuals>,
373            bevy_ecs::query::Changed<TextStyle>,
374            bevy_ecs::query::Changed<TextContent>,
375            bevy_ecs::query::Changed<TextInput>,
376            bevy_ecs::query::Changed<TextInputScroll>,
377            bevy_ecs::query::Changed<Opacity>,
378            bevy_ecs::query::Changed<Visible>,
379            bevy_ecs::query::Changed<crate::components::LumenClasses>,
380            // Overlay-scrollbar fade: alpha steps must repaint even when
381            // nothing else changed (the fade-out frames).
382            bevy_ecs::query::Changed<crate::input::ScrollbarState>,
383            // A scroll moves the content and the scrollbar thumb, neither of
384            // which is a component write of its own; the scrollbar's fade
385            // state stops changing once the bar is fully shown.
386            bevy_ecs::query::Changed<ScrollOffset>,
387            // Runtime `type` / echo-mode flips (`bind-*`) must repaint even
388            // when the underlying text is unchanged (mask <-> plaintext).
389            bevy_ecs::query::Changed<EchoMode>,
390            bevy_ecs::query::Added<EchoMode>,
391            bevy_ecs::query::Added<Visible>,
392        )>,
393    >,
394    mut removed_childof: RemovedComponents<bevy_ecs::hierarchy::ChildOf>,
395    trace: (
396        Query<Entity, bevy_ecs::query::Changed<Transform>>,
397        Query<Entity, bevy_ecs::query::Changed<Visuals>>,
398        Query<Entity, bevy_ecs::query::Changed<TextStyle>>,
399        Query<Entity, bevy_ecs::query::Changed<TextContent>>,
400        Query<Entity, bevy_ecs::query::Changed<TextInput>>,
401        Query<Entity, bevy_ecs::query::Changed<TextInputScroll>>,
402        Query<Entity, bevy_ecs::query::Changed<Opacity>>,
403        Query<Entity, bevy_ecs::query::Changed<Visible>>,
404        Query<Entity, bevy_ecs::query::Changed<crate::components::LumenClasses>>,
405        Query<Entity, bevy_ecs::query::Changed<crate::input::ScrollbarState>>,
406        Query<Entity, bevy_ecs::query::Changed<ScrollOffset>>,
407    ),
408) {
409    // Fully drain the `RemovedComponents` reader every tick. `.next()` would
410    // consume only ONE entry: a tick that removes K `ChildOf` (closing a
411    // `<for>` list, a page switch) would leave K-1 stale entries, each raising
412    // `FrameDirty` on a later idle tick and preventing the app from parking.
413    // `.count()` advances the cursor past every entry this tick while still
414    // reporting whether any ChildOf was removed.
415    let removed_any = removed_childof.read().count() > 0;
416    if fd.dirty {
417        return;
418    }
419    let property_dirty = property_store
420        .as_ref()
421        .is_some_and(|s| !s.dirty_peek().is_empty());
422    if property_dirty || removed_any || viewport.is_changed() || !component_changed.is_empty() {
423        fd.dirty = true;
424        // Diagnostic: name the source(s). Env read is cached; disabled runs
425        // pay one atomic load.
426        static TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
427        let trace_on = *TRACE.get_or_init(|| std::env::var_os("LUMEN_TRACE_FRAME_DIRTY").is_some());
428        if trace_on {
429            let mut sources: Vec<String> = Vec::new();
430            if property_dirty {
431                let keys: Vec<String> = property_store
432                    .as_ref()
433                    .map(|s| s.dirty_peek().iter().map(|k| format!("{k:?}")).collect())
434                    .unwrap_or_default();
435                sources.push(format!("property_store{keys:?}"));
436            }
437            if removed_any {
438                sources.push("removed ChildOf".into());
439            }
440            if viewport.is_changed() {
441                sources.push("viewport".into());
442            }
443            let names = [
444                "Transform",
445                "Visuals",
446                "TextStyle",
447                "TextContent",
448                "TextInput",
449                "TextInputScroll",
450                "Opacity",
451                "Visible",
452                "LumenClasses",
453                "ScrollbarState",
454                "ScrollOffset",
455            ];
456            let counts = [
457                trace.0.iter().count(),
458                trace.1.iter().count(),
459                trace.2.iter().count(),
460                trace.3.iter().count(),
461                trace.4.iter().count(),
462                trace.5.iter().count(),
463                trace.6.iter().count(),
464                trace.7.iter().count(),
465                trace.8.iter().count(),
466                trace.9.iter().count(),
467                trace.10.iter().count(),
468            ];
469            for (name, count) in names.iter().zip(counts) {
470                if count > 0 {
471                    sources.push(format!("Changed<{name}>x{count}"));
472                }
473            }
474            eprintln!("lumen-core: FrameDirty raised by {}", sources.join(", "));
475        }
476    }
477}
478
479/// Painter-algorithm sort key for `Extracted*` entries. Higher values paint later (closer to the viewer).
480///
481/// Encoded as `document_order_rank * 2`, where the rank is the pre-order DFS index over the entity
482/// hierarchy forest computed by [`build_parent_map`]: ancestors paint before descendants and siblings
483/// paint in `Children`-list order (markup / reconcile order - entity-id allocation order plays no part).
484/// The `x2` stride keeps `order - 1` free so [`extract_shadows`] can slot shadows directly under their
485/// source rect without colliding with the preceding leaf. Because pre-order ranks make every subtree a
486/// contiguous range, an [`ExtractedClipBox`]'s `[start_order, end_order]` bracket covers exactly the
487/// clipping entity's descendants.
488///
489/// The `u32` key space is partitioned into three bands, low to high:
490///
491/// 1. **Normal tree content** - `[0, OVERLAY_ORDER_BASE)`: the pre-order forest ranks described above,
492///    excluding any subtree rooted at an [`OverlayLayer`] entity.
493/// 2. **Top layer** - `[OVERLAY_ORDER_BASE, 0x8000_0000)`: subtrees rooted at an [`OverlayLayer`]
494///    entity (dropdown / menu panels, tooltips, dialogs). Each subtree keeps contiguous internal
495///    pre-order ranks; whole subtrees stack by open order (later-opened paints on top - see
496///    [`OverlayOpenOrder`]).
497/// 3. **Orphans** - `0x8000_0000 | (entity_index << 1)`: entities outside the hierarchy forest, ordered
498///    by entity index - see [`paint_order_of`].
499pub type PaintOrder = u32;
500
501/// First [`PaintOrder`] of the top-layer band. Every rank in an [`OverlayLayer`] subtree is
502/// `>= OVERLAY_ORDER_BASE`; every normal-tree rank is below it (real UI trees are nowhere near
503/// `0x2000_0000` entities), so overlay content always paints after all normal content.
504pub const OVERLAY_ORDER_BASE: PaintOrder = 0x4000_0000;
505
506/// Marker component lifting an entity and its whole subtree into the top-layer paint band
507/// (browser top-layer / Qt popup-window semantics): the subtree paints after ALL normal tree
508/// content regardless of its document position, keeps its internal document order, and escapes
509/// ancestor clip / scroll-cull rects (its own internal clips still apply).
510///
511/// Attach to popup roots: `<dropdown>` / `<menu>` floating panels, tooltips, `<dialog>` wrappers.
512/// Painting only - hit-testing and layout are unaffected.
513#[derive(Component, Clone, Copy, Debug, Default)]
514pub struct OverlayLayer;
515
516/// Main-world resource tracking the order in which [`OverlayLayer`] roots became visible, so
517/// concurrently open popups stack later-opened-on-top (like OS popup windows).
518///
519/// Maintained by [`build_parent_map`]: a visible (not [`Visible(false)`]-hidden) overlay root gets a
520/// monotonically increasing stamp on first sight; hiding or despawning the root drops its stamp, so
521/// re-opening restamps it on top. Self-inserted on first use.
522#[derive(Resource, Default, Debug)]
523pub struct OverlayOpenOrder {
524    /// Overlay root -> open stamp. Lower stamps paint first (under later-opened popups).
525    pub stamps: HashMap<Entity, u64>,
526    /// Next stamp to hand out.
527    pub next: u64,
528}
529
530/// Fill brush for an [`ExtractedRect`]. Gradient variants store their stops in an `Arc<[...]>` so cloning the brush bumps the Arc instead of deep-copying.
531///
532/// `PartialEq` compares appearance so the retained Node-IR damage diff
533/// ([`crate::node_ir`]) can tell an unchanged fill from a changed one - the
534/// producer rebuilds a fresh brush every frame, so identity (`Arc::ptr_eq`)
535/// alone never matches across frames.
536#[derive(Clone, Debug, PartialEq)]
537pub enum Brush {
538    /// Solid fill.
539    Solid(Color),
540    /// Linear gradient. `angle_deg` follows the CSS convention (`0` = left->right, `90` = bottom->top, `180` = top->bottom); `stops` are sorted by ascending offset.
541    Linear {
542        /// Direction in degrees.
543        angle_deg: f32,
544        /// `(offset, color)` pairs with `offset` in `0..=1`.
545        stops: std::sync::Arc<[(f32, Color)]>,
546    },
547    /// Radial gradient centred at 50% / 50% of the entity rect.
548    Radial {
549        /// Normalised radius in `0..=1` relative to half the rect's min dimension.
550        radius: f32,
551        /// `(offset, color)` pairs with `offset` in `0..=1`.
552        stops: std::sync::Arc<[(f32, Color)]>,
553    },
554    /// Conic (sweep) gradient centred at 50% / 50%.
555    Conic {
556        /// Starting angle in degrees.
557        from_deg: f32,
558        /// `(offset, color)` pairs with `offset` in `0..=1`.
559        stops: std::sync::Arc<[(f32, Color)]>,
560    },
561}
562
563impl Brush {
564    /// Returns a fresh brush with `alpha` multiplied into every color/stop.
565    /// Gradient variants allocate a new stops Arc; the solid variant only updates the inner color.
566    pub fn with_opacity(self, alpha: Opacity) -> Self {
567        match self {
568            Brush::Solid(c) => Brush::Solid(alpha.apply(c)),
569            Brush::Linear { angle_deg, stops } => {
570                let mapped: std::sync::Arc<[(f32, Color)]> =
571                    stops.iter().map(|(o, c)| (*o, alpha.apply(*c))).collect();
572                Brush::Linear {
573                    angle_deg,
574                    stops: mapped,
575                }
576            }
577            Brush::Radial { radius, stops } => {
578                let mapped: std::sync::Arc<[(f32, Color)]> =
579                    stops.iter().map(|(o, c)| (*o, alpha.apply(*c))).collect();
580                Brush::Radial {
581                    radius,
582                    stops: mapped,
583                }
584            }
585            Brush::Conic { from_deg, stops } => {
586                let mapped: std::sync::Arc<[(f32, Color)]> =
587                    stops.iter().map(|(o, c)| (*o, alpha.apply(*c))).collect();
588                Brush::Conic {
589                    from_deg,
590                    stops: mapped,
591                }
592            }
593        }
594    }
595}
596
597impl From<&Fill> for Brush {
598    fn from(f: &Fill) -> Self {
599        match f {
600            Fill::Solid(c) => Brush::Solid(*c),
601            Fill::Linear { angle_deg, stops } => Brush::Linear {
602                angle_deg: *angle_deg,
603                stops: stops.iter().copied().collect(),
604            },
605            Fill::Radial { radius, stops } => Brush::Radial {
606                radius: *radius,
607                stops: stops.iter().copied().collect(),
608            },
609            Fill::Conic { from_deg, stops } => Brush::Conic {
610                from_deg: *from_deg,
611                stops: stops.iter().copied().collect(),
612            },
613        }
614    }
615}
616
617/// One filled rectangle to render this frame.
618#[derive(Component, Clone, Debug)]
619pub struct ExtractedRect {
620    /// Top-left in window coordinates.
621    pub origin: Vec2,
622    /// Width x height.
623    pub size: Vec2,
624    /// Fill brush - solid or linear gradient.
625    pub brush: Brush,
626    /// Uniform corner radius in logical pixels (`0.0` = sharp).
627    pub radius: f32,
628    /// Per-corner radii `[top-left, top-right, bottom-right,
629    /// bottom-left]`; `None` = uniform [`Self::radius`] everywhere.
630    pub corner_radii: Option<[f32; 4]>,
631    /// Global paint order (see [`PaintOrder`]).
632    pub order: PaintOrder,
633}
634
635/// Shadow extracted for one entity. The renderer draws it at `order = rect.order - 1` so it appears underneath the source rect without bleeding onto unrelated siblings.
636#[derive(Component, Clone, Copy, Debug)]
637pub struct ExtractedShadow {
638    /// Top-left in window coordinates, already including the per-shadow offset.
639    pub origin: Vec2,
640    /// Size of the source rect, used by vello to compute the blur bounding box.
641    pub size: Vec2,
642    /// Corner radius of the source rect.
643    pub radius: f32,
644    /// CSS spread radius: inflate (positive) / deflate (negative) the
645    /// shadow rect on every side before blurring.
646    pub spread: f32,
647    /// Gaussian blur std-dev.
648    pub blur: f32,
649    /// Shadow color (alpha controls softness).
650    pub color: Color,
651    /// Global paint order: below the source rect for a drop shadow, the
652    /// rect's own order for an inset one.
653    pub order: PaintOrder,
654    /// `true` for an inset shadow: the renderer shades the source rect minus a blurred copy of it moved by the offset. `false` for a drop shadow.
655    pub inner: bool,
656    /// Source rect top-left (`origin` minus the per-shadow offset). Inset shadows use it for the clip boundary and offset flip.
657    pub rect_origin: Vec2,
658}
659
660/// CSS border for one entity: per-side widths, one solid color, painted
661/// between the outer border-box edge and the padding box (inside the
662/// rect, unlike [`ExtractedOutline`] which strokes centered on the box
663/// edge). Emitted by [`extract_borders`] at the entity's own
664/// [`PaintOrder`]; the IR builder pushes borders after rects, so at the
665/// shared order key the border paints above the background fill and
666/// below all descendants - CSS's background -> border -> content order.
667#[derive(Component, Clone, Copy, Debug)]
668pub struct ExtractedBorder {
669    /// Border-box top-left in window coordinates.
670    pub origin: Vec2,
671    /// Border-box size.
672    pub size: Vec2,
673    /// Per-side widths `[top, right, bottom, left]` in logical pixels.
674    pub widths: [f32; 4],
675    /// Solid border color (all four sides).
676    pub color: Color,
677    /// Per-side color overrides `[top, right, bottom, left]`; `None` =
678    /// every side paints [`Self::color`].
679    pub side_colors: Option<[Color; 4]>,
680    /// Outer corner radius (matches the entity's [`Visuals::radius`]).
681    pub radius: f32,
682    /// Per-corner outer radii `[tl, tr, br, bl]`; `None` = uniform.
683    pub corner_radii: Option<[f32; 4]>,
684    /// Global paint order (see [`PaintOrder`]).
685    pub order: PaintOrder,
686}
687
688/// Stroked outline ring rendered around an entity (typically when
689/// [`crate::input::Focused`]).
690#[derive(Component, Clone, Copy, Debug)]
691pub struct ExtractedOutline {
692    /// Top-left in window coordinates (matches the rect being outlined).
693    pub origin: Vec2,
694    /// Width x height of the box being outlined.
695    pub size: Vec2,
696    /// Stroke color.
697    pub stroke: Color,
698    /// Stroke width in logical pixels.
699    pub width: f32,
700    /// Uniform corner radius (matches the underlying box).
701    pub radius: f32,
702    /// Global paint order (see [`PaintOrder`]).
703    pub order: PaintOrder,
704}
705
706/// One decoded image to render this frame in RGBA8 row-major pixels.
707/// The renderer wraps `rgba` in a `peniko::Blob` whose stable identity keys the vello/wgpu upload cache across frames.
708#[derive(Component, Clone, Debug)]
709pub struct ExtractedImage {
710    /// Top-left in window coordinates.
711    pub origin: Vec2,
712    /// Drawn size (the image is scaled into `size` according to [`Self::fit`]).
713    pub size: Vec2,
714    /// Source pixel width.
715    pub width: u32,
716    /// Source pixel height.
717    pub height: u32,
718    /// RGBA8 pixels, `width * height * 4` bytes.
719    pub rgba: std::sync::Arc<[u8]>,
720    /// How the source image fits into the drawn rectangle.
721    pub fit: crate::components::ImageFit,
722    /// Global paint order (see [`PaintOrder`]).
723    pub order: PaintOrder,
724    /// Alpha multiplier from [`crate::components::Opacity`] (1.0 when absent). Applied via `push_layer` at draw time so the whole image fades together.
725    pub alpha: f32,
726    /// `Some` when the image is its element's background (`bg: url(...)`)
727    /// rather than an `<image>`. A background paints over the element's fill
728    /// and under its border, at the element's own [`PaintOrder`].
729    pub background: Option<BackgroundClip>,
730}
731
732/// The box a background image is clipped to: the element's border box with
733/// its corner radii, the same shape its background colour fills.
734#[derive(Clone, Copy, Debug, PartialEq)]
735pub struct BackgroundClip {
736    /// Top-left in window coordinates.
737    pub origin: Vec2,
738    /// Border-box size.
739    pub size: Vec2,
740    /// Per-corner radii `[top-left, top-right, bottom-right, bottom-left]`.
741    pub radii: [f32; 4],
742}
743
744/// Single built-in fallback for the selection highlight when no
745/// `selection-color` token is set - the platform "highlight" blue at ~40 %
746/// alpha (mirrors macOS / web selection tints and Qt's `Highlight` role).
747/// Translucent on purpose so the text underneath keeps its contrast, so no
748/// `selection-text-color` is required for legibility. Skins override the
749/// whole look via the `--lumen-selection` token; this is the one Rust
750/// fallback.
751pub const DEFAULT_SELECTION_BG: Color = Color::rgba(0.20, 0.51, 0.98, 0.40);
752
753/// One text run to render this frame.
754///
755/// `PartialEq` compares every visual field so the retained Node-IR damage
756/// diff can skip an unchanged label without re-shaping - deterministic
757/// shaping means identical fields => identical glyphs => identical pixels.
758#[derive(Component, Clone, Debug, PartialEq)]
759pub struct ExtractedText {
760    /// Baseline origin in window coordinates at the container's leading edge. The renderer shifts by `(container_width - measured_width)` times an alignment fraction to honour [`Self::align`].
761    pub origin: Vec2,
762    /// Unshaped text passed to the shaper.
763    pub text: String,
764    /// Font size in logical pixels.
765    pub size_px: f32,
766    /// Fill color.
767    pub fill: Color,
768    /// `Some(byte_offset)` paints a vertical caret at the corresponding pixel position inside `text`.
769    pub caret: Option<usize>,
770    /// `Some((start, end))` paints a translucent selection highlight between the byte offsets; `start < end`.
771    pub selection: Option<(usize, usize)>,
772    /// Selection highlight background. `None` falls back to
773    /// [`DEFAULT_SELECTION_BG`]. Sourced from
774    /// [`TextStyle::selection_color`] (`selection-color` CSS property).
775    pub selection_color: Option<Color>,
776    /// Selected-glyph color (Qt `HighlightedText` / Slint
777    /// `selection-foreground-color`). `None` keeps glyphs their normal
778    /// [`Self::fill`]. Sourced from [`TextStyle::selection_foreground`].
779    pub selection_foreground: Option<Color>,
780    /// Caret color. `None` falls back to [`Self::fill`] (the text color).
781    /// Sourced from [`TextStyle::caret_color`] (`caret-color`).
782    pub caret_color: Option<Color>,
783    /// Global paint order.
784    pub order: PaintOrder,
785    /// Container width within which the text is aligned. With `align == Start` the renderer draws at `origin` directly; other alignments measure the run and shift inside this width.
786    pub container_width: f32,
787    /// Horizontal alignment policy.
788    pub align: TextAlign,
789    /// Wrap policy passed to the shaper.
790    pub wrap: crate::components::TextWrap,
791    /// Hard cap on shaped line count; `None` is unbounded.
792    pub max_lines: Option<u32>,
793    /// CSS `font-family` fallback chain (`None` = platform sans-serif).
794    pub family: Option<std::sync::Arc<str>>,
795    /// CSS `font-weight` (1-1000; 400 = normal).
796    pub weight: u16,
797    /// Resolved CSS `line-height` in logical pixels (already resolved
798    /// against [`Self::size_px`] via [`resolve_line_height`]). Drives
799    /// inter-line spacing in the shaper and the newline-caret math.
800    pub line_height_px: f32,
801    /// Resolved text-input caret stroke width in logical pixels (already
802    /// resolved against [`CARET_WIDTH_PX`] or a [`CaretWidth`] override).
803    pub caret_width_px: f32,
804}
805
806/// One rectangular clip region constraining descendant paints.
807///
808/// - The renderer pushes a vello layer at `start_order` and pops it at `end_order`.
809/// - Emitted for `<scroll>` containers and entities with `overflow: hidden`.
810#[derive(Component, Clone, Copy, Debug)]
811pub struct ExtractedClipBox {
812    /// Top-left in window coordinates, ancestor scroll offsets already applied - the same space the
813    /// leaves it clips are emitted in.
814    pub origin: Vec2,
815    /// Width x height in logical pixels.
816    pub size: Vec2,
817    /// Corner radius of the clip rect; matches the entity's visual radius for rounded clips.
818    pub radius: f32,
819    /// Paint-order key at which the layer is pushed - the clipping entity's own [`PaintOrder`].
820    /// Because paint order is a pre-order document rank, everything in `[start_order, end_order]`
821    /// is the clipping entity itself plus exactly its descendants.
822    pub start_order: PaintOrder,
823    /// Paint-order key at which the layer is popped - the maximum [`PaintOrder`] across the clipping
824    /// entity and its descendants, so the pop trails every descendant and nothing else.
825    pub end_order: PaintOrder,
826}
827
828/// One element whose paints composite as a single layer that fades as a whole: CSS group opacity.
829///
830/// - Emitted for an element with [`Opacity`] below one that has a visible child in its own paint band.
831///   Its own paints and its descendants' paint at full alpha into the layer, and the layer composites
832///   at `alpha` when it closes, so a child over its parent hides the parent the way it would at full
833///   opacity, and the two fade once, together.
834/// - An element with no such child folds its opacity into its own paints instead; nothing of its own
835///   overlaps anything else of its own by more than a border or a text run, and a layer costs a pass.
836#[derive(Component, Clone, Copy, Debug, PartialEq)]
837pub struct ExtractedOpacityGroup {
838    /// The element's own [`Opacity`], in `[0, 1)`.
839    pub alpha: f32,
840    /// Paint-order key at which the layer opens: the slot under the element's own [`PaintOrder`] that
841    /// its drop shadows take, so they fade with the element.
842    pub start_order: PaintOrder,
843    /// Paint-order key at which the layer closes - the maximum [`PaintOrder`] across the element and its
844    /// descendants in the same paint band, as for an [`ExtractedClipBox`].
845    pub end_order: PaintOrder,
846    /// The faded element's identity, the same every frame, so a renderer can keep the group's
847    /// layer from one frame to the next ([`crate::node_ir::Node::Opacity`]'s `key`).
848    pub key: u64,
849}
850
851/// One rounded solid rect of an overlay scrollbar (track or thumb).
852#[derive(Clone, Copy, Debug, PartialEq)]
853pub struct ScrollbarDrawRect {
854    /// Top-left in window coordinates.
855    pub origin: Vec2,
856    /// Width x height in logical pixels.
857    pub size: Vec2,
858    /// Solid fill (fade alpha already folded in).
859    pub color: Color,
860    /// Corner radius (pill = half the bar thickness).
861    pub radius: f32,
862}
863
864/// Overlay-scrollbar draw list for one scroll container (spec section 16.2 /
865/// section 16.6). Emitted by [`extract_scrollbars`]; the `order` is the
866/// container's `max descendant order + 1`, so the bars paint above all of the
867/// container's content, in `draws` order, and outside its clip bracket -
868/// overlay bars are never clipped by their own viewport. That slot is also
869/// where the next element's drop shadows go; the IR builder keys the bars
870/// under them, so a later sibling paints over an earlier one's bars and an
871/// opacity group the sibling opens there leaves the bars out.
872#[derive(Component, Clone, Debug)]
873pub struct ExtractedScrollbar {
874    /// Track / thumb rects in back-to-front paint order.
875    pub draws: Vec<ScrollbarDrawRect>,
876    /// Global paint order shared by every rect in `draws`.
877    pub order: PaintOrder,
878}
879
880/// Extract fn emitting one [`ExtractedScrollbar`] per [`crate::input::Scroll`]
881/// container whose content overflows on an allowed axis and whose fade
882/// alpha is above zero. Geometry comes from the shared
883/// [`crate::input::vertical_scrollbar`] / [`crate::input::horizontal_scrollbar`]
884/// math so painted pixels and hit regions always agree. All visuals
885/// (colors, thickness, minimums) resolve through
886/// [`crate::input::ScrollbarStyle`] - CSS `scrollbar-color` /
887/// `scrollbar-width` per container, with the component's [`Default`] as
888/// the no-stylesheet fallback.
889pub fn extract_scrollbars(main: &mut World, render: &mut World) {
890    use crate::input::{
891        Scroll, ScrollbarAxisPick, ScrollbarInteraction, ScrollbarState, ScrollbarStyle,
892        horizontal_scrollbar, vertical_scrollbar,
893    };
894    let (parents, mut depth_cache) = build_parent_map(main);
895    let hidden = hidden_entities(main, &parents);
896    let scroll_offsets = parent_scroll_offsets(main, &parents);
897    let interaction = main
898        .get_resource::<ScrollbarInteraction>()
899        .copied()
900        .unwrap_or_default();
901
902    // Child lookup for content extents + descendant paint orders.
903    let mut children: std::collections::HashMap<Entity, Vec<Entity>> =
904        std::collections::HashMap::new();
905    for (&e, &p) in parents.iter() {
906        children.entry(p).or_default().push(e);
907    }
908    let transforms: std::collections::HashMap<Entity, Transform> = {
909        let mut q = main.query::<(Entity, &Transform)>();
910        q.iter(main).map(|(e, t)| (e, *t)).collect()
911    };
912
913    type Row<'a> = (
914        Entity,
915        &'a Transform,
916        &'a Scroll,
917        &'a ScrollOffset,
918        &'a ScrollbarState,
919        Option<&'a ScrollbarStyle>,
920    );
921    let mut q = main.query::<Row>();
922    let mut pairs: Vec<(Entity, ExtractedScrollbar)> = Vec::new();
923    for (e, tf, scroll, offset, state, style) in q.iter(main) {
924        if hidden.contains(&e) || state.alpha <= 0.001 {
925            continue;
926        }
927        let style = style.copied().unwrap_or_default();
928        // `scrollbar-width: none` - content scrolls, bars never paint.
929        let Some(metrics) = style.metrics() else {
930            continue;
931        };
932        // Content extent: bbox of direct children relative to the
933        // container - same rule `clamp_scroll_offsets` applies.
934        let (mut content_w, mut content_h) = (0.0_f32, 0.0_f32);
935        if let Some(kids) = children.get(&e) {
936            for kid in kids {
937                if let Some(kt) = transforms.get(kid) {
938                    content_w = content_w.max((kt.absolute.x - tf.absolute.x) + kt.size.x);
939                    content_h = content_h.max((kt.absolute.y - tf.absolute.y) + kt.size.y);
940                }
941            }
942        }
943        let allow_y = scroll.axis.allows_y();
944        let allow_x = scroll.axis.allows_x();
945        // The viewport box itself translates with ANCESTOR scrollers
946        // (its own offset moves content, not its box).
947        let anc_off = scroll_offsets.get(&e).copied().unwrap_or(Vec2::ZERO);
948        let origin = tf.absolute - anc_off;
949        let v_overflow = allow_y && content_h - tf.size.y > 0.5;
950        let h_overflow = allow_x && content_w - tf.size.x > 0.5;
951        let v_geo = if v_overflow {
952            vertical_scrollbar(origin, tf.size, content_h, offset.0.y, h_overflow, metrics)
953        } else {
954            None
955        };
956        let h_geo = if h_overflow {
957            horizontal_scrollbar(origin, tf.size, content_w, offset.0.x, v_overflow, metrics)
958        } else {
959            None
960        };
961        if v_geo.is_none() && h_geo.is_none() {
962            continue;
963        }
964
965        let fade = state.alpha.clamp(0.0, 1.0);
966        let radius = metrics.thickness / 2.0;
967
968        let mut draws: Vec<ScrollbarDrawRect> = Vec::with_capacity(4);
969        let mut push_bar = |geo: crate::input::ScrollbarGeometry, axis: ScrollbarAxisPick| {
970            let hovered = interaction
971                .drag
972                .map(|d| d.entity == e && d.axis == axis)
973                .unwrap_or(false)
974                || interaction
975                    .hover
976                    .map(|(he, ha, _)| he == e && ha == axis)
977                    .unwrap_or(false);
978            // Track: an explicit `scrollbar-color` track paints whenever
979            // the bar is visible (CSS semantics); the fallback track
980            // shows on hover only (overlay convention).
981            let track = match style.track {
982                Some(c) => Some(c),
983                None if hovered => Some(style.hover_track),
984                None => None,
985            };
986            if let Some(mut track) = track {
987                track.a *= fade;
988                draws.push(ScrollbarDrawRect {
989                    origin: geo.track_origin,
990                    size: geo.track_size,
991                    color: track,
992                    radius,
993                });
994            }
995            let mut thumb = style.thumb;
996            thumb.a =
997                (thumb.a * if hovered { style.hover_boost } else { 1.0 }).clamp(0.0, 1.0) * fade;
998            draws.push(ScrollbarDrawRect {
999                origin: geo.thumb_origin,
1000                size: geo.thumb_size,
1001                color: thumb,
1002                radius,
1003            });
1004        };
1005        if let Some(geo) = v_geo {
1006            push_bar(geo, ScrollbarAxisPick::Vertical);
1007        }
1008        if let Some(geo) = h_geo {
1009            push_bar(geo, ScrollbarAxisPick::Horizontal);
1010        }
1011
1012        // Paint above every descendant: max-descendant-order + 1 (odd,
1013        // so it never collides with a document rank and sits after the
1014        // container's clip bracket pops).
1015        let mut end = paint_order_of(e, &parents, &mut depth_cache);
1016        let mut stack = vec![e];
1017        while let Some(n) = stack.pop() {
1018            let o = paint_order_of(n, &parents, &mut depth_cache);
1019            if o > end {
1020                end = o;
1021            }
1022            if let Some(kids) = children.get(&n) {
1023                stack.extend(kids.iter().copied());
1024            }
1025        }
1026        pairs.push((
1027            e,
1028            ExtractedScrollbar {
1029                draws,
1030                order: end.saturating_add(1),
1031            },
1032        ));
1033    }
1034
1035    // Keyed-upsert against `RenderEntityMap.scrollbar` - same lifecycle
1036    // as `extract_rects`.
1037    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().scrollbar);
1038    let mut next: std::collections::HashMap<Entity, Entity> =
1039        std::collections::HashMap::with_capacity(pairs.len());
1040    for (main_e, bar) in pairs {
1041        let reuse = prior
1042            .get(&main_e)
1043            .copied()
1044            .filter(|&re| render.get_entity(re).is_ok());
1045        let render_e = match reuse {
1046            Some(re) => {
1047                render.entity_mut(re).insert(bar);
1048                re
1049            }
1050            None => render.spawn(bar).id(),
1051        };
1052        next.insert(main_e, render_e);
1053    }
1054    for (main_e, render_e) in &prior {
1055        if !next.contains_key(main_e)
1056            && let Ok(em) = render.get_entity_mut(*render_e)
1057        {
1058            em.despawn();
1059        }
1060    }
1061    render.resource_mut::<RenderEntityMap>().scrollbar = next;
1062}
1063
1064/// Render-world snapshot of the set of MAIN-world entities that are hidden by
1065/// a [`Visible(false)`] on themselves or any ancestor (CSS `visibility:
1066/// hidden` semantics - the box keeps its layout space but paints nothing).
1067///
1068/// Written every extract phase by [`stash_hidden_entities`] and consumed by
1069/// the [`cull_hidden`] prepare system, which despawns any [`RenderEntityMap`]
1070/// entry whose owning main entity is hidden. This is the general guarantee
1071/// that a hidden subtree contributes ZERO paint nodes regardless of which
1072/// extractor emitted it: the per-extractor `hidden_entities` filters keep
1073/// hidden content from ever being extracted (the fast path), and this guard
1074/// catches anything an extractor forgets - so no extractor, core or plugin,
1075/// can leak a hidden subtree into the scene.
1076#[derive(Resource, Default, Debug)]
1077pub struct HiddenExtracts(pub std::collections::HashSet<Entity>);
1078
1079/// Extract fn: recompute the hidden-subtree set from the main-world hierarchy
1080/// and mirror it into the render world for [`cull_hidden`].
1081///
1082/// Registered as the FIRST default extract so it primes the shared
1083/// [`ExtractContextCache`] hierarchy memos (`parents`, `hidden`) that every
1084/// following extractor reuses within the same phase.
1085pub fn stash_hidden_entities(main: &mut World, render: &mut World) {
1086    let (parents, _) = build_parent_map(main);
1087    let hidden = hidden_entities(main, &parents);
1088    render.resource_mut::<HiddenExtracts>().0 = hidden;
1089}
1090
1091/// Prepare-stage guard that despawns every extracted render entity whose
1092/// owning main entity is hidden (per [`HiddenExtracts`]), then drops those
1093/// entries from [`RenderEntityMap`] so the next frame's keyed upserts start
1094/// clean. Runs before [`crate::node_ir::transform_extracted_to_nodes`] so no
1095/// hidden leaf reaches the retained tree.
1096///
1097/// Belt-and-suspenders behind the per-extractor `hidden_entities` filters:
1098/// with every current extractor filtering, this system finds nothing to do
1099/// on the common path; it exists so a future extractor that forgets the
1100/// filter still cannot paint a hidden subtree.
1101pub fn cull_hidden(
1102    mut commands: Commands,
1103    hidden: Res<HiddenExtracts>,
1104    mut map: ResMut<RenderEntityMap>,
1105) {
1106    if hidden.0.is_empty() {
1107        return;
1108    }
1109    let h = &hidden.0;
1110    let mut victims: Vec<Entity> = Vec::new();
1111    // Reborrow through `DerefMut` once so the per-field borrows below are seen
1112    // as disjoint (a `ResMut` smart pointer borrows the whole resource).
1113    let map = &mut *map;
1114    for m in [
1115        &mut map.rect,
1116        &mut map.text,
1117        &mut map.outline,
1118        &mut map.border,
1119        &mut map.image,
1120        &mut map.svg,
1121        &mut map.clip,
1122        &mut map.scrollbar,
1123    ] {
1124        m.retain(|main_e, render_e| {
1125            if h.contains(main_e) {
1126                victims.push(*render_e);
1127                false
1128            } else {
1129                true
1130            }
1131        });
1132    }
1133    // Plugin leaves key on their extension id as well as the entity.
1134    map.native.retain(|(_, main_e), render_e| {
1135        if h.contains(main_e) {
1136            victims.push(*render_e);
1137            false
1138        } else {
1139            true
1140        }
1141    });
1142    // Shadows map one main entity to a stack of render entities.
1143    map.shadow.retain(|main_e, render_es| {
1144        if h.contains(main_e) {
1145            victims.extend(render_es.iter().copied());
1146            false
1147        } else {
1148            true
1149        }
1150    });
1151    for e in victims {
1152        commands.entity(e).despawn();
1153    }
1154}
1155
1156/// Despawns extracted entities whose AABB lies fully outside the [`Viewport`].
1157///
1158/// - Runs in [`RenderStage::Prepare`] (registered automatically by `App::new`).
1159/// - Performs only an AABB test against the viewport; partial-overlap clipping is the renderer's responsibility.
1160pub fn cull_offscreen(
1161    mut commands: Commands,
1162    viewport: Res<Viewport>,
1163    rects: Query<(Entity, &ExtractedRect)>,
1164    texts: Query<(Entity, &ExtractedText)>,
1165    natives: Query<(Entity, &crate::native::ExtractedNative)>,
1166) {
1167    let vw = viewport.size.x;
1168    let vh = viewport.size.y;
1169    // Plugin leaves declare their bounds, so they cull on the same AABB test as rects. Without it
1170    // an off-screen leaf keeps calling its painter and keeps reporting damage outside the window.
1171    for (e, n) in &natives {
1172        if n.bounds.origin.x + n.bounds.size.x <= 0.0
1173            || n.bounds.origin.y + n.bounds.size.y <= 0.0
1174            || n.bounds.origin.x >= vw
1175            || n.bounds.origin.y >= vh
1176        {
1177            commands.entity(e).despawn();
1178        }
1179    }
1180    for (e, r) in &rects {
1181        if r.origin.x + r.size.x <= 0.0
1182            || r.origin.y + r.size.y <= 0.0
1183            || r.origin.x >= vw
1184            || r.origin.y >= vh
1185        {
1186            commands.entity(e).despawn();
1187        }
1188    }
1189    for (e, t) in &texts {
1190        // Approximate the text bounds with `size_px * char_count + 1` for width and `size_px` for height.
1191        let h = t.size_px;
1192        let w = t.size_px * (t.text.chars().count() as f32 + 1.0);
1193        if t.origin.x + w <= 0.0
1194            || t.origin.y + h <= 0.0
1195            || t.origin.x >= vw
1196            // Text uses the same top-left AABB convention as rects: cull below
1197            // when the top edge is at/under the viewport bottom. Previously
1198            // `origin.y - h >= vh` treated `origin` as a baseline only for the
1199            // below test, keeping text just past the bottom alive and reshaping
1200            // it every frame during a scroll.
1201            || t.origin.y >= vh
1202        {
1203            commands.entity(e).despawn();
1204        }
1205    }
1206}
1207
1208// `lumen-assets::extract_loaded_images` provides the image extract fn and is registered externally.
1209
1210/// Persistent map from a main-world entity to its render-world entity for each `Extracted*` type.
1211/// Upserting extracts read the prior map, update render entities in place, and write the new map back; legacy despawn-and-respawn extracts leave their slot untouched.
1212#[derive(Resource, Default, Debug)]
1213pub struct RenderEntityMap {
1214    /// `main_entity -> render_entity` for [`ExtractedRect`].
1215    pub rect: std::collections::HashMap<Entity, Entity>,
1216    /// `main_entity -> render_entity` for [`ExtractedText`].
1217    pub text: std::collections::HashMap<Entity, Entity>,
1218    /// `main_entity -> Vec<render_entity>` for [`ExtractedShadow`]; one main entity can carry multiple stacked shadows.
1219    pub shadow: std::collections::HashMap<Entity, Vec<Entity>>,
1220    /// `main_entity -> render_entity` for [`ExtractedOutline`].
1221    pub outline: std::collections::HashMap<Entity, Entity>,
1222    /// `main_entity -> render_entity` for [`ExtractedBorder`].
1223    pub border: std::collections::HashMap<Entity, Entity>,
1224    /// `main_entity -> render_entity` for [`ExtractedImage`].
1225    pub image: std::collections::HashMap<Entity, Entity>,
1226    /// `main_entity -> render_entity` for `lumen_assets::ExtractedSvg`. Stores only `Entity` ids so the core crate avoids a vello dependency.
1227    pub svg: std::collections::HashMap<Entity, Entity>,
1228    /// `main_entity -> render_entity` for [`ExtractedClipBox`]; one entry per scrollable or overflow-hidden container.
1229    pub clip: std::collections::HashMap<Entity, Entity>,
1230    /// `main_entity -> render_entity` for [`ExtractedOpacityGroup`]; one entry per faded element with
1231    /// children.
1232    pub opacity_group: std::collections::HashMap<Entity, Entity>,
1233    /// `main_entity -> render_entity` for [`ExtractedScrollbar`]; one
1234    /// entry per scroll container with visible overlay bars.
1235    pub scrollbar: std::collections::HashMap<Entity, Entity>,
1236    /// `(extension_id, main_entity) -> render_entity` for
1237    /// [`crate::native::ExtractedNative`]. Keyed by extension as well as entity so two plugins
1238    /// painting in the same frame keep separate sets: each retires only the leaves it produced.
1239    /// Maintained by [`crate::native::upsert_native_leaves`].
1240    pub native: std::collections::HashMap<(std::sync::Arc<str>, Entity), Entity>,
1241}
1242
1243/// Despawns transient render-world entities carrying `Extracted*` components, called once per frame before any [`ExtractFn`] runs.
1244///
1245/// - Entities listed in [`RenderEntityMap`] are preserved; their owning extract upserts them in place.
1246/// - Resources and non-send resources (vello scenes, GPU caches) are unaffected.
1247pub fn clear_extracted(render: &mut World) {
1248    let upserted: std::collections::HashSet<Entity> = {
1249        let map = render.resource::<RenderEntityMap>();
1250        let mut set: std::collections::HashSet<Entity> =
1251            std::collections::HashSet::with_capacity(map.rect.len() + map.shadow.len());
1252        set.extend(map.rect.values().copied());
1253        set.extend(map.shadow.values().flatten().copied());
1254        set.extend(map.text.values().copied());
1255        set.extend(map.outline.values().copied());
1256        set.extend(map.border.values().copied());
1257        set.extend(map.clip.values().copied());
1258        set.extend(map.opacity_group.values().copied());
1259        set.extend(map.image.values().copied());
1260        set.extend(map.svg.values().copied());
1261        set.extend(map.scrollbar.values().copied());
1262        set.extend(map.native.values().copied());
1263        set
1264    };
1265    let to_despawn: Vec<Entity> = render
1266        .query_filtered::<Entity, Or<(
1267            With<ExtractedRect>,
1268            With<ExtractedText>,
1269            With<ExtractedImage>,
1270            With<ExtractedOutline>,
1271            With<ExtractedBorder>,
1272            With<ExtractedShadow>,
1273            With<ExtractedScrollbar>,
1274            With<crate::native::ExtractedNative>,
1275        )>>()
1276        .iter(render)
1277        .filter(|e| !upserted.contains(e))
1278        .collect();
1279    for e in to_despawn {
1280        render.despawn(e);
1281    }
1282}
1283
1284/// Extract fn that emits one [`ExtractedShadow`] per entry in [`Visuals::shadows`].
1285/// A drop shadow is placed at `rect.order - 1 + idx` so it paints under the source rect and stacked shadows keep source order.
1286/// An inset shadow takes the rect's own order: it paints over the background and under the border, as CSS draws it.
1287pub fn extract_shadows(main: &mut World, render: &mut World) {
1288    let (parents, mut depth_cache) = build_parent_map(main);
1289    let hidden = hidden_entities(main, &parents);
1290    let scroll = parent_scroll_offsets(main, &parents);
1291    let alphas = opacity_model(main, &parents).draw;
1292    let mut q = main.query::<(Entity, &Transform, &Visuals)>();
1293    // Group shadows by main entity so the upsert can grow or shrink each entity's render-side set.
1294    let mut groups: std::collections::HashMap<Entity, Vec<ExtractedShadow>> =
1295        std::collections::HashMap::new();
1296    for (e, t, v) in q.iter(main) {
1297        if hidden.contains(&e) || v.shadows.is_empty() {
1298            continue;
1299        }
1300        let alpha = draw_opacity(&alphas, e);
1301        let off = scroll.get(&e).copied().unwrap_or(Vec2::ZERO);
1302        let own_order = paint_order_of(e, &parents, &mut depth_cache);
1303        let base_order = own_order.saturating_sub(1);
1304        let mut entries = Vec::with_capacity(v.shadows.len());
1305        let mut outer_idx = 0u32;
1306        for s in &v.shadows {
1307            let order = if s.inner {
1308                own_order
1309            } else {
1310                outer_idx += 1;
1311                base_order.saturating_add(outer_idx - 1)
1312            };
1313            entries.push(ExtractedShadow {
1314                origin: Vec2::new(t.absolute.x + s.offset_x, t.absolute.y + s.offset_y) - off,
1315                size: t.size,
1316                radius: v.radius,
1317                spread: s.spread,
1318                blur: s.blur,
1319                color: alpha.apply(s.color),
1320                order,
1321                inner: s.inner,
1322                rect_origin: t.absolute - off,
1323            });
1324        }
1325        groups.insert(e, entries);
1326    }
1327
1328    // Keyed-upsert against `RenderEntityMap.shadow` (`main -> Vec<render>`).
1329    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().shadow);
1330    let mut next: std::collections::HashMap<Entity, Vec<Entity>> =
1331        std::collections::HashMap::with_capacity(groups.len());
1332    for (main_e, shadows) in groups {
1333        // Filter prior slots by current render-world validity to drop recycled entity indices.
1334        let mut slots: Vec<Entity> = prior
1335            .get(&main_e)
1336            .cloned()
1337            .unwrap_or_default()
1338            .into_iter()
1339            .filter(|&re| render.get_entity(re).is_ok())
1340            .collect();
1341        while slots.len() < shadows.len() {
1342            slots.push(
1343                render
1344                    .spawn(ExtractedShadow {
1345                        origin: Vec2::ZERO,
1346                        size: Vec2::ZERO,
1347                        radius: 0.0,
1348                        spread: 0.0,
1349                        blur: 0.0,
1350                        color: Color::rgba(0.0, 0.0, 0.0, 0.0),
1351                        order: 0,
1352                        inner: false,
1353                        rect_origin: Vec2::ZERO,
1354                    })
1355                    .id(),
1356            );
1357        }
1358        while slots.len() > shadows.len() {
1359            let drop = slots.pop().unwrap();
1360            if let Ok(em) = render.get_entity_mut(drop) {
1361                em.despawn();
1362            }
1363        }
1364        for (re, s) in slots.iter().copied().zip(shadows) {
1365            render.entity_mut(re).insert(s);
1366        }
1367        next.insert(main_e, slots);
1368    }
1369    // Despawn the entire render-side stack for main entities not present in `next`.
1370    for (main_e, slots) in &prior {
1371        if !next.contains_key(main_e) {
1372            for re in slots {
1373                if let Ok(em) = render.get_entity_mut(*re) {
1374                    em.despawn();
1375                }
1376            }
1377        }
1378    }
1379    render.resource_mut::<RenderEntityMap>().shadow = next;
1380}
1381
1382/// Snap a box's edges to the device pixel grid, as browsers do before
1383/// painting backgrounds. Each edge rounds on its own, so two boxes that
1384/// share an edge still share it after snapping: a scroll offset or a layout
1385/// position with a fraction no longer leaves the antialiased seam between
1386/// adjacent backgrounds through which whatever is behind them shows. A box
1387/// with any width or height keeps at least one device pixel of it.
1388pub fn snap_to_device_pixels(origin: Vec2, size: Vec2, scale: f32) -> (Vec2, Vec2) {
1389    let axis = |start: f32, extent: f32| {
1390        let s0 = (start * scale).round() / scale;
1391        let mut s1 = ((start + extent) * scale).round() / scale;
1392        if extent > 0.0 && s1 <= s0 {
1393            s1 = s0 + 1.0 / scale;
1394        }
1395        (s0, (s1 - s0).max(0.0))
1396    };
1397    let (x, w) = axis(origin.x, size.x);
1398    let (y, h) = axis(origin.y, size.y);
1399    (Vec2::new(x, y), Vec2::new(w, h))
1400}
1401
1402/// Default extract fn that emits one [`ExtractedRect`] per main-world entity carrying a [`Transform`] and a [`Visuals::fill`].
1403///
1404/// - Paints in deterministic order via [`PaintOrder`]: pre-order document/tree order.
1405/// - Skips entities whose AABB falls fully outside the nearest scroll / overflow-hidden ancestor.
1406/// - Snaps each rect's edges to device pixels ([`snap_to_device_pixels`]).
1407pub fn extract_rects(main: &mut World, render: &mut World) {
1408    let scale = main
1409        .get_resource::<Viewport>()
1410        .map(|v| v.scale_factor)
1411        .filter(|s| s.is_finite() && *s > 0.0)
1412        .unwrap_or(1.0);
1413    let (parents, mut depth_cache) = build_parent_map(main);
1414    let hidden = hidden_entities(main, &parents);
1415    let scroll = parent_scroll_offsets(main, &parents);
1416    let alphas = opacity_model(main, &parents).draw;
1417    let clip = parent_scroll_clip_rects(main, &parents);
1418    let mut q = main.query::<(Entity, &Transform, &Visuals)>();
1419    let pairs: Vec<(Entity, ExtractedRect)> = q
1420        .iter(main)
1421        .filter(|(e, _, _)| !hidden.contains(e))
1422        .filter_map(|(e, t, v)| {
1423            let alpha = draw_opacity(&alphas, e);
1424            let brush = Brush::from(v.fill.as_ref()?).with_opacity(alpha);
1425            let off = scroll.get(&e).copied().unwrap_or(Vec2::ZERO);
1426            let origin = t.absolute - off;
1427            // Drop entities whose AABB is fully outside the nearest scroll / overflow-hidden ancestor's clip rect.
1428            if let Some(clip_rect) = clip.get(&e)
1429                && aabb_outside(origin, t.size, *clip_rect)
1430            {
1431                return None;
1432            }
1433            let (origin, size) = snap_to_device_pixels(origin, t.size, scale);
1434            Some((
1435                e,
1436                ExtractedRect {
1437                    origin,
1438                    size,
1439                    brush,
1440                    radius: v.radius,
1441                    corner_radii: v.corner_radii,
1442                    order: paint_order_of(e, &parents, &mut depth_cache),
1443                },
1444            ))
1445        })
1446        .collect();
1447
1448    // Keyed-upsert against `RenderEntityMap.rect`.
1449    // `mem::take` releases the resource borrow so the render entities can be mutated below.
1450    // Each prior render entity is re-validated; a recycled id is treated as a miss and spawned fresh.
1451    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().rect);
1452    let mut next: std::collections::HashMap<Entity, Entity> =
1453        std::collections::HashMap::with_capacity(pairs.len());
1454    for (main_e, rect) in pairs {
1455        let reuse = prior
1456            .get(&main_e)
1457            .copied()
1458            .filter(|&re| render.get_entity(re).is_ok());
1459        let render_e = match reuse {
1460            Some(re) => {
1461                render.entity_mut(re).insert(rect);
1462                re
1463            }
1464            None => render.spawn(rect).id(),
1465        };
1466        next.insert(main_e, render_e);
1467    }
1468    // Despawn render entities whose main entity is no longer in `next`; validate first to avoid panicking on recycled ids.
1469    for (main_e, render_e) in &prior {
1470        if !next.contains_key(main_e)
1471            && let Ok(em) = render.get_entity_mut(*render_e)
1472        {
1473            em.despawn();
1474        }
1475    }
1476    render.resource_mut::<RenderEntityMap>().rect = next;
1477}
1478
1479/// Extract fn that emits one [`ExtractedBorder`] per main-world entity
1480/// carrying a [`Transform`] and a [`Visuals::border`]. A border paints
1481/// even when the entity has no background fill (CSS: `background: none;
1482/// border: 1px solid ...` still draws the border). Logical border edges
1483/// (`border-inline-*`) are resolved against the entity's
1484/// [`crate::components::ResolvedDirection`].
1485pub fn extract_borders(main: &mut World, render: &mut World) {
1486    use crate::components::ResolvedDirection;
1487    let (parents, mut depth_cache) = build_parent_map(main);
1488    let hidden = hidden_entities(main, &parents);
1489    let scroll = parent_scroll_offsets(main, &parents);
1490    let alphas = opacity_model(main, &parents).draw;
1491    let clip = parent_scroll_clip_rects(main, &parents);
1492    type Row<'a> = (
1493        Entity,
1494        &'a Transform,
1495        &'a Visuals,
1496        Option<&'a ResolvedDirection>,
1497    );
1498    let mut q = main.query::<Row>();
1499    let pairs: Vec<(Entity, ExtractedBorder)> = q
1500        .iter(main)
1501        .filter(|(e, _, _, _)| !hidden.contains(e))
1502        .filter_map(|(e, t, v, dir)| {
1503            let border = v.border.as_ref()?;
1504            let widths = border
1505                .widths
1506                .resolved(dir.map(|d| d.direction()).unwrap_or_default());
1507            if widths.top <= 0.0
1508                && widths.right <= 0.0
1509                && widths.bottom <= 0.0
1510                && widths.left <= 0.0
1511            {
1512                return None;
1513            }
1514            let alpha = draw_opacity(&alphas, e);
1515            let off = scroll.get(&e).copied().unwrap_or(Vec2::ZERO);
1516            let origin = t.absolute - off;
1517            if let Some(clip_rect) = clip.get(&e)
1518                && aabb_outside(origin, t.size, *clip_rect)
1519            {
1520                return None;
1521            }
1522            Some((
1523                e,
1524                ExtractedBorder {
1525                    origin,
1526                    size: t.size,
1527                    widths: [widths.top, widths.right, widths.bottom, widths.left],
1528                    color: alpha.apply(border.color),
1529                    side_colors: border.side_colors.map(|cs| cs.map(|c| alpha.apply(c))),
1530                    radius: v.radius,
1531                    corner_radii: v.corner_radii,
1532                    order: paint_order_of(e, &parents, &mut depth_cache),
1533                },
1534            ))
1535        })
1536        .collect();
1537
1538    // Keyed-upsert against `RenderEntityMap.border` - same lifecycle as
1539    // `extract_rects`.
1540    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().border);
1541    let mut next: std::collections::HashMap<Entity, Entity> =
1542        std::collections::HashMap::with_capacity(pairs.len());
1543    for (main_e, border) in pairs {
1544        let reuse = prior
1545            .get(&main_e)
1546            .copied()
1547            .filter(|&re| render.get_entity(re).is_ok());
1548        let render_e = match reuse {
1549            Some(re) => {
1550                render.entity_mut(re).insert(border);
1551                re
1552            }
1553            None => render.spawn(border).id(),
1554        };
1555        next.insert(main_e, render_e);
1556    }
1557    for (main_e, render_e) in &prior {
1558        if !next.contains_key(main_e)
1559            && let Ok(em) = render.get_entity_mut(*render_e)
1560        {
1561            em.despawn();
1562        }
1563    }
1564    render.resource_mut::<RenderEntityMap>().border = next;
1565}
1566
1567/// Returns the nearest clipping-ancestor rect (origin and size in window coordinates) for every entity.
1568///
1569/// - A clipping ancestor is one carrying a [`crate::input::Scroll`] component or a [`crate::components::Style`] with `overflow_x` or `overflow_y` set to [`crate::components::Overflow::Hidden`].
1570/// - Entities without a clipping ancestor are absent from the returned map.
1571/// - Rects are on-screen: a clipper nested inside a scrolled container is shifted by that container's
1572///   [`crate::input::ScrollOffset`], the same shift every leaf under it gets (see [`parent_scroll_offsets`]).
1573///   Its own offset is not applied - a scroll box moves its content, not its own frame.
1574pub fn parent_scroll_clip_rects(
1575    main: &mut World,
1576    parents: &std::collections::HashMap<Entity, Entity>,
1577) -> std::collections::HashMap<Entity, (Vec2, Vec2)> {
1578    use crate::components::{Overflow, Style, Transform};
1579    use crate::input::Scroll;
1580    if let Some(c) = main.get_resource::<ExtractContextCache>()
1581        && c.active
1582        && let Some(v) = &c.clip
1583    {
1584        return v.clone();
1585    }
1586    let offsets = parent_scroll_offsets(main, parents);
1587    // Per-clipper rect (origin, size) in window coords.
1588    let clippers: std::collections::HashMap<Entity, (Vec2, Vec2)> = {
1589        let mut q = main.query::<(Entity, &Transform, &Style, Option<&Scroll>)>();
1590        q.iter(main)
1591            .filter_map(|(e, t, style, scroll)| {
1592                let qualifies = scroll.is_some()
1593                    || matches!(style.overflow_y, Overflow::Hidden)
1594                    || matches!(style.overflow_x, Overflow::Hidden);
1595                let off = offsets.get(&e).copied().unwrap_or(Vec2::ZERO);
1596                qualifies.then_some((e, (t.absolute - off, t.size)))
1597            })
1598            .collect()
1599    };
1600    if clippers.is_empty() {
1601        if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
1602            && c.active
1603        {
1604            c.clip = Some(std::collections::HashMap::new());
1605        }
1606        return std::collections::HashMap::new();
1607    }
1608    // Top-layer roots escape ancestor clips (browser top-layer semantics): the upward walk stops at
1609    // an [`OverlayLayer`] entity, so popup content is neither culled nor clipped by a scroll /
1610    // overflow-hidden ancestor outside the popup. Clippers inside the popup subtree still apply.
1611    let overlay: std::collections::HashSet<Entity> = {
1612        let mut oq = main.query_filtered::<Entity, With<OverlayLayer>>();
1613        oq.iter(main).collect()
1614    };
1615    let mut out: std::collections::HashMap<Entity, (Vec2, Vec2)> = std::collections::HashMap::new();
1616    let mut cache: std::collections::HashMap<Entity, Option<(Vec2, Vec2)>> =
1617        std::collections::HashMap::new();
1618    fn resolve(
1619        e: Entity,
1620        parents: &std::collections::HashMap<Entity, Entity>,
1621        clippers: &std::collections::HashMap<Entity, (Vec2, Vec2)>,
1622        overlay: &std::collections::HashSet<Entity>,
1623        cache: &mut std::collections::HashMap<Entity, Option<(Vec2, Vec2)>>,
1624    ) -> Option<(Vec2, Vec2)> {
1625        if let Some(v) = cache.get(&e) {
1626            return *v;
1627        }
1628        let result = if overlay.contains(&e) {
1629            // Top-layer root: no clip ancestor applies past this boundary.
1630            None
1631        } else if let Some(p) = parents.get(&e).copied() {
1632            if let Some(rect) = clippers.get(&p) {
1633                Some(*rect)
1634            } else {
1635                resolve(p, parents, clippers, overlay, cache)
1636            }
1637        } else {
1638            None
1639        };
1640        cache.insert(e, result);
1641        result
1642    }
1643    for &e in parents.keys() {
1644        if let Some(rect) = resolve(e, parents, &clippers, &overlay, &mut cache) {
1645            out.insert(e, rect);
1646        }
1647    }
1648    if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
1649        && c.active
1650    {
1651        c.clip = Some(out.clone());
1652    }
1653    out
1654}
1655
1656/// Returns `true` when the rect at `(origin, size)` lies fully outside `clip = (corigin, csize)`;
1657/// the two AABBs share no area at all.
1658///
1659/// Partially-visible content must not be culled here: the clip layer emitted by
1660/// [`extract_clips`] (vello push/pop) trims the overflowing part at paint time. The previous
1661/// any-part-outside test made every child that overhangs its scroll container by even one
1662/// pixel - e.g. `width: 100%` plus a horizontal margin, or a row straddling the container's
1663/// bottom edge - vanish entirely (W6 T2, the invisible counter tiles).
1664pub(crate) fn aabb_outside(origin: Vec2, size: Vec2, clip: (Vec2, Vec2)) -> bool {
1665    let (co, cs) = clip;
1666    origin.x + size.x <= co.x
1667        || origin.y + size.y <= co.y
1668        || origin.x >= co.x + cs.x
1669        || origin.y >= co.y + cs.y
1670}
1671
1672/// Extracts one [`ExtractedClipBox`] per clipping entity (carrying [`crate::input::Scroll`] or `Style.overflow_x` / `overflow_y == Hidden`).
1673/// Each emission carries the containing rect plus the `(start_order, end_order)` range bracketing descendant paints so the renderer can push/pop a vello layer.
1674///
1675/// The rect is where the box lands on screen: a clipper inside a scrolled container carries that
1676/// container's [`crate::input::ScrollOffset`], exactly like the leaves it clips (see
1677/// [`parent_scroll_offsets`]). Its own scroll offset stays out - that one moves its content.
1678///
1679/// W2.3 wires this back into the default extract chain - the boxes feed [`crate::node_ir::Node::Clip`]
1680/// wrappers inside [`crate::node_ir::transform_extracted_to_nodes`].
1681pub fn extract_clips(main: &mut World, render: &mut World) {
1682    use crate::components::{Overflow, Style, Transform};
1683    use crate::input::Scroll;
1684    let (parents, mut depth_cache) = build_parent_map(main);
1685    let hidden = hidden_entities(main, &parents);
1686    let offsets = parent_scroll_offsets(main, &parents);
1687    // Invert the parent map into a child lookup so descendant orders can be computed.
1688    let mut children: std::collections::HashMap<Entity, Vec<Entity>> =
1689        std::collections::HashMap::new();
1690    for (&e, &p) in parents.iter() {
1691        children.entry(p).or_default().push(e);
1692    }
1693    // Collect candidate entities (scroll or overflow-hidden).
1694    let mut candidates: Vec<Entity> = Vec::new();
1695    {
1696        let mut q = main.query::<(Entity, &Style, Option<&Scroll>)>();
1697        for (e, style, scroll) in q.iter(main) {
1698            if hidden.contains(&e) {
1699                continue;
1700            }
1701            let clip_y = matches!(style.overflow_y, Overflow::Hidden) || scroll.is_some();
1702            let clip_x = matches!(style.overflow_x, Overflow::Hidden) || scroll.is_some();
1703            if clip_x || clip_y {
1704                candidates.push(e);
1705            }
1706        }
1707    }
1708    if candidates.is_empty() {
1709        // No candidates this frame; drop any leftover clip render entities.
1710        let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().clip);
1711        for re in prior.values() {
1712            if let Ok(em) = render.get_entity_mut(*re) {
1713                em.despawn();
1714            }
1715        }
1716        return;
1717    }
1718    // Materialise a `Entity -> Transform` lookup so the DFS below need not re-query the world.
1719    let transforms: std::collections::HashMap<Entity, Transform> = {
1720        let mut q = main.query::<(Entity, &Transform)>();
1721        q.iter(main).map(|(e, t)| (e, *t)).collect()
1722    };
1723    // Top-layer roots: their subtrees live in the overlay band and must not extend an outer clip's
1724    // bracket (the popup escapes ancestor clips; its own internal clips are separate candidates).
1725    let overlay: std::collections::HashSet<Entity> = {
1726        let mut oq = main.query_filtered::<Entity, With<OverlayLayer>>();
1727        oq.iter(main).collect()
1728    };
1729    let pairs: Vec<(Entity, ExtractedClipBox)> = candidates
1730        .into_iter()
1731        .filter_map(|e| {
1732            let t = transforms.get(&e).copied()?;
1733            let own = paint_order_of(e, &parents, &mut depth_cache);
1734            let end = max_desc_order(e, &children, &parents, &overlay, &mut depth_cache);
1735            let radius = main
1736                .get::<crate::components::Visuals>(e)
1737                .map(|v| v.radius)
1738                .unwrap_or(0.0);
1739            let off = offsets.get(&e).copied().unwrap_or(Vec2::ZERO);
1740            Some((
1741                e,
1742                ExtractedClipBox {
1743                    origin: t.absolute - off,
1744                    size: t.size,
1745                    radius,
1746                    start_order: own,
1747                    end_order: end,
1748                },
1749            ))
1750        })
1751        .collect();
1752    // Keyed-upsert against `RenderEntityMap.clip`.
1753    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().clip);
1754    let mut next: std::collections::HashMap<Entity, Entity> =
1755        std::collections::HashMap::with_capacity(pairs.len());
1756    for (main_e, clip) in pairs {
1757        let reuse = prior
1758            .get(&main_e)
1759            .copied()
1760            .filter(|&re| render.get_entity(re).is_ok());
1761        let render_e = match reuse {
1762            Some(re) => {
1763                render.entity_mut(re).insert(clip);
1764                re
1765            }
1766            None => render.spawn(clip).id(),
1767        };
1768        next.insert(main_e, render_e);
1769    }
1770    for (main_e, render_e) in &prior {
1771        if !next.contains_key(main_e)
1772            && let Ok(em) = render.get_entity_mut(*render_e)
1773        {
1774            em.despawn();
1775        }
1776    }
1777    render.resource_mut::<RenderEntityMap>().clip = next;
1778}
1779
1780/// Returns `(parent_map, document_order_map)` used by every extract fn to compute [`PaintOrder`] consistently.
1781///
1782/// - `parent_map`: `child -> parent` for every entity carrying [`bevy_ecs::hierarchy::ChildOf`].
1783/// - `document_order_map`: entity -> [`PaintOrder`], assigned by a pre-order DFS over the hierarchy
1784///   forest. Parents rank before their children; siblings rank in `Children`-list order (bevy keeps the
1785///   list in insertion order, which matches markup order for static spawns and reconcile order for
1786///   runtime `<if>` / `<for>` clones). Entity-id allocation order plays no part, so entities spawned out
1787///   of document order (children before parents, reconciler respawns) still paint in tree order and
1788///   [`ExtractedClipBox`] ranges bracket exactly the descendant set.
1789///
1790/// Ranks are multiplied by 2 (see [`PaintOrder`]) so [`extract_shadows`] can place shadows at
1791/// `order - 1` without colliding with the preceding leaf. Consume the maps via [`paint_order_of`].
1792///
1793/// Subtrees rooted at an [`OverlayLayer`] entity are excluded from the normal-band DFS and
1794/// re-ranked into the top-layer band (`>= OVERLAY_ORDER_BASE`), stacked among themselves by
1795/// [`OverlayOpenOrder`] stamp (later-opened on top), each keeping contiguous internal pre-order
1796/// ranks. Idempotent within a frame: repeated calls (one per extract fn) see the same visibility
1797/// state and hand out the same ranks.
1798pub fn build_parent_map(
1799    main: &mut World,
1800) -> (
1801    std::collections::HashMap<Entity, Entity>,
1802    std::collections::HashMap<Entity, u32>,
1803) {
1804    // Extract-phase reuse: the first extractor of a frame builds this,
1805    // the rest clone it back (see [`ExtractContextCache`]).
1806    if let Some(c) = main.get_resource::<ExtractContextCache>()
1807        && c.active
1808        && let Some(pm) = &c.parent_map
1809    {
1810        return pm.clone();
1811    }
1812    use bevy_ecs::hierarchy::{ChildOf, Children};
1813    let mut pq = main.query::<(Entity, &ChildOf)>();
1814    let parents: std::collections::HashMap<Entity, Entity> =
1815        pq.iter(main).map(|(e, p)| (e, p.parent())).collect();
1816    // CSS `z-index`: paint-order override among siblings. Entities
1817    // without the component are `auto` (= 0, document order).
1818    let z_of: std::collections::HashMap<Entity, i32> = {
1819        let mut zq = main.query::<(Entity, &crate::components::ZIndex)>();
1820        zq.iter(main).map(|(e, z)| (e, z.0)).collect()
1821    };
1822    // Ordered child lists straight from the bevy-maintained `Children` relationship target.
1823    // Each list is stable-sorted by `(z_index, document order)` so a
1824    // higher-z sibling (with its whole subtree) receives later pre-order
1825    // ranks and paints on top - CSS stacking within one parent context.
1826    let children: std::collections::HashMap<Entity, Vec<Entity>> = {
1827        let mut cq = main.query::<(Entity, &Children)>();
1828        cq.iter(main)
1829            .map(|(e, kids)| {
1830                let mut list: Vec<Entity> = kids.iter().collect();
1831                if !z_of.is_empty() {
1832                    list.sort_by_key(|c| z_of.get(c).copied().unwrap_or(0));
1833                }
1834                (e, list)
1835            })
1836            .collect()
1837    };
1838    // Top-layer roots: their subtrees are skipped by the normal DFS and re-banded below.
1839    let overlay_roots: Vec<Entity> = {
1840        let mut oq = main.query_filtered::<Entity, With<OverlayLayer>>();
1841        oq.iter(main).collect()
1842    };
1843    let overlay_set: std::collections::HashSet<Entity> = overlay_roots.iter().copied().collect();
1844    // Forest roots: entities that have children but no parent. Sorted by entity bits for determinism
1845    // (real apps have a single markup root; extra roots only occur in tests / embedder worlds).
1846    let mut roots: Vec<Entity> = children
1847        .keys()
1848        .copied()
1849        .filter(|e| !parents.contains_key(e))
1850        .collect();
1851    roots.sort_by_key(|e| e.to_bits());
1852    // Pre-order DFS assigning stride-2 document-order ranks. Overlay roots are neither ranked nor
1853    // descended into here - their subtrees land in the top-layer band instead.
1854    let mut order: std::collections::HashMap<Entity, u32> =
1855        std::collections::HashMap::with_capacity(parents.len() + roots.len());
1856    let mut stack: Vec<Entity> = roots
1857        .into_iter()
1858        .rev()
1859        .filter(|e| !overlay_set.contains(e))
1860        .collect();
1861    let mut rank: u32 = 0;
1862    while let Some(e) = stack.pop() {
1863        order.insert(e, rank.saturating_mul(2));
1864        rank = rank.saturating_add(1);
1865        if let Some(kids) = children.get(&e) {
1866            stack.extend(
1867                kids.iter()
1868                    .rev()
1869                    .copied()
1870                    .filter(|k| !overlay_set.contains(k)),
1871            );
1872        }
1873    }
1874    if !overlay_roots.is_empty() {
1875        rank_overlay_band(
1876            main,
1877            &parents,
1878            &children,
1879            &overlay_roots,
1880            &overlay_set,
1881            &mut order,
1882        );
1883    }
1884    let result = (parents, order);
1885    if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
1886        && c.active
1887    {
1888        c.parent_map = Some(result.clone());
1889    }
1890    result
1891}
1892
1893/// The maximum [`PaintOrder`] across `root` and its descendants, by DFS over `children`, not descending
1894/// into nested [`OverlayLayer`] roots: their ranks sit in the top-layer band and would wrongly stretch a
1895/// bracket across all the content between.
1896fn max_desc_order(
1897    root: Entity,
1898    children: &HashMap<Entity, Vec<Entity>>,
1899    parents: &HashMap<Entity, Entity>,
1900    overlay: &std::collections::HashSet<Entity>,
1901    depth_cache: &mut HashMap<Entity, u32>,
1902) -> PaintOrder {
1903    let mut stack = vec![root];
1904    let mut best = paint_order_of(root, parents, depth_cache);
1905    while let Some(n) = stack.pop() {
1906        let order = paint_order_of(n, parents, depth_cache);
1907        if order > best {
1908            best = order;
1909        }
1910        if let Some(kids) = children.get(&n) {
1911            for &k in kids {
1912                if !overlay.contains(&k) {
1913                    stack.push(k);
1914                }
1915            }
1916        }
1917    }
1918    best
1919}
1920
1921/// How [`Opacity`] reaches the paints of one frame: the alpha each entity's own paints carry, and the
1922/// elements whose subtree composites as one layer ([`ExtractedOpacityGroup`]).
1923#[derive(Clone, Debug, Default)]
1924pub struct OpacityModel {
1925    /// The alpha to fold into each entity's own paints, for the entities where it is below one.
1926    pub draw: HashMap<Entity, f32>,
1927    /// The elements that fade as a group, with their own opacity.
1928    pub groups: Vec<(Entity, f32)>,
1929}
1930
1931/// Resolves CSS `opacity` for the frame.
1932///
1933/// An element with [`Opacity`] below one and a visible child in its own paint band is a group: it and
1934/// its subtree paint at full alpha into a layer that fades once ([`ExtractedOpacityGroup`]). Every other
1935/// faded element folds its opacity into its own paints. A paint inside a group therefore carries none
1936/// of that group's alpha. The one way out of an ancestor's group is an [`OverlayLayer`] root, whose
1937/// subtree paints in the top-layer band outside the group's bracket; its paints carry the opacity of
1938/// every element above it instead. Returns an empty model when no entity carries an [`Opacity`] (the
1939/// overwhelmingly common case, so extracts skip the lookup entirely).
1940pub fn opacity_model(main: &mut World, parents: &HashMap<Entity, Entity>) -> OpacityModel {
1941    if let Some(c) = main.get_resource::<ExtractContextCache>()
1942        && c.active
1943        && let Some(v) = &c.opacities
1944    {
1945        return v.clone();
1946    }
1947    let direct: HashMap<Entity, f32> = {
1948        let mut q = main.query::<(Entity, &Opacity)>();
1949        q.iter(main)
1950            .filter(|(_, o)| o.0 < 1.0)
1951            .map(|(e, o)| (e, o.0.max(0.0)))
1952            .collect()
1953    };
1954    let model = if direct.is_empty() {
1955        OpacityModel::default()
1956    } else {
1957        let hidden = hidden_entities(main, parents);
1958        let overlay: std::collections::HashSet<Entity> = {
1959            let mut q = main.query_filtered::<Entity, With<OverlayLayer>>();
1960            q.iter(main).collect()
1961        };
1962        let mut children: HashMap<Entity, Vec<Entity>> = HashMap::new();
1963        for (&e, &p) in parents {
1964            children.entry(p).or_default().push(e);
1965        }
1966        let mut groups: Vec<(Entity, f32)> = direct
1967            .iter()
1968            .filter(|(e, _)| !hidden.contains(e))
1969            .filter(|(e, _)| {
1970                children.get(e).is_some_and(|kids| {
1971                    kids.iter()
1972                        .any(|k| !hidden.contains(k) && !overlay.contains(k))
1973                })
1974            })
1975            .map(|(&e, &a)| (e, a))
1976            .collect();
1977        groups.sort_by_key(|(e, _)| *e);
1978        let grouped: std::collections::HashSet<Entity> = groups.iter().map(|(e, _)| *e).collect();
1979
1980        // The product of the opacity of `e` and every ancestor of it.
1981        fn chain(
1982            e: Entity,
1983            parents: &HashMap<Entity, Entity>,
1984            direct: &HashMap<Entity, f32>,
1985            memo: &mut HashMap<Entity, f32>,
1986        ) -> f32 {
1987            if let Some(v) = memo.get(&e) {
1988                return *v;
1989            }
1990            let above = parents
1991                .get(&e)
1992                .map(|p| chain(*p, parents, direct, memo))
1993                .unwrap_or(1.0);
1994            let total = above * direct.get(&e).copied().unwrap_or(1.0);
1995            memo.insert(e, total);
1996            total
1997        }
1998        // What the ancestors outside every group holding `e` contribute: nothing unless `e` sits under
1999        // an overlay root, and then the opacity of everything above the nearest one.
2000        fn outer(
2001            e: Entity,
2002            parents: &HashMap<Entity, Entity>,
2003            direct: &HashMap<Entity, f32>,
2004            overlay: &std::collections::HashSet<Entity>,
2005            chains: &mut HashMap<Entity, f32>,
2006            memo: &mut HashMap<Entity, f32>,
2007        ) -> f32 {
2008            if let Some(v) = memo.get(&e) {
2009                return *v;
2010            }
2011            let value = if overlay.contains(&e) {
2012                parents
2013                    .get(&e)
2014                    .map(|p| chain(*p, parents, direct, chains))
2015                    .unwrap_or(1.0)
2016            } else {
2017                parents
2018                    .get(&e)
2019                    .map(|p| outer(*p, parents, direct, overlay, chains, memo))
2020                    .unwrap_or(1.0)
2021            };
2022            memo.insert(e, value);
2023            value
2024        }
2025        let mut chains = HashMap::new();
2026        let mut outers = HashMap::new();
2027        let mut draw = HashMap::new();
2028        let entities: Vec<Entity> = parents
2029            .keys()
2030            .chain(parents.values())
2031            .chain(direct.keys())
2032            .copied()
2033            .collect();
2034        for e in entities {
2035            if draw.contains_key(&e) {
2036                continue;
2037            }
2038            let own = if grouped.contains(&e) {
2039                1.0
2040            } else {
2041                direct.get(&e).copied().unwrap_or(1.0)
2042            };
2043            let alpha = own * outer(e, parents, &direct, &overlay, &mut chains, &mut outers);
2044            if alpha < 1.0 {
2045                draw.insert(e, alpha);
2046            }
2047        }
2048        OpacityModel { draw, groups }
2049    };
2050    if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
2051        && c.active
2052    {
2053        c.opacities = Some(model.clone());
2054    }
2055    model
2056}
2057
2058/// The alpha an entity's own paints carry, from [`OpacityModel::draw`].
2059pub fn draw_opacity(draw: &HashMap<Entity, f32>, e: Entity) -> Opacity {
2060    Opacity(draw.get(&e).copied().unwrap_or(1.0))
2061}
2062
2063/// Extracts one [`ExtractedOpacityGroup`] per element that fades as a group (see [`opacity_model`]).
2064/// Each carries the element's opacity and the `(start_order, end_order)` bracket of the paints it fades,
2065/// which [`crate::node_ir::transform_extracted_to_nodes`] folds into a [`crate::node_ir::Node::Opacity`].
2066pub fn extract_opacity_groups(main: &mut World, render: &mut World) {
2067    let (parents, mut depth_cache) = build_parent_map(main);
2068    let groups = opacity_model(main, &parents).groups;
2069    let mut pairs: Vec<(Entity, ExtractedOpacityGroup)> = Vec::with_capacity(groups.len());
2070    if !groups.is_empty() {
2071        let overlay: std::collections::HashSet<Entity> = {
2072            let mut q = main.query_filtered::<Entity, With<OverlayLayer>>();
2073            q.iter(main).collect()
2074        };
2075        let mut children: HashMap<Entity, Vec<Entity>> = HashMap::new();
2076        for (&e, &p) in &parents {
2077            children.entry(p).or_default().push(e);
2078        }
2079        for (e, alpha) in groups {
2080            let own = paint_order_of(e, &parents, &mut depth_cache);
2081            let end = max_desc_order(e, &children, &parents, &overlay, &mut depth_cache);
2082            pairs.push((
2083                e,
2084                ExtractedOpacityGroup {
2085                    alpha,
2086                    start_order: own.saturating_sub(1),
2087                    end_order: end,
2088                    key: e.to_bits(),
2089                },
2090            ));
2091        }
2092    }
2093    // Keyed-upsert against `RenderEntityMap.opacity_group`.
2094    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().opacity_group);
2095    let mut next: HashMap<Entity, Entity> = HashMap::with_capacity(pairs.len());
2096    for (main_e, group) in pairs {
2097        let reuse = prior
2098            .get(&main_e)
2099            .copied()
2100            .filter(|&re| render.get_entity(re).is_ok());
2101        let render_e = match reuse {
2102            Some(re) => {
2103                render.entity_mut(re).insert(group);
2104                re
2105            }
2106            None => render.spawn(group).id(),
2107        };
2108        next.insert(main_e, render_e);
2109    }
2110    for (main_e, render_e) in &prior {
2111        if !next.contains_key(main_e)
2112            && let Ok(em) = render.get_entity_mut(*render_e)
2113        {
2114            em.despawn();
2115        }
2116    }
2117    render.resource_mut::<RenderEntityMap>().opacity_group = next;
2118}
2119
2120/// Ranks every [`OverlayLayer`] subtree into the top-layer band (`>= OVERLAY_ORDER_BASE`).
2121///
2122/// - Visible overlay roots are stamped via [`OverlayOpenOrder`] (first sighting = lowest stamp) and
2123///   ranked in stamp order, so a later-opened popup paints over an earlier one.
2124/// - Hidden overlay roots lose their stamp (re-opening restamps on top) and are ranked after all
2125///   stamped roots, by entity bits - deterministic, though never painted while hidden.
2126/// - Each subtree gets contiguous pre-order stride-2 ranks; nested overlay roots are skipped and
2127///   ranked by their own stamp (a submenu opened after its parent menu lands above it).
2128fn rank_overlay_band(
2129    main: &mut World,
2130    parents: &std::collections::HashMap<Entity, Entity>,
2131    children: &std::collections::HashMap<Entity, Vec<Entity>>,
2132    overlay_roots: &[Entity],
2133    overlay_set: &std::collections::HashSet<Entity>,
2134    order: &mut std::collections::HashMap<Entity, u32>,
2135) {
2136    let hidden = hidden_entities(main, parents);
2137    if main.get_resource::<OverlayOpenOrder>().is_none() {
2138        main.insert_resource(OverlayOpenOrder::default());
2139    }
2140    let ordered_roots: Vec<Entity> = {
2141        let mut oo = main.resource_mut::<OverlayOpenOrder>();
2142        let oo = &mut *oo;
2143        // Drop stamps for despawned or hidden roots so the next open lands on top.
2144        oo.stamps
2145            .retain(|e, _| overlay_set.contains(e) && !hidden.contains(e));
2146        // Stamp newly visible roots. Entity-bits order breaks the tie when several open on the
2147        // same tick (deterministic; simultaneous opens have no meaningful "later").
2148        let mut newly_open: Vec<Entity> = overlay_roots
2149            .iter()
2150            .copied()
2151            .filter(|e| !hidden.contains(e) && !oo.stamps.contains_key(e))
2152            .collect();
2153        newly_open.sort_by_key(|e| e.to_bits());
2154        for e in newly_open {
2155            let s = oo.next;
2156            oo.next += 1;
2157            oo.stamps.insert(e, s);
2158        }
2159        let mut stamped: Vec<(u64, Entity)> = oo.stamps.iter().map(|(&e, &s)| (s, e)).collect();
2160        stamped.sort_unstable_by_key(|&(s, e)| (s, e.to_bits()));
2161        let mut list: Vec<Entity> = stamped.into_iter().map(|(_, e)| e).collect();
2162        let mut closed: Vec<Entity> = overlay_roots
2163            .iter()
2164            .copied()
2165            .filter(|e| hidden.contains(e))
2166            .collect();
2167        closed.sort_by_key(|e| e.to_bits());
2168        list.extend(closed);
2169        list
2170    };
2171    // Contiguous pre-order ranks continuing across subtrees, starting at the band base.
2172    let mut orank: u32 = OVERLAY_ORDER_BASE / 2;
2173    for root in ordered_roots {
2174        let mut stack = vec![root];
2175        while let Some(e) = stack.pop() {
2176            order.insert(e, orank.saturating_mul(2));
2177            orank = orank.saturating_add(1);
2178            if let Some(kids) = children.get(&e) {
2179                stack.extend(
2180                    kids.iter()
2181                        .rev()
2182                        .copied()
2183                        .filter(|k| !overlay_set.contains(k)),
2184                );
2185            }
2186        }
2187    }
2188}
2189
2190/// Returns each entity's cumulative ancestor-chain [`ScrollOffset`].
2191///
2192/// - The entity's own `ScrollOffset` is excluded; only its descendants translate.
2193/// - Returns an empty map when no [`ScrollOffset`] components are present.
2194/// - Consumed by [`extract_rects`], [`extract_text`], and [`extract_shadows`] to subtract the offset from rendered origins.
2195pub fn parent_scroll_offsets(
2196    main: &mut World,
2197    parents: &HashMap<Entity, Entity>,
2198) -> HashMap<Entity, Vec2> {
2199    if let Some(c) = main.get_resource::<ExtractContextCache>()
2200        && c.active
2201        && let Some(v) = &c.scroll
2202    {
2203        return v.clone();
2204    }
2205    let direct: HashMap<Entity, Vec2> = {
2206        let mut q = main.query::<(Entity, &ScrollOffset)>();
2207        q.iter(main).map(|(e, o)| (e, o.0)).collect()
2208    };
2209    let by_entity: HashMap<Entity, Vec2> = if direct.is_empty() {
2210        HashMap::new()
2211    } else {
2212        fn cumulative(
2213            e: Entity,
2214            parents: &HashMap<Entity, Entity>,
2215            direct: &HashMap<Entity, Vec2>,
2216            cache: &mut HashMap<Entity, Vec2>,
2217        ) -> Vec2 {
2218            if let Some(v) = cache.get(&e) {
2219                return *v;
2220            }
2221            let parent_off = parents
2222                .get(&e)
2223                .map(|p| cumulative(*p, parents, direct, cache))
2224                .unwrap_or(Vec2::ZERO);
2225            let own = direct.get(&e).copied().unwrap_or(Vec2::ZERO);
2226            let total = parent_off + own;
2227            cache.insert(e, total);
2228            total
2229        }
2230        let mut cache: HashMap<Entity, Vec2> = HashMap::new();
2231        let mut by_entity: HashMap<Entity, Vec2> = HashMap::new();
2232        for &e in parents.keys() {
2233            if let Some(p) = parents.get(&e) {
2234                let off = cumulative(*p, parents, &direct, &mut cache);
2235                if off != Vec2::ZERO {
2236                    by_entity.insert(e, off);
2237                }
2238            }
2239        }
2240        by_entity
2241    };
2242    if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
2243        && c.active
2244    {
2245        c.scroll = Some(by_entity.clone());
2246    }
2247    by_entity
2248}
2249
2250/// Returns the set of entities hidden by a [`Visible(false)`] or a
2251/// `display: none` on themselves or any ancestor.
2252///
2253/// - Used by extract fns to skip subtrees of `<if mode="hide">` blocks and of `display: none` boxes without despawning.
2254/// - First pass collects every hiding root; second pass walks each `parents` chain upward, memoising the answer per entity.
2255/// - Matches [`crate::components::hidden_via_ancestors`], the main-world walk the widget systems use.
2256pub fn hidden_entities(
2257    main: &mut World,
2258    parents: &std::collections::HashMap<Entity, Entity>,
2259) -> std::collections::HashSet<Entity> {
2260    if let Some(c) = main.get_resource::<ExtractContextCache>()
2261        && c.active
2262        && let Some(v) = &c.hidden
2263    {
2264        return v.clone();
2265    }
2266    let mut hide_roots = std::collections::HashSet::new();
2267    let mut q = main.query::<(Entity, &Visible)>();
2268    for (e, v) in q.iter(main) {
2269        if !v.0 {
2270            hide_roots.insert(e);
2271        }
2272    }
2273    // `display: none` takes the box out of layout, which leaves it 0x0 at
2274    // its parent's origin; its descendants still carry text and fills, and
2275    // would paint there without this.
2276    let mut styles = main.query::<(Entity, &Style)>();
2277    for (e, style) in styles.iter(main) {
2278        if matches!(style.display, Display::None) {
2279            hide_roots.insert(e);
2280        }
2281    }
2282    let hidden: std::collections::HashSet<Entity> = if hide_roots.is_empty() {
2283        std::collections::HashSet::new()
2284    } else {
2285        let mut cache: std::collections::HashMap<Entity, bool> = std::collections::HashMap::new();
2286        let mut hidden = std::collections::HashSet::new();
2287        for &entity in parents.keys().chain(hide_roots.iter()) {
2288            if is_hidden_walk(entity, &hide_roots, parents, &mut cache) {
2289                hidden.insert(entity);
2290            }
2291        }
2292        hidden
2293    };
2294    if let Some(mut c) = main.get_resource_mut::<ExtractContextCache>()
2295        && c.active
2296    {
2297        c.hidden = Some(hidden.clone());
2298    }
2299    hidden
2300}
2301
2302fn is_hidden_walk(
2303    e: Entity,
2304    roots: &std::collections::HashSet<Entity>,
2305    parents: &std::collections::HashMap<Entity, Entity>,
2306    cache: &mut std::collections::HashMap<Entity, bool>,
2307) -> bool {
2308    if let Some(v) = cache.get(&e) {
2309        return *v;
2310    }
2311    let v = if roots.contains(&e) {
2312        true
2313    } else {
2314        match parents.get(&e) {
2315            Some(p) => is_hidden_walk(*p, roots, parents, cache),
2316            None => false,
2317        }
2318    };
2319    cache.insert(e, v);
2320    v
2321}
2322
2323/// Returns the [`PaintOrder`] for `e` - the document-order rank precomputed by [`build_parent_map`]
2324/// (whose second return value is the map passed here as `cache`).
2325///
2326/// Entities absent from the map (standalone drawables with no hierarchy links) fall back to
2327/// `0x8000_0000 | (entity_index << 1)`: they paint after all tree content in entity-allocation order,
2328/// and - because the high bit clears every tree rank - no [`ExtractedClipBox`] descendant range can
2329/// accidentally absorb them.
2330pub fn paint_order_of(
2331    e: Entity,
2332    _parents: &std::collections::HashMap<Entity, Entity>,
2333    cache: &mut std::collections::HashMap<Entity, u32>,
2334) -> PaintOrder {
2335    if let Some(o) = cache.get(&e) {
2336        return *o;
2337    }
2338    let idx = (e.to_bits() as u32) & 0x3FFF_FFFF;
2339    let o = 0x8000_0000 | (idx << 1);
2340    cache.insert(e, o);
2341    o
2342}
2343
2344/// One CPU-side snapshot of the on-screen window surface produced by a GPU->CPU readback.
2345#[derive(Clone, Debug)]
2346pub struct SurfaceFrame {
2347    /// Pixel width.
2348    pub width: u32,
2349    /// Pixel height.
2350    pub height: u32,
2351    /// Tightly-packed RGBA8 pixels, top-to-bottom, sRGB-encoded (no pre-multiplied alpha).
2352    pub rgba8: Vec<u8>,
2353}
2354
2355/// Coordination handle for on-screen surface screenshots, inserted as a `Resource` into both worlds.
2356///
2357/// - The render backend inspects [`Self::is_requested`] each frame; when set, it performs a GPU->CPU readback, writes the result via [`Self::write`], and clears the flag.
2358/// - Both fields are `Arc`-wrapped (`Send + Sync`) so MCP-server worker threads can clone the handle and read filled frames.
2359#[derive(Resource, Clone, Default)]
2360pub struct SurfaceCapture {
2361    /// One-shot capture request flag; the renderer clears it after writing.
2362    pub request: Arc<AtomicBool>,
2363    /// Latest captured frame; the renderer replaces it wholesale.
2364    pub store: Arc<Mutex<Option<SurfaceFrame>>>,
2365    /// Optional handle to interrupt a parked platform event loop. The
2366    /// windowed backend wires this in [`crate::app::EventLoopWaker`] via
2367    /// [`Self::set_waker`] once its event-loop proxy exists; [`Self::request`]
2368    /// then nudges the loop so a screenshot request from the off-thread MCP
2369    /// server is serviced promptly instead of waiting for an unrelated OS
2370    /// event (the redraw scheduler otherwise leaves the loop parked, so the
2371    /// capture never runs and the request times out - the "no SurfaceCapture
2372    /// wired" failure). Shared `Arc<OnceLock>` so every clone - including the
2373    /// server thread's - observes a waker set on any one of them.
2374    pub waker: Arc<std::sync::OnceLock<crate::app::EventLoopWaker>>,
2375}
2376
2377impl SurfaceCapture {
2378    /// Returns `true` while a capture has been requested but not yet fulfilled.
2379    pub fn is_requested(&self) -> bool {
2380        self.request.load(Ordering::Acquire)
2381    }
2382
2383    /// Installs the platform event-loop waker. First write wins
2384    /// (`OnceLock`); later calls are ignored. Idempotent and thread-safe.
2385    pub fn set_waker(&self, waker: crate::app::EventLoopWaker) {
2386        let _ = self.waker.set(waker);
2387    }
2388
2389    /// Sets the request flag with `Release` ordering, then nudges the
2390    /// platform event loop (if [`Self::set_waker`] wired one) so a parked
2391    /// windowed backend wakes to service the readback this frame instead of
2392    /// sitting idle until the request times out. Idempotent.
2393    pub fn request(&self) {
2394        self.request.store(true, Ordering::Release);
2395        if let Some(waker) = self.waker.get() {
2396            waker.wake();
2397        }
2398    }
2399
2400    /// Clears the request flag with `Release` ordering.
2401    pub fn clear_request(&self) {
2402        self.request.store(false, Ordering::Release);
2403    }
2404
2405    /// Replaces the stored frame with `frame`.
2406    pub fn write(&self, frame: SurfaceFrame) {
2407        if let Ok(mut slot) = self.store.lock() {
2408            *slot = Some(frame);
2409        }
2410    }
2411
2412    /// Returns a clone of the latest stored frame, if any.
2413    pub fn read(&self) -> Option<SurfaceFrame> {
2414        self.store.lock().ok().and_then(|g| g.clone())
2415    }
2416}
2417
2418/// Byte offset into a masked run that corresponds to `plain_byte` in the
2419/// plaintext `text`, where each scalar renders as one `mask` char.
2420/// Snaps `plain_byte` down to a char boundary first, then counts scalars
2421/// before it and scales by the mask char's UTF-8 width.
2422fn masked_offset(text: &str, plain_byte: usize, mask: char) -> usize {
2423    let mut b = plain_byte.min(text.len());
2424    while b > 0 && !text.is_char_boundary(b) {
2425        b -= 1;
2426    }
2427    text[..b].chars().count() * mask.len_utf8()
2428}
2429
2430/// Rewrite a display run and its caret / selection byte offsets for a
2431/// concealed [`EchoMode`]:
2432/// - [`EchoMode::Password`] -> one `mask` glyph per scalar ([`PASSWORD_MASK_CHAR`]
2433///   unless a [`PasswordCharacter`] override is present), with caret /
2434///   selection remapped into the masked string.
2435/// - [`EchoMode::NoEcho`] -> empty run; caret collapses to the origin and
2436///   no selection is painted (there is nothing to highlight).
2437///
2438/// [`EchoMode::Normal`] is handled by the caller and never reaches here.
2439fn mask_echo(
2440    mode: EchoMode,
2441    text: &str,
2442    caret: Option<usize>,
2443    selection: Option<(usize, usize)>,
2444    mask: char,
2445) -> (String, Option<usize>, Option<(usize, usize)>) {
2446    match mode {
2447        EchoMode::NoEcho => (String::new(), caret.map(|_| 0), None),
2448        EchoMode::Password => {
2449            let scalars = text.chars().count();
2450            let masked: String = mask.to_string().repeat(scalars);
2451            let caret = caret.map(|c| masked_offset(text, c, mask));
2452            let selection = selection
2453                .map(|(s, e)| (masked_offset(text, s, mask), masked_offset(text, e, mask)));
2454            (masked, caret, selection)
2455        }
2456        EchoMode::Normal => (text.to_string(), caret, selection),
2457    }
2458}
2459
2460/// Where a run of text sits along the main axis of the box holding it.
2461///
2462/// A flex box lays the text inside it out as an item of its own, sized to
2463/// the words rather than to the box, so `justify-content: center` on a
2464/// `<button>` centres its label the same way it centres a child. That is
2465/// what Qt and Slint do with a button's contents, and what a browser does
2466/// with the anonymous item it wraps a flex container's text in - which is
2467/// why `text-align` is not the property an author reaches for first.
2468///
2469/// An authored [`TextAlign`] wins: it places the lines, and
2470/// `justify-content` only speaks for the run when nothing else has. The
2471/// two agree on a single line either way, and on several the run is placed
2472/// as a block with its lines aligned inside it, which is the arrangement
2473/// the anonymous item produces.
2474///
2475/// Read on a horizontal main axis only. Under `flex-direction: column`
2476/// `justify-content` distributes vertically and has nothing to say about
2477/// where a line starts.
2478fn text_run_align(align: TextAlign, style: Option<&Style>) -> TextAlign {
2479    if align != TextAlign::Start {
2480        return align;
2481    }
2482    let Some(style) = style else {
2483        return align;
2484    };
2485    let placed = match style.justify {
2486        // One item is centred by every distribution that leaves space on
2487        // both sides of it, and left where it started by the one that does
2488        // not (CSS Flexbox 8.2).
2489        FlexJustify::Center | FlexJustify::SpaceAround | FlexJustify::SpaceEvenly => {
2490            TextAlign::Center
2491        }
2492        FlexJustify::End => TextAlign::End,
2493        FlexJustify::Start | FlexJustify::SpaceBetween => TextAlign::Start,
2494    };
2495    match style.flex_direction {
2496        FlexDirection::Row => placed,
2497        // The main axis runs the other way, so the ends swap.
2498        FlexDirection::RowReverse => match placed {
2499            TextAlign::Start => TextAlign::End,
2500            TextAlign::End => TextAlign::Start,
2501            TextAlign::Center => TextAlign::Center,
2502        },
2503        FlexDirection::Column | FlexDirection::ColumnReverse => align,
2504    }
2505}
2506
2507/// Default extract fn that emits one [`ExtractedText`] per entity with [`Transform`] and [`TextContent`].
2508///
2509/// - When an [`ImeState`] is present, its `preedit` is concatenated onto the committed text.
2510/// - For focused `<input>` entities, caret offset and selection range are propagated.
2511/// - Under a concealed [`EchoMode`] the display glyphs, caret, and selection are masked (see [`mask_echo`]); the buffer plaintext is untouched.
2512/// - Hidden subtrees and entities clipped fully outside their scroll/overflow ancestor are skipped.
2513pub fn extract_text(main: &mut World, render: &mut World) {
2514    let (parents, mut depth_cache) = build_parent_map(main);
2515    let hidden = hidden_entities(main, &parents);
2516    let scroll = parent_scroll_offsets(main, &parents);
2517    let alphas = opacity_model(main, &parents).draw;
2518    let clip = parent_scroll_clip_rects(main, &parents);
2519    // Caret blink gate: when the blink resource says "hidden half of the
2520    // phase", withhold the caret byte so the renderer paints no bar.
2521    // Absent resource (headless / embedder without the blink system) =>
2522    // always visible.
2523    let caret_visible = main
2524        .get_resource::<CaretBlink>()
2525        .map(|b| b.visible)
2526        .unwrap_or(true);
2527    type RowFor<'a> = (
2528        Entity,
2529        &'a Transform,
2530        &'a TextContent,
2531        Option<&'a TextStyle>,
2532        Option<&'a ImeState>,
2533        Option<&'a TextInput>,
2534        Option<&'a Focused>,
2535        Option<&'a Style>,
2536        Option<&'a TextInputScroll>,
2537        Option<&'a EchoMode>,
2538        Option<&'a TextInputPaint>,
2539        Option<&'a TextBlockOrigin>,
2540        Option<&'a CaretWidth>,
2541        Option<&'a PasswordCharacter>,
2542    );
2543    let mut q = main.query::<RowFor>();
2544    let pairs: Vec<(Entity, ExtractedText)> = q
2545        .iter(main)
2546        .filter(|(e, ..)| !hidden.contains(e))
2547        .filter_map(
2548            |(
2549                e,
2550                t,
2551                text,
2552                ts,
2553                ime,
2554                input,
2555                focused,
2556                style,
2557                edit_scroll,
2558                echo,
2559                paint,
2560                block_origin,
2561                caret_width,
2562                password_char,
2563            )| {
2564                let ts = ts.cloned().unwrap_or_default();
2565                let size_px = ts.size_px;
2566                let line_height_px = resolve_line_height(ts.line_height, size_px);
2567                let caret_width_px = caret_width.map(|w| w.0).unwrap_or(CARET_WIDTH_PX);
2568                let mask_char = password_char.map(|c| c.0).unwrap_or(PASSWORD_MASK_CHAR);
2569                let preedit = ime.map(|i| i.preedit.as_str()).unwrap_or("");
2570                // Show the placeholder while the input holds neither committed
2571                // text nor preedit - focused or not (Qt shows the hint under a
2572                // blinking caret until the first keystroke).
2573                let placeholder = match input {
2574                    Some(i) if text.0.is_empty() && preedit.is_empty() => i.placeholder.as_str(),
2575                    _ => "",
2576                };
2577                if input.is_none() && text.0.is_empty() && preedit.is_empty() {
2578                    return None;
2579                }
2580                let caret = match (input, focused) {
2581                    (Some(i), Some(_)) if caret_visible => {
2582                        // `caret = TextInput.cursor (clamped) + preedit.len()` so the bar trails the composition buffer.
2583                        let base = i.cursor.min(text.0.len());
2584                        Some(base + preedit.len())
2585                    }
2586                    _ => None,
2587                };
2588                // While the placeholder is showing, the buffer is empty - pin
2589                // the caret to offset 0 so it doesn't index into hint text.
2590                let caret = caret.map(|c| if placeholder.is_empty() { c } else { 0 });
2591                let combined = if !placeholder.is_empty() {
2592                    placeholder.to_string()
2593                } else {
2594                    format!("{}{}", text.0, preedit)
2595                };
2596                // Emit a selection range only when the input is focused and the anchor differs from the cursor.
2597                let selection = match (input, focused) {
2598                    (Some(i), Some(_)) => i.selection_anchor.and_then(|a| {
2599                        let cur = i.cursor.min(text.0.len());
2600                        let a = a.min(text.0.len());
2601                        if a == cur {
2602                            None
2603                        } else {
2604                            Some((a.min(cur), a.max(cur)))
2605                        }
2606                    }),
2607                    _ => None,
2608                };
2609                // Password / no-echo masking (Qt `QLineEdit::EchoMode`).
2610                // The plaintext never leaves the buffer - only the display
2611                // run, caret, and selection offsets are rewritten against
2612                // the masked glyphs. Placeholder hint text is never masked
2613                // (it is not the secret). Char-based (one mask per Unicode
2614                // scalar) so `lumen-core` stays dependency-free; matches
2615                // Qt, whose password display is also per-code-unit.
2616                let (combined, caret, selection) = match echo {
2617                    Some(mode) if mode.is_concealed() && placeholder.is_empty() => {
2618                        mask_echo(*mode, &combined, caret, selection, mask_char)
2619                    }
2620                    _ => (combined, caret, selection),
2621                };
2622                // Vertical origin. `TextBlockOrigin` carries the producer's
2623                // soft-wrap-aware answer; without it, fall back to the same
2624                // rule over the logical line count so the drawn baseline
2625                // still agrees with the pointer hit test.
2626                let pad_left = style.map(|s| s.padding.left).unwrap_or(0.0);
2627                let pad_right = style.map(|s| s.padding.right).unwrap_or(0.0);
2628                let pad_top = style.map(|s| s.padding.top).unwrap_or(0.0);
2629                let pad_bottom = style.map(|s| s.padding.bottom).unwrap_or(0.0);
2630                let inner_h = (t.size.y - pad_top - pad_bottom).max(size_px);
2631                let block_top = block_origin.map(|b| b.top).unwrap_or_else(|| {
2632                    let stacked = input.is_some_and(|i| i.multiline) || combined.contains('\n');
2633                    text_block_top(inner_h, line_height_px, stacked)
2634                });
2635                let baseline_y = t.absolute.y
2636                    + pad_top
2637                    + block_top
2638                    + text_baseline_in_line(size_px, line_height_px);
2639                let container_width = (t.size.x - pad_left - pad_right).max(0.0);
2640                let alpha = draw_opacity(&alphas, e);
2641                let off = scroll.get(&e).copied().unwrap_or(Vec2::ZERO);
2642                // AABB-cull against the nearest scroll / overflow-hidden ancestor; matches the rule applied in `extract_rects`.
2643                if let Some(clip_rect) = clip.get(&e) {
2644                    let probe_origin = Vec2::new(t.absolute.x, t.absolute.y) - off;
2645                    if aabb_outside(probe_origin, t.size, *clip_rect) {
2646                        return None;
2647                    }
2648                }
2649                // Per-input caret-keep-visible offset: shift the whole run
2650                // (glyphs, caret, selection move together since the renderer
2651                // derives caret / selection x from the shifted origin).
2652                let edit_off = edit_scroll.map(|s| s.offset).unwrap_or(Vec2::ZERO);
2653                Some((
2654                    e,
2655                    ExtractedText {
2656                        origin: Vec2::new(t.absolute.x + pad_left, baseline_y) - off - edit_off,
2657                        text: combined,
2658                        size_px,
2659                        fill: alpha.apply(ts.color),
2660                        caret,
2661                        selection,
2662                        selection_color: ts.selection_color.map(|c| alpha.apply(c)),
2663                        selection_foreground: paint
2664                            .and_then(|p| p.selection_foreground)
2665                            .map(|c| alpha.apply(c)),
2666                        caret_color: paint.and_then(|p| p.caret_color).map(|c| alpha.apply(c)),
2667                        container_width,
2668                        align: text_run_align(ts.align, style),
2669                        wrap: ts.wrap,
2670                        max_lines: ts.max_lines,
2671                        family: ts.family.clone(),
2672                        weight: ts.weight,
2673                        line_height_px,
2674                        caret_width_px,
2675                        order: paint_order_of(e, &parents, &mut depth_cache),
2676                    },
2677                ))
2678            },
2679        )
2680        .collect();
2681    // Keyed-upsert against `RenderEntityMap.text`; reused render entities are validated to drop recycled ids.
2682    let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().text);
2683    let mut next: std::collections::HashMap<Entity, Entity> =
2684        std::collections::HashMap::with_capacity(pairs.len());
2685    for (main_e, et) in pairs {
2686        let reuse = prior
2687            .get(&main_e)
2688            .copied()
2689            .filter(|&re| render.get_entity(re).is_ok());
2690        let render_e = match reuse {
2691            Some(re) => {
2692                render.entity_mut(re).insert(et);
2693                re
2694            }
2695            None => render.spawn(et).id(),
2696        };
2697        next.insert(main_e, render_e);
2698    }
2699    for (main_e, render_e) in &prior {
2700        if !next.contains_key(main_e)
2701            && let Ok(em) = render.get_entity_mut(*render_e)
2702        {
2703            em.despawn();
2704        }
2705    }
2706    render.resource_mut::<RenderEntityMap>().text = next;
2707}
2708
2709#[cfg(test)]
2710mod echo_mask_tests {
2711    //! `EchoMode` display masking (Qt `QLineEdit::EchoMode`). The buffer
2712    //! plaintext is untouched; only the display run + caret / selection
2713    //! offsets are rewritten against the mask glyphs.
2714    use super::*;
2715
2716    #[test]
2717    fn password_masks_each_scalar_and_remaps_caret() {
2718        // "abc" caret after 'b' (byte 2). Masked = three [`PASSWORD_MASK_CHAR`]
2719        // bullets, 3 bytes each, so the caret lands after the second -> byte 6.
2720        let (disp, caret, sel) =
2721            mask_echo(EchoMode::Password, "abc", Some(2), None, PASSWORD_MASK_CHAR);
2722        assert_eq!(disp.chars().count(), 3);
2723        assert!(disp.chars().all(|c| c == PASSWORD_MASK_CHAR));
2724        assert_eq!(caret, Some(6));
2725        assert_eq!(sel, None);
2726    }
2727
2728    #[test]
2729    fn password_remaps_selection_range() {
2730        // Select "bc" (bytes 1..3) in "abc" -> masked bytes 3..9.
2731        let (_disp, _caret, sel) = mask_echo(
2732            EchoMode::Password,
2733            "abc",
2734            None,
2735            Some((1, 3)),
2736            PASSWORD_MASK_CHAR,
2737        );
2738        assert_eq!(sel, Some((3, 9)));
2739    }
2740
2741    #[test]
2742    fn password_scalar_count_not_byte_count() {
2743        // "\u{e9}" is one scalar (2 bytes) -> exactly one mask glyph, and a
2744        // caret at end (byte 2) maps to one mask width (3 bytes).
2745        let (disp, caret, _) = mask_echo(
2746            EchoMode::Password,
2747            "\u{e9}",
2748            Some(2),
2749            None,
2750            PASSWORD_MASK_CHAR,
2751        );
2752        assert_eq!(disp.chars().count(), 1);
2753        assert_eq!(caret, Some(3));
2754    }
2755
2756    /// A `password-character` override (here `*`, a 1-byte ASCII glyph vs
2757    /// the 3-byte default bullet) reaches `mask_echo` as a plain `char`
2758    /// parameter and both the display run and the remapped caret honour
2759    /// the override's own byte width, not the default's.
2760    #[test]
2761    fn password_character_override_changes_mask_glyph_and_width() {
2762        let (disp, caret, _) = mask_echo(EchoMode::Password, "abc", Some(2), None, '*');
2763        assert_eq!(disp, "***");
2764        assert_eq!(caret, Some(2), "1-byte mask -> caret byte == scalar count");
2765    }
2766
2767    #[test]
2768    fn masked_offset_snaps_mid_codepoint_down() {
2769        // byte 1 is mid-'\u{e9}' -> snap to 0 -> zero mask widths.
2770        assert_eq!(EchoMode::Password.display_offset("\u{e9}", 1), 0);
2771    }
2772
2773    #[test]
2774    fn masked_offset_round_trips_to_the_plaintext_byte() {
2775        // The pointer hit test resolves a masked byte and maps it back;
2776        // both directions must land on the same scalar edge.
2777        let plain = "a\u{e9}bc";
2778        for (i, _) in plain
2779            .char_indices()
2780            .chain(std::iter::once((plain.len(), 'x')))
2781        {
2782            let d = EchoMode::Password.display_offset(plain, i);
2783            assert_eq!(EchoMode::Password.plain_offset(plain, d), i);
2784        }
2785    }
2786
2787    #[test]
2788    fn no_echo_hides_everything_and_collapses_caret() {
2789        let (disp, caret, sel) = mask_echo(
2790            EchoMode::NoEcho,
2791            "secret",
2792            Some(4),
2793            Some((0, 6)),
2794            PASSWORD_MASK_CHAR,
2795        );
2796        assert!(disp.is_empty());
2797        assert_eq!(caret, Some(0), "caret collapses to the origin");
2798        assert_eq!(sel, None, "nothing to highlight under no-echo");
2799    }
2800
2801    /// End-to-end through the real `extract_text`: a focused password
2802    /// input emits a fully-masked run while the buffer keeps its
2803    /// plaintext, and the placeholder hint is never masked.
2804    #[test]
2805    fn extract_masks_password_run_but_not_placeholder() {
2806        use crate::components::{TextContent, TextInput, Transform};
2807        use crate::input::Focused;
2808
2809        fn masked_text_for(content: &str, placeholder: &str) -> String {
2810            let mut main = World::new();
2811            let mut render = World::new();
2812            render.init_resource::<RenderEntityMap>();
2813            let e = main
2814                .spawn((
2815                    Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
2816                    TextContent(content.to_string()),
2817                    TextInput {
2818                        placeholder: placeholder.to_string(),
2819                        cursor: content.len(),
2820                        ..Default::default()
2821                    },
2822                    EchoMode::Password,
2823                    Focused,
2824                ))
2825                .id();
2826            let _ = e;
2827            extract_text(&mut main, &mut render);
2828            let mut q = render.query::<&ExtractedText>();
2829            q.iter(&render).next().unwrap().text.clone()
2830        }
2831
2832        // Non-empty buffer -> every scalar becomes a bullet.
2833        let masked = masked_text_for("hunter2", "");
2834        assert_eq!(masked.chars().count(), 7);
2835        assert!(masked.chars().all(|c| c == PASSWORD_MASK_CHAR));
2836
2837        // Empty buffer -> the placeholder hint shows verbatim (a hint is
2838        // not the secret; Qt shows placeholder text under password mode).
2839        assert_eq!(masked_text_for("", "Password"), "Password");
2840    }
2841
2842    /// A [`PasswordCharacter`] component (spawned from CSS
2843    /// `password-character`) overrides [`PASSWORD_MASK_CHAR`] end-to-end
2844    /// through `extract_text`; absent, the extract keeps using the
2845    /// built-in bullet.
2846    #[test]
2847    fn extract_password_character_override_replaces_default_mask() {
2848        use crate::components::{TextContent, TextInput, Transform};
2849        use crate::input::Focused;
2850
2851        let mut main = World::new();
2852        let mut render = World::new();
2853        render.init_resource::<RenderEntityMap>();
2854        main.spawn((
2855            Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
2856            TextContent("hunter2".to_string()),
2857            TextInput {
2858                cursor: 7,
2859                ..Default::default()
2860            },
2861            EchoMode::Password,
2862            PasswordCharacter('*'),
2863            Focused,
2864        ));
2865        extract_text(&mut main, &mut render);
2866        let mut q = render.query::<&ExtractedText>();
2867        let text = q.iter(&render).next().unwrap().text.clone();
2868        assert_eq!(text, "*******");
2869    }
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874    use super::*;
2875    use bevy_ecs::hierarchy::ChildOf;
2876
2877    /// A `Visible(false)` root (e.g. the dev-tools overlay, hidden until
2878    /// toggled) must suppress paint for its ENTIRE descendant subtree, not
2879    /// just the root entity. Regression for the hidden-overlay pixel leak:
2880    /// with the fix, an extract of a hidden root's subtree yields zero
2881    /// `ExtractedRect` / `ExtractedText`, matching the no-overlay scene.
2882    #[test]
2883    fn hidden_root_subtree_extracts_zero_paint_nodes() {
2884        use crate::components::{Fill, TextContent, Transform, Visible, Visuals};
2885
2886        let mut main = World::new();
2887        let mut render = World::new();
2888        render.insert_resource(RenderEntityMap::default());
2889
2890        // Visible base scene: one filled rect.
2891        let base = main
2892            .spawn((
2893                Transform {
2894                    absolute: Vec2::ZERO,
2895                    size: Vec2::new(100.0, 100.0),
2896                    baseline_y: None,
2897                },
2898                Visuals {
2899                    fill: Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0))),
2900                    ..Default::default()
2901                },
2902            ))
2903            .id();
2904
2905        // Hidden second root + a subtree of rects and text under it.
2906        let overlay = main
2907            .spawn((
2908                Transform {
2909                    absolute: Vec2::ZERO,
2910                    size: Vec2::new(100.0, 100.0),
2911                    baseline_y: None,
2912                },
2913                Visuals {
2914                    fill: Some(Fill::Solid(Color::rgba(0.0, 1.0, 0.0, 1.0))),
2915                    ..Default::default()
2916                },
2917                Visible(false),
2918            ))
2919            .id();
2920        let panel = main
2921            .spawn((
2922                Transform {
2923                    absolute: Vec2::new(10.0, 10.0),
2924                    size: Vec2::new(50.0, 50.0),
2925                    baseline_y: None,
2926                },
2927                Visuals {
2928                    fill: Some(Fill::Solid(Color::rgba(0.0, 0.0, 1.0, 1.0))),
2929                    ..Default::default()
2930                },
2931                ChildOf(overlay),
2932            ))
2933            .id();
2934        let _label = main
2935            .spawn((
2936                Transform {
2937                    absolute: Vec2::new(12.0, 12.0),
2938                    size: Vec2::new(40.0, 16.0),
2939                    baseline_y: None,
2940                },
2941                TextContent("devtools".into()),
2942                ChildOf(panel),
2943            ))
2944            .id();
2945
2946        extract_rects(&mut main, &mut render);
2947        extract_text(&mut main, &mut render);
2948
2949        // Exactly one rect (the base), zero from the hidden subtree.
2950        let rects: Vec<(Entity, Vec2)> = {
2951            let mut q = render.query::<(Entity, &ExtractedRect)>();
2952            q.iter(&render).map(|(e, r)| (e, r.origin)).collect()
2953        };
2954        assert_eq!(
2955            rects.len(),
2956            1,
2957            "only the visible base rect extracts; the hidden subtree contributes none (got {rects:?})"
2958        );
2959        // The one rect must be the base, not the overlay/panel.
2960        let map = render.resource::<RenderEntityMap>();
2961        assert_eq!(map.rect.get(&base).copied(), Some(rects[0].0));
2962        assert!(!map.rect.contains_key(&overlay));
2963        assert!(!map.rect.contains_key(&panel));
2964
2965        // Zero text: the label is inside the hidden subtree.
2966        let text_count = render.query::<&ExtractedText>().iter(&render).count();
2967        assert_eq!(text_count, 0, "hidden subtree text must not extract");
2968    }
2969
2970    /// Two backgrounds that share an edge at a fractional position (a
2971    /// scroll offset mid-inertia) still share it once extracted, on whole
2972    /// device pixels. Each once antialiased the edge on its own, and what
2973    /// was behind them showed through the seam.
2974    #[test]
2975    fn adjacent_rects_at_a_fractional_offset_share_a_pixel_edge() {
2976        use crate::components::{Fill, Transform, Visuals};
2977        let mut main = World::new();
2978        let mut render = World::new();
2979        render.insert_resource(RenderEntityMap::default());
2980        main.insert_resource(Viewport {
2981            scale_factor: 2.0,
2982            ..Viewport::default()
2983        });
2984        let row = |y: f32| {
2985            (
2986                Transform {
2987                    absolute: Vec2::new(0.0, y),
2988                    size: Vec2::new(100.0, 30.0),
2989                    baseline_y: None,
2990                },
2991                Visuals {
2992                    fill: Some(Fill::Solid(Color::rgba(0.2, 0.3, 0.4, 1.0))),
2993                    ..Default::default()
2994                },
2995            )
2996        };
2997        main.spawn(row(-1693.68 + 1680.0));
2998        main.spawn(row(-1693.68 + 1710.0));
2999        extract_rects(&mut main, &mut render);
3000        let mut edges: Vec<(f32, f32)> = render
3001            .query::<&ExtractedRect>()
3002            .iter(&render)
3003            .map(|r| (r.origin.y, r.origin.y + r.size.y))
3004            .collect();
3005        edges.sort_by(|a, b| a.0.total_cmp(&b.0));
3006        assert_eq!(edges[0].1, edges[1].0, "one shared edge: {edges:?}");
3007        for (top, bottom) in edges {
3008            assert_eq!((top * 2.0).fract(), 0.0, "top on a device pixel");
3009            assert_eq!((bottom * 2.0).fract(), 0.0, "bottom on a device pixel");
3010        }
3011    }
3012
3013    /// Snapping keeps at least one device pixel of a box that has any size.
3014    #[test]
3015    fn snapping_keeps_a_hairline() {
3016        let (origin, size) = snap_to_device_pixels(Vec2::new(0.2, 0.2), Vec2::new(10.0, 0.3), 1.0);
3017        assert_eq!(origin, Vec2::ZERO);
3018        assert_eq!(size, Vec2::new(10.0, 1.0));
3019    }
3020
3021    /// An inset shadow extracts at its element's own paint order, so it can
3022    /// paint over the element's background; a drop shadow stays below it.
3023    #[test]
3024    fn inset_shadow_takes_the_element_order() {
3025        use crate::components::{Fill, ShadowSpec, Transform, Visuals};
3026        let mut main = World::new();
3027        let mut render = World::new();
3028        render.insert_resource(RenderEntityMap::default());
3029        let shadow = |inner: bool| ShadowSpec {
3030            offset_x: 0.0,
3031            offset_y: 0.0,
3032            blur: 4.0,
3033            spread: 0.0,
3034            color: Color::rgba(1.0, 0.0, 0.0, 1.0),
3035            inner,
3036        };
3037        main.spawn((
3038            Transform {
3039                absolute: Vec2::ZERO,
3040                size: Vec2::new(50.0, 50.0),
3041                baseline_y: None,
3042            },
3043            Visuals {
3044                fill: Some(Fill::Solid(Color::rgba(1.0, 1.0, 1.0, 1.0))),
3045                shadows: vec![shadow(false), shadow(true)],
3046                ..Default::default()
3047            },
3048        ));
3049        extract_rects(&mut main, &mut render);
3050        extract_shadows(&mut main, &mut render);
3051        let rect = render
3052            .query::<&ExtractedRect>()
3053            .iter(&render)
3054            .next()
3055            .expect("rect")
3056            .order;
3057        let mut shadows: Vec<(bool, PaintOrder)> = render
3058            .query::<&ExtractedShadow>()
3059            .iter(&render)
3060            .map(|s| (s.inner, s.order))
3061            .collect();
3062        shadows.sort();
3063        assert_eq!(shadows, vec![(false, rect - 1), (true, rect)]);
3064    }
3065
3066    /// `display: none` hides the descendants' paint as well as the box.
3067    /// Layout collapses the box to 0x0 at its parent's origin, and the
3068    /// labels inside it once drew their text there.
3069    #[test]
3070    fn display_none_subtree_extracts_no_text() {
3071        use crate::components::{Display, Style, TextContent, Transform};
3072
3073        let mut main = World::new();
3074        let mut render = World::new();
3075        render.insert_resource(RenderEntityMap::default());
3076        let at = |x: f32, y: f32, w: f32, h: f32| Transform {
3077            absolute: Vec2::new(x, y),
3078            size: Vec2::new(w, h),
3079            baseline_y: None,
3080        };
3081        let root = main.spawn(at(0.0, 0.0, 200.0, 200.0)).id();
3082        let hidden = main
3083            .spawn((
3084                at(0.0, 0.0, 0.0, 0.0),
3085                Style {
3086                    display: Display::None,
3087                    ..Default::default()
3088                },
3089                ChildOf(root),
3090            ))
3091            .id();
3092        main.spawn((
3093            at(0.0, 0.0, 80.0, 16.0),
3094            TextContent("should be hidden".into()),
3095            ChildOf(hidden),
3096        ));
3097        main.spawn((
3098            at(0.0, 40.0, 80.0, 16.0),
3099            TextContent("shown".into()),
3100            ChildOf(root),
3101        ));
3102
3103        extract_text(&mut main, &mut render);
3104
3105        let texts: Vec<String> = render
3106            .query::<&ExtractedText>()
3107            .iter(&render)
3108            .map(|t| t.text.to_string())
3109            .collect();
3110        assert_eq!(texts, vec!["shown".to_string()]);
3111    }
3112
3113    /// The `cull_hidden` guard is the safety net behind the per-extractor
3114    /// filters: even if some extractor leaks a render entity for a hidden
3115    /// main entity, the guard despawns it and prunes its `RenderEntityMap`
3116    /// slot before the retained tree is built.
3117    #[test]
3118    fn cull_hidden_despawns_leaked_render_entities() {
3119        use crate::components::{Transform, Visible};
3120
3121        let mut main = World::new();
3122        let mut render = World::new();
3123        render.insert_resource(RenderEntityMap::default());
3124        render.insert_resource(HiddenExtracts::default());
3125
3126        let hidden_root = main.spawn((Transform::default(), Visible(false))).id();
3127        let child = main
3128            .spawn((Transform::default(), ChildOf(hidden_root)))
3129            .id();
3130
3131        // Simulate an extractor that forgot the `hidden` filter: two render
3132        // entities keyed to the hidden main entities land in the map.
3133        let leaked_rect = render
3134            .spawn(ExtractedRect {
3135                origin: Vec2::ZERO,
3136                size: Vec2::new(10.0, 10.0),
3137                brush: Brush::Solid(Color::rgba(0.0, 1.0, 0.0, 1.0)),
3138                radius: 0.0,
3139                corner_radii: None,
3140                order: 0,
3141            })
3142            .id();
3143        let leaked_img = render
3144            .spawn(ExtractedRect {
3145                origin: Vec2::ZERO,
3146                size: Vec2::new(10.0, 10.0),
3147                brush: Brush::Solid(Color::rgba(0.0, 0.0, 1.0, 1.0)),
3148                radius: 0.0,
3149                corner_radii: None,
3150                order: 0,
3151            })
3152            .id();
3153        {
3154            let mut map = render.resource_mut::<RenderEntityMap>();
3155            map.rect.insert(hidden_root, leaked_rect);
3156            map.image.insert(child, leaked_img);
3157        }
3158
3159        // Refresh the hidden snapshot as the extract phase would, then cull.
3160        stash_hidden_entities(&mut main, &mut render);
3161        let mut schedule = Schedule::default();
3162        schedule.add_systems(cull_hidden);
3163        schedule.run(&mut render);
3164
3165        assert!(
3166            render.get_entity(leaked_rect).is_err(),
3167            "leaked rect for the hidden root must be despawned"
3168        );
3169        assert!(
3170            render.get_entity(leaked_img).is_err(),
3171            "leaked image for the hidden child must be despawned"
3172        );
3173        let map = render.resource::<RenderEntityMap>();
3174        assert!(map.rect.is_empty(), "hidden slot pruned from rect map");
3175        assert!(map.image.is_empty(), "hidden slot pruned from image map");
3176    }
3177
3178    /// RC2 regression: paint order must follow document/tree order, not entity-id allocation order.
3179    /// Children are spawned BEFORE their parent so entity ids run opposite to document order.
3180    #[test]
3181    fn paint_order_follows_document_order_not_entity_ids() {
3182        let mut world = World::new();
3183        let child_a = world.spawn_empty().id();
3184        let child_b = world.spawn_empty().id();
3185        let grandchild = world.spawn_empty().id();
3186        let parent = world.spawn_empty().id();
3187        // Attach in document order: parent -> [child_a -> [grandchild], child_b].
3188        world.entity_mut(child_a).insert(ChildOf(parent));
3189        world.entity_mut(child_b).insert(ChildOf(parent));
3190        world.entity_mut(grandchild).insert(ChildOf(child_a));
3191
3192        let (parents, mut cache) = build_parent_map(&mut world);
3193        let po_parent = paint_order_of(parent, &parents, &mut cache);
3194        let po_a = paint_order_of(child_a, &parents, &mut cache);
3195        let po_gc = paint_order_of(grandchild, &parents, &mut cache);
3196        let po_b = paint_order_of(child_b, &parents, &mut cache);
3197
3198        assert!(
3199            po_parent < po_a && po_a < po_gc && po_gc < po_b,
3200            "expected pre-order parent < child_a < grandchild < child_b, got {po_parent} {po_a} {po_gc} {po_b}"
3201        );
3202    }
3203
3204    /// R-css-flex: `z-index` overrides sibling paint order (higher paints
3205    /// later / on top), while equal z keeps document order.
3206    #[test]
3207    fn z_index_reorders_sibling_paint_order() {
3208        use crate::components::ZIndex;
3209        let mut world = World::new();
3210        let parent = world.spawn_empty().id();
3211        let a = world.spawn(ChildOf(parent)).id();
3212        let b = world.spawn((ChildOf(parent), ZIndex(-1))).id();
3213        let c = world.spawn(ChildOf(parent)).id();
3214
3215        let (parents, mut cache) = build_parent_map(&mut world);
3216        let po_a = paint_order_of(a, &parents, &mut cache);
3217        let po_b = paint_order_of(b, &parents, &mut cache);
3218        let po_c = paint_order_of(c, &parents, &mut cache);
3219        assert!(
3220            po_b < po_a && po_a < po_c,
3221            "z:-1 sibling paints first; equal-z siblings keep document order (got {po_b} {po_a} {po_c})"
3222        );
3223
3224        // Raise `a` above `c`.
3225        world.entity_mut(a).insert(ZIndex(5));
3226        let (parents, mut cache) = build_parent_map(&mut world);
3227        let po_a = paint_order_of(a, &parents, &mut cache);
3228        let po_c = paint_order_of(c, &parents, &mut cache);
3229        assert!(po_a > po_c, "z:5 sibling paints above z:auto");
3230    }
3231
3232    /// W6 T2 regression (the invisible counter tiles): a child that
3233    /// PARTIALLY overhangs its scroll container's clip rect - e.g.
3234    /// `width: 100%` + margin pushing the right edge past the container,
3235    /// or a row straddling the container's bottom edge - must still be
3236    /// extracted (the vello clip layer trims it at paint time). Only a
3237    /// child with NO overlap at all may be culled.
3238    #[test]
3239    fn partially_clipped_child_is_extracted_fully_outside_is_culled() {
3240        use crate::components::Style;
3241        use crate::input::Scroll;
3242        let mut main = World::new();
3243        let mut render = World::new();
3244        render.insert_resource(RenderEntityMap::default());
3245
3246        // Scroll container: clip rect (0,0)-(960,504).
3247        let container = main
3248            .spawn((
3249                Transform::new(Vec2::ZERO, Vec2::new(960.0, 504.0)),
3250                Style::default(),
3251                Scroll::default(),
3252            ))
3253            .id();
3254        // Tile shape from the counter app: margin shifts it to x=4 while
3255        // width:100% keeps it 960 wide -> right edge 964 > 960 (partial).
3256        let tile = main
3257            .spawn((
3258                Transform::new(Vec2::new(4.0, 8.0), Vec2::new(960.0, 80.0)),
3259                Visuals {
3260                    fill: Some(Fill::Solid(Color::rgb(1.0, 0.0, 0.0))),
3261                    radius: 0.0,
3262                    corner_radii: None,
3263                    shadows: Vec::new(),
3264                    border: None,
3265                },
3266                ChildOf(container),
3267            ))
3268            .id();
3269        // Straddles the container's bottom edge (480..560 vs clip 504).
3270        let straddler = main
3271            .spawn((
3272                Transform::new(Vec2::new(4.0, 480.0), Vec2::new(960.0, 80.0)),
3273                Visuals {
3274                    fill: Some(Fill::Solid(Color::rgb(0.0, 1.0, 0.0))),
3275                    radius: 0.0,
3276                    corner_radii: None,
3277                    shadows: Vec::new(),
3278                    border: None,
3279                },
3280                ChildOf(container),
3281            ))
3282            .id();
3283        // Fully below the clip rect: no overlap -> culled.
3284        let outside = main
3285            .spawn((
3286                Transform::new(Vec2::new(0.0, 600.0), Vec2::new(100.0, 50.0)),
3287                Visuals {
3288                    fill: Some(Fill::Solid(Color::rgb(0.0, 0.0, 1.0))),
3289                    radius: 0.0,
3290                    corner_radii: None,
3291                    shadows: Vec::new(),
3292                    border: None,
3293                },
3294                ChildOf(container),
3295            ))
3296            .id();
3297
3298        extract_rects(&mut main, &mut render);
3299
3300        let map = render.resource::<RenderEntityMap>();
3301        assert!(
3302            map.rect.contains_key(&tile),
3303            "width:100% + margin tile (4px overhang) must extract"
3304        );
3305        assert!(
3306            map.rect.contains_key(&straddler),
3307            "row straddling the clip bottom must extract"
3308        );
3309        assert!(
3310            !map.rect.contains_key(&outside),
3311            "zero-overlap child stays culled"
3312        );
3313        // Full size survives - the clip layer, not the extract, trims it.
3314        let re = map.rect[&tile];
3315        let rect = render.get::<ExtractedRect>(re).cloned().unwrap();
3316        assert_eq!(rect.size, Vec2::new(960.0, 80.0));
3317        assert_eq!(rect.origin, Vec2::new(4.0, 8.0));
3318    }
3319
3320    /// R-css-flex: `extract_borders` emits one `ExtractedBorder` per
3321    /// entity with a `Visuals::border`, at the entity's own paint order,
3322    /// with widths in `[top, right, bottom, left]` order - and emits
3323    /// nothing for border-less visuals.
3324    #[test]
3325    fn extract_borders_emits_expected_widths_and_order() {
3326        use crate::components::{Border, Edges, Style};
3327        let mut main = World::new();
3328        let mut render = World::new();
3329        render.insert_resource(RenderEntityMap::default());
3330
3331        let bordered = main
3332            .spawn((
3333                Transform::new(Vec2::new(5.0, 6.0), Vec2::new(50.0, 40.0)),
3334                Style::default(),
3335                Visuals {
3336                    fill: None,
3337                    radius: 8.0,
3338                    corner_radii: None,
3339                    shadows: Vec::new(),
3340                    border: Some(Border {
3341                        widths: Edges {
3342                            top: 1.0,
3343                            right: 2.0,
3344                            bottom: 3.0,
3345                            left: 4.0,
3346                            ..Edges::default()
3347                        },
3348                        color: Color::rgb(1.0, 0.0, 0.0),
3349                        side_colors: None,
3350                    }),
3351                },
3352            ))
3353            .id();
3354        let _plain = main
3355            .spawn((
3356                Transform::new(Vec2::ZERO, Vec2::new(10.0, 10.0)),
3357                Visuals {
3358                    fill: Some(Fill::Solid(Color::rgb(0.0, 1.0, 0.0))),
3359                    radius: 0.0,
3360                    corner_radii: None,
3361                    shadows: Vec::new(),
3362                    border: None,
3363                },
3364            ))
3365            .id();
3366
3367        extract_borders(&mut main, &mut render);
3368
3369        let borders: Vec<ExtractedBorder> = {
3370            let mut q = render.query::<&ExtractedBorder>();
3371            q.iter(&render).copied().collect()
3372        };
3373        assert_eq!(borders.len(), 1, "only the bordered entity extracts");
3374        let b = borders[0];
3375        assert_eq!(b.origin, Vec2::new(5.0, 6.0));
3376        assert_eq!(b.size, Vec2::new(50.0, 40.0));
3377        assert_eq!(b.widths, [1.0, 2.0, 3.0, 4.0]);
3378        assert_eq!(b.radius, 8.0);
3379
3380        // Clearing the border removes the render entity on the next pass.
3381        main.get_mut::<Visuals>(bordered).unwrap().border = None;
3382        extract_borders(&mut main, &mut render);
3383        let count = {
3384            let mut q = render.query::<&ExtractedBorder>();
3385            q.iter(&render).count()
3386        };
3387        assert_eq!(count, 0);
3388    }
3389
3390    /// CSS opacity semantics: an ancestor's `Opacity` multiplies into
3391    /// every descendant's painted alpha (this is what makes a fading
3392    /// dialog fade its content, not just its scrim).
3393    #[test]
3394    fn a_faded_parent_fades_its_subtree_as_a_group() {
3395        let mut main = World::new();
3396        let mut render = World::new();
3397        render.insert_resource(RenderEntityMap::default());
3398        let parent = main
3399            .spawn((
3400                Transform::new(Vec2::ZERO, Vec2::new(100.0, 100.0)),
3401                Opacity(0.5),
3402            ))
3403            .id();
3404        let child = main
3405            .spawn((
3406                Transform::new(Vec2::ZERO, Vec2::new(50.0, 50.0)),
3407                Visuals {
3408                    fill: Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0))),
3409                    radius: 0.0,
3410                    corner_radii: None,
3411                    shadows: Vec::new(),
3412                    border: None,
3413                },
3414                Opacity(0.5),
3415                ChildOf(parent),
3416            ))
3417            .id();
3418        extract_rects(&mut main, &mut render);
3419        extract_opacity_groups(&mut main, &mut render);
3420        let rects: Vec<ExtractedRect> = {
3421            let mut q = render.query::<&ExtractedRect>();
3422            q.iter(&render).cloned().collect()
3423        };
3424        assert_eq!(rects.len(), 1);
3425        let Brush::Solid(c) = &rects[0].brush else {
3426            panic!("solid brush expected");
3427        };
3428        // The leaf child folds its own 0.5; the parent's 0.5 is the group's to apply.
3429        assert!((c.a - 0.5).abs() < 1e-4, "expected 0.5, got {}", c.a);
3430        let groups: Vec<ExtractedOpacityGroup> = {
3431            let mut q = render.query::<&ExtractedOpacityGroup>();
3432            q.iter(&render).copied().collect()
3433        };
3434        assert_eq!(groups.len(), 1, "only the parent has children to fade");
3435        let (parents, mut depth) = build_parent_map(&mut main);
3436        let parent_order = paint_order_of(parent, &parents, &mut depth);
3437        let child_order = paint_order_of(child, &parents, &mut depth);
3438        assert_eq!(groups[0].alpha, 0.5);
3439        assert_eq!(groups[0].start_order, parent_order.saturating_sub(1));
3440        assert_eq!(groups[0].end_order, child_order);
3441    }
3442
3443    /// A popup under a faded element paints in the top layer, outside the element's group, so its
3444    /// paints carry the element's opacity themselves.
3445    #[test]
3446    fn an_overlay_under_a_faded_parent_carries_the_parent_s_opacity() {
3447        let mut main = World::new();
3448        let parent = main
3449            .spawn((
3450                Transform::new(Vec2::ZERO, Vec2::new(100.0, 100.0)),
3451                Opacity(0.5),
3452            ))
3453            .id();
3454        let inline = main.spawn((Transform::default(), ChildOf(parent))).id();
3455        let popup = main
3456            .spawn((Transform::default(), OverlayLayer, ChildOf(parent)))
3457            .id();
3458        let item = main.spawn((Transform::default(), ChildOf(popup))).id();
3459        let (parents, _) = build_parent_map(&mut main);
3460        let model = opacity_model(&mut main, &parents);
3461        assert_eq!(model.groups, vec![(parent, 0.5)]);
3462        assert_eq!(draw_opacity(&model.draw, parent).0, 1.0);
3463        assert_eq!(draw_opacity(&model.draw, inline).0, 1.0);
3464        assert_eq!(draw_opacity(&model.draw, popup).0, 0.5);
3465        assert_eq!(draw_opacity(&model.draw, item).0, 0.5);
3466    }
3467
3468    /// An element whose only children are hidden has nothing to fade with it, so it folds its
3469    /// opacity into its own paints.
3470    #[test]
3471    fn a_faded_element_with_no_visible_child_folds_its_own_opacity() {
3472        let mut main = World::new();
3473        let leaf = main.spawn((Transform::default(), Opacity(0.25))).id();
3474        main.spawn((Transform::default(), Visible(false), ChildOf(leaf)));
3475        let (parents, _) = build_parent_map(&mut main);
3476        let model = opacity_model(&mut main, &parents);
3477        assert!(model.groups.is_empty());
3478        assert_eq!(draw_opacity(&model.draw, leaf).0, 0.25);
3479    }
3480
3481    /// Spec section 16.2: overlay bars extract only when content overflows, and
3482    /// paint ABOVE every descendant (order strictly greater than the
3483    /// deepest child's paint order).
3484    #[test]
3485    fn scrollbar_extracts_above_content_only_when_overflowing() {
3486        use crate::input::{Scroll, ScrollbarState};
3487        let mut main = World::new();
3488        main.insert_resource(crate::input::ScrollbarInteraction::default());
3489        let mut render = World::new();
3490        render.insert_resource(RenderEntityMap::default());
3491
3492        let scroller = main
3493            .spawn((
3494                Transform::new(Vec2::ZERO, Vec2::new(200.0, 400.0)),
3495                Scroll::vertical(),
3496                ScrollOffset::default(),
3497                ScrollbarState::default(),
3498            ))
3499            .id();
3500        // Content taller than the viewport.
3501        let content = main
3502            .spawn((
3503                Transform::new(Vec2::ZERO, Vec2::new(200.0, 900.0)),
3504                ChildOf(scroller),
3505            ))
3506            .id();
3507        extract_scrollbars(&mut main, &mut render);
3508        let bars: Vec<ExtractedScrollbar> = {
3509            let mut q = render.query::<&ExtractedScrollbar>();
3510            q.iter(&render).cloned().collect()
3511        };
3512        assert_eq!(bars.len(), 1, "overflowing scroller gets a bar");
3513        assert!(!bars[0].draws.is_empty());
3514        let (parents, mut cache) = build_parent_map(&mut main);
3515        let content_order = paint_order_of(content, &parents, &mut cache);
3516        assert!(
3517            bars[0].order > content_order,
3518            "bar order {} must sit above content order {content_order}",
3519            bars[0].order
3520        );
3521
3522        // Shrink the content to fit - the bar disappears on next extract.
3523        main.get_mut::<Transform>(content).unwrap().size = Vec2::new(200.0, 300.0);
3524        extract_scrollbars(&mut main, &mut render);
3525        let count = {
3526            let mut q = render.query::<&ExtractedScrollbar>();
3527            q.iter(&render).count()
3528        };
3529        assert_eq!(count, 0, "as-needed visibility: no overflow, no bar");
3530    }
3531
3532    /// `scrollbar-width: none` disables painting entirely while the
3533    /// container still scrolls.
3534    #[test]
3535    fn scrollbar_width_none_paints_nothing() {
3536        use crate::input::{Scroll, ScrollbarState, ScrollbarStyle, ScrollbarWidthMode};
3537        let mut main = World::new();
3538        main.insert_resource(crate::input::ScrollbarInteraction::default());
3539        let mut render = World::new();
3540        render.insert_resource(RenderEntityMap::default());
3541        let scroller = main
3542            .spawn((
3543                Transform::new(Vec2::ZERO, Vec2::new(200.0, 400.0)),
3544                Scroll::vertical(),
3545                ScrollOffset::default(),
3546                ScrollbarState::default(),
3547                ScrollbarStyle {
3548                    width: ScrollbarWidthMode::None,
3549                    ..Default::default()
3550                },
3551            ))
3552            .id();
3553        main.spawn((
3554            Transform::new(Vec2::ZERO, Vec2::new(200.0, 900.0)),
3555            ChildOf(scroller),
3556        ));
3557        extract_scrollbars(&mut main, &mut render);
3558        let count = {
3559            let mut q = render.query::<&ExtractedScrollbar>();
3560            q.iter(&render).count()
3561        };
3562        assert_eq!(count, 0);
3563    }
3564
3565    /// RC2 regression: an entity outside the hierarchy forest must never land inside a clip bracket.
3566    #[test]
3567    fn orphan_entities_sort_after_tree_content() {
3568        let mut world = World::new();
3569        let orphan = world.spawn_empty().id();
3570        let child = world.spawn_empty().id();
3571        let parent = world.spawn_empty().id();
3572        world.entity_mut(child).insert(ChildOf(parent));
3573
3574        let (parents, mut cache) = build_parent_map(&mut world);
3575        let po_orphan = paint_order_of(orphan, &parents, &mut cache);
3576        let po_child = paint_order_of(child, &parents, &mut cache);
3577        assert!(
3578            po_orphan > po_child,
3579            "orphan ({po_orphan}) must paint after tree content ({po_child})"
3580        );
3581    }
3582
3583    /// RC2 regression: `ExtractedClipBox` `[start_order, end_order]` must bracket exactly the clip
3584    /// entity's descendants - a later sibling spawned with a LOWER entity id (the kanban failure
3585    /// mode: entity-id tiebreakers interleaved unrelated siblings into scroll/lane clips) must fall
3586    /// strictly outside the range.
3587    #[test]
3588    fn clip_ranges_bracket_exactly_descendants() {
3589        use crate::components::{Overflow, Style, Transform};
3590        let mut main = World::new();
3591        let mut render = World::new();
3592        render.insert_resource(RenderEntityMap::default());
3593
3594        // Allocate leaf ids first so document order and entity-id order disagree.
3595        let row1 = main.spawn(Transform::default()).id();
3596        let row2 = main.spawn(Transform::default()).id();
3597        let button = main.spawn(Transform::default()).id();
3598        let scrollbox = main
3599            .spawn((
3600                Style {
3601                    overflow_y: Overflow::Hidden,
3602                    ..Default::default()
3603                },
3604                Transform {
3605                    absolute: Vec2::new(10.0, 10.0),
3606                    size: Vec2::new(200.0, 100.0),
3607                    baseline_y: None,
3608                },
3609            ))
3610            .id();
3611        let root = main.spawn(Transform::default()).id();
3612        // Document order: root -> [scrollbox -> [row1, row2], button].
3613        main.entity_mut(scrollbox).insert(ChildOf(root));
3614        main.entity_mut(button).insert(ChildOf(root));
3615        main.entity_mut(row1).insert(ChildOf(scrollbox));
3616        main.entity_mut(row2).insert(ChildOf(scrollbox));
3617
3618        extract_clips(&mut main, &mut render);
3619
3620        let clip = {
3621            let mut q = render.query::<&ExtractedClipBox>();
3622            let boxes: Vec<ExtractedClipBox> = q.iter(&render).copied().collect();
3623            assert_eq!(boxes.len(), 1, "exactly one clip candidate");
3624            boxes[0]
3625        };
3626
3627        let (parents, mut cache) = build_parent_map(&mut main);
3628        let po_scrollbox = paint_order_of(scrollbox, &parents, &mut cache);
3629        let po_row1 = paint_order_of(row1, &parents, &mut cache);
3630        let po_row2 = paint_order_of(row2, &parents, &mut cache);
3631        let po_button = paint_order_of(button, &parents, &mut cache);
3632        let po_root = paint_order_of(root, &parents, &mut cache);
3633
3634        assert_eq!(clip.start_order, po_scrollbox);
3635        assert_eq!(clip.end_order, po_row1.max(po_row2));
3636        // Descendants inside the bracket...
3637        assert!(clip.start_order < po_row1 && po_row1 <= clip.end_order);
3638        assert!(clip.start_order < po_row2 && po_row2 <= clip.end_order);
3639        // ...non-descendants strictly outside, despite the button's lower entity id.
3640        assert!(
3641            po_button > clip.end_order,
3642            "later sibling (order {po_button}) must not be swallowed by clip range [{}, {}]",
3643            clip.start_order,
3644            clip.end_order
3645        );
3646        assert!(po_root < clip.start_order);
3647    }
3648
3649    /// Overlay bug repro: a popup panel early in document order must paint AFTER later-document-order
3650    /// content (the widget-garden "Long dropdown over textarea" bleed-through). The whole overlay
3651    /// subtree lands in the top-layer band with contiguous internal pre-order ranks.
3652    #[test]
3653    fn overlay_subtree_paints_after_all_normal_content() {
3654        let mut world = World::new();
3655        let root = world.spawn_empty().id();
3656        // Document order: root -> [dropdown_row -> [wrapper -> [panel -> [opt1, opt2]]], textarea_row].
3657        let dropdown_row = world.spawn(ChildOf(root)).id();
3658        let wrapper = world.spawn(ChildOf(dropdown_row)).id();
3659        let panel = world.spawn((ChildOf(wrapper), OverlayLayer)).id();
3660        let opt1 = world.spawn(ChildOf(panel)).id();
3661        let opt2 = world.spawn(ChildOf(panel)).id();
3662        let textarea_row = world.spawn(ChildOf(root)).id();
3663        let textarea = world.spawn(ChildOf(textarea_row)).id();
3664
3665        let (parents, mut cache) = build_parent_map(&mut world);
3666        let po_panel = paint_order_of(panel, &parents, &mut cache);
3667        let po_opt1 = paint_order_of(opt1, &parents, &mut cache);
3668        let po_opt2 = paint_order_of(opt2, &parents, &mut cache);
3669        for normal in [root, dropdown_row, wrapper, textarea_row, textarea] {
3670            let o = paint_order_of(normal, &parents, &mut cache);
3671            assert!(
3672                o < OVERLAY_ORDER_BASE,
3673                "normal content stays below the overlay band, got {o:#x}"
3674            );
3675            assert!(
3676                po_panel > o,
3677                "panel ({po_panel:#x}) must paint after normal content ({o:#x})"
3678            );
3679        }
3680        // Internal document order + stride-2 contiguity survive the re-banding.
3681        assert_eq!(po_opt1, po_panel + 2, "panel then first option");
3682        assert_eq!(po_opt2, po_opt1 + 2, "options keep sibling order");
3683        // Overlay band sits below the orphan fallback band.
3684        assert!((OVERLAY_ORDER_BASE..0x8000_0000).contains(&po_panel));
3685    }
3686
3687    /// Two popups open on different ticks: the later-opened one must paint on top, and re-opening a
3688    /// popup must restamp it above a still-open one.
3689    #[test]
3690    fn overlays_stack_by_open_order() {
3691        let mut world = World::new();
3692        let root = world.spawn_empty().id();
3693        // Popup A is first in document order; both carry a child so the forest includes them.
3694        let a = world.spawn((ChildOf(root), OverlayLayer)).id();
3695        let _a_kid = world.spawn(ChildOf(a)).id();
3696        let b = world
3697            .spawn((ChildOf(root), OverlayLayer, Visible(false)))
3698            .id();
3699        let _b_kid = world.spawn(ChildOf(b)).id();
3700
3701        // Tick 1: only A is open.
3702        let (parents, mut cache) = build_parent_map(&mut world);
3703        let po_a_t1 = paint_order_of(a, &parents, &mut cache);
3704        assert!(po_a_t1 >= OVERLAY_ORDER_BASE);
3705
3706        // Tick 2: B opens later -> stacks above A.
3707        world.entity_mut(b).insert(Visible(true));
3708        let (parents, mut cache) = build_parent_map(&mut world);
3709        let po_a = paint_order_of(a, &parents, &mut cache);
3710        let po_b = paint_order_of(b, &parents, &mut cache);
3711        assert!(
3712            po_b > po_a,
3713            "later-opened popup B ({po_b:#x}) must paint over A ({po_a:#x})"
3714        );
3715
3716        // Tick 3: A closes then re-opens -> restamped above B.
3717        world.entity_mut(a).insert(Visible(false));
3718        let _ = build_parent_map(&mut world);
3719        world.entity_mut(a).insert(Visible(true));
3720        let (parents, mut cache) = build_parent_map(&mut world);
3721        let po_a = paint_order_of(a, &parents, &mut cache);
3722        let po_b = paint_order_of(b, &parents, &mut cache);
3723        assert!(
3724            po_a > po_b,
3725            "re-opened popup A ({po_a:#x}) must now paint over B ({po_b:#x})"
3726        );
3727    }
3728
3729    /// An ancestor scroll/overflow clip must not stretch its bracket over an overlay subtree inside
3730    /// it - otherwise the pushed layer would clip all content ranked between the bracket ends.
3731    #[test]
3732    fn clip_ranges_exclude_overlay_subtrees() {
3733        use crate::components::{Overflow, Style, Transform};
3734        let mut main = World::new();
3735        let mut render = World::new();
3736        render.insert_resource(RenderEntityMap::default());
3737
3738        let root = main.spawn(Transform::default()).id();
3739        let scrollbox = main
3740            .spawn((
3741                Style {
3742                    overflow_y: Overflow::Hidden,
3743                    ..Default::default()
3744                },
3745                Transform {
3746                    absolute: Vec2::new(0.0, 0.0),
3747                    size: Vec2::new(200.0, 100.0),
3748                    baseline_y: None,
3749                },
3750                ChildOf(root),
3751            ))
3752            .id();
3753        let row = main.spawn((Transform::default(), ChildOf(scrollbox))).id();
3754        let panel = main
3755            .spawn((Transform::default(), ChildOf(scrollbox), OverlayLayer))
3756            .id();
3757        let opt = main.spawn((Transform::default(), ChildOf(panel))).id();
3758
3759        extract_clips(&mut main, &mut render);
3760        let clip = {
3761            let mut q = render.query::<&ExtractedClipBox>();
3762            let boxes: Vec<ExtractedClipBox> = q.iter(&render).copied().collect();
3763            assert_eq!(boxes.len(), 1, "exactly one clip candidate");
3764            boxes[0]
3765        };
3766
3767        let (parents, mut cache) = build_parent_map(&mut main);
3768        let po_row = paint_order_of(row, &parents, &mut cache);
3769        let po_panel = paint_order_of(panel, &parents, &mut cache);
3770        let po_opt = paint_order_of(opt, &parents, &mut cache);
3771        assert_eq!(
3772            clip.end_order, po_row,
3773            "bracket ends at the last NORMAL descendant"
3774        );
3775        assert!(
3776            po_panel > clip.end_order && po_opt > clip.end_order,
3777            "overlay subtree ({po_panel:#x}, {po_opt:#x}) must fall outside the clip bracket [{:#x}, {:#x}]",
3778            clip.start_order,
3779            clip.end_order
3780        );
3781    }
3782
3783    /// A clip owned INSIDE the overlay subtree (scrollable long dropdown) keeps a paired bracket in
3784    /// the overlay band covering exactly its descendants.
3785    #[test]
3786    fn overlay_internal_clip_brackets_intact() {
3787        use crate::components::{Overflow, Style, Transform};
3788        let mut main = World::new();
3789        let mut render = World::new();
3790        render.insert_resource(RenderEntityMap::default());
3791
3792        let root = main.spawn(Transform::default()).id();
3793        let panel = main
3794            .spawn((
3795                Style {
3796                    overflow_y: Overflow::Hidden,
3797                    ..Default::default()
3798                },
3799                Transform {
3800                    absolute: Vec2::new(0.0, 0.0),
3801                    size: Vec2::new(200.0, 100.0),
3802                    baseline_y: None,
3803                },
3804                ChildOf(root),
3805                OverlayLayer,
3806            ))
3807            .id();
3808        let opt1 = main.spawn((Transform::default(), ChildOf(panel))).id();
3809        let opt2 = main.spawn((Transform::default(), ChildOf(panel))).id();
3810
3811        extract_clips(&mut main, &mut render);
3812        let clip = {
3813            let mut q = render.query::<&ExtractedClipBox>();
3814            let boxes: Vec<ExtractedClipBox> = q.iter(&render).copied().collect();
3815            assert_eq!(boxes.len(), 1);
3816            boxes[0]
3817        };
3818
3819        let (parents, mut cache) = build_parent_map(&mut main);
3820        let po_panel = paint_order_of(panel, &parents, &mut cache);
3821        let po_opt1 = paint_order_of(opt1, &parents, &mut cache);
3822        let po_opt2 = paint_order_of(opt2, &parents, &mut cache);
3823        assert_eq!(clip.start_order, po_panel);
3824        assert_eq!(clip.end_order, po_opt1.max(po_opt2));
3825        assert!(
3826            clip.start_order >= OVERLAY_ORDER_BASE,
3827            "bracket lives in the overlay band"
3828        );
3829        assert!(clip.start_order < po_opt1 && po_opt1 <= clip.end_order);
3830        assert!(clip.start_order < po_opt2 && po_opt2 <= clip.end_order);
3831    }
3832
3833    /// Issue 136 regression: an `overflow: hidden` box inside a scrolled container clips where it is
3834    /// drawn, not where it was laid out. The clip box and the nearest-clip-ancestor rect used to keep
3835    /// the raw layout origin while every leaf under them moved with the scroll offset, so scrolling
3836    /// far enough carried a subtree clean out of its own clip and the box painted nothing.
3837    #[test]
3838    fn clip_rects_follow_the_scroll_offset_of_their_ancestors() {
3839        use crate::components::{Overflow, Style, Transform, Visuals};
3840        use crate::input::{Scroll, ScrollOffset};
3841        let mut main = World::new();
3842        let mut render = World::new();
3843        render.insert_resource(RenderEntityMap::default());
3844
3845        // A 200x100 scroll viewport, scrolled 200px down, holding a 60px-tall
3846        // overflow-hidden card that starts at y = 200 - i.e. exactly at the top
3847        // of the viewport once scrolled.
3848        let scroller = main
3849            .spawn((
3850                Scroll::default(),
3851                ScrollOffset(Vec2::new(0.0, 200.0)),
3852                Style::default(),
3853                Transform {
3854                    absolute: Vec2::ZERO,
3855                    size: Vec2::new(200.0, 100.0),
3856                    baseline_y: None,
3857                },
3858            ))
3859            .id();
3860        let card = main
3861            .spawn((
3862                Style {
3863                    overflow_y: Overflow::Hidden,
3864                    ..Default::default()
3865                },
3866                Transform {
3867                    absolute: Vec2::new(0.0, 200.0),
3868                    size: Vec2::new(200.0, 60.0),
3869                    baseline_y: None,
3870                },
3871                ChildOf(scroller),
3872            ))
3873            .id();
3874        let inner = main
3875            .spawn((
3876                Transform {
3877                    absolute: Vec2::new(0.0, 200.0),
3878                    size: Vec2::new(50.0, 50.0),
3879                    baseline_y: None,
3880                },
3881                Visuals {
3882                    fill: Some(crate::components::Fill::Solid(Color::rgb(1.0, 0.0, 0.0))),
3883                    ..Default::default()
3884                },
3885                ChildOf(card),
3886            ))
3887            .id();
3888
3889        let (parents, _) = build_parent_map(&mut main);
3890        let clips = parent_scroll_clip_rects(&mut main, &parents);
3891        assert_eq!(
3892            clips.get(&inner).copied(),
3893            Some((Vec2::new(0.0, 0.0), Vec2::new(200.0, 60.0))),
3894            "the card clips its child where the card is drawn"
3895        );
3896
3897        extract_clips(&mut main, &mut render);
3898        let boxes: Vec<ExtractedClipBox> = {
3899            let mut q = render.query::<&ExtractedClipBox>();
3900            q.iter(&render).copied().collect()
3901        };
3902        let card_box = boxes
3903            .iter()
3904            .find(|b| b.size == Vec2::new(200.0, 60.0))
3905            .expect("clip box for the card");
3906        assert_eq!(card_box.origin, Vec2::new(0.0, 0.0));
3907        let scroller_box = boxes
3908            .iter()
3909            .find(|b| b.size == Vec2::new(200.0, 100.0))
3910            .expect("clip box for the scroll container");
3911        assert_eq!(
3912            scroller_box.origin,
3913            Vec2::ZERO,
3914            "a scroll box moves its content, not its own frame"
3915        );
3916
3917        extract_rects(&mut main, &mut render);
3918        let origins: Vec<Vec2> = {
3919            let mut q = render.query::<&ExtractedRect>();
3920            q.iter(&render).map(|r| r.origin).collect()
3921        };
3922        assert_eq!(
3923            origins,
3924            vec![Vec2::ZERO],
3925            "the scrolled-into-view child still paints"
3926        );
3927    }
3928
3929    /// Overlay content escapes ancestor scroll/overflow clip rects (top-layer semantics): the
3930    /// nearest-clip-ancestor map must stop at the overlay root, while normal siblings keep theirs.
3931    #[test]
3932    fn popup_content_escapes_ancestor_clip_rect() {
3933        use crate::components::{Overflow, Style, Transform};
3934        let mut main = World::new();
3935
3936        let root = main.spawn(Transform::default()).id();
3937        let scrollbox = main
3938            .spawn((
3939                Style {
3940                    overflow_y: Overflow::Hidden,
3941                    ..Default::default()
3942                },
3943                Transform {
3944                    absolute: Vec2::new(0.0, 0.0),
3945                    size: Vec2::new(200.0, 50.0),
3946                    baseline_y: None,
3947                },
3948                ChildOf(root),
3949            ))
3950            .id();
3951        let row = main.spawn((Transform::default(), ChildOf(scrollbox))).id();
3952        let panel = main
3953            .spawn((Transform::default(), ChildOf(scrollbox), OverlayLayer))
3954            .id();
3955        let opt = main.spawn((Transform::default(), ChildOf(panel))).id();
3956
3957        let (parents, _) = build_parent_map(&mut main);
3958        let clips = parent_scroll_clip_rects(&mut main, &parents);
3959        assert!(
3960            clips.contains_key(&row),
3961            "normal child keeps its scroll-ancestor clip"
3962        );
3963        assert!(
3964            !clips.contains_key(&panel) && !clips.contains_key(&opt),
3965            "overlay subtree escapes the ancestor clip rect"
3966        );
3967    }
3968
3969    /// `ExtractedText::line_height_px` falls back to
3970    /// `size_px * DEFAULT_LINE_HEIGHT_MULTIPLIER` when the entity's
3971    /// `TextStyle::line_height` is absent (no `line-height` CSS reached
3972    /// this element) - preserves today's `1.2` behaviour exactly.
3973    #[test]
3974    fn line_height_px_falls_back_to_default_multiplier() {
3975        use crate::components::{
3976            DEFAULT_LINE_HEIGHT_MULTIPLIER, TextContent, TextStyle, Transform,
3977        };
3978
3979        let mut main = World::new();
3980        let mut render = World::new();
3981        render.init_resource::<RenderEntityMap>();
3982        main.spawn((
3983            Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
3984            TextContent("hi".to_string()),
3985            TextStyle {
3986                size_px: 20.0,
3987                ..Default::default()
3988            },
3989        ));
3990        extract_text(&mut main, &mut render);
3991        let mut q = render.query::<&ExtractedText>();
3992        let et = q.iter(&render).next().unwrap();
3993        assert_eq!(et.line_height_px, 20.0 * DEFAULT_LINE_HEIGHT_MULTIPLIER);
3994    }
3995
3996    /// A CSS `line-height` value (here an explicit multiplier) overrides
3997    /// the default `1.2` ratio end-to-end through `extract_text`.
3998    #[test]
3999    fn line_height_px_honours_css_multiplier_override() {
4000        use crate::components::{LineHeightSpec, TextContent, TextStyle, Transform};
4001
4002        let mut main = World::new();
4003        let mut render = World::new();
4004        render.init_resource::<RenderEntityMap>();
4005        main.spawn((
4006            Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
4007            TextContent("hi".to_string()),
4008            TextStyle {
4009                size_px: 20.0,
4010                line_height: Some(LineHeightSpec::Multiplier(1.5)),
4011                ..Default::default()
4012            },
4013        ));
4014        extract_text(&mut main, &mut render);
4015        let mut q = render.query::<&ExtractedText>();
4016        let et = q.iter(&render).next().unwrap();
4017        assert_eq!(et.line_height_px, 30.0);
4018    }
4019
4020    /// A CSS `line-height` value expressed in absolute pixels
4021    /// ([`LineHeightSpec::Px`]) does not scale with `size_px`.
4022    #[test]
4023    fn line_height_px_honours_css_absolute_override() {
4024        use crate::components::{LineHeightSpec, TextContent, TextStyle, Transform};
4025
4026        let mut main = World::new();
4027        let mut render = World::new();
4028        render.init_resource::<RenderEntityMap>();
4029        main.spawn((
4030            Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
4031            TextContent("hi".to_string()),
4032            TextStyle {
4033                size_px: 20.0,
4034                line_height: Some(LineHeightSpec::Px(19.0)),
4035                ..Default::default()
4036            },
4037        ));
4038        extract_text(&mut main, &mut render);
4039        let mut q = render.query::<&ExtractedText>();
4040        let et = q.iter(&render).next().unwrap();
4041        assert_eq!(et.line_height_px, 19.0);
4042    }
4043
4044    /// `ExtractedText::caret_width_px` falls back to [`CARET_WIDTH_PX`]
4045    /// absent a [`CaretWidth`] override, and honours the override when
4046    /// present - the same override/fallback shape as every other
4047    /// CSS-supplied value.
4048    #[test]
4049    fn caret_width_px_falls_back_then_honours_override() {
4050        use crate::components::{CaretWidth, TextContent, Transform};
4051
4052        let mut main = World::new();
4053        let mut render = World::new();
4054        render.init_resource::<RenderEntityMap>();
4055        let plain = main
4056            .spawn((
4057                Transform::new(Vec2::ZERO, Vec2::new(120.0, 24.0)),
4058                TextContent("a".to_string()),
4059            ))
4060            .id();
4061        let overridden = main
4062            .spawn((
4063                Transform::new(Vec2::new(0.0, 40.0), Vec2::new(120.0, 24.0)),
4064                TextContent("b".to_string()),
4065                CaretWidth(4.0),
4066            ))
4067            .id();
4068        extract_text(&mut main, &mut render);
4069        let map = render.resource::<RenderEntityMap>().text.clone();
4070        let plain_et = render.get::<ExtractedText>(map[&plain]).unwrap();
4071        let overridden_et = render.get::<ExtractedText>(map[&overridden]).unwrap();
4072        assert_eq!(plain_et.caret_width_px, CARET_WIDTH_PX);
4073        assert_eq!(overridden_et.caret_width_px, 4.0);
4074    }
4075
4076    /// Every distribution a box can take, mapped onto where it leaves the
4077    /// one item a run of text becomes. Exercised directly because the
4078    /// arms differ only in the value they return, and a pipeline test can
4079    /// only reach one of them per app it builds.
4080    #[test]
4081    fn a_distribution_places_the_run_the_way_it_places_an_item() {
4082        use crate::components::{FlexDirection, FlexJustify, Style};
4083
4084        let row = |justify| Style {
4085            justify,
4086            ..Style::default()
4087        };
4088        let cases = [
4089            (FlexJustify::Start, TextAlign::Start),
4090            (FlexJustify::SpaceBetween, TextAlign::Start),
4091            (FlexJustify::Center, TextAlign::Center),
4092            (FlexJustify::SpaceAround, TextAlign::Center),
4093            (FlexJustify::SpaceEvenly, TextAlign::Center),
4094            (FlexJustify::End, TextAlign::End),
4095        ];
4096        for (justify, want) in cases {
4097            assert_eq!(
4098                text_run_align(TextAlign::Start, Some(&row(justify))),
4099                want,
4100                "{justify:?} must leave the run at {want:?}"
4101            );
4102        }
4103
4104        // Reversed, the ends swap and the middle stays put.
4105        let reversed = |justify| Style {
4106            justify,
4107            flex_direction: FlexDirection::RowReverse,
4108            ..Style::default()
4109        };
4110        assert_eq!(
4111            text_run_align(TextAlign::Start, Some(&reversed(FlexJustify::Start))),
4112            TextAlign::End
4113        );
4114        assert_eq!(
4115            text_run_align(TextAlign::Start, Some(&reversed(FlexJustify::End))),
4116            TextAlign::Start
4117        );
4118        assert_eq!(
4119            text_run_align(TextAlign::Start, Some(&reversed(FlexJustify::Center))),
4120            TextAlign::Center
4121        );
4122
4123        // A vertical main axis distributes down the box, and an element
4124        // with no style at all has nothing to read.
4125        for direction in [FlexDirection::Column, FlexDirection::ColumnReverse] {
4126            let column = Style {
4127                justify: FlexJustify::Center,
4128                flex_direction: direction,
4129                ..Style::default()
4130            };
4131            assert_eq!(
4132                text_run_align(TextAlign::Start, Some(&column)),
4133                TextAlign::Start
4134            );
4135        }
4136        assert_eq!(text_run_align(TextAlign::Start, None), TextAlign::Start);
4137
4138        // An authored alignment is the answer whatever the box says.
4139        assert_eq!(
4140            text_run_align(TextAlign::End, Some(&row(FlexJustify::Center))),
4141            TextAlign::End
4142        );
4143    }
4144}