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