Skip to main content

aetna_core/paint/
draw_ops.rs

1//! Tree → [`DrawOp`] resolution.
2//!
3//! Walks the laid-out [`El`] tree and emits a flat [`Vec<DrawOp>`] in
4//! paint order. Each visual fact resolves to a `Quad` (bound to a stock
5//! or custom shader, with uniforms packed) or a `GlyphRun`.
6//!
7//! State styling lands here on the CPU side. Hover lightens / press
8//! darkens / ring fade come from the eased envelopes in
9//! `UiState`'s eased envelope side map, written by
10//! [`UiState::tick_visual_animations`] in the prior pass. What this
11//! module computes are the deltas: lerp the build-time colours toward
12//! the state-modulated ones by the envelope amount, plus the non-eased
13//! `Disabled` (alpha multiply) and `Loading` (text suffix) deltas.
14
15use crate::ir::*;
16use crate::palette::Palette;
17use crate::shader::*;
18use crate::state::{EnvelopeKind, UiState};
19use crate::text::atlas::RunStyle;
20use crate::text::metrics as text_metrics;
21use crate::theme::Theme;
22use crate::tokens;
23use crate::tree::*;
24use crate::widgets::text_area::{TEXT_AREA_CARET_LAYER, TEXT_AREA_SELECTION_LAYER};
25
26/// Walk the laid-out tree and emit draw ops in paint order.
27pub fn draw_ops(root: &El, ui_state: &UiState) -> Vec<DrawOp> {
28    draw_ops_with_theme(root, ui_state, &Theme::default())
29}
30
31/// Walk the laid-out tree and emit draw ops using a caller-supplied theme.
32pub fn draw_ops_with_theme(root: &El, ui_state: &UiState, theme: &Theme) -> Vec<DrawOp> {
33    let mut stats = DrawOpsStats::default();
34    draw_ops_with_theme_and_stats(root, ui_state, theme, &mut stats)
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct DrawOpsStats {
39    pub culled_text_ops: u64,
40}
41
42/// Walk the laid-out tree and emit draw ops, reporting cheap culling
43/// decisions made before expensive text measurement.
44pub fn draw_ops_with_theme_and_stats(
45    root: &El,
46    ui_state: &UiState,
47    theme: &Theme,
48    stats: &mut DrawOpsStats,
49) -> Vec<DrawOp> {
50    let mut out = Vec::new();
51    push_node(
52        root,
53        ui_state,
54        theme,
55        &mut out,
56        None,
57        (0.0, 0.0),
58        1.0,
59        1.0,
60        0.0,
61        0.0,
62        0.0,
63        stats,
64    );
65    resolve_palette(&mut out, theme.palette());
66    out
67}
68
69/// Replace every `Color` in `ops` with its palette-resolved version.
70///
71/// This is the single chokepoint where token names become rgba: the
72/// per-node passes write `Color` values straight from `tokens::*`
73/// (preserving the `token: Some(name)` metadata), then this pass walks
74/// every emitted [`DrawOp`] and rewrites each color through
75/// [`Palette::resolve`]. Token names survive resolution, so shader
76/// manifest / tree-dump / lint output still see `fill=card` rather
77/// than rgba bytes.
78pub fn resolve_palette(ops: &mut [DrawOp], palette: &Palette) {
79    for op in ops {
80        match op {
81            DrawOp::Quad { uniforms, .. } => {
82                resolve_uniform_block(uniforms, palette);
83            }
84            DrawOp::GlyphRun { color, .. } => {
85                *color = palette.resolve(*color);
86            }
87            DrawOp::AttributedText { runs, .. } => {
88                for (_, style) in runs {
89                    style.color = palette.resolve(style.color);
90                    if let Some(bg) = &mut style.bg {
91                        *bg = palette.resolve(*bg);
92                    }
93                }
94            }
95            DrawOp::Icon { color, .. } => {
96                *color = palette.resolve(*color);
97            }
98            DrawOp::Image { tint, .. } => {
99                if let Some(t) = tint {
100                    *t = palette.resolve(*t);
101                }
102            }
103            DrawOp::AppTexture { .. } => {}
104            DrawOp::Vector {
105                asset, render_mode, ..
106            } => {
107                *render_mode = render_mode.resolved_palette(palette);
108                if matches!(render_mode, crate::vector::VectorRenderMode::Painted) {
109                    *asset = std::sync::Arc::new(asset.resolved_palette(palette));
110                }
111            }
112            DrawOp::BackdropSnapshot => {}
113        }
114    }
115}
116
117fn resolve_uniform_block(uniforms: &mut UniformBlock, palette: &Palette) {
118    let keys: Vec<&'static str> = uniforms
119        .iter()
120        .filter_map(|(k, v)| matches!(v, UniformValue::Color(_)).then_some(*k))
121        .collect();
122    for k in keys {
123        if let Some(UniformValue::Color(c)) = uniforms.get(k).copied() {
124            uniforms.insert(k, UniformValue::Color(palette.resolve(c)));
125        }
126    }
127}
128
129// Recursion threads seven "inherited from parent" paint values
130// (scissor, translate, opacity, focus / hover / press envelopes from
131// the nearest focusable ancestor, plus the *strict* nearest-focusable-
132// ancestor's combined subtree-interaction envelope used by
133// `hover_alpha`) and the four shared references (node, ui_state,
134// theme, out accumulator). The explicit signature documents the
135// dataflow more clearly than a bundling struct would.
136#[allow(clippy::too_many_arguments)]
137fn push_node(
138    n: &El,
139    ui_state: &UiState,
140    theme: &Theme,
141    out: &mut Vec<DrawOp>,
142    inherited_scissor: Option<Rect>,
143    inherited_translate: (f32, f32),
144    inherited_opacity: f32,
145    inherited_focus_envelope: f32,
146    inherited_hover_envelope: f32,
147    inherited_press_envelope: f32,
148    inherited_interaction_envelope: f32,
149    stats: &mut DrawOpsStats,
150) {
151    let computed = ui_state.rect(&n.computed_id);
152    let state = ui_state.node_state(&n.computed_id);
153    let hover_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Hover);
154    let press_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Press);
155    let focus_ring_alpha = ui_state.envelope(&n.computed_id, EnvelopeKind::FocusRing);
156
157    // `state_follows_interactive_ancestor` borrows the nearest
158    // focusable ancestor's hover / press envelopes for paint. The
159    // hit-test only ever lands on the focusable container above, so
160    // child elements (slider thumb, etc.) never receive their own
161    // envelope — without this, hover / press are dead on those
162    // children.
163    let (effective_hover, effective_press) = if n.state_follows_interactive_ancestor {
164        (inherited_hover_envelope, inherited_press_envelope)
165    } else {
166        (hover_amount, press_amount)
167    };
168
169    let (fill, stroke, text_color, weight, suffix) =
170        apply_state(n, state, effective_hover, effective_press, theme.palette());
171
172    // `translate` is subtree-inheriting: descendants paint at their
173    // computed rect plus all ancestor `translate` accumulated through
174    // the recursion. `scale` and `opacity` apply to this node only —
175    // a parent fading to 0.5 multiplies through to descendants via
176    // `inherited_opacity`, but `scale` doesn't propagate (descendants
177    // keep their own paint metrics).
178    let total_translate = (
179        inherited_translate.0 + n.translate.0,
180        inherited_translate.1 + n.translate.1,
181    );
182    // Nodes flagged with `alpha_follows_focused_ancestor` fade with
183    // their nearest focusable ancestor's focus envelope. The flag is
184    // layout-neutral; we just multiply the ancestor's envelope into
185    // this node's paint opacity, and the existing alpha modulation in
186    // `opaque(...)` propagates that to fill / stroke / text colors.
187    let focus_alpha_mul = if n.alpha_follows_focused_ancestor {
188        inherited_focus_envelope
189    } else {
190        1.0
191    };
192    // Caret blink: nodes flagged `blink_when_focused` are additionally
193    // multiplied by the runtime's caret-blink alpha. Composes with the
194    // focus envelope above so the caret bar fades in on focus, then
195    // settles into the on/off cycle while focus stays.
196    let blink_alpha_mul = if n.blink_when_focused {
197        // No activity recorded yet → caret stays solid. This keeps
198        // headless / pre-event tests deterministic without forcing
199        // them to drive the animation tick.
200        if ui_state.caret.activity_at.is_some() {
201            ui_state.caret.blink_alpha
202        } else {
203            1.0
204        }
205    } else {
206        1.0
207    };
208    // Subtree interaction envelope for this node: max of the hover,
209    // focus, and press envelopes covering "is the active target this
210    // node or any descendant?". Tracked only on nodes that consume it
211    // (focusable nodes plus `hover_alpha` consumers); other nodes read
212    // back as `0.0` and don't contribute. Used immediately for
213    // `hover_alpha` and below to update the cascade for descendants.
214    let self_interaction_envelope = ui_state
215        .envelope(&n.computed_id, EnvelopeKind::SubtreeHover)
216        .max(ui_state.envelope(&n.computed_id, EnvelopeKind::SubtreePress))
217        .max(ui_state.envelope(&n.computed_id, EnvelopeKind::SubtreeFocus));
218    // `hover_alpha` lerps the node's drawn alpha between `rest` and
219    // `peak` along the **subtree interaction envelope of the
220    // surrounding interaction region** — `max` of the nearest
221    // focusable ancestor's subtree envelope (cascaded as
222    // `inherited_interaction_envelope`) and this node's own subtree
223    // envelope when the consumer is itself focusable / a hover_alpha
224    // wrapper.
225    //
226    // The ancestor half handles the close-×-on-tab pattern: the close
227    // is below a focusable tab; when the tab (or anything inside it)
228    // is the hot target, the tab's subtree envelope rises and the
229    // close fades in. The self half handles the action-pill pattern:
230    // a non-focusable wrapper carrying `hover_alpha` whose own
231    // descendants are the hot target — the wrapper's own subtree
232    // envelope captures that case directly.
233    //
234    // Distinct from the per-node `Hover` / `Press` / `FocusRing`
235    // envelopes used by `apply_state` (single-target visuals like
236    // hover-lighten) and from `inherited_hover_envelope` /
237    // `inherited_press_envelope` (the per-node envelope cascade for
238    // `state_follows_interactive_ancestor`). Three independent
239    // mechanisms, each answering a different question:
240    //   - "is this node the hot target?" → per-node envelopes
241    //   - "is the slider's focusable container hot?" → per-node
242    //     cascade (state_follows_interactive_ancestor)
243    //   - "is anything in the surrounding interaction region hot?" →
244    //     subtree-interaction cascade (hover_alpha)
245    let hover_alpha_mul = match n.hover_alpha {
246        Some(cfg) => {
247            let combined = inherited_interaction_envelope.max(self_interaction_envelope);
248            cfg.rest + (cfg.peak - cfg.rest) * combined
249        }
250        None => 1.0,
251    };
252    let opacity =
253        inherited_opacity * n.opacity * focus_alpha_mul * blink_alpha_mul * hover_alpha_mul;
254    // Children inherit the *immediate* focusable ancestor's envelope.
255    // When this node is itself focusable, its envelope replaces the
256    // inherited one; otherwise the inherited value passes through.
257    // Hover / press follow the same rule so opt-in descendants can
258    // borrow their interactive ancestor's state envelopes (see
259    // `state_follows_interactive_ancestor`).
260    let child_focus_envelope = if n.focusable {
261        focus_ring_alpha
262    } else {
263        inherited_focus_envelope
264    };
265    let child_hover_envelope = if n.focusable {
266        hover_amount
267    } else {
268        inherited_hover_envelope
269    };
270    let child_press_envelope = if n.focusable {
271        press_amount
272    } else {
273        inherited_press_envelope
274    };
275    // The interaction-envelope cascade replaces at focusable nodes:
276    // descendants of a focusable container read *that* container's
277    // subtree envelope, not the grandparent's. `hover_alpha` consumers
278    // OR-merge with their own (via `self_interaction_envelope` above)
279    // so a focusable consumer like an `icon_button` close-× still
280    // sees its parent tab's envelope through this cascade.
281    let child_interaction_envelope = if n.focusable {
282        self_interaction_envelope
283    } else {
284        inherited_interaction_envelope
285    };
286
287    let translated_rect = translated(computed, total_translate);
288    // The layout rect, post translate + scale, is the visual boundary the
289    // SDF and clip both anchor to. `painted_rect` extends it by
290    // `paint_overflow` so the quad has room to draw focus rings, drop
291    // shadows, and other halos *outside* the layout box without
292    // affecting sibling positions. Drop shadow auto-widens the band
293    // (per-side max with explicit `paint_overflow`) so `.shadow(s)`
294    // works without every shadow-using widget remembering to set
295    // `paint_overflow` separately. The stock-shader branch resolves the
296    // *effective* shadow (post-theme) before computing `painted_rect`,
297    // since surface roles can rewrite the shadow uniform.
298    let inner_painted_rect = scaled_around_center(translated_rect, n.scale);
299    let painted_font_size = n.font_size * n.scale;
300
301    // Clip uses the layout rect, not the overflowed painted rect:
302    // `clip()` is about constraining descendants to the layout box, not
303    // about whether this element's own paint can spill into its
304    // overflow band.
305    let own_scissor = if n.clip {
306        intersect_scissor(inherited_scissor, inner_painted_rect)
307    } else {
308        inherited_scissor
309    };
310
311    if matches!(
312        n.kind,
313        Kind::Custom(TEXT_AREA_SELECTION_LAYER) | Kind::Custom(TEXT_AREA_CARET_LAYER)
314    ) {
315        push_text_area_editor_overlay(
316            n,
317            ui_state,
318            theme,
319            out,
320            inner_painted_rect,
321            own_scissor,
322            opacity,
323            inherited_focus_envelope,
324            painted_font_size,
325            weight,
326        );
327    }
328
329    // Surface paint. Either a custom shader override, or the implicit
330    // `stock::rounded_rect` driven by the El's fill/stroke/radius/shadow.
331    if let Some(custom) = &n.shader_override {
332        // Custom shaders manage their own paint extent; we only honor
333        // explicit `paint_overflow` here. They may pack a shadow into
334        // their own uniform name, which we can't introspect.
335        let painted_rect = inner_painted_rect.outset(n.paint_overflow);
336        let mut uniforms = custom.uniforms.clone();
337        uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
338        out.push(DrawOp::Quad {
339            id: n.computed_id.clone(),
340            rect: painted_rect,
341            scissor: own_scissor,
342            shader: custom.handle,
343            uniforms,
344        });
345    } else if fill.is_some() || stroke.is_some() || focus_ring_alpha > 0.0 {
346        let mut uniforms = UniformBlock::new();
347        if let Some(c) = fill {
348            // `dim_fill` lerps the painted color toward `fill` as the
349            // inherited focus envelope rises. `inherited_focus_envelope`
350            // here is the nearest focusable ancestor's envelope (the
351            // band's parent text_input / text_area), so the band reads
352            // as muted while the input is unfocused and saturates as
353            // the focus animation completes.
354            //
355            // Resolve `dim` through the palette before mixing — `c` is
356            // already palette-resolved by `apply_state` above, but
357            // `dim_fill` comes straight from the El. Without this, the
358            // unfocused band reads against the compile-time dark rgb
359            // of the dim token and doesn't track a runtime palette swap.
360            let resolved = match n.dim_fill {
361                Some(dim) => theme.resolve(dim).mix(c, inherited_focus_envelope),
362                None => c,
363            };
364            uniforms.insert("fill", UniformValue::Color(opaque(resolved, opacity)));
365        }
366        if let Some(c) = stroke {
367            uniforms.insert("stroke", UniformValue::Color(opaque(c, opacity)));
368            uniforms.insert("stroke_width", UniformValue::F32(n.stroke_width));
369        }
370        // `radius` carries the max corner so custom shaders that read
371        // a scalar uniform see the same shape as before. Per-corner
372        // values go on `radii` (tl, tr, br, bl) — stock::rounded_rect
373        // and stock::image read this for the SDF; SVG bundle output
374        // emits a `<path>` when corners differ, `<rect rx>` otherwise.
375        uniforms.insert("radius", UniformValue::F32(n.radius.max()));
376        uniforms.insert("radii", UniformValue::Vec4(n.radius.to_array()));
377        if n.shadow > 0.0 {
378            uniforms.insert("shadow", UniformValue::F32(n.shadow));
379        }
380        uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
381        // Focus ring rides on the node's own quad: the library injects a
382        // `focus_color` (with the eased focus alpha already multiplied
383        // into its rgba) plus `focus_width`. Positive width means outside
384        // the layout rect; negative means an inside ring for dense flush rows.
385        // Custom shaders read the same uniforms and decide for
386        // themselves what to paint — the symmetry rule.
387        if n.focusable && focus_ring_alpha > 0.0 {
388            let base = tokens::RING;
389            let eased_alpha = (base.a as f32 * focus_ring_alpha * opacity)
390                .round()
391                .clamp(0.0, 255.0) as u8;
392            uniforms.insert(
393                "focus_color",
394                UniformValue::Color(base.with_alpha(eased_alpha)),
395            );
396            let focus_width = match n.focus_ring_placement {
397                FocusRingPlacement::Outside => tokens::RING_WIDTH,
398                FocusRingPlacement::Inside => -tokens::RING_WIDTH,
399            };
400            uniforms.insert("focus_width", UniformValue::F32(focus_width));
401        }
402        theme.apply_surface_uniforms(n.surface_role, &mut uniforms);
403        // Read shadow + stroke *after* theme has had its say — surface
404        // roles (Panel/Popover/Sunken/...) can override either uniform,
405        // and we want the painted rect to track what actually renders.
406        let effective_shadow = match uniforms.get("shadow") {
407            Some(UniformValue::F32(s)) => *s,
408            _ => 0.0,
409        };
410        let effective_stroke_width = if uniforms.contains_key("stroke") {
411            match uniforms.get("stroke_width") {
412                Some(UniformValue::F32(w)) => *w,
413                _ => 0.0,
414            }
415        } else {
416            0.0
417        };
418        let focus_width = if n.focusable
419            && focus_ring_alpha > 0.0
420            && matches!(n.focus_ring_placement, FocusRingPlacement::Outside)
421        {
422            tokens::RING_WIDTH
423        } else {
424            0.0
425        };
426        let painted_rect = inner_painted_rect.outset(combined_overflow(
427            n.paint_overflow,
428            effective_shadow,
429            effective_stroke_width,
430            focus_width,
431        ));
432        out.push(DrawOp::Quad {
433            id: n.computed_id.clone(),
434            rect: painted_rect,
435            scissor: own_scissor,
436            shader: theme.surface_handle(n.surface_role),
437            uniforms,
438        });
439    }
440
441    if let Some(text) = &n.text {
442        // `padding` on a text-bearing node insets the glyph rect the
443        // same way it insets the children of a container node — so
444        // `text("X").padding(...)` and `column([text("X")]).padding(...)`
445        // produce visually identical results. Without this, padding on
446        // a text node would silently inflate intrinsic measurement only
447        // and disappear once `Align::Stretch` flattened the Hug width.
448        let glyph_rect = inner_painted_rect.inset(n.padding);
449        if !rect_visible_in_scissor(glyph_rect, own_scissor) {
450            stats.culled_text_ops += 1;
451        } else {
452            let display = match suffix {
453                Some(s) => format!("{text}{s}"),
454                None => text.clone(),
455            };
456            let display = match (n.text_wrap, n.text_max_lines) {
457                (TextWrap::Wrap, Some(max_lines)) => text_metrics::clamp_text_to_lines_with_family(
458                    &display,
459                    painted_font_size,
460                    n.font_family,
461                    weight,
462                    n.font_mono,
463                    glyph_rect.w,
464                    max_lines,
465                ),
466                _ => display,
467            };
468            let display = match (n.text_wrap, n.text_overflow) {
469                (TextWrap::NoWrap, TextOverflow::Ellipsis) => {
470                    text_metrics::ellipsize_text_with_family(
471                        &display,
472                        painted_font_size,
473                        n.font_family,
474                        weight,
475                        n.font_mono,
476                        glyph_rect.w,
477                    )
478                }
479                _ => display,
480            };
481            let anchor = match n.text_align {
482                TextAlign::Start => TextAnchor::Start,
483                TextAlign::Center => TextAnchor::Middle,
484                TextAlign::End => TextAnchor::End,
485            };
486            let text_color = opaque(text_color.unwrap_or(tokens::FOREGROUND), opacity);
487            let layout = text_metrics::layout_text_with_line_height_and_family(
488                &display,
489                painted_font_size,
490                n.line_height * n.scale,
491                n.font_family,
492                weight,
493                n.font_mono,
494                n.text_wrap,
495                match n.text_wrap {
496                    TextWrap::NoWrap => None,
497                    TextWrap::Wrap => Some(glyph_rect.w),
498                },
499            );
500
501            push_selection_bands_for_text(
502                n,
503                ui_state,
504                out,
505                glyph_rect,
506                own_scissor,
507                opacity,
508                &display,
509                painted_font_size,
510                effective_text_family(n),
511                weight,
512                n.text_wrap,
513            );
514
515            out.push(DrawOp::GlyphRun {
516                id: n.computed_id.clone(),
517                rect: glyph_rect,
518                scissor: own_scissor,
519                shader: ShaderHandle::Stock(StockShader::Text),
520                color: text_color,
521                text: display,
522                size: painted_font_size,
523                line_height: n.line_height * n.scale,
524                family: n.font_family,
525                mono_family: n.mono_font_family,
526                weight,
527                mono: n.font_mono,
528                wrap: n.text_wrap,
529                anchor,
530                layout,
531                underline: n.text_underline,
532                strikethrough: n.text_strikethrough,
533                link: n.text_link.clone(),
534            });
535        }
536    }
537
538    if let Some(source) = &n.icon {
539        let color = opaque(text_color.unwrap_or(tokens::FOREGROUND), opacity);
540        let inner = inner_painted_rect.inset(n.padding);
541        let icon_size = painted_font_size.min(inner.w).min(inner.h).max(1.0);
542        let icon_rect = Rect::new(
543            inner.center_x() - icon_size * 0.5,
544            inner.center_y() - icon_size * 0.5,
545            icon_size,
546            icon_size,
547        );
548        out.push(DrawOp::Icon {
549            id: n.computed_id.clone(),
550            rect: icon_rect,
551            scissor: own_scissor,
552            source: source.clone(),
553            color,
554            size: icon_size,
555            stroke_width: n.icon_stroke_width * n.scale,
556        });
557    }
558
559    if let Some(image) = &n.image {
560        let inner = inner_painted_rect.inset(n.padding);
561        let dest = n.image_fit.project(image.width(), image.height(), inner);
562        // Always clip image draws to the El's content rect so `Cover`
563        // / `None` overflow is cropped without forcing every author to
564        // call `.clip()`. The clamp respects any inherited scissor —
565        // when the El's `inner` is fully outside an ancestor clip,
566        // `intersect_scissor` produces `Some(Rect::zero)` and the
567        // renderer drops the draw (a bare `s.intersect(inner)` would
568        // hand back `None`, which downstream means "no scissor" and
569        // would paint the image full-bleed past the ancestor clip).
570        let scissor = intersect_scissor(own_scissor, inner);
571        let tint = n.image_tint.map(|c| opaque(c, opacity));
572        out.push(DrawOp::Image {
573            id: n.computed_id.clone(),
574            rect: dest,
575            scissor,
576            image: image.clone(),
577            tint,
578            radius: n.radius,
579            fit: n.image_fit,
580        });
581    }
582
583    if let Some(crate::surface::SurfaceSource::Texture(tex)) = &n.surface_source {
584        let inner = inner_painted_rect.inset(n.padding);
585        let (tw, th) = tex.size_px();
586        let dest = n.surface_fit.project(tw, th, inner);
587        // Always clip surface draws to the El's content rect so
588        // `Cover` / `None` overflow and any out-of-bounds
589        // `surface_transform` is cropped without forcing every author
590        // to call `.clip()`. The clamp respects any inherited scissor;
591        // see the matching block on the image branch above for why
592        // this goes through `intersect_scissor` rather than a bare
593        // `s.intersect(inner)`.
594        let scissor = intersect_scissor(own_scissor, inner);
595        out.push(DrawOp::AppTexture {
596            id: n.computed_id.clone(),
597            rect: dest,
598            scissor,
599            texture: tex.clone(),
600            alpha: n.surface_alpha,
601            fit: n.surface_fit,
602            transform: n.surface_transform,
603        });
604    }
605
606    if let Some(asset) = &n.vector_source {
607        let inner = inner_painted_rect.inset(n.padding);
608        // See the image branch above for the empty-intersection
609        // rationale behind `intersect_scissor`.
610        let scissor = intersect_scissor(own_scissor, inner);
611        out.push(DrawOp::Vector {
612            id: n.computed_id.clone(),
613            rect: inner,
614            scissor,
615            asset: asset.clone(),
616            render_mode: n.vector_render_mode,
617        });
618    }
619
620    if matches!(n.kind, Kind::Math) {
621        if let Some(source) = &n.selection_source {
622            push_atomic_selection_band(
623                n,
624                ui_state,
625                out,
626                inner_painted_rect.inset(n.padding),
627                own_scissor,
628                opacity,
629                source.visible_len(),
630            );
631        }
632        if let Some(expr) = &n.math {
633            push_math_ops(
634                n,
635                expr,
636                inner_painted_rect.inset(n.padding),
637                own_scissor,
638                opacity,
639                out,
640            );
641        }
642        return;
643    }
644
645    // Attributed paragraph: aggregate child Text/HardBreak runs into one
646    // DrawOp::AttributedText so cosmic-text shapes the runs together
647    // (wrapping crosses run boundaries like real prose). Skip recursion
648    // into children — they're encoded in the runs and don't paint
649    // independently.
650    if matches!(n.kind, Kind::Inlines) {
651        let glyph_rect = inner_painted_rect.inset(n.padding);
652        if !rect_visible_in_scissor(glyph_rect, own_scissor) {
653            stats.culled_text_ops += 1;
654            return;
655        }
656        let inline_size = inline_paragraph_font_size(n) * n.scale;
657        let inline_line_height = inline_paragraph_line_height(n) * n.scale;
658        if n.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
659            push_inline_mixed_ops(n, ui_state, glyph_rect, own_scissor, opacity, out);
660            return;
661        }
662        if let Some(source) = &n.selection_source {
663            if matches!(n.text_wrap, TextWrap::NoWrap) {
664                push_selection_bands_for_inlines(
665                    n,
666                    ui_state,
667                    out,
668                    glyph_rect,
669                    own_scissor,
670                    opacity,
671                    &source.visible,
672                    inline_size,
673                    inline_line_height,
674                );
675            } else {
676                push_selection_bands_for_text(
677                    n,
678                    ui_state,
679                    out,
680                    glyph_rect,
681                    own_scissor,
682                    opacity,
683                    &source.visible,
684                    inline_size,
685                    effective_text_family(n),
686                    FontWeight::Regular,
687                    n.text_wrap,
688                );
689            }
690        }
691        let runs = collect_inline_runs(n, opacity);
692        let concat: String = runs.iter().map(|(t, _)| t.as_str()).collect();
693        let anchor = match n.text_align {
694            TextAlign::Start => TextAnchor::Start,
695            TextAlign::Center => TextAnchor::Middle,
696            TextAlign::End => TextAnchor::End,
697        };
698        let layout = text_metrics::layout_text_with_line_height_and_family(
699            &concat,
700            inline_size,
701            inline_line_height,
702            n.font_family,
703            FontWeight::Regular,
704            false,
705            n.text_wrap,
706            match n.text_wrap {
707                TextWrap::NoWrap => None,
708                TextWrap::Wrap => Some(glyph_rect.w),
709            },
710        );
711        out.push(DrawOp::AttributedText {
712            id: n.computed_id.clone(),
713            rect: glyph_rect,
714            scissor: own_scissor,
715            shader: ShaderHandle::Stock(StockShader::Text),
716            runs,
717            size: inline_size,
718            line_height: inline_line_height,
719            wrap: n.text_wrap,
720            anchor,
721            layout,
722        });
723        return;
724    }
725
726    for c in &n.children {
727        push_node(
728            c,
729            ui_state,
730            theme,
731            out,
732            own_scissor,
733            total_translate,
734            opacity,
735            child_focus_envelope,
736            child_hover_envelope,
737            child_press_envelope,
738            child_interaction_envelope,
739            stats,
740        );
741    }
742
743    // Scrollbar thumb. Painted *after* children so it sits on top
744    // visually, with `own_scissor` so it inherits the scrollable's
745    // clip but is otherwise free of the scroll offset (the layout
746    // pass shifts the children, not the thumb). `scroll.thumb_rects` is
747    // populated only when the scrollable opted in and content
748    // overflows, so the gating is implicit. When the pointer is
749    // anywhere within the track or a drag is active, the visible
750    // thumb expands to `SCROLLBAR_THUMB_WIDTH_ACTIVE` (right-anchored)
751    // so the cursor sits inside the thumb instead of pinning the
752    // track's right edge.
753    if let Some(thumb_rect) = ui_state.scroll.thumb_rects.get(&n.computed_id) {
754        let active = thumb_is_active(n, ui_state);
755        let visible = if active {
756            let new_w = tokens::SCROLLBAR_THUMB_WIDTH_ACTIVE.max(thumb_rect.w);
757            Rect::new(
758                thumb_rect.right() - new_w,
759                thumb_rect.y,
760                new_w,
761                thumb_rect.h,
762            )
763        } else {
764            *thumb_rect
765        };
766        let painted_thumb = translated(visible, total_translate);
767        let base_fill = if active {
768            tokens::SCROLLBAR_THUMB_FILL_ACTIVE
769        } else {
770            tokens::SCROLLBAR_THUMB_FILL
771        };
772        let mut uniforms = UniformBlock::new();
773        uniforms.insert("fill", UniformValue::Color(opaque(base_fill, opacity)));
774        uniforms.insert("radius", UniformValue::F32(visible.w.min(visible.h) * 0.5));
775        uniforms.insert("inner_rect", inner_rect_uniform(painted_thumb));
776        out.push(DrawOp::Quad {
777            id: format!("{}.scrollbar-thumb", n.computed_id),
778            rect: painted_thumb,
779            scissor: own_scissor,
780            shader: ShaderHandle::Stock(StockShader::RoundedRect),
781            uniforms,
782        });
783    }
784}
785
786fn push_math_ops(
787    n: &El,
788    expr: &crate::math::MathExpr,
789    rect: Rect,
790    scissor: Option<Rect>,
791    opacity: f32,
792    out: &mut Vec<DrawOp>,
793) {
794    let layout = crate::math::layout_math(expr, n.font_size * n.scale, n.math_display);
795    let origin_x = match n.math_display {
796        crate::math::MathDisplay::Inline => rect.x,
797        crate::math::MathDisplay::Block => rect.x + ((rect.w - layout.width) * 0.5).max(0.0),
798    };
799    let baseline_y = rect.y + layout.ascent;
800    let color = opaque(crate::math::resolved_math_color(n.text_color), opacity);
801    for (i, atom) in layout.atoms.iter().enumerate() {
802        match atom {
803            crate::math::MathAtom::Glyph {
804                text,
805                x,
806                y_baseline,
807                size,
808                weight,
809                ..
810            } => {
811                let glyph_layout = crate::math::math_glyph_layout(text, *size, *weight);
812                let glyph_baseline = glyph_layout
813                    .lines
814                    .first()
815                    .map(|line| line.baseline)
816                    .unwrap_or_else(|| crate::text::metrics::line_height(*size) * 0.75);
817                let glyph_rect = Rect::new(
818                    origin_x + x,
819                    baseline_y + y_baseline - glyph_baseline,
820                    glyph_layout.width,
821                    glyph_layout.height,
822                );
823                out.push(DrawOp::GlyphRun {
824                    id: format!("{}.math-glyph.{i}", n.computed_id),
825                    rect: glyph_rect,
826                    scissor,
827                    shader: ShaderHandle::Stock(StockShader::Text),
828                    color,
829                    text: text.clone(),
830                    size: *size,
831                    line_height: crate::text::metrics::line_height(*size),
832                    family: n.font_family,
833                    mono_family: n.mono_font_family,
834                    weight: *weight,
835                    mono: false,
836                    wrap: TextWrap::NoWrap,
837                    anchor: TextAnchor::Start,
838                    layout: glyph_layout,
839                    underline: false,
840                    strikethrough: false,
841                    link: None,
842                });
843            }
844            crate::math::MathAtom::GlyphId {
845                glyph_id,
846                rect,
847                view_box,
848            } => {
849                push_math_glyph_id_op(
850                    n, *glyph_id, *rect, *view_box, origin_x, baseline_y, scissor, color, i, out,
851                );
852            }
853            crate::math::MathAtom::Rule { rect: atom_rect } => {
854                let rule_rect = Rect::new(
855                    origin_x + atom_rect.x,
856                    baseline_y + atom_rect.y,
857                    atom_rect.w,
858                    atom_rect.h,
859                );
860                let mut uniforms = UniformBlock::new();
861                uniforms.insert("fill", UniformValue::Color(color));
862                uniforms.insert("radius", UniformValue::F32(0.0));
863                uniforms.insert("inner_rect", inner_rect_uniform(rule_rect));
864                out.push(DrawOp::Quad {
865                    id: format!("{}.math-rule.{i}", n.computed_id),
866                    rect: rule_rect,
867                    scissor,
868                    shader: ShaderHandle::Stock(StockShader::RoundedRect),
869                    uniforms,
870                });
871            }
872            crate::math::MathAtom::Radical { points, thickness } => {
873                push_math_radical_op(
874                    n, points, *thickness, origin_x, baseline_y, scissor, color, i, out,
875                );
876            }
877            crate::math::MathAtom::Delimiter {
878                delimiter,
879                rect,
880                thickness,
881            } => {
882                push_math_delimiter_op(
883                    n, delimiter, *rect, *thickness, origin_x, baseline_y, scissor, color, i, out,
884                );
885            }
886        }
887    }
888}
889
890#[allow(clippy::too_many_arguments)]
891fn push_math_glyph_id_op(
892    n: &El,
893    glyph_id: u16,
894    atom_rect: Rect,
895    view_box: Rect,
896    origin_x: f32,
897    baseline_y: f32,
898    scissor: Option<Rect>,
899    color: Color,
900    atom_index: usize,
901    out: &mut Vec<DrawOp>,
902) {
903    use crate::vector::VectorRenderMode;
904
905    let Some(asset) = math_glyph_vector_asset(glyph_id, view_box) else {
906        return;
907    };
908    out.push(DrawOp::Vector {
909        id: format!("{}.math-glyph-id.{atom_index}", n.computed_id),
910        rect: Rect::new(
911            origin_x + atom_rect.x,
912            baseline_y + atom_rect.y,
913            atom_rect.w,
914            atom_rect.h,
915        ),
916        scissor,
917        asset: std::sync::Arc::new(asset),
918        render_mode: VectorRenderMode::Mask { color },
919    });
920}
921
922fn math_glyph_vector_asset(glyph_id: u16, view_box: Rect) -> Option<crate::vector::VectorAsset> {
923    use crate::vector::{
924        VectorAsset, VectorColor, VectorFill, VectorFillRule, VectorPath, VectorSegment,
925    };
926
927    const MAX_SOURCE_DIM: f32 = 24.0;
928
929    struct Outline {
930        segments: Vec<VectorSegment>,
931    }
932
933    impl ttf_parser::OutlineBuilder for Outline {
934        fn move_to(&mut self, x: f32, y: f32) {
935            self.segments.push(VectorSegment::MoveTo([x, -y]));
936        }
937
938        fn line_to(&mut self, x: f32, y: f32) {
939            self.segments.push(VectorSegment::LineTo([x, -y]));
940        }
941
942        fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
943            self.segments
944                .push(VectorSegment::QuadTo([x1, -y1], [x, -y]));
945        }
946
947        fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
948            self.segments
949                .push(VectorSegment::CubicTo([x1, -y1], [x2, -y2], [x, -y]));
950        }
951
952        fn close(&mut self) {
953            self.segments.push(VectorSegment::Close);
954        }
955    }
956
957    let Ok(face) = ttf_parser::Face::parse(aetna_fonts::NOTO_SANS_MATH_REGULAR, 0) else {
958        return None;
959    };
960    let mut outline = Outline {
961        segments: Vec::new(),
962    };
963    let _ = face.outline_glyph(ttf_parser::GlyphId(glyph_id), &mut outline)?;
964    if outline.segments.is_empty() {
965        return None;
966    }
967    if view_box.w <= 0.0 || view_box.h <= 0.0 {
968        return None;
969    }
970    let scale = MAX_SOURCE_DIM / view_box.w.max(view_box.h);
971    normalize_vector_segments(&mut outline.segments, view_box, scale);
972    let normalized_view_box = [0.0, 0.0, view_box.w * scale, view_box.h * scale];
973    let path = VectorPath {
974        segments: outline.segments,
975        fill: Some(VectorFill {
976            color: VectorColor::CurrentColor,
977            opacity: 1.0,
978            rule: VectorFillRule::NonZero,
979        }),
980        stroke: None,
981    };
982    Some(VectorAsset::from_paths(normalized_view_box, vec![path]))
983}
984
985fn normalize_vector_segments(
986    segments: &mut [crate::vector::VectorSegment],
987    view_box: Rect,
988    scale: f32,
989) {
990    use crate::vector::VectorSegment;
991
992    let normalize = |point: &mut [f32; 2]| {
993        point[0] = (point[0] - view_box.x) * scale;
994        point[1] = (point[1] - view_box.y) * scale;
995    };
996    for segment in segments {
997        match segment {
998            VectorSegment::MoveTo(point) | VectorSegment::LineTo(point) => normalize(point),
999            VectorSegment::QuadTo(control, point) => {
1000                normalize(control);
1001                normalize(point);
1002            }
1003            VectorSegment::CubicTo(control_a, control_b, point) => {
1004                normalize(control_a);
1005                normalize(control_b);
1006                normalize(point);
1007            }
1008            VectorSegment::Close => {}
1009        }
1010    }
1011}
1012
1013#[allow(clippy::too_many_arguments)]
1014fn push_math_radical_op(
1015    n: &El,
1016    points: &[[f32; 2]; 5],
1017    thickness: f32,
1018    origin_x: f32,
1019    baseline_y: f32,
1020    scissor: Option<Rect>,
1021    color: Color,
1022    atom_index: usize,
1023    out: &mut Vec<DrawOp>,
1024) {
1025    use crate::vector::{PathBuilder, VectorAsset, VectorLineJoin, VectorRenderMode};
1026
1027    let min_x = points.iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
1028    let max_x = points
1029        .iter()
1030        .map(|p| p[0])
1031        .fold(f32::NEG_INFINITY, f32::max);
1032    let min_y = points.iter().map(|p| p[1]).fold(f32::INFINITY, f32::min);
1033    let max_y = points
1034        .iter()
1035        .map(|p| p[1])
1036        .fold(f32::NEG_INFINITY, f32::max);
1037    let pad = thickness * 0.5;
1038    let local = |p: [f32; 2]| [p[0] - min_x + pad, p[1] - min_y + pad];
1039    let [p0, p1, p2, p3, p4] = points.map(local);
1040    let rect = Rect::new(
1041        origin_x + min_x - pad,
1042        baseline_y + min_y - pad,
1043        max_x - min_x + pad * 2.0,
1044        max_y - min_y + pad * 2.0,
1045    );
1046    let path = PathBuilder::new()
1047        .move_to(p0[0], p0[1])
1048        .line_to(p1[0], p1[1])
1049        .line_to(p2[0], p2[1])
1050        .line_to(p3[0], p3[1])
1051        .line_to(p4[0], p4[1])
1052        .stroke_solid(color, thickness)
1053        .stroke_line_join(VectorLineJoin::Miter)
1054        .build();
1055    let asset = VectorAsset::from_paths([0.0, 0.0, rect.w, rect.h], vec![path]);
1056    out.push(DrawOp::Vector {
1057        id: format!("{}.math-radical.{atom_index}", n.computed_id),
1058        rect,
1059        scissor,
1060        asset: std::sync::Arc::new(asset),
1061        render_mode: VectorRenderMode::Painted,
1062    });
1063}
1064
1065#[allow(clippy::too_many_arguments)]
1066fn push_math_delimiter_op(
1067    n: &El,
1068    delimiter: &str,
1069    atom_rect: Rect,
1070    thickness: f32,
1071    origin_x: f32,
1072    baseline_y: f32,
1073    scissor: Option<Rect>,
1074    color: Color,
1075    atom_index: usize,
1076    out: &mut Vec<DrawOp>,
1077) {
1078    use crate::vector::{
1079        PathBuilder, VectorAsset, VectorLineCap, VectorLineJoin, VectorRenderMode,
1080    };
1081
1082    let pad = thickness * 0.5;
1083    let rect = Rect::new(
1084        origin_x + atom_rect.x - pad,
1085        baseline_y + atom_rect.y - pad,
1086        atom_rect.w + pad * 2.0,
1087        atom_rect.h + pad * 2.0,
1088    );
1089    let w = atom_rect.w;
1090    let h = atom_rect.h;
1091    let x = |v: f32| v + pad;
1092    let y = |v: f32| v + pad;
1093    let base = PathBuilder::new();
1094    let path = match delimiter {
1095        "(" => base.move_to(x(w * 0.86), y(0.0)).cubic_to(
1096            x(w * 0.10),
1097            y(h * 0.10),
1098            x(w * 0.10),
1099            y(h * 0.90),
1100            x(w * 0.86),
1101            y(h),
1102        ),
1103        ")" => base.move_to(x(w * 0.14), y(0.0)).cubic_to(
1104            x(w * 0.90),
1105            y(h * 0.10),
1106            x(w * 0.90),
1107            y(h * 0.90),
1108            x(w * 0.14),
1109            y(h),
1110        ),
1111        "[" => base
1112            .move_to(x(w * 0.88), y(0.0))
1113            .line_to(x(w * 0.12), y(0.0))
1114            .line_to(x(w * 0.12), y(h))
1115            .line_to(x(w * 0.88), y(h)),
1116        "]" => base
1117            .move_to(x(w * 0.12), y(0.0))
1118            .line_to(x(w * 0.88), y(0.0))
1119            .line_to(x(w * 0.88), y(h))
1120            .line_to(x(w * 0.12), y(h)),
1121        "{" => base
1122            .move_to(x(w * 0.86), y(0.0))
1123            .cubic_to(
1124                x(w * 0.20),
1125                y(h * 0.04),
1126                x(w * 0.56),
1127                y(h * 0.39),
1128                x(w * 0.18),
1129                y(h * 0.48),
1130            )
1131            .quad_to(x(w * 0.04), y(h * 0.50), x(w * 0.18), y(h * 0.52))
1132            .cubic_to(
1133                x(w * 0.56),
1134                y(h * 0.61),
1135                x(w * 0.20),
1136                y(h * 0.96),
1137                x(w * 0.86),
1138                y(h),
1139            ),
1140        "}" => base
1141            .move_to(x(w * 0.14), y(0.0))
1142            .cubic_to(
1143                x(w * 0.80),
1144                y(h * 0.04),
1145                x(w * 0.44),
1146                y(h * 0.39),
1147                x(w * 0.82),
1148                y(h * 0.48),
1149            )
1150            .quad_to(x(w * 0.96), y(h * 0.50), x(w * 0.82), y(h * 0.52))
1151            .cubic_to(
1152                x(w * 0.44),
1153                y(h * 0.61),
1154                x(w * 0.80),
1155                y(h * 0.96),
1156                x(w * 0.14),
1157                y(h),
1158            ),
1159        "|" => base.move_to(x(w * 0.5), y(0.0)).line_to(x(w * 0.5), y(h)),
1160        "‖" => base
1161            .move_to(x(w * 0.34), y(0.0))
1162            .line_to(x(w * 0.34), y(h))
1163            .move_to(x(w * 0.66), y(0.0))
1164            .line_to(x(w * 0.66), y(h)),
1165        "⟨" => base
1166            .move_to(x(w * 0.84), y(0.0))
1167            .line_to(x(w * 0.18), y(h * 0.5))
1168            .line_to(x(w * 0.84), y(h)),
1169        "⟩" => base
1170            .move_to(x(w * 0.16), y(0.0))
1171            .line_to(x(w * 0.82), y(h * 0.5))
1172            .line_to(x(w * 0.16), y(h)),
1173        "⌊" => base
1174            .move_to(x(w * 0.18), y(0.0))
1175            .line_to(x(w * 0.18), y(h))
1176            .line_to(x(w * 0.88), y(h)),
1177        "⌋" => base
1178            .move_to(x(w * 0.82), y(0.0))
1179            .line_to(x(w * 0.82), y(h))
1180            .line_to(x(w * 0.12), y(h)),
1181        "⌈" => base
1182            .move_to(x(w * 0.88), y(0.0))
1183            .line_to(x(w * 0.18), y(0.0))
1184            .line_to(x(w * 0.18), y(h)),
1185        "⌉" => base
1186            .move_to(x(w * 0.12), y(0.0))
1187            .line_to(x(w * 0.82), y(0.0))
1188            .line_to(x(w * 0.82), y(h)),
1189        _ => return,
1190    }
1191    .stroke_solid(color, thickness)
1192    .stroke_line_cap(VectorLineCap::Round)
1193    .stroke_line_join(VectorLineJoin::Round)
1194    .build();
1195
1196    let asset = VectorAsset::from_paths([0.0, 0.0, rect.w, rect.h], vec![path]);
1197    out.push(DrawOp::Vector {
1198        id: format!("{}.math-delimiter.{atom_index}", n.computed_id),
1199        rect,
1200        scissor,
1201        asset: std::sync::Arc::new(asset),
1202        render_mode: VectorRenderMode::Painted,
1203    });
1204}
1205
1206fn push_inline_mixed_ops(
1207    n: &El,
1208    ui_state: &UiState,
1209    rect: Rect,
1210    scissor: Option<Rect>,
1211    opacity: f32,
1212    out: &mut Vec<DrawOp>,
1213) {
1214    let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
1215        n.text_wrap,
1216        Some(rect.w),
1217        n.font_size * 0.82,
1218        n.font_size * 0.22,
1219        n.line_height,
1220    );
1221    let mut line_items = Vec::new();
1222    let selected = n.selection_source.as_ref().and_then(|source| {
1223        selection_range_for_node(n, ui_state, source.visible_len()).map(|(lo, hi)| lo..hi)
1224    });
1225    let paint = InlineMixedLinePaint {
1226        parent: n,
1227        rect,
1228        scissor,
1229        opacity,
1230        selected: selected.as_ref(),
1231    };
1232    let mut visible_cursor = 0usize;
1233
1234    let finish_line =
1235        |line_items: &mut Vec<InlineMixedItem>,
1236         out: &mut Vec<DrawOp>,
1237         breaker: &mut crate::text::inline_mixed::MixedInlineBreaker| {
1238            let line = breaker.finish_line();
1239            flush_inline_mixed_line(&paint, line.top, line.ascent, line_items, out);
1240        };
1241
1242    for (i, child) in n.children.iter().enumerate() {
1243        match child.kind {
1244            Kind::HardBreak => {
1245                finish_line(&mut line_items, out, &mut breaker);
1246                visible_cursor += "\n".len();
1247                continue;
1248            }
1249            Kind::Text => {
1250                if let Some(text) = &child.text {
1251                    for (chunk_i, chunk) in inline_text_chunks(text).into_iter().enumerate() {
1252                        let chunk_visible = visible_cursor..(visible_cursor + chunk.len());
1253                        visible_cursor += chunk.len();
1254                        let is_space = chunk.chars().all(char::is_whitespace);
1255                        if breaker.skips_leading_space(is_space) {
1256                            continue;
1257                        }
1258                        let (w, ascent, descent) = inline_text_chunk_paint_metrics(child, chunk);
1259                        if breaker.wraps_before(is_space, w) {
1260                            finish_line(&mut line_items, out, &mut breaker);
1261                        }
1262                        if breaker.skips_overflowing_space(is_space, w) {
1263                            continue;
1264                        }
1265                        if is_space && !matches!(line_items.last(), Some(InlineMixedItem::Text(_)))
1266                        {
1267                            breaker.push(w, ascent, descent);
1268                            continue;
1269                        }
1270                        push_inline_text_item(
1271                            &mut line_items,
1272                            child,
1273                            i,
1274                            chunk_i,
1275                            chunk,
1276                            chunk_visible,
1277                            breaker.x(),
1278                        );
1279                        breaker.push(w, ascent, descent);
1280                    }
1281                }
1282                continue;
1283            }
1284            Kind::Math => {
1285                if let Some(expr) = &child.math {
1286                    let layout =
1287                        crate::math::layout_math(expr, child.font_size, child.math_display);
1288                    if breaker.wraps_before(false, layout.width) {
1289                        finish_line(&mut line_items, out, &mut breaker);
1290                    }
1291                    let width = layout.width;
1292                    let ascent = layout.ascent;
1293                    let descent = layout.descent;
1294                    let visible_len = "\u{fffc}".len();
1295                    let visible = visible_cursor..(visible_cursor + visible_len);
1296                    visible_cursor += visible_len;
1297                    line_items.push(InlineMixedItem::Math {
1298                        child: child.clone(),
1299                        expr: expr.clone(),
1300                        x: breaker.x(),
1301                        layout,
1302                        visible,
1303                    });
1304                    breaker.push(width, ascent, descent);
1305                }
1306            }
1307            _ => {
1308                let (w, ascent, descent) = inline_child_paint_metrics(child);
1309                if breaker.wraps_before(false, w) {
1310                    finish_line(&mut line_items, out, &mut breaker);
1311                }
1312                breaker.push(w, ascent, descent);
1313            }
1314        }
1315    }
1316    let line = breaker.finish_line();
1317    flush_inline_mixed_line(&paint, line.top, line.ascent, &mut line_items, out);
1318}
1319
1320enum InlineMixedItem {
1321    Text(InlineTextItem),
1322    Math {
1323        child: El,
1324        expr: std::sync::Arc<crate::math::MathExpr>,
1325        x: f32,
1326        layout: crate::math::MathLayout,
1327        visible: std::ops::Range<usize>,
1328    },
1329}
1330
1331struct InlineTextItem {
1332    child: El,
1333    text: String,
1334    x: f32,
1335    child_index: usize,
1336    chunk_index: usize,
1337    visible: std::ops::Range<usize>,
1338}
1339
1340struct InlineMixedLinePaint<'a> {
1341    parent: &'a El,
1342    rect: Rect,
1343    scissor: Option<Rect>,
1344    opacity: f32,
1345    selected: Option<&'a std::ops::Range<usize>>,
1346}
1347
1348fn push_inline_text_item(
1349    items: &mut Vec<InlineMixedItem>,
1350    child: &El,
1351    child_index: usize,
1352    chunk_index: usize,
1353    text: &str,
1354    visible: std::ops::Range<usize>,
1355    x: f32,
1356) {
1357    if text.is_empty() {
1358        return;
1359    }
1360    if let Some(InlineMixedItem::Text(prev)) = items.last_mut()
1361        && same_inline_text_style(&prev.child, child)
1362    {
1363        prev.text.push_str(text);
1364        prev.visible.end = visible.end;
1365        return;
1366    }
1367    items.push(InlineMixedItem::Text(InlineTextItem {
1368        child: child.clone(),
1369        text: text.to_string(),
1370        x,
1371        child_index,
1372        chunk_index,
1373        visible,
1374    }));
1375}
1376
1377fn flush_inline_mixed_line(
1378    paint: &InlineMixedLinePaint<'_>,
1379    line_top: f32,
1380    line_ascent: f32,
1381    items: &mut Vec<InlineMixedItem>,
1382    out: &mut Vec<DrawOp>,
1383) {
1384    let baseline_y = paint.rect.y + line_top + line_ascent;
1385    for item in items.drain(..) {
1386        match item {
1387            InlineMixedItem::Text(item) => {
1388                push_inline_text_chunk(
1389                    paint.parent,
1390                    &item.child,
1391                    &item.text,
1392                    item.child_index,
1393                    item.chunk_index,
1394                    selection_overlap(paint.selected, &item.visible),
1395                    paint.rect,
1396                    paint.scissor,
1397                    paint.opacity,
1398                    item.x,
1399                    baseline_y,
1400                    out,
1401                );
1402            }
1403            InlineMixedItem::Math {
1404                child,
1405                expr,
1406                x,
1407                layout,
1408                visible,
1409            } => {
1410                let math_rect = Rect::new(
1411                    paint.rect.x + x,
1412                    baseline_y - layout.ascent,
1413                    layout.width,
1414                    layout.height(),
1415                );
1416                if selection_overlap(paint.selected, &visible).is_some() {
1417                    push_selection_band_rect(
1418                        paint.parent,
1419                        out,
1420                        math_rect,
1421                        paint.scissor,
1422                        paint.opacity,
1423                    );
1424                }
1425                push_math_ops(&child, &expr, math_rect, paint.scissor, paint.opacity, out);
1426            }
1427        }
1428    }
1429}
1430
1431fn same_inline_text_style(a: &El, b: &El) -> bool {
1432    a.font_size == b.font_size
1433        && a.line_height == b.line_height
1434        && a.font_family == b.font_family
1435        && a.mono_font_family == b.mono_font_family
1436        && a.font_weight == b.font_weight
1437        && a.font_mono == b.font_mono
1438        && a.text_color == b.text_color
1439        && a.text_underline == b.text_underline
1440        && a.text_strikethrough == b.text_strikethrough
1441        && a.text_link == b.text_link
1442}
1443
1444#[allow(clippy::too_many_arguments)]
1445fn push_inline_text_chunk(
1446    parent: &El,
1447    child: &El,
1448    text: &str,
1449    child_index: usize,
1450    chunk_index: usize,
1451    selected: Option<std::ops::Range<usize>>,
1452    rect: Rect,
1453    scissor: Option<Rect>,
1454    opacity: f32,
1455    x: f32,
1456    baseline_y: f32,
1457    out: &mut Vec<DrawOp>,
1458) {
1459    let size = child.font_size * parent.scale;
1460    let glyph_layout = crate::text::metrics::layout_text_with_line_height_and_family(
1461        text,
1462        size,
1463        child.line_height * parent.scale,
1464        child.font_family,
1465        child.font_weight,
1466        child.font_mono,
1467        TextWrap::NoWrap,
1468        None,
1469    );
1470    let glyph_baseline = glyph_layout
1471        .lines
1472        .first()
1473        .map(|line| line.baseline)
1474        .unwrap_or_else(|| crate::text::metrics::line_height(size) * 0.75);
1475    let glyph_rect = Rect::new(
1476        rect.x + x,
1477        baseline_y - glyph_baseline,
1478        glyph_layout.width,
1479        glyph_layout.height,
1480    );
1481    if let Some(selected) = selected {
1482        let lo = clamp_to_char_boundary(text, selected.start.min(text.len()));
1483        let hi = clamp_to_char_boundary(text, selected.end.min(text.len()));
1484        if lo < hi {
1485            let prefix = &text[..lo];
1486            let slice = &text[lo..hi];
1487            let band_x = glyph_rect.x
1488                + crate::text::metrics::line_width_with_family(
1489                    prefix,
1490                    size,
1491                    child.font_family,
1492                    child.font_weight,
1493                    child.font_mono,
1494                );
1495            let band_w = crate::text::metrics::line_width_with_family(
1496                slice,
1497                size,
1498                child.font_family,
1499                child.font_weight,
1500                child.font_mono,
1501            );
1502            push_selection_band_rect(
1503                parent,
1504                out,
1505                Rect::new(band_x, glyph_rect.y, band_w, glyph_rect.h),
1506                scissor,
1507                opacity,
1508            );
1509        }
1510    }
1511    let color = opaque(child.text_color.unwrap_or(tokens::FOREGROUND), opacity);
1512    out.push(DrawOp::GlyphRun {
1513        id: format!(
1514            "{}.inline-text.{child_index}.{chunk_index}",
1515            parent.computed_id
1516        ),
1517        rect: glyph_rect,
1518        scissor,
1519        shader: ShaderHandle::Stock(StockShader::Text),
1520        color,
1521        text: text.to_string(),
1522        size,
1523        line_height: child.line_height * parent.scale,
1524        family: child.font_family,
1525        mono_family: child.mono_font_family,
1526        weight: child.font_weight,
1527        mono: child.font_mono,
1528        wrap: TextWrap::NoWrap,
1529        anchor: TextAnchor::Start,
1530        layout: glyph_layout,
1531        underline: child.text_underline || child.text_link.is_some(),
1532        strikethrough: child.text_strikethrough,
1533        link: child.text_link.clone(),
1534    });
1535}
1536
1537fn inline_text_chunks(text: &str) -> Vec<&str> {
1538    let mut chunks = Vec::new();
1539    let mut start = 0;
1540    let mut last_space = None;
1541    for (i, ch) in text.char_indices() {
1542        let is_space = ch.is_whitespace();
1543        match last_space {
1544            None => last_space = Some(is_space),
1545            Some(prev) if prev != is_space => {
1546                chunks.push(&text[start..i]);
1547                start = i;
1548                last_space = Some(is_space);
1549            }
1550            _ => {}
1551        }
1552    }
1553    if start < text.len() {
1554        chunks.push(&text[start..]);
1555    }
1556    chunks
1557}
1558
1559fn inline_text_chunk_paint_metrics(child: &El, text: &str) -> (f32, f32, f32) {
1560    let layout = crate::text::metrics::layout_text_with_line_height_and_family(
1561        text,
1562        child.font_size,
1563        child.line_height,
1564        child.font_family,
1565        child.font_weight,
1566        child.font_mono,
1567        TextWrap::NoWrap,
1568        None,
1569    );
1570    (layout.width, child.font_size * 0.82, child.font_size * 0.22)
1571}
1572
1573fn inline_child_paint_metrics(child: &El) -> (f32, f32, f32) {
1574    match child.kind {
1575        Kind::Text => inline_text_chunk_paint_metrics(child, child.text.as_deref().unwrap_or("")),
1576        Kind::Math => {
1577            if let Some(expr) = &child.math {
1578                let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
1579                (layout.width, layout.ascent, layout.descent)
1580            } else {
1581                (0.0, 0.0, 0.0)
1582            }
1583        }
1584        _ => (0.0, 0.0, 0.0),
1585    }
1586}
1587
1588/// Active when the user is actively dragging this scrollable's thumb
1589/// or the pointer is hovering anywhere inside its track (the
1590/// generous-hitbox column on the right). Hover is computed against
1591/// the *un-translated* track rect since the pointer position is
1592/// captured pre-translate.
1593fn thumb_is_active(n: &El, ui_state: &UiState) -> bool {
1594    if let Some(drag) = ui_state.scroll.thumb_drag.as_ref()
1595        && drag.scroll_id == n.computed_id
1596    {
1597        return true;
1598    }
1599    if let (Some((px, py)), Some(track)) = (
1600        ui_state.pointer_pos,
1601        ui_state.scroll.thumb_tracks.get(&n.computed_id),
1602    ) {
1603        return track.contains(px, py);
1604    }
1605    false
1606}
1607
1608/// Walk an Inlines paragraph's children and produce source-order
1609/// (text, RunStyle) tuples. Each `Kind::Text` child contributes one
1610/// run carrying its `font_weight`, `text_italic`, `font_mono`, and
1611/// `text_color`. `Kind::HardBreak` contributes a `\n` run with default
1612/// styling — cosmic-text turns the newline into a line break during
1613/// shaping, so style doesn't matter (no glyph is emitted).
1614fn collect_inline_runs(node: &El, opacity: f32) -> Vec<(String, RunStyle)> {
1615    let mut runs: Vec<(String, RunStyle)> = Vec::with_capacity(node.children.len());
1616    for c in &node.children {
1617        match c.kind {
1618            Kind::Text => {
1619                if let Some(text) = &c.text {
1620                    let color = opaque(c.text_color.unwrap_or(tokens::FOREGROUND), opacity);
1621                    let mut style = RunStyle::new(c.font_weight, color)
1622                        .family(c.font_family)
1623                        .mono_family(c.mono_font_family);
1624                    if c.text_italic {
1625                        style = style.italic();
1626                    }
1627                    if c.font_mono {
1628                        style = style.mono();
1629                    }
1630                    if let Some(bg) = c.text_bg {
1631                        style = style.with_bg(opaque(bg, opacity));
1632                    }
1633                    if let Some(url) = &c.text_link {
1634                        // .with_link sets color + underline; do it
1635                        // before the standalone underline / strike
1636                        // checks so an explicit `.underline()` on a
1637                        // link is a no-op rather than re-stomping.
1638                        style = style.with_link(url.clone());
1639                    }
1640                    if c.text_underline {
1641                        style = style.underline();
1642                    }
1643                    if c.text_strikethrough {
1644                        style = style.strikethrough();
1645                    }
1646                    runs.push((text.clone(), style));
1647                }
1648            }
1649            Kind::HardBreak => {
1650                runs.push((
1651                    "\n".to_string(),
1652                    RunStyle::new(FontWeight::Regular, tokens::FOREGROUND),
1653                ));
1654            }
1655            _ => {}
1656        }
1657    }
1658    runs
1659}
1660
1661/// Pick the dominant font size for the paragraph's approximate
1662/// pre-shaping layout (used by SVG and lint). Mirrors the layout
1663/// pass's `inline_paragraph_size` heuristic — max across text
1664/// children, falling back to the parent's own `font_size`.
1665fn inline_paragraph_font_size(node: &El) -> f32 {
1666    let mut size: f32 = node.font_size;
1667    for c in &node.children {
1668        if matches!(c.kind, Kind::Text) {
1669            size = size.max(c.font_size);
1670        }
1671    }
1672    size
1673}
1674
1675fn inline_paragraph_line_height(node: &El) -> f32 {
1676    let mut line_height: f32 = node.line_height;
1677    let mut max_size: f32 = node.font_size;
1678    for c in &node.children {
1679        if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
1680            max_size = c.font_size;
1681            line_height = c.line_height;
1682        }
1683    }
1684    line_height
1685}
1686
1687#[allow(clippy::too_many_arguments)]
1688fn push_selection_bands_for_inlines(
1689    n: &El,
1690    ui_state: &UiState,
1691    out: &mut Vec<DrawOp>,
1692    glyph_rect: Rect,
1693    scissor: Option<Rect>,
1694    opacity: f32,
1695    visible: &str,
1696    font_size: f32,
1697    line_height: f32,
1698) {
1699    let Some((lo, hi)) = selection_range_for_node(n, ui_state, visible.len()) else {
1700        return;
1701    };
1702
1703    let mut lines = vec![InlineSelectionLine::default()];
1704    let mut visible_cursor = 0usize;
1705    for child in &n.children {
1706        match child.kind {
1707            Kind::Text => {
1708                let Some(text) = child.text.as_deref() else {
1709                    continue;
1710                };
1711                for segment in text.split_inclusive('\n') {
1712                    let (segment, hard_break) = segment
1713                        .strip_suffix('\n')
1714                        .map(|line| (line, true))
1715                        .unwrap_or((segment, false));
1716                    if !segment.is_empty() {
1717                        let line_index = lines.len() - 1;
1718                        let x = lines[line_index].width;
1719                        let width = inline_selection_run_width(child, segment, font_size);
1720                        let end = visible_cursor + segment.len();
1721                        lines[line_index].runs.push(InlineSelectionRun {
1722                            child: child.clone(),
1723                            text: segment.to_string(),
1724                            visible: visible_cursor..end,
1725                            x,
1726                        });
1727                        lines[line_index].width += width;
1728                        visible_cursor = end;
1729                    }
1730                    if hard_break {
1731                        visible_cursor += "\n".len();
1732                        lines.push(InlineSelectionLine::default());
1733                    }
1734                }
1735            }
1736            Kind::HardBreak => {
1737                visible_cursor += "\n".len();
1738                lines.push(InlineSelectionLine::default());
1739            }
1740            _ => {}
1741        }
1742    }
1743
1744    for (line_index, line) in lines.into_iter().enumerate() {
1745        let line_x = match n.text_align {
1746            TextAlign::Start => 0.0,
1747            TextAlign::Center => (glyph_rect.w - line.width).max(0.0) * 0.5,
1748            TextAlign::End => (glyph_rect.w - line.width).max(0.0),
1749        };
1750        let line_y = line_index as f32 * line_height;
1751        for run in line.runs {
1752            let Some(selected) = selection_overlap(Some(&(lo..hi)), &run.visible) else {
1753                continue;
1754            };
1755            let lo = clamp_to_char_boundary(&run.text, selected.start.min(run.text.len()));
1756            let hi = clamp_to_char_boundary(&run.text, selected.end.min(run.text.len()));
1757            if lo >= hi {
1758                continue;
1759            }
1760            let prefix = &run.text[..lo];
1761            let slice = &run.text[lo..hi];
1762            let band_x = glyph_rect.x
1763                + line_x
1764                + run.x
1765                + inline_selection_run_width(&run.child, prefix, font_size);
1766            let band_w = inline_selection_run_width(&run.child, slice, font_size);
1767            push_selection_band_rect(
1768                n,
1769                out,
1770                Rect::new(band_x, glyph_rect.y + line_y, band_w, line_height),
1771                scissor,
1772                opacity,
1773            );
1774        }
1775    }
1776}
1777
1778#[derive(Default)]
1779struct InlineSelectionLine {
1780    width: f32,
1781    runs: Vec<InlineSelectionRun>,
1782}
1783
1784struct InlineSelectionRun {
1785    child: El,
1786    text: String,
1787    visible: std::ops::Range<usize>,
1788    x: f32,
1789}
1790
1791fn inline_selection_run_width(child: &El, text: &str, font_size: f32) -> f32 {
1792    text_metrics::line_width_with_family(
1793        text,
1794        font_size,
1795        child.font_family,
1796        child.font_weight,
1797        child.font_mono,
1798    )
1799}
1800
1801#[allow(clippy::too_many_arguments)]
1802fn push_selection_bands_for_text(
1803    n: &El,
1804    ui_state: &UiState,
1805    out: &mut Vec<DrawOp>,
1806    glyph_rect: Rect,
1807    scissor: Option<Rect>,
1808    opacity: f32,
1809    display: &str,
1810    font_size: f32,
1811    family: FontFamily,
1812    weight: FontWeight,
1813    wrap: TextWrap,
1814) {
1815    // Selection band — emit behind the glyph run when this leaf is
1816    // selectable, keyed, and (part of) its bytes fall inside the active
1817    // selection range. Source-backed rich text passes its visible text
1818    // here, while copy routes through the source mapping.
1819    if n.selectable
1820        && let Some(key) = &n.key
1821        && let Some((lo, hi)) = crate::selection::slice_for_leaf(
1822            &ui_state.current_selection,
1823            &ui_state.selection.order,
1824            key,
1825            display.len(),
1826        )
1827    {
1828        let rects = text_metrics::selection_rects_with_family(
1829            display,
1830            lo,
1831            hi,
1832            font_size,
1833            family,
1834            weight,
1835            wrap,
1836            match wrap {
1837                TextWrap::NoWrap => None,
1838                TextWrap::Wrap => Some(glyph_rect.w),
1839            },
1840        );
1841        for (rx, ry, rw, rh) in rects {
1842            let band = Rect::new(glyph_rect.x + rx, glyph_rect.y + ry, rw, rh);
1843            let mut band_uniforms = UniformBlock::new();
1844            band_uniforms.insert(
1845                "fill",
1846                UniformValue::Color(opaque(tokens::SELECTION_BG, opacity)),
1847            );
1848            band_uniforms.insert("radius", UniformValue::F32(2.0));
1849            band_uniforms.insert("inner_rect", inner_rect_uniform(band));
1850            out.push(DrawOp::Quad {
1851                id: format!("{}.selection-band", n.computed_id),
1852                rect: band,
1853                scissor,
1854                shader: ShaderHandle::Stock(StockShader::RoundedRect),
1855                uniforms: band_uniforms,
1856            });
1857        }
1858    }
1859}
1860
1861fn effective_text_family(n: &El) -> FontFamily {
1862    if n.font_mono {
1863        n.mono_font_family
1864    } else {
1865        n.font_family
1866    }
1867}
1868
1869fn selection_range_for_node(
1870    n: &El,
1871    ui_state: &UiState,
1872    visible_len: usize,
1873) -> Option<(usize, usize)> {
1874    let key = n.key.as_ref()?;
1875    crate::selection::slice_for_leaf(
1876        &ui_state.current_selection,
1877        &ui_state.selection.order,
1878        key,
1879        visible_len,
1880    )
1881}
1882
1883fn selection_overlap(
1884    selected: Option<&std::ops::Range<usize>>,
1885    item: &std::ops::Range<usize>,
1886) -> Option<std::ops::Range<usize>> {
1887    let selected = selected?;
1888    let start = selected.start.max(item.start);
1889    let end = selected.end.min(item.end);
1890    if start < end {
1891        Some((start - item.start)..(end - item.start))
1892    } else {
1893        None
1894    }
1895}
1896
1897fn push_selection_band_rect(
1898    n: &El,
1899    out: &mut Vec<DrawOp>,
1900    rect: Rect,
1901    scissor: Option<Rect>,
1902    opacity: f32,
1903) {
1904    let mut band_uniforms = UniformBlock::new();
1905    band_uniforms.insert(
1906        "fill",
1907        UniformValue::Color(opaque(tokens::SELECTION_BG, opacity)),
1908    );
1909    band_uniforms.insert("radius", UniformValue::F32(4.0));
1910    band_uniforms.insert("inner_rect", inner_rect_uniform(rect));
1911    out.push(DrawOp::Quad {
1912        id: format!("{}.selection-band", n.computed_id),
1913        rect,
1914        scissor,
1915        shader: ShaderHandle::Stock(StockShader::RoundedRect),
1916        uniforms: band_uniforms,
1917    });
1918}
1919
1920fn push_atomic_selection_band(
1921    n: &El,
1922    ui_state: &UiState,
1923    out: &mut Vec<DrawOp>,
1924    rect: Rect,
1925    scissor: Option<Rect>,
1926    opacity: f32,
1927    visible_len: usize,
1928) {
1929    if visible_len == 0 {
1930        return;
1931    }
1932    if n.selectable
1933        && let Some(key) = &n.key
1934        && crate::selection::slice_for_leaf(
1935            &ui_state.current_selection,
1936            &ui_state.selection.order,
1937            key,
1938            visible_len,
1939        )
1940        .is_some()
1941    {
1942        push_selection_band_rect(n, out, rect, scissor, opacity);
1943    }
1944}
1945
1946fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
1947    let mut byte = byte.min(text.len());
1948    while byte > 0 && !text.is_char_boundary(byte) {
1949        byte -= 1;
1950    }
1951    byte
1952}
1953
1954#[allow(clippy::too_many_arguments)]
1955fn push_text_area_editor_overlay(
1956    n: &El,
1957    ui_state: &UiState,
1958    theme: &Theme,
1959    out: &mut Vec<DrawOp>,
1960    rect: Rect,
1961    scissor: Option<Rect>,
1962    opacity: f32,
1963    focus_envelope: f32,
1964    font_size: f32,
1965    weight: FontWeight,
1966) {
1967    let (Some(key), Some(value)) = (n.text_link.as_deref(), n.tooltip.as_deref()) else {
1968        return;
1969    };
1970    let Some(view) = ui_state.current_selection.within(key) else {
1971        return;
1972    };
1973    match n.kind {
1974        Kind::Custom(TEXT_AREA_SELECTION_LAYER) => {
1975            if view.is_collapsed() {
1976                return;
1977            }
1978            let (lo, hi) = view.ordered();
1979            let rects = text_metrics::selection_rects(
1980                value,
1981                lo.min(value.len()),
1982                hi.min(value.len()),
1983                font_size,
1984                weight,
1985                TextWrap::Wrap,
1986                Some(rect.w.max(1.0)),
1987            );
1988            let fill = theme
1989                .resolve(tokens::SELECTION_BG_UNFOCUSED)
1990                .mix(theme.resolve(tokens::SELECTION_BG), focus_envelope);
1991            for (i, (rx, ry, rw, rh)) in rects.into_iter().enumerate() {
1992                let band = Rect::new(rect.x + rx, rect.y + ry, rw, rh);
1993                let mut uniforms = UniformBlock::new();
1994                uniforms.insert("fill", UniformValue::Color(opaque(fill, opacity)));
1995                uniforms.insert("radius", UniformValue::F32(2.0));
1996                uniforms.insert("inner_rect", inner_rect_uniform(band));
1997                out.push(DrawOp::Quad {
1998                    id: format!("{}.selection-band.{i}", n.computed_id),
1999                    rect: band,
2000                    scissor,
2001                    shader: ShaderHandle::Stock(StockShader::RoundedRect),
2002                    uniforms,
2003                });
2004            }
2005        }
2006        Kind::Custom(TEXT_AREA_CARET_LAYER) => {
2007            let head = view.head.min(value.len());
2008            let (x, y) = text_metrics::caret_xy(
2009                value,
2010                head,
2011                font_size,
2012                weight,
2013                TextWrap::Wrap,
2014                Some(rect.w.max(1.0)),
2015            );
2016            let caret = Rect::new(rect.x + x, rect.y + y, 2.0, tokens::TEXT_SM.line_height);
2017            let mut uniforms = UniformBlock::new();
2018            uniforms.insert(
2019                "fill",
2020                UniformValue::Color(opaque(theme.resolve(tokens::FOREGROUND), opacity)),
2021            );
2022            uniforms.insert("radius", UniformValue::F32(1.0));
2023            uniforms.insert("inner_rect", inner_rect_uniform(caret));
2024            out.push(DrawOp::Quad {
2025                id: format!("{}.caret", n.computed_id),
2026                rect: caret,
2027                scissor,
2028                shader: ShaderHandle::Stock(StockShader::RoundedRect),
2029                uniforms,
2030            });
2031        }
2032        _ => {}
2033    }
2034}
2035
2036fn translated(r: Rect, offset: (f32, f32)) -> Rect {
2037    if offset.0 == 0.0 && offset.1 == 0.0 {
2038        return r;
2039    }
2040    Rect::new(r.x + offset.0, r.y + offset.1, r.w, r.h)
2041}
2042
2043/// Combine an element's explicit `paint_overflow` with the implicit
2044/// halo a non-zero `shadow` and / or `stroke` needs around the layout
2045/// rect. The shadow's SDF in `stock::rounded_rect` softens over a
2046/// `blur`-wide band around an offset-down silhouette: alpha hits zero
2047/// at distance `blur` outside the (offset) box, so left/right need
2048/// `blur`, top needs `blur*0.5` (offset reduces upward extent), bottom
2049/// needs `blur*1.5`. Stroke straddles the boundary — its outside half
2050/// (`stroke_width*0.5`) plus the AA tail (≈1 px) lives just outside the
2051/// layout rect, so the painted quad needs that much room on every side
2052/// or the cardinal pixels of curved boundaries (the radio indicator's
2053/// circle, switch thumb, …) get clipped and the shape looks flattened
2054/// at top / bottom / left / right. Per-side max with the user's
2055/// `paint_overflow` so a ring outset + shadow + stroke on the
2056/// same node all fit.
2057fn combined_overflow(
2058    paint_overflow: Sides,
2059    shadow: f32,
2060    stroke_width: f32,
2061    focus_width: f32,
2062) -> Sides {
2063    let stroke_halo = if stroke_width > 0.0 {
2064        stroke_width * 0.5 + 1.0
2065    } else {
2066        0.0
2067    };
2068    let halo = stroke_halo.max(focus_width);
2069    let stroked = if halo > 0.0 {
2070        Sides {
2071            left: paint_overflow.left.max(halo),
2072            right: paint_overflow.right.max(halo),
2073            top: paint_overflow.top.max(halo),
2074            bottom: paint_overflow.bottom.max(halo),
2075        }
2076    } else {
2077        paint_overflow
2078    };
2079    if shadow <= 0.0 {
2080        return stroked;
2081    }
2082    Sides {
2083        left: stroked.left.max(shadow),
2084        right: stroked.right.max(shadow),
2085        top: stroked.top.max(shadow * 0.5),
2086        bottom: stroked.bottom.max(shadow * 1.5),
2087    }
2088}
2089
2090/// Scale `r` uniformly by `s` around its centre. `s == 1.0` short-circuits
2091/// to identity so the common case is allocation-free of float drift.
2092fn scaled_around_center(r: Rect, s: f32) -> Rect {
2093    if (s - 1.0).abs() < f32::EPSILON {
2094        return r;
2095    }
2096    let cx = r.center_x();
2097    let cy = r.center_y();
2098    let w = r.w * s;
2099    let h = r.h * s;
2100    Rect::new(cx - w * 0.5, cy - h * 0.5, w, h)
2101}
2102
2103fn opaque(c: Color, opacity: f32) -> Color {
2104    if (opacity - 1.0).abs() < f32::EPSILON {
2105        return c;
2106    }
2107    let a = (c.a as f32 * opacity.clamp(0.0, 1.0)).round() as u8;
2108    c.with_alpha(a)
2109}
2110
2111/// Resolve the effective `(fill, stroke, text_color, font_weight,
2112/// optional text suffix)` for paint.
2113///
2114/// Hover and press are applied as **envelope mixes**: the eased amounts
2115/// `hover` / `press` (both 0..1, written by the animation tracker into
2116/// [`UiState::envelope`]) lerp the build-time colour toward its
2117/// state-modulated form. This composition keeps state easing
2118/// independent of mid-flight changes to `n.fill` — the author can swap
2119/// a button's colour during a hover and the new colour appears with
2120/// the same eased lighten amount, no fighting between trackers.
2121///
2122/// Surfaces with no resting fill (`.ghost()`, `.outline()`, inactive tab
2123/// triggers) get a **synthesized state-only fill** instead — a faint
2124/// `ACCENT` whose alpha rises with hover and press. Mirrors the
2125/// shadcn idiom `hover:bg-accent active:bg-accent/80`: transparent at
2126/// rest, a soft surface fades in on interaction. Without this, the
2127/// envelope mix above has nothing to land on (`None.map(...)` is
2128/// `None`) and ghost surfaces show no feedback at all.
2129///
2130/// The synthesis only fires when the node already declares some
2131/// surface affordance — a non-zero radius or an explicit stroke. That
2132/// excludes layout-only focusable containers (the `stack(...)` outers
2133/// of `slider`, `switch`, `resize_handle`) where a translucent
2134/// rectangle behind the actual visual would compete with the widget's
2135/// own thumb / track / hairline.
2136///
2137/// Disabled (alpha multiply) and Loading (text suffix) aren't eased
2138/// and are still applied here, branching on the resolved `state`.
2139fn apply_state(
2140    n: &El,
2141    state: InteractionState,
2142    hover: f32,
2143    press: f32,
2144    palette: &Palette,
2145) -> (
2146    Option<Color>,
2147    Option<Color>,
2148    Option<Color>,
2149    FontWeight,
2150    Option<&'static str>,
2151) {
2152    // Resolve token rgb against the active palette *before* applying
2153    // any rgb-modifying op. lighten/darken/mix bake the result and
2154    // strip the token, so we have to compose the op against the
2155    // palette's rgb here — otherwise hover/press visuals are computed
2156    // off the compile-time dark fallback regardless of theme.
2157    let mut fill = n.fill.map(|c| palette.resolve(c));
2158    let mut stroke = n.stroke.map(|c| palette.resolve(c));
2159    let mut text_color = n.text_color.map(|c| palette.resolve(c));
2160    let weight = n.font_weight;
2161    let mut suffix = None;
2162
2163    if hover > 0.0 {
2164        fill = fill.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
2165        stroke = stroke.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
2166        text_color = text_color.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN * 0.5), hover));
2167    }
2168    if press > 0.0 {
2169        fill = fill.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
2170        stroke = stroke.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
2171        text_color = text_color.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN * 0.5), press));
2172    }
2173    if n.fill.is_none()
2174        && (hover > 0.0 || press > 0.0)
2175        && (n.radius.any_nonzero() || n.stroke.is_some())
2176    {
2177        let alpha = (hover * tokens::STATE_FILL_HOVER_ALPHA
2178            + press * tokens::STATE_FILL_PRESS_ALPHA)
2179            .clamp(0.0, 1.0);
2180        // ACCENT.with_alpha keeps the token name, so the final
2181        // resolve_palette walk swaps the rgb to the active palette.
2182        fill = Some(tokens::ACCENT.with_alpha((alpha * 255.0).round() as u8));
2183    }
2184
2185    match state {
2186        InteractionState::Default
2187        | InteractionState::Focus
2188        | InteractionState::Hover
2189        | InteractionState::Press => {}
2190        InteractionState::Disabled => {
2191            let alpha = (255.0 * tokens::DISABLED_ALPHA) as u8;
2192            fill = fill.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2193            stroke = stroke.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2194            text_color =
2195                text_color.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
2196        }
2197        InteractionState::Loading => {
2198            text_color = text_color.map(|c| c.with_alpha(((c.a as u32 * 200) / 255) as u8));
2199            suffix = Some(" ⋯");
2200        }
2201    }
2202    (fill, stroke, text_color, weight, suffix)
2203}
2204
2205/// Pack a rect as the `inner_rect` uniform value (vec4 of x, y, w, h).
2206fn inner_rect_uniform(r: Rect) -> UniformValue {
2207    UniformValue::Vec4([r.x, r.y, r.w, r.h])
2208}
2209
2210fn intersect_scissor(current: Option<Rect>, next: Rect) -> Option<Rect> {
2211    match current {
2212        Some(r) => Some(r.intersect(next).unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0))),
2213        None => Some(next),
2214    }
2215}
2216
2217fn rect_visible_in_scissor(rect: Rect, scissor: Option<Rect>) -> bool {
2218    if rect.w <= 0.0 || rect.h <= 0.0 {
2219        return false;
2220    }
2221    match scissor {
2222        Some(clip) => rect.intersect(clip).is_some(),
2223        None => true,
2224    }
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229    use super::*;
2230    use crate::state::UiState;
2231    use crate::{button, column, row};
2232
2233    #[test]
2234    fn ghost_surface_synthesizes_state_fill_for_hover_and_press() {
2235        // Surfaces with no resting fill (`.ghost()`, inactive tab
2236        // triggers, `.outline()`) must still show interaction feedback.
2237        // The hover/press envelope mix is `fill.map(...)` which
2238        // collapses to `None` when there's nothing to lerp from, so
2239        // `apply_state` synthesizes a translucent ACCENT fill whose
2240        // alpha rises with hover and press.
2241        // `.ghost()` clears fill / stroke; a real tab trigger or
2242        // ghost button also carries a radius (the visual affordance
2243        // the synthesis gates on).
2244        let ghost = El::new(Kind::Custom("tab_trigger"))
2245            .ghost()
2246            .radius(tokens::RADIUS_SM);
2247        assert!(ghost.fill.is_none(), "ghost has no resting fill");
2248
2249        let (rest_fill, ..) = apply_state(
2250            &ghost,
2251            InteractionState::Default,
2252            0.0,
2253            0.0,
2254            &Palette::aetna_dark(),
2255        );
2256        assert_eq!(rest_fill, None, "no envelope, no synthesized fill");
2257
2258        let (hover_fill, ..) = apply_state(
2259            &ghost,
2260            InteractionState::Hover,
2261            1.0,
2262            0.0,
2263            &Palette::aetna_dark(),
2264        );
2265        let hover_alpha = (tokens::STATE_FILL_HOVER_ALPHA * 255.0).round() as u8;
2266        assert_eq!(
2267            hover_fill,
2268            Some(tokens::ACCENT.with_alpha(hover_alpha)),
2269            "hover at peak fades a faint ACCENT in",
2270        );
2271
2272        let (press_fill, ..) = apply_state(
2273            &ghost,
2274            InteractionState::Press,
2275            1.0,
2276            1.0,
2277            &Palette::aetna_dark(),
2278        );
2279        let press_alpha = ((tokens::STATE_FILL_HOVER_ALPHA + tokens::STATE_FILL_PRESS_ALPHA)
2280            * 255.0)
2281            .round() as u8;
2282        assert_eq!(
2283            press_fill,
2284            Some(tokens::ACCENT.with_alpha(press_alpha)),
2285            "press while hovered sums the two envelope contributions",
2286        );
2287    }
2288
2289    #[test]
2290    fn hover_alpha_fades_child_with_focusable_ancestor_envelope() {
2291        // A non-interactive child flagged with `hover_alpha` sits below
2292        // a focusable container. With no interaction anywhere, the
2293        // child paints at `rest` * its declared alpha. When the
2294        // container picks up hover, the cascade through the focusable
2295        // ancestor's subtree-interaction envelope animates the child's
2296        // effective alpha to `peak`.
2297        use crate::layout::layout;
2298
2299        let make_tree = || {
2300            column([row([crate::stack([El::new(Kind::Custom("badge"))
2301                .width(Size::Fixed(14.0))
2302                .height(Size::Fixed(14.0))
2303                .fill(tokens::FOREGROUND)
2304                .hover_alpha(0.25, 1.0)])
2305            .key("container")
2306            .focusable()
2307            .width(Size::Fixed(120.0))
2308            .height(Size::Fixed(18.0))])])
2309            .padding(20.0)
2310        };
2311
2312        // No hover: the child paints with alpha ≈ 0.25 * 255.
2313        {
2314            let mut tree = make_tree();
2315            let mut state = UiState::new();
2316            layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2317            state.set_animation_mode(crate::state::AnimationMode::Settled);
2318            state.tick_visual_animations(&mut tree, web_time::Instant::now());
2319
2320            let ops = draw_ops(&tree, &state);
2321            let badge = find_quad(&ops, "badge").expect("badge quad");
2322            let DrawOp::Quad { uniforms, .. } = badge else {
2323                unreachable!()
2324            };
2325            let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2326                panic!("expected color uniform");
2327            };
2328            // FOREGROUND is fully opaque in source; alpha after
2329            // composition should be ~0.25 (rest_opacity).
2330            let expected = (255.0_f32 * 0.25).round() as u8;
2331            assert!(
2332                (fill.a as i32 - expected as i32).abs() <= 2,
2333                "rest opacity should hold the child near 0.25 alpha; got {}",
2334                fill.a,
2335            );
2336        }
2337
2338        // Container hovered: the child's effective alpha rises to full.
2339        {
2340            let mut tree = make_tree();
2341            let mut state = UiState::new();
2342            layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2343            let container_target = state
2344                .target_of_key(&tree, "container")
2345                .expect("container target");
2346            state.hovered = Some(container_target);
2347            state.apply_to_state();
2348            state.set_animation_mode(crate::state::AnimationMode::Settled);
2349            state.tick_visual_animations(&mut tree, web_time::Instant::now());
2350
2351            let ops = draw_ops(&tree, &state);
2352            let badge = find_quad(&ops, "badge").expect("badge quad");
2353            let DrawOp::Quad { uniforms, .. } = badge else {
2354                unreachable!()
2355            };
2356            let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2357                panic!("expected color uniform");
2358            };
2359            assert_eq!(
2360                fill.a, 255,
2361                "ancestor hover should pull the child's alpha to full",
2362            );
2363        }
2364    }
2365
2366    #[test]
2367    fn hover_alpha_keeps_child_visible_while_self_hovered() {
2368        // Even with no ancestor hover, a keyed focusable child
2369        // carrying `hover_alpha` stays visible while the cursor is
2370        // directly on it — the cascade carries the parent's subtree
2371        // envelope down, and `max(inherited, self)` saturates when
2372        // either side fires.
2373        use crate::layout::layout;
2374
2375        let mut tree = column([row([crate::stack([El::new(Kind::Custom("close"))
2376            .key("close")
2377            .focusable()
2378            .width(Size::Fixed(14.0))
2379            .height(Size::Fixed(14.0))
2380            .fill(tokens::FOREGROUND)
2381            .hover_alpha(0.0, 1.0)])
2382        .key("container")
2383        .focusable()
2384        .width(Size::Fixed(120.0))
2385        .height(Size::Fixed(18.0))])])
2386        .padding(20.0);
2387
2388        let mut state = UiState::new();
2389        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2390        // Hit-test only resolves to the deepest interactive target,
2391        // so cursor-on-close hovers the close, not the container.
2392        let close_target = state.target_of_key(&tree, "close").expect("close target");
2393        state.hovered = Some(close_target);
2394        state.apply_to_state();
2395        state.set_animation_mode(crate::state::AnimationMode::Settled);
2396        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2397
2398        let ops = draw_ops(&tree, &state);
2399        let close = find_quad(&ops, "close").expect("close quad");
2400        let DrawOp::Quad { uniforms, .. } = close else {
2401            unreachable!()
2402        };
2403        let UniformValue::Color(fill) = uniforms.get("fill").expect("close fill") else {
2404            panic!("expected color uniform");
2405        };
2406        assert_eq!(
2407            fill.a, 255,
2408            "self-hover should keep a hover_alpha element fully visible \
2409             even when no ancestor is hovered",
2410        );
2411    }
2412
2413    #[test]
2414    fn hover_alpha_does_not_affect_unmarked_descendants() {
2415        // Sibling control: a sibling without `hover_alpha` paints at
2416        // its declared alpha regardless of ancestor hover, so the
2417        // modifier is opt-in and doesn't bleed.
2418        use crate::layout::layout;
2419
2420        let mut tree = column([row([crate::stack([
2421            El::new(Kind::Custom("tagged"))
2422                .width(Size::Fixed(8.0))
2423                .height(Size::Fixed(8.0))
2424                .fill(tokens::FOREGROUND)
2425                .hover_alpha(0.0, 1.0),
2426            El::new(Kind::Custom("plain"))
2427                .width(Size::Fixed(8.0))
2428                .height(Size::Fixed(8.0))
2429                .fill(tokens::FOREGROUND),
2430        ])
2431        .key("container")
2432        .focusable()
2433        .width(Size::Fixed(120.0))
2434        .height(Size::Fixed(18.0))])])
2435        .padding(20.0);
2436
2437        let mut state = UiState::new();
2438        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2439        state.set_animation_mode(crate::state::AnimationMode::Settled);
2440        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2441
2442        let ops = draw_ops(&tree, &state);
2443        let tagged = find_quad(&ops, "tagged").expect("tagged quad");
2444        let plain = find_quad(&ops, "plain").expect("plain quad");
2445        let DrawOp::Quad {
2446            uniforms: tagged_u, ..
2447        } = tagged
2448        else {
2449            unreachable!()
2450        };
2451        let DrawOp::Quad {
2452            uniforms: plain_u, ..
2453        } = plain
2454        else {
2455            unreachable!()
2456        };
2457        let UniformValue::Color(t) = tagged_u.get("fill").unwrap() else {
2458            panic!()
2459        };
2460        let UniformValue::Color(p) = plain_u.get("fill").unwrap() else {
2461            panic!()
2462        };
2463        assert_eq!(t.a, 0, "tagged child invisible at rest with rest=0");
2464        assert_eq!(p.a, 255, "unmarked sibling unaffected");
2465    }
2466
2467    #[test]
2468    fn hover_alpha_stays_revealed_when_focusable_descendant_is_hovered() {
2469        // gh#11. A non-focusable wrapper carrying `hover_alpha` (the
2470        // action-pill pattern) sits between a focusable card and the
2471        // focusable buttons inside it. With the cursor on a button, the
2472        // pill must stay revealed — the cascade reads the *card's*
2473        // subtree envelope, which sees the hovered button as a
2474        // descendant.
2475        use crate::layout::layout;
2476
2477        let mut tree = column([row([crate::stack([
2478            // Pill wrapper: not keyed, not focusable, but carries
2479            // hover_alpha. Wraps two focusable buttons.
2480            El::new(Kind::Custom("pill"))
2481                .width(Size::Fixed(80.0))
2482                .height(Size::Fixed(20.0))
2483                .fill(tokens::FOREGROUND)
2484                .hover_alpha(0.0, 1.0)
2485                .axis(crate::tree::Axis::Row),
2486        ])
2487        .key("card")
2488        .focusable()
2489        .width(Size::Fixed(160.0))
2490        .height(Size::Fixed(40.0))])])
2491        .padding(20.0);
2492        // Drop two focusable button keys directly under the pill.
2493        tree.children[0].children[0].children[0]
2494            .children
2495            .push(El::new(Kind::Custom("play")).key("play").focusable());
2496        tree.children[0].children[0].children[0]
2497            .children
2498            .push(El::new(Kind::Custom("more")).key("more").focusable());
2499
2500        let mut state = UiState::new();
2501        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2502        // Hover the focusable descendant (button), not the card or
2503        // the pill background. Pre-fix this caused the pill to fade
2504        // out: card lost hover, pill inherited 0.0, descendant button
2505        // didn't reach the pill via the focusable-ancestor cascade.
2506        let play = state.target_of_key(&tree, "play").expect("play target");
2507        state.hovered = Some(play);
2508        state.apply_to_state();
2509        state.set_animation_mode(crate::state::AnimationMode::Settled);
2510        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2511
2512        let ops = draw_ops(&tree, &state);
2513        let pill = find_quad(&ops, "pill").expect("pill quad");
2514        let DrawOp::Quad { uniforms, .. } = pill else {
2515            unreachable!()
2516        };
2517        let UniformValue::Color(fill) = uniforms.get("fill").expect("pill fill") else {
2518            panic!("expected color uniform");
2519        };
2520        assert_eq!(
2521            fill.a, 255,
2522            "pill must stay fully revealed while a focusable descendant is hovered",
2523        );
2524    }
2525
2526    #[test]
2527    fn hover_alpha_reveals_on_keyboard_focus_of_focusable_ancestor() {
2528        // gh#8. A close-× icon inside an inactive editor tab uses
2529        // `hover_alpha(0.0, 1.0)`. When the tab is keyboard-focused,
2530        // the close affordance must reveal so a keyboard-only user
2531        // sees that closing exists. Pre-fix `reveal_on_hover` only
2532        // read the hover envelope and the close stayed at α=0.
2533        use crate::layout::layout;
2534
2535        let mut tree = column([row([crate::stack([El::new(Kind::Custom("close"))
2536            .key("close")
2537            .focusable()
2538            .width(Size::Fixed(14.0))
2539            .height(Size::Fixed(14.0))
2540            .fill(tokens::FOREGROUND)
2541            .hover_alpha(0.0, 1.0)])
2542        .key("tab")
2543        .focusable()
2544        .width(Size::Fixed(120.0))
2545        .height(Size::Fixed(28.0))])])
2546        .padding(20.0);
2547
2548        let mut state = UiState::new();
2549        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2550        let tab = state.target_of_key(&tree, "tab").expect("tab target");
2551        state.focused = Some(tab);
2552        state.focus_visible = true;
2553        state.apply_to_state();
2554        state.set_animation_mode(crate::state::AnimationMode::Settled);
2555        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2556
2557        let ops = draw_ops(&tree, &state);
2558        let close = find_quad(&ops, "close").expect("close quad");
2559        let DrawOp::Quad { uniforms, .. } = close else {
2560            unreachable!()
2561        };
2562        let UniformValue::Color(fill) = uniforms.get("fill").expect("close fill") else {
2563            panic!("expected color uniform");
2564        };
2565        assert_eq!(
2566            fill.a, 255,
2567            "keyboard focus on the tab should reveal the close affordance",
2568        );
2569    }
2570
2571    #[test]
2572    fn hover_alpha_returns_to_rest_when_subtree_loses_interaction() {
2573        // Inverse of #11 / #8: once the cursor leaves the surrounding
2574        // interaction region, the affordance fades back to `rest`.
2575        use crate::layout::layout;
2576
2577        let mut tree = column([
2578            row([crate::stack([El::new(Kind::Custom("badge"))
2579                .width(Size::Fixed(14.0))
2580                .height(Size::Fixed(14.0))
2581                .fill(tokens::FOREGROUND)
2582                .hover_alpha(0.25, 1.0)])
2583            .key("container")
2584            .focusable()
2585            .width(Size::Fixed(120.0))
2586            .height(Size::Fixed(18.0))]),
2587            // A second focusable that the cursor moves to. Its
2588            // subtree envelope rises but the badge's interaction
2589            // region (rooted at "container") does not.
2590            row([
2591                crate::stack([El::new(Kind::Custom("other_body")).width(Size::Fixed(80.0))])
2592                    .key("other")
2593                    .focusable()
2594                    .width(Size::Fixed(120.0))
2595                    .height(Size::Fixed(18.0)),
2596            ]),
2597        ])
2598        .padding(20.0);
2599
2600        let mut state = UiState::new();
2601        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2602        let other = state.target_of_key(&tree, "other").expect("other target");
2603        state.hovered = Some(other);
2604        state.apply_to_state();
2605        state.set_animation_mode(crate::state::AnimationMode::Settled);
2606        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2607
2608        let ops = draw_ops(&tree, &state);
2609        let badge = find_quad(&ops, "badge").expect("badge quad");
2610        let DrawOp::Quad { uniforms, .. } = badge else {
2611            unreachable!()
2612        };
2613        let UniformValue::Color(fill) = uniforms.get("fill").expect("badge fill") else {
2614            panic!("expected color uniform");
2615        };
2616        let expected = (255.0_f32 * 0.25).round() as u8;
2617        assert!(
2618            (fill.a as i32 - expected as i32).abs() <= 2,
2619            "badge should be at rest opacity when interaction is on a sibling region; got {}",
2620            fill.a,
2621        );
2622    }
2623
2624    fn find_quad<'a>(ops: &'a [DrawOp], id_substr: &str) -> Option<&'a DrawOp> {
2625        ops.iter().find(|op| op.id().contains(id_substr))
2626    }
2627
2628    #[test]
2629    fn state_follows_interactive_ancestor_borrows_envelopes() {
2630        // A child flagged with `state_follows_interactive_ancestor` —
2631        // the slider thumb pattern — borrows hover and press
2632        // envelopes from its focusable container, since hit-test
2633        // never resolves to it directly.
2634        use crate::layout::layout;
2635
2636        let mut tree = column([row([crate::stack([El::new(Kind::Custom("thumb"))
2637            .key("thumb")
2638            .width(Size::Fixed(14.0))
2639            .height(Size::Fixed(14.0))
2640            .fill(tokens::FOREGROUND)
2641            .radius(tokens::RADIUS_PILL)
2642            .state_follows_interactive_ancestor()])
2643        .key("container")
2644        .focusable()
2645        .width(Size::Fixed(120.0))
2646        .height(Size::Fixed(18.0))])])
2647        .padding(20.0);
2648        let mut state = UiState::new();
2649        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
2650
2651        // Drive the container into Press by setting both `pressed` and
2652        // `hovered` (post-fix gating requires hover==pressed for press
2653        // to fire) and snap envelopes via Settled mode.
2654        let container_target = state
2655            .target_of_key(&tree, "container")
2656            .expect("container target");
2657        state.hovered = Some(container_target.clone());
2658        state.pressed = Some(container_target);
2659        state.apply_to_state();
2660        state.set_animation_mode(crate::state::AnimationMode::Settled);
2661        state.tick_visual_animations(&mut tree, web_time::Instant::now());
2662
2663        // The thumb's *own* envelopes stay zero — only the container
2664        // got the press. But via the cascade flag, the thumb's paint
2665        // sees the container's press envelope.
2666        let ops = draw_ops(&tree, &state);
2667        let thumb_op = ops
2668            .iter()
2669            .find(|op| op.id().contains("thumb"))
2670            .expect("thumb quad");
2671        let DrawOp::Quad { uniforms, .. } = thumb_op else {
2672            panic!("expected thumb quad");
2673        };
2674        let UniformValue::Color(thumb_fill) = uniforms.get("fill").expect("thumb fill") else {
2675            panic!("expected color uniform");
2676        };
2677        // Press darkens FOREGROUND by PRESS_DARKEN. Without the
2678        // cascade, the thumb would paint at FOREGROUND unchanged.
2679        let expected = tokens::FOREGROUND.mix(tokens::FOREGROUND.darken(tokens::PRESS_DARKEN), 1.0);
2680        assert_eq!(
2681            (thumb_fill.r, thumb_fill.g, thumb_fill.b),
2682            (expected.r, expected.g, expected.b),
2683            "flagged thumb borrows the container's press envelope",
2684        );
2685    }
2686
2687    #[test]
2688    fn cross_leaf_selection_paints_a_band_on_each_spanned_leaf() {
2689        use crate::selection::{Selection, SelectionPoint, SelectionRange};
2690
2691        let mut tree = column([
2692            crate::widgets::text::paragraph("First")
2693                .key("a")
2694                .selectable(),
2695            crate::widgets::text::paragraph("Second")
2696                .key("b")
2697                .selectable(),
2698            crate::widgets::text::paragraph("Third")
2699                .key("c")
2700                .selectable(),
2701        ])
2702        .padding(20.0);
2703        let mut state = UiState::new();
2704        crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 300.0));
2705        state.sync_selection_order(&tree);
2706
2707        // anchor at byte 2 in "First", head at byte 3 in "Third":
2708        // span includes a partial of a, all of b, partial of c.
2709        state.current_selection = Selection {
2710            range: Some(SelectionRange {
2711                anchor: SelectionPoint::new("a", 2),
2712                head: SelectionPoint::new("c", 3),
2713            }),
2714        };
2715
2716        let ops = draw_ops(&tree, &state);
2717        let band_ids: Vec<&str> = ops
2718            .iter()
2719            .filter_map(|op| {
2720                if let DrawOp::Quad { id, .. } = op
2721                    && id.contains("selection-band")
2722                {
2723                    Some(id.as_str())
2724                } else {
2725                    None
2726                }
2727            })
2728            .collect();
2729        // One band per spanned leaf (3 leaves: a, b, c).
2730        assert_eq!(
2731            band_ids.len(),
2732            3,
2733            "cross-leaf selection should emit a band on each of {{a, b, c}}; got {band_ids:?}"
2734        );
2735    }
2736
2737    #[test]
2738    fn mixed_inline_math_selection_band_uses_math_rect() {
2739        use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2740
2741        let object = "\u{fffc}";
2742        let visible = format!("Inline {object} math");
2743        let mut source = SelectionSource::new("Inline $\\frac{a+b}{c+d}$ math", visible.clone());
2744        let math_start = "Inline ".len();
2745        let math_end = math_start + object.len();
2746        source.push_span(0..math_start, 0.."Inline ".len(), false);
2747        source.push_span(
2748            math_start..math_end,
2749            "Inline $".len()..(source.source.len() - " math".len()),
2750            true,
2751        );
2752        source.push_span(
2753            math_end..visible.len(),
2754            (source.source.len() - " math".len())..source.source.len(),
2755            false,
2756        );
2757
2758        let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("fixture TeX parses");
2759        let mut tree = crate::text_runs([
2760            crate::text("Inline "),
2761            crate::math_inline(expr),
2762            crate::text(" math"),
2763        ])
2764        .key("p")
2765        .selectable()
2766        .selection_source(source)
2767        .padding(20.0);
2768        let mut state = UiState::new();
2769        crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2770        state.sync_selection_order(&tree);
2771        state.current_selection = Selection {
2772            range: Some(SelectionRange {
2773                anchor: SelectionPoint::new("p", math_start),
2774                head: SelectionPoint::new("p", math_end),
2775            }),
2776        };
2777
2778        let ops = draw_ops(&tree, &state);
2779        let bands: Vec<Rect> = ops
2780            .iter()
2781            .filter_map(|op| {
2782                if let DrawOp::Quad { id, rect, .. } = op
2783                    && id.contains("selection-band")
2784                {
2785                    Some(*rect)
2786                } else {
2787                    None
2788                }
2789            })
2790            .collect();
2791        assert_eq!(bands.len(), 1, "expected one atomic math band");
2792        let placeholder_width =
2793            crate::text::metrics::line_width(object, 16.0, FontWeight::Regular, false);
2794        assert!(
2795            bands[0].w > placeholder_width * 1.5,
2796            "inline math selection band should cover the rendered fraction box instead of the placeholder glyph, got {:?}",
2797            bands[0],
2798        );
2799    }
2800
2801    #[test]
2802    fn source_backed_mono_inlines_measure_selection_with_mono_family() {
2803        use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2804
2805        let visible = "iiii\nwwww";
2806        let mut tree = crate::text_runs([
2807            crate::text("iiii").mono(),
2808            crate::hard_break(),
2809            crate::text("wwww").mono(),
2810        ])
2811        .mono()
2812        .font_size(16.0)
2813        .nowrap_text()
2814        .key("code")
2815        .selectable()
2816        .selection_source(SelectionSource::identity(visible))
2817        .padding(20.0);
2818        let mut state = UiState::new();
2819        crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2820        state.sync_selection_order(&tree);
2821
2822        let selected_band_width = |state: &mut UiState, start: usize, end: usize| {
2823            state.current_selection = Selection {
2824                range: Some(SelectionRange {
2825                    anchor: SelectionPoint::new("code", start),
2826                    head: SelectionPoint::new("code", end),
2827                }),
2828            };
2829            let ops = draw_ops(&tree, state);
2830            let bands: Vec<Rect> = ops
2831                .iter()
2832                .filter_map(|op| {
2833                    if let DrawOp::Quad { id, rect, .. } = op
2834                        && id.contains("selection-band")
2835                    {
2836                        Some(*rect)
2837                    } else {
2838                        None
2839                    }
2840                })
2841                .collect();
2842            assert_eq!(bands.len(), 1, "expected one selected visual line");
2843            bands[0].w
2844        };
2845
2846        let i_width = selected_band_width(&mut state, 0, 4);
2847        let w_width = selected_band_width(&mut state, 5, visible.len());
2848        assert!(
2849            (i_width - w_width).abs() <= 0.5,
2850            "mono code selection should measure equal-length lines equally; got iiii={i_width}, wwww={w_width}",
2851        );
2852    }
2853
2854    #[test]
2855    fn source_backed_attributed_inlines_measure_selection_per_run_style() {
2856        use crate::selection::{Selection, SelectionPoint, SelectionRange, SelectionSource};
2857
2858        let visible = "prefix iiii suffix";
2859        let code_start = "prefix ".len();
2860        let code_end = code_start + "iiii".len();
2861        let mut tree = crate::text_runs([
2862            crate::text("prefix "),
2863            crate::text("iiii").code(),
2864            crate::text(" suffix"),
2865        ])
2866        .font_size(16.0)
2867        .nowrap_text()
2868        .key("rich")
2869        .selectable()
2870        .selection_source(SelectionSource::identity(visible))
2871        .padding(20.0);
2872        let mut state = UiState::new();
2873        crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
2874        state.sync_selection_order(&tree);
2875        state.current_selection = Selection {
2876            range: Some(SelectionRange {
2877                anchor: SelectionPoint::new("rich", code_start),
2878                head: SelectionPoint::new("rich", code_end),
2879            }),
2880        };
2881
2882        let ops = draw_ops(&tree, &state);
2883        let bands: Vec<Rect> = ops
2884            .iter()
2885            .filter_map(|op| {
2886                if let DrawOp::Quad { id, rect, .. } = op
2887                    && id.contains("selection-band")
2888                {
2889                    Some(*rect)
2890                } else {
2891                    None
2892                }
2893            })
2894            .collect();
2895        assert_eq!(bands.len(), 1, "expected one inline-code selection band");
2896
2897        let regular_width = crate::text::metrics::line_width_with_family(
2898            "iiii",
2899            16.0,
2900            FontFamily::Inter,
2901            FontWeight::Regular,
2902            false,
2903        );
2904        let mono_width = crate::text::metrics::line_width_with_family(
2905            "iiii",
2906            16.0,
2907            FontFamily::Inter,
2908            FontWeight::Regular,
2909            true,
2910        );
2911        assert!(
2912            (bands[0].w - mono_width).abs() <= 0.75,
2913            "inline-code selection should use mono run width; band={:?}, mono={mono_width}, regular={regular_width}",
2914            bands[0],
2915        );
2916        assert!(
2917            bands[0].w > regular_width * 1.5,
2918            "regression guard: measuring the code run as regular text would be visibly too short"
2919        );
2920    }
2921
2922    #[test]
2923    fn drag_select_through_runtime_paints_band_in_next_frame() {
2924        // End-to-end: simulate pointer_down + pointer_moved on a
2925        // selectable paragraph, then drive a fresh `prepare_layout`
2926        // and verify the band is in the resulting DrawOps. Catches
2927        // regressions where the runtime's per-frame updates would
2928        // overwrite the live selection or where the painter doesn't
2929        // see the manager's writes.
2930        use crate::event::{Pointer, PointerButton};
2931        use crate::runtime::{PrepareTimings, RunnerCore};
2932
2933        let mut core = RunnerCore::new();
2934        let mut tree = column([crate::widgets::text::paragraph("Hello, world!")
2935            .key("p")
2936            .selectable()])
2937        .padding(20.0);
2938        let viewport = Rect::new(0.0, 0.0, 400.0, 200.0);
2939        // First prepare_layout populates the selection_order, etc.
2940        let mut t = PrepareTimings::default();
2941        let _ = core.prepare_layout(
2942            &mut tree,
2943            viewport,
2944            1.0,
2945            &mut t,
2946            RunnerCore::no_time_shaders,
2947        );
2948        // Snapshot so pointer events can hit-test against this frame.
2949        core.snapshot(&tree, &mut t);
2950
2951        let p_rect = core.rect_of_key("p").expect("p rect");
2952        let cy = p_rect.y + p_rect.h * 0.5;
2953        let _ = core.pointer_down(Pointer::mouse(p_rect.x + 4.0, cy, PointerButton::Primary));
2954        // Drag to extend.
2955        let _ = core.pointer_moved(Pointer::moving(p_rect.x + p_rect.w - 8.0, cy));
2956
2957        // Selection in UiState must be a non-collapsed range now.
2958        let sel = &core.ui_state.current_selection;
2959        let r = sel.range.as_ref().expect("selection set");
2960        assert!(
2961            r.anchor.byte != r.head.byte,
2962            "drag should extend head past anchor (anchor={}, head={})",
2963            r.anchor.byte,
2964            r.head.byte
2965        );
2966
2967        // Re-run prepare_layout (the per-frame loop). The painter
2968        // should emit a selection band Quad on this frame.
2969        let mut t2 = PrepareTimings::default();
2970        let crate::runtime::LayoutPrepared { ops, .. } = core.prepare_layout(
2971            &mut tree,
2972            viewport,
2973            1.0,
2974            &mut t2,
2975            RunnerCore::no_time_shaders,
2976        );
2977        let bands: Vec<&DrawOp> = ops
2978            .iter()
2979            .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
2980            .collect();
2981        assert!(
2982            !bands.is_empty(),
2983            "after drag-select, prepare_layout should emit a selection band Quad"
2984        );
2985        // Verify the band's painted rect overlaps the leaf's painted
2986        // rect — otherwise the highlight is rendered, but off-screen.
2987        if let DrawOp::Quad { rect, .. } = bands[0] {
2988            assert!(
2989                rect.intersect(p_rect).is_some(),
2990                "band rect = {rect:?} doesn't overlap leaf rect = {p_rect:?}"
2991            );
2992        }
2993    }
2994
2995    #[test]
2996    fn selectable_leaf_paints_selection_band_when_key_matches_active_selection() {
2997        use crate::selection::{Selection, SelectionPoint, SelectionRange};
2998
2999        let mut tree = column([crate::widgets::text::paragraph("Hello, world!")
3000            .key("p")
3001            .selectable()])
3002        .padding(20.0);
3003        let mut state = UiState::new();
3004        crate::layout::layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3005        // Pre-painter sanity: no current selection → no band.
3006        let ops_pre = draw_ops(&tree, &state);
3007        let bands_pre = ops_pre
3008            .iter()
3009            .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
3010            .count();
3011        assert_eq!(bands_pre, 0, "no band should paint when selection is empty");
3012
3013        state.current_selection = Selection {
3014            range: Some(SelectionRange {
3015                anchor: SelectionPoint::new("p", 0),
3016                head: SelectionPoint::new("p", 5),
3017            }),
3018        };
3019        let ops = draw_ops(&tree, &state);
3020        let bands: Vec<&DrawOp> = ops
3021            .iter()
3022            .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("selection-band")))
3023            .collect();
3024        assert!(
3025            !bands.is_empty(),
3026            "selection range over keyed selectable leaf should emit at least one band Quad"
3027        );
3028        if let DrawOp::Quad { rect, .. } = bands[0] {
3029            // Band must overlap the leaf's painted rect (positive area).
3030            assert!(rect.w > 0.0 && rect.h > 0.0, "band rect = {rect:?}");
3031        }
3032    }
3033
3034    #[test]
3035    fn layout_only_focusable_container_does_not_synthesize_fill() {
3036        // The outer wrappers of `slider`, `switch`, and `resize_handle`
3037        // are `focusable` `stack(...)`s with no fill, no radius, and no
3038        // stroke — they exist purely to capture pointer/keyboard events
3039        // for the visible children below. Synthesizing a state fill
3040        // here would paint a translucent rectangle across the widget's
3041        // hit area on hover / press, competing with the actual thumb /
3042        // track / hairline. Gate the synthesis on the node having some
3043        // surface affordance of its own.
3044        let layout_only = El::new(Kind::Custom("slider")).focusable();
3045        assert!(layout_only.fill.is_none());
3046        assert_eq!(layout_only.radius, crate::tree::Corners::ZERO);
3047        assert!(layout_only.stroke.is_none());
3048
3049        let (rest_fill, ..) = apply_state(
3050            &layout_only,
3051            InteractionState::Default,
3052            0.0,
3053            0.0,
3054            &Palette::aetna_dark(),
3055        );
3056        let (hover_fill, ..) = apply_state(
3057            &layout_only,
3058            InteractionState::Hover,
3059            1.0,
3060            0.0,
3061            &Palette::aetna_dark(),
3062        );
3063        let (press_fill, ..) = apply_state(
3064            &layout_only,
3065            InteractionState::Press,
3066            1.0,
3067            1.0,
3068            &Palette::aetna_dark(),
3069        );
3070        assert_eq!(rest_fill, None);
3071        assert_eq!(hover_fill, None);
3072        assert_eq!(press_fill, None);
3073    }
3074
3075    #[test]
3076    fn solid_surface_keeps_envelope_mix_unchanged() {
3077        // Surfaces with a resting fill still go through the existing
3078        // lighten/darken envelope mix — the synthesized state fill only
3079        // kicks in when the resting fill is None.
3080        let solid = El::new(Kind::Custom("button")).fill(tokens::MUTED);
3081        let (rest_fill, ..) = apply_state(
3082            &solid,
3083            InteractionState::Default,
3084            0.0,
3085            0.0,
3086            &Palette::aetna_dark(),
3087        );
3088        assert_eq!(rest_fill, Some(tokens::MUTED));
3089
3090        let (hover_fill, ..) = apply_state(
3091            &solid,
3092            InteractionState::Hover,
3093            1.0,
3094            0.0,
3095            &Palette::aetna_dark(),
3096        );
3097        assert_eq!(
3098            hover_fill,
3099            Some(tokens::MUTED.mix(tokens::MUTED.lighten(tokens::HOVER_LIGHTEN), 1.0)),
3100            "solid surfaces lighten existing fill, not synthesize a new one",
3101        );
3102    }
3103
3104    #[test]
3105    fn state_envelope_composes_against_active_palette() {
3106        // Hover/press lighten/darken must compose against the active
3107        // palette's rgb, not the token's compile-time dark fallback —
3108        // otherwise hover visuals are dark-derived even in light mode.
3109        let solid = El::new(Kind::Custom("button")).fill(tokens::MUTED);
3110        let light = Palette::aetna_light();
3111        let (hover_fill, ..) = apply_state(&solid, InteractionState::Hover, 1.0, 0.0, &light);
3112        let expected = light
3113            .muted
3114            .mix(light.muted.lighten(tokens::HOVER_LIGHTEN), 1.0);
3115        assert_eq!(
3116            hover_fill,
3117            Some(expected),
3118            "hover lighten composes against the active palette",
3119        );
3120    }
3121
3122    #[test]
3123    fn clip_sets_scissor_on_descendant_ops() {
3124        let mut root = column([row([
3125            button("Inside").key("inside"),
3126            button("Too wide").key("outside").width(Size::Fixed(300.0)),
3127        ])
3128        .clip()
3129        .width(Size::Fixed(120.0))]);
3130        let mut state = UiState::new();
3131        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3132
3133        let ops = draw_ops(&root, &state);
3134        let clipped = ops
3135            .iter()
3136            .find(|op| op.id().contains("outside"))
3137            .expect("outside button op");
3138        let DrawOp::Quad { scissor, .. } = clipped else {
3139            panic!("expected button surface quad");
3140        };
3141        assert_eq!(*scissor, Some(Rect::new(0.0, 0.0, 120.0, 32.0)));
3142    }
3143
3144    #[test]
3145    fn draw_ops_culls_text_fully_outside_inherited_clip() {
3146        let clipped = column([
3147            crate::widgets::text::text("visible").key("visible"),
3148            crate::tree::spacer().height(Size::Fixed(40.0)),
3149            crate::widgets::text::text("offscreen").key("offscreen"),
3150        ])
3151        .clip()
3152        .width(Size::Fixed(200.0))
3153        .height(Size::Fixed(24.0));
3154        let mut root = column([clipped]);
3155        let mut state = UiState::new();
3156        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 80.0));
3157
3158        let mut stats = DrawOpsStats::default();
3159        let ops = draw_ops_with_theme_and_stats(&root, &state, &Theme::default(), &mut stats);
3160
3161        assert_eq!(stats.culled_text_ops, 1);
3162        assert!(
3163            ops.iter().any(|op| op.id().contains("visible")),
3164            "visible text still emits a draw op"
3165        );
3166        assert!(
3167            !ops.iter().any(|op| op.id().contains("offscreen")),
3168            "fully clipped text should not reach draw ops"
3169        );
3170    }
3171
3172    #[test]
3173    fn inline_text_bg_propagates_to_run_style() {
3174        // text_runs([..text("hit").background(...)..]) flows into the
3175        // Inlines collector and lands on the per-run RunStyle.bg of
3176        // the AttributedText draw op. Other runs keep `bg: None`.
3177        let highlight = Color::rgb(220, 200, 60);
3178        let mut root = crate::text_runs([
3179            crate::text("plain "),
3180            crate::text("marked").background(highlight),
3181            crate::text(" rest"),
3182        ]);
3183        let mut state = UiState::new();
3184        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 80.0));
3185
3186        let ops = draw_ops(&root, &state);
3187        let DrawOp::AttributedText { runs, .. } = ops
3188            .iter()
3189            .find(|op| matches!(op, DrawOp::AttributedText { .. }))
3190            .expect("attr op")
3191        else {
3192            unreachable!()
3193        };
3194        assert_eq!(runs.len(), 3);
3195        assert_eq!(runs[0].1.bg, None);
3196        assert_eq!(runs[1].1.bg, Some(highlight));
3197        assert_eq!(runs[2].1.bg, None);
3198    }
3199
3200    #[test]
3201    fn text_align_center_emits_middle_anchor() {
3202        let mut root = crate::text("Centered").center_text();
3203        let mut state = UiState::new();
3204        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 80.0));
3205
3206        let ops = draw_ops(&root, &state);
3207        let DrawOp::GlyphRun { anchor, .. } = &ops[0] else {
3208            panic!("expected glyph run");
3209        };
3210        assert_eq!(*anchor, TextAnchor::Middle);
3211    }
3212
3213    #[test]
3214    fn paragraph_emits_wrapped_glyph_run() {
3215        let mut root = crate::paragraph("This sentence should wrap in a narrow box.")
3216            .width(Size::Fixed(120.0));
3217        let mut state = UiState::new();
3218        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 120.0, 120.0));
3219
3220        let ops = draw_ops(&root, &state);
3221        let DrawOp::GlyphRun { wrap, .. } = &ops[0] else {
3222            panic!("expected glyph run");
3223        };
3224        assert_eq!(*wrap, TextWrap::Wrap);
3225    }
3226
3227    #[test]
3228    fn inline_math_batches_same_style_text_runs() {
3229        let expr = crate::math::parse_tex("x_1+x_2").expect("valid tex");
3230        let mut root = crate::text_runs([
3231            crate::text("Alpha beta gamma "),
3232            crate::math_inline(expr),
3233            crate::text(" delta epsilon"),
3234        ])
3235        .width(Size::Fixed(600.0));
3236        let mut state = UiState::new();
3237        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 80.0));
3238
3239        let ops = draw_ops(&root, &state);
3240        let inline_runs: Vec<(&str, Rect)> = ops
3241            .iter()
3242            .filter_map(|op| {
3243                let DrawOp::GlyphRun { id, text, rect, .. } = op else {
3244                    return None;
3245                };
3246                if id.contains(".inline-text.") {
3247                    return Some((text.as_str(), *rect));
3248                }
3249                None
3250            })
3251            .collect();
3252
3253        assert_eq!(inline_runs.len(), 2);
3254        assert_eq!(inline_runs[0].0, "Alpha beta gamma ");
3255        assert_eq!(inline_runs[1].0, "delta epsilon");
3256        assert!(
3257            inline_runs[1].1.x > inline_runs[0].1.right(),
3258            "post-math text keeps the leading-space advance without painting a separate space run"
3259        );
3260    }
3261
3262    #[test]
3263    fn inline_math_uses_line_ascent_for_mixed_baseline() {
3264        let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3265        let mut root = crate::text_runs([
3266            crate::text("Before "),
3267            crate::math_inline(expr),
3268            crate::text(" after"),
3269        ])
3270        .width(Size::Fixed(600.0));
3271        let mut state = UiState::new();
3272        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 120.0));
3273
3274        let ops = draw_ops(&root, &state);
3275        let min_math_y = ops
3276            .iter()
3277            .filter_map(|op| match op {
3278                DrawOp::GlyphRun { id, rect, .. } if id.contains(".math-glyph.") => Some(rect.y),
3279                DrawOp::Quad { id, rect, .. } if id.contains(".math-rule.") => Some(rect.y),
3280                DrawOp::Vector { id, rect, .. } if id.contains(".math-") => Some(rect.y),
3281                _ => None,
3282            })
3283            .fold(f32::INFINITY, f32::min);
3284
3285        assert!(
3286            min_math_y >= -3.0,
3287            "built-up inline math should sit inside the line box, min y = {min_math_y}"
3288        );
3289    }
3290
3291    #[test]
3292    fn mixed_inline_wrap_paint_stays_inside_layout_height() {
3293        let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3294        let mut root = crate::text_runs([
3295            crate::text("Alpha beta "),
3296            crate::math_inline(expr),
3297            crate::text(" after wrap"),
3298        ])
3299        .width(Size::Fixed(116.0));
3300        let mut state = UiState::new();
3301        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 116.0, 200.0));
3302
3303        let root_rect = state
3304            .layout
3305            .computed_rects
3306            .get(&root.computed_id)
3307            .copied()
3308            .expect("root rect");
3309        let ops = draw_ops(&root, &state);
3310        let paint_bounds = mixed_inline_paint_bounds(&ops).expect("mixed inline paint bounds");
3311
3312        assert!(
3313            paint_bounds.bottom() <= root_rect.bottom() + 3.0,
3314            "paint bounds {paint_bounds:?} should fit layout rect {root_rect:?}"
3315        );
3316    }
3317
3318    #[test]
3319    fn mixed_inline_hard_break_paint_stays_inside_layout_height() {
3320        let expr = crate::math::parse_tex(r"\frac{a+b}{c+d}").expect("valid tex");
3321        let mut root = crate::text_runs([
3322            crate::text("Before "),
3323            crate::math_inline(expr),
3324            crate::hard_break(),
3325            crate::text("after"),
3326        ])
3327        .width(Size::Fixed(400.0));
3328        let mut state = UiState::new();
3329        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3330
3331        let root_rect = state
3332            .layout
3333            .computed_rects
3334            .get(&root.computed_id)
3335            .copied()
3336            .expect("root rect");
3337        let ops = draw_ops(&root, &state);
3338        let paint_bounds = mixed_inline_paint_bounds(&ops).expect("mixed inline paint bounds");
3339
3340        assert!(
3341            paint_bounds.bottom() <= root_rect.bottom() + 3.0,
3342            "paint bounds {paint_bounds:?} should fit layout rect {root_rect:?}"
3343        );
3344    }
3345
3346    fn mixed_inline_paint_bounds(ops: &[DrawOp]) -> Option<Rect> {
3347        let mut bounds: Option<Rect> = None;
3348        for op in ops {
3349            let candidate = match op {
3350                DrawOp::GlyphRun { id, rect, .. }
3351                    if id.contains(".inline-text.") || id.contains(".math-glyph.") =>
3352                {
3353                    Some(*rect)
3354                }
3355                DrawOp::Quad { id, rect, .. } if id.contains(".math-rule.") => Some(*rect),
3356                DrawOp::Vector { id, rect, .. } if id.contains(".math-") => Some(*rect),
3357                _ => None,
3358            };
3359            if let Some(rect) = candidate {
3360                bounds = Some(match bounds {
3361                    Some(prev) => union_rect(prev, rect),
3362                    None => rect,
3363                });
3364            }
3365        }
3366        bounds
3367    }
3368
3369    fn union_rect(a: Rect, b: Rect) -> Rect {
3370        let left = a.x.min(b.x);
3371        let top = a.y.min(b.y);
3372        let right = a.right().max(b.right());
3373        let bottom = a.bottom().max(b.bottom());
3374        Rect::new(left, top, right - left, bottom - top)
3375    }
3376
3377    #[test]
3378    fn padding_on_text_node_insets_glyph_rect() {
3379        // Regression: `text("X").padding(...)` used to inflate the
3380        // node's intrinsic size but emit the GlyphRun against the full
3381        // (uninset) layout rect, so glyphs anchored at the parent's
3382        // edge whenever Stretch flattened the Hug width. The fix is
3383        // for draw_ops to inset the glyph rect by `node.padding`,
3384        // making text padding behave the same as container padding.
3385        let mut root = column([crate::text("Chat").padding(Sides::xy(12.0, 8.0))])
3386            .width(Size::Fixed(320.0))
3387            .height(Size::Fill(1.0));
3388        let mut state = UiState::new();
3389        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 600.0));
3390
3391        let ops = draw_ops(&root, &state);
3392        let DrawOp::GlyphRun { rect, .. } = ops
3393            .iter()
3394            .find(|op| matches!(op, DrawOp::GlyphRun { .. }))
3395            .expect("text node emits a glyph run")
3396        else {
3397            unreachable!()
3398        };
3399        // Column stretched the text element to 320×(text_height + 16);
3400        // the glyph rect should be inset by the padding on each side.
3401        assert!(
3402            (rect.x - 12.0).abs() < 1e-3,
3403            "glyph rect.x = {}, expected 12 (left padding)",
3404            rect.x,
3405        );
3406        assert!(
3407            (rect.w - (320.0 - 24.0)).abs() < 1e-3,
3408            "glyph rect.w = {}, expected 296 (320 minus 12+12)",
3409            rect.w,
3410        );
3411        assert!(
3412            (rect.y - 8.0).abs() < 1e-3,
3413            "glyph rect.y = {}, expected 8 (top padding)",
3414            rect.y,
3415        );
3416    }
3417
3418    #[test]
3419    fn padding_on_icon_node_insets_icon_rect() {
3420        // Same fix applies to icon nodes: the centered icon should
3421        // center in the inset rect, not the full layout rect. Override
3422        // the Fixed width that `icon_size(...)` sets so the padding
3423        // has room — without the override, padding(20) on a 16-wide
3424        // element would produce a negative inset.
3425        let mut root = column([crate::icon(IconName::Folder)
3426            .icon_size(crate::tokens::ICON_SM)
3427            .width(Size::Fixed(80.0))
3428            .height(Size::Fixed(40.0))
3429            .padding(Sides::xy(20.0, 0.0))]);
3430        let mut state = UiState::new();
3431        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3432
3433        let ops = draw_ops(&root, &state);
3434        let DrawOp::Icon { rect, .. } = ops
3435            .iter()
3436            .find(|op| matches!(op, DrawOp::Icon { .. }))
3437            .expect("icon node emits an icon op")
3438        else {
3439            unreachable!()
3440        };
3441        // Element 80×40, inner after Sides::xy(20, 0) → (20, 0, 40, 40),
3442        // inner.center_x() = 40, 16px icon → x = 32.
3443        assert!(
3444            (rect.x - 32.0).abs() < 1e-3,
3445            "icon rect.x = {}, expected 32 (centered in inset rect)",
3446            rect.x,
3447        );
3448    }
3449
3450    #[test]
3451    fn image_intrinsic_is_natural_pixel_size() {
3452        let pixels = vec![0u8; 80 * 40 * 4];
3453        let img = crate::image::Image::from_rgba8(80, 40, pixels);
3454        let el = crate::tree::image(img);
3455        let (w, h) = crate::layout::intrinsic(&el);
3456        assert!((w - 80.0).abs() < 1e-3, "intrinsic w = {w}");
3457        assert!((h - 40.0).abs() < 1e-3, "intrinsic h = {h}");
3458    }
3459
3460    #[test]
3461    fn image_emits_draw_op_with_fit_projection() {
3462        // 100×50 image into a 400×400 box with Cover: dest = 800×400.
3463        let pixels = vec![0u8; 100 * 50 * 4];
3464        let img = crate::image::Image::from_rgba8(100, 50, pixels);
3465        let mut root = crate::row([crate::tree::image(img)
3466            .image_fit(crate::image::ImageFit::Cover)
3467            .width(Size::Fixed(400.0))
3468            .height(Size::Fixed(400.0))]);
3469        let mut state = UiState::new();
3470        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3471        let ops = draw_ops(&root, &state);
3472        let img_op = ops
3473            .iter()
3474            .find(|op| matches!(op, DrawOp::Image { .. }))
3475            .expect("image El emits a DrawOp::Image");
3476        let DrawOp::Image {
3477            rect, scissor, fit, ..
3478        } = img_op
3479        else {
3480            unreachable!()
3481        };
3482        assert_eq!(*fit, crate::image::ImageFit::Cover);
3483        // Cover scale = max(400/100, 400/50) = 8 → 800×400 dest.
3484        assert!((rect.w - 800.0).abs() < 1e-3, "rect.w = {}", rect.w);
3485        assert!((rect.h - 400.0).abs() < 1e-3, "rect.h = {}", rect.h);
3486        // Scissor clamps to content (400×400 box) so the horizontal
3487        // overflow is cropped without an explicit `.clip()`.
3488        let s = scissor.expect("image draw op carries a scissor");
3489        assert!((s.w - 400.0).abs() < 1e-3, "scissor.w = {}", s.w);
3490        assert!((s.h - 400.0).abs() < 1e-3, "scissor.h = {}", s.h);
3491    }
3492
3493    #[test]
3494    fn image_fully_outside_inherited_clip_emits_zero_scissor_not_none() {
3495        // Regression: an image El whose computed rect falls fully
3496        // outside an ancestor `clip()` must not paint past the clip.
3497        // The previous open-coded `s.intersect(inner)` returned `None`
3498        // when the rects didn't overlap, and `scissor: None` is
3499        // interpreted downstream as "no scissor" — so the image
3500        // painted full-bleed against the framebuffer instead of being
3501        // dropped. The fix routes through `intersect_scissor`, which
3502        // hands back `Some(Rect::zero)` and lets the renderer skip
3503        // the draw via its `phys.w == 0 || phys.h == 0` guard.
3504        //
3505        // Repro: a clipped row whose first child (Fixed 150) pushes
3506        // the second image child entirely past the row's right edge.
3507        let pixels = vec![0u8; 10 * 10 * 4];
3508        let img = crate::image::Image::from_rgba8(10, 10, pixels);
3509        // Wrap the clipped row in a column so the layout entry point
3510        // doesn't paste the viewport rect onto the row itself —
3511        // `layout()` forces the root rect to the viewport regardless
3512        // of the El's stated width/height, which collapses the
3513        // overflow we want to repro.
3514        let mut root = crate::column([crate::row([
3515            crate::column(Vec::<El>::new())
3516                .width(Size::Fixed(150.0))
3517                .height(Size::Fixed(50.0)),
3518            crate::tree::image(img)
3519                .width(Size::Fixed(60.0))
3520                .height(Size::Fixed(50.0)),
3521        ])
3522        .width(Size::Fixed(100.0))
3523        .height(Size::Fixed(100.0))
3524        .clip()]);
3525        let mut state = UiState::new();
3526        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3527        let ops = draw_ops(&root, &state);
3528        let DrawOp::Image { scissor, .. } = ops
3529            .iter()
3530            .find(|op| matches!(op, DrawOp::Image { .. }))
3531            .expect("image El still emits a DrawOp::Image when fully clipped")
3532        else {
3533            unreachable!()
3534        };
3535        let s = scissor.expect(
3536            "scissor must be Some(_) so the renderer drops the draw — \
3537             None would let it paint past the ancestor clip",
3538        );
3539        assert!(
3540            s.w <= 0.0 || s.h <= 0.0,
3541            "image fully outside ancestor clip must yield a zero-sized scissor, got {s:?}",
3542        );
3543    }
3544
3545    #[test]
3546    fn image_tint_propagates_with_opacity() {
3547        let pixels = vec![0u8; 4 * 4 * 4];
3548        let img = crate::image::Image::from_rgba8(4, 4, pixels);
3549        let mut root = crate::tree::image(img)
3550            .image_tint(Color::rgb(200, 100, 50))
3551            .opacity(0.5);
3552        let mut state = UiState::new();
3553        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3554        let ops = draw_ops(&root, &state);
3555        let DrawOp::Image { tint, .. } = ops
3556            .iter()
3557            .find(|op| matches!(op, DrawOp::Image { .. }))
3558            .expect("image emits draw op")
3559        else {
3560            unreachable!()
3561        };
3562        let tint = tint.expect("image_tint set, draw op carries tint");
3563        // Opacity halves the alpha channel of the tint (255 → 128).
3564        assert_eq!(tint.a, 128, "tint.a after 0.5 opacity = {}", tint.a);
3565        assert_eq!((tint.r, tint.g, tint.b), (200, 100, 50));
3566    }
3567
3568    /// Stub backend used by the surface-emission test. Nothing
3569    /// inspects the texture at this layer, so the impl is minimal.
3570    #[derive(Debug)]
3571    struct StubAppTextureBackend {
3572        id: crate::surface::AppTextureId,
3573        size: (u32, u32),
3574    }
3575
3576    impl crate::surface::AppTextureBackend for StubAppTextureBackend {
3577        fn id(&self) -> crate::surface::AppTextureId {
3578            self.id
3579        }
3580        fn size_px(&self) -> (u32, u32) {
3581            self.size
3582        }
3583        fn format(&self) -> crate::surface::SurfaceFormat {
3584            crate::surface::SurfaceFormat::Rgba8UnormSrgb
3585        }
3586        fn as_any(&self) -> &dyn std::any::Any {
3587            self
3588        }
3589    }
3590
3591    fn stub_app_texture(w: u32, h: u32) -> crate::surface::AppTexture {
3592        crate::surface::AppTexture::from_backend(std::sync::Arc::new(StubAppTextureBackend {
3593            id: crate::surface::next_app_texture_id(),
3594            size: (w, h),
3595        }))
3596    }
3597
3598    #[test]
3599    fn surface_emits_app_texture_op_filling_rect() {
3600        let tex = stub_app_texture(64, 32);
3601        let mut root = crate::row([crate::tree::surface(tex)
3602            .width(Size::Fixed(200.0))
3603            .height(Size::Fixed(100.0))
3604            .surface_alpha(crate::surface::SurfaceAlpha::Opaque)]);
3605        let mut state = UiState::new();
3606        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3607        let ops = draw_ops(&root, &state);
3608        let surf_op = ops
3609            .iter()
3610            .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3611            .expect("Kind::Surface emits a DrawOp::AppTexture");
3612        let DrawOp::AppTexture {
3613            rect,
3614            scissor,
3615            alpha,
3616            fit,
3617            transform,
3618            ..
3619        } = surf_op
3620        else {
3621            unreachable!()
3622        };
3623        // Default surface_fit is Fill — rect matches the content rect 1:1.
3624        assert_eq!(*fit, crate::image::ImageFit::Fill);
3625        assert!((rect.w - 200.0).abs() < 1e-3, "rect.w = {}", rect.w);
3626        assert!((rect.h - 100.0).abs() < 1e-3, "rect.h = {}", rect.h);
3627        // Default surface_transform is identity.
3628        assert!(transform.is_identity());
3629        // Auto-clip applies regardless of `.clip()`.
3630        let s = scissor.expect("surface op carries a scissor");
3631        assert!((s.w - 200.0).abs() < 1e-3, "scissor.w = {}", s.w);
3632        assert_eq!(*alpha, crate::surface::SurfaceAlpha::Opaque);
3633    }
3634
3635    #[test]
3636    fn surface_fit_contain_letterboxes_aspect_mismatch() {
3637        // 100×50 texture (2:1) into a 400×400 box with Contain →
3638        // dest = 400×200 centred vertically.
3639        let tex = stub_app_texture(100, 50);
3640        let mut root = crate::row([crate::tree::surface(tex)
3641            .surface_fit(crate::image::ImageFit::Contain)
3642            .width(Size::Fixed(400.0))
3643            .height(Size::Fixed(400.0))]);
3644        let mut state = UiState::new();
3645        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3646        let ops = draw_ops(&root, &state);
3647        let DrawOp::AppTexture {
3648            rect, scissor, fit, ..
3649        } = ops
3650            .iter()
3651            .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3652            .expect("surface emits a DrawOp::AppTexture")
3653        else {
3654            unreachable!()
3655        };
3656        assert_eq!(*fit, crate::image::ImageFit::Contain);
3657        assert!((rect.w - 400.0).abs() < 1e-3, "rect.w = {}", rect.w);
3658        assert!((rect.h - 200.0).abs() < 1e-3, "rect.h = {}", rect.h);
3659        // Scissor still clamps to the 400×400 content rect.
3660        let s = scissor.expect("surface op carries a scissor");
3661        assert!((s.h - 400.0).abs() < 1e-3, "scissor.h = {}", s.h);
3662    }
3663
3664    #[test]
3665    fn surface_fit_cover_overflows_rect_with_scissor_clamp() {
3666        // 100×50 texture into a 400×400 box with Cover → dest = 800×400
3667        // (overflowing horizontally). Scissor clamps to 400×400.
3668        let tex = stub_app_texture(100, 50);
3669        let mut root = crate::row([crate::tree::surface(tex)
3670            .surface_fit(crate::image::ImageFit::Cover)
3671            .width(Size::Fixed(400.0))
3672            .height(Size::Fixed(400.0))]);
3673        let mut state = UiState::new();
3674        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3675        let ops = draw_ops(&root, &state);
3676        let DrawOp::AppTexture {
3677            rect, scissor, fit, ..
3678        } = ops
3679            .iter()
3680            .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3681            .expect("surface emits a DrawOp::AppTexture")
3682        else {
3683            unreachable!()
3684        };
3685        assert_eq!(*fit, crate::image::ImageFit::Cover);
3686        assert!((rect.w - 800.0).abs() < 1e-3, "rect.w = {}", rect.w);
3687        assert!((rect.h - 400.0).abs() < 1e-3, "rect.h = {}", rect.h);
3688        let s = scissor.expect("surface op carries a scissor");
3689        assert!((s.w - 400.0).abs() < 1e-3, "scissor.w = {}", s.w);
3690    }
3691
3692    #[test]
3693    fn surface_transform_propagates_through_to_draw_op() {
3694        let tex = stub_app_texture(64, 32);
3695        let m = crate::affine::Affine2::rotate(0.5);
3696        let mut root = crate::row([crate::tree::surface(tex)
3697            .surface_transform(m)
3698            .width(Size::Fixed(200.0))
3699            .height(Size::Fixed(100.0))]);
3700        let mut state = UiState::new();
3701        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3702        let ops = draw_ops(&root, &state);
3703        let DrawOp::AppTexture { transform, .. } = ops
3704            .iter()
3705            .find(|op| matches!(op, DrawOp::AppTexture { .. }))
3706            .expect("surface emits a DrawOp::AppTexture")
3707        else {
3708            unreachable!()
3709        };
3710        assert_eq!(*transform, m);
3711    }
3712
3713    #[test]
3714    fn vector_emits_draw_op_carrying_asset() {
3715        use crate::vector::{PathBuilder, VectorAsset};
3716        let curve = PathBuilder::new()
3717            .move_to(0.0, 0.0)
3718            .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
3719            .stroke_solid(Color::rgb(80, 200, 240), 2.0)
3720            .build();
3721        let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
3722        let expected_hash = asset.content_hash();
3723        let mut root = crate::row([crate::tree::vector(asset)
3724            .width(Size::Fixed(40.0))
3725            .height(Size::Fixed(120.0))]);
3726        let mut state = UiState::new();
3727        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3728        let ops = draw_ops(&root, &state);
3729        let op = ops
3730            .iter()
3731            .find(|op| matches!(op, DrawOp::Vector { .. }))
3732            .expect("Kind::Vector emits a DrawOp::Vector");
3733        let DrawOp::Vector {
3734            rect,
3735            scissor,
3736            asset,
3737            render_mode,
3738            ..
3739        } = op
3740        else {
3741            unreachable!()
3742        };
3743        // Widget's resolved rect drives paint, not the asset's view box.
3744        assert!((rect.w - 40.0).abs() < 1e-3, "rect.w = {}", rect.w);
3745        assert!((rect.h - 120.0).abs() < 1e-3, "rect.h = {}", rect.h);
3746        // Auto-clip applies.
3747        let s = scissor.expect("vector op carries a scissor");
3748        assert!((s.w - 40.0).abs() < 1e-3, "scissor.w = {}", s.w);
3749        // Content hash round-trips through Arc into the op.
3750        assert_eq!(asset.content_hash(), expected_hash);
3751        assert_eq!(
3752            *render_mode,
3753            crate::vector::VectorRenderMode::Painted,
3754            "app vectors default to painted rendering"
3755        );
3756        // The asset's first segment is preserved (sanity-check that the
3757        // PathBuilder fed through correctly).
3758        let first_seg = asset.paths[0].segments.first().copied();
3759        assert_eq!(
3760            first_seg,
3761            Some(crate::vector::VectorSegment::MoveTo([0.0, 0.0]))
3762        );
3763    }
3764
3765    #[test]
3766    fn vector_asset_colors_resolve_against_active_palette() {
3767        use crate::vector::{PathBuilder, VectorAsset, VectorColor};
3768
3769        let path = PathBuilder::new()
3770            .move_to(0.0, 0.0)
3771            .line_to(10.0, 10.0)
3772            .stroke_solid(tokens::PRIMARY, 1.0)
3773            .build();
3774        let mut root =
3775            crate::tree::vector(VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![path]));
3776        let mut state = UiState::new();
3777        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3778
3779        let ops = draw_ops_with_theme(&root, &state, &Theme::aetna_light());
3780        let DrawOp::Vector { asset, .. } = ops
3781            .iter()
3782            .find(|op| matches!(op, DrawOp::Vector { .. }))
3783            .expect("vector op")
3784        else {
3785            unreachable!()
3786        };
3787        let stroke = asset.paths[0].stroke.expect("stroke");
3788        assert_eq!(
3789            stroke.color,
3790            VectorColor::Solid(crate::Palette::aetna_light().primary),
3791            "vector token colors should resolve through the active palette"
3792        );
3793    }
3794
3795    #[test]
3796    fn vector_mask_mode_resolves_mask_color_against_active_palette() {
3797        use crate::vector::{PathBuilder, VectorAsset, VectorRenderMode};
3798
3799        let path = PathBuilder::new()
3800            .move_to(0.0, 0.0)
3801            .line_to(10.0, 10.0)
3802            .stroke_solid(Color::rgb(1, 2, 3), 1.0)
3803            .build();
3804        let mut root =
3805            crate::tree::vector(VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![path]))
3806                .vector_mask(tokens::PRIMARY);
3807        let mut state = UiState::new();
3808        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3809
3810        let ops = draw_ops_with_theme(&root, &state, &Theme::aetna_light());
3811        let DrawOp::Vector { render_mode, .. } = ops
3812            .iter()
3813            .find(|op| matches!(op, DrawOp::Vector { .. }))
3814            .expect("vector op")
3815        else {
3816            unreachable!()
3817        };
3818        assert_eq!(
3819            *render_mode,
3820            VectorRenderMode::Mask {
3821                color: crate::Palette::aetna_light().primary
3822            }
3823        );
3824    }
3825
3826    #[test]
3827    fn math_exact_glyph_assets_are_normalized_before_msdf_rasterization() {
3828        let face = ttf_parser::Face::parse(aetna_fonts::NOTO_SANS_MATH_REGULAR, 0).unwrap();
3829        let glyph_id = face.glyph_index('√').expect("math radical glyph").0;
3830        let asset = math_glyph_vector_asset(glyph_id, Rect::new(-64.0, -3200.0, 1280.0, 4096.0))
3831            .expect("math glyph vector asset");
3832
3833        assert!(
3834            asset.view_box[2].max(asset.view_box[3]) <= 24.001,
3835            "font-unit view box should be normalized before hitting the icon MSDF path: {:?}",
3836            asset.view_box
3837        );
3838
3839        let mut atlas = crate::icons::msdf_atlas::IconMsdfAtlas::default();
3840        let slot = atlas
3841            .ensure_vector_asset(&asset)
3842            .expect("normalized glyph should rasterize");
3843        assert!(
3844            slot.rect.w <= 80 && slot.rect.h <= 80,
3845            "normalized math glyph should produce icon-sized MSDFs, got {:?}",
3846            slot.rect
3847        );
3848    }
3849
3850    #[test]
3851    fn vector_asset_content_hash_is_stable_and_distinguishing() {
3852        use crate::vector::{PathBuilder, VectorAsset};
3853        let make = |sx: f32| {
3854            let p = PathBuilder::new()
3855                .move_to(0.0, 0.0)
3856                .line_to(sx, 1.0)
3857                .stroke_solid(Color::rgb(0, 0, 0), 1.0)
3858                .build();
3859            VectorAsset::from_paths([0.0, 0.0, 10.0, 10.0], vec![p])
3860        };
3861        // Same inputs → same hash, across repeated builds.
3862        assert_eq!(make(1.0).content_hash(), make(1.0).content_hash());
3863        // Different geometry → different hash.
3864        assert_ne!(make(1.0).content_hash(), make(2.0).content_hash());
3865    }
3866
3867    #[test]
3868    fn opacity_multiplies_alpha_on_quad_uniforms() {
3869        let mut root = button("X")
3870            .fill(Color::rgba(200, 100, 50, 200))
3871            .opacity(0.5);
3872        let mut state = UiState::new();
3873        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3874        let ops = draw_ops(&root, &state);
3875        let DrawOp::Quad { uniforms, .. } = &ops[0] else {
3876            panic!("expected quad op");
3877        };
3878        let UniformValue::Color(c) = uniforms.get("fill").expect("fill") else {
3879            panic!("fill should be a colour");
3880        };
3881        // 200 * 0.5 = 100
3882        assert_eq!(c.a, 100, "alpha should be halved by opacity 0.5");
3883    }
3884
3885    #[test]
3886    fn theme_can_route_implicit_surfaces_to_custom_shader() {
3887        let mut root = button("X").primary();
3888        let mut state = UiState::new();
3889        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3890
3891        let theme = Theme::default()
3892            .with_surface_shader("xp_surface")
3893            .with_surface_uniform("theme_strength", UniformValue::F32(0.75));
3894        let ops = draw_ops_with_theme(&root, &state, &theme);
3895        let DrawOp::Quad {
3896            shader, uniforms, ..
3897        } = &ops[0]
3898        else {
3899            panic!("expected themed surface quad");
3900        };
3901
3902        assert_eq!(*shader, ShaderHandle::Custom("xp_surface"));
3903        assert_eq!(
3904            uniforms.get("theme_strength"),
3905            Some(&UniformValue::F32(0.75))
3906        );
3907        assert!(
3908            matches!(uniforms.get("fill"), Some(UniformValue::Color(_))),
3909            "familiar rounded-rect uniforms should stay available for manifests"
3910        );
3911        assert!(
3912            matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
3913            "custom surface shaders should also receive packed instance slots"
3914        );
3915        assert_eq!(
3916            uniforms.get("vec_c"),
3917            Some(&UniformValue::Vec4([
3918                1.0,
3919                tokens::RADIUS_MD,
3920                tokens::SHADOW_SM * 0.5,
3921                0.0
3922            ]))
3923        );
3924    }
3925
3926    #[test]
3927    fn theme_can_route_surface_role_to_custom_shader() {
3928        let mut root = crate::titled_card("Panel", [crate::text("Body")])
3929            .surface_role(SurfaceRole::Popover)
3930            .key("panel");
3931        let mut state = UiState::new();
3932        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 120.0));
3933
3934        let theme = Theme::default()
3935            .with_role_shader(SurfaceRole::Popover, "popover_surface")
3936            .with_role_uniform(SurfaceRole::Popover, "elevation", UniformValue::F32(2.0));
3937        let ops = draw_ops_with_theme(&root, &state, &theme);
3938        let DrawOp::Quad {
3939            shader, uniforms, ..
3940        } = &ops[0]
3941        else {
3942            panic!("expected themed surface quad");
3943        };
3944
3945        assert_eq!(*shader, ShaderHandle::Custom("popover_surface"));
3946        assert_eq!(uniforms.get("elevation"), Some(&UniformValue::F32(2.0)));
3947        assert_eq!(
3948            uniforms.get("surface_role"),
3949            Some(&UniformValue::F32(SurfaceRole::Popover.uniform_id()))
3950        );
3951        assert!(
3952            matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
3953            "role-routed custom shaders should receive packed rect slots"
3954        );
3955        assert_eq!(
3956            uniforms.get("vec_c"),
3957            Some(&UniformValue::Vec4([
3958                1.0,
3959                tokens::RADIUS_LG,
3960                tokens::SHADOW_LG,
3961                0.0
3962            ]))
3963        );
3964    }
3965
3966    #[test]
3967    fn translate_offsets_paint_rect_and_inherits_to_children() {
3968        // Parent translate of (50, 30) should land child rects at
3969        // child.computed + (50, 30). The button widget uses
3970        // `paint_overflow` for its focus ring, which grows the painted
3971        // rect outward — so we compare against the `inner_rect` uniform
3972        // (the post-translate layout rect) rather than the raw quad rect.
3973        let mut root = column([button("X").key("x")]).translate(50.0, 30.0);
3974        let mut state = UiState::new();
3975        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3976        let inner = inner_rect_quad_for(&root, &state, "x").expect("x quad inner_rect");
3977        let untranslated = find_computed(&root, &state, "x").expect("x computed");
3978
3979        assert!((inner.x - (untranslated.x + 50.0)).abs() < 0.5);
3980        assert!((inner.y - (untranslated.y + 30.0)).abs() < 0.5);
3981    }
3982
3983    #[test]
3984    fn scale_scales_rect_around_center() {
3985        let mut root = column([button("X").key("x").scale(2.0).width(Size::Fixed(40.0))]);
3986        let mut state = UiState::new();
3987        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3988        let pre = find_computed(&root, &state, "x").expect("computed");
3989        let post = inner_rect_quad_for(&root, &state, "x").expect("painted inner_rect");
3990
3991        // 2x scale around centre: w doubles, x shifts left by w/2.
3992        assert!((post.w - pre.w * 2.0).abs() < 0.5);
3993        assert!((post.h - pre.h * 2.0).abs() < 0.5);
3994        let pre_cx = pre.center_x();
3995        let post_cx = post.center_x();
3996        assert!(
3997            (pre_cx - post_cx).abs() < 0.5,
3998            "centre should be preserved by scale-around-centre",
3999        );
4000    }
4001
4002    #[test]
4003    fn shadow_auto_expands_painted_rect_around_inner_rect() {
4004        // `.shadow(s)` should auto-widen the painted quad without the
4005        // widget needing to set `paint_overflow` — the shader needs the
4006        // halo room to draw the soft band outside the layout rect.
4007        // No surface_role here so the El's shadow value reaches the
4008        // shader unchanged and we can assert the exact halo geometry.
4009        let mut root = column([El::new(Kind::Group)
4010            .key("c")
4011            .fill(tokens::CARD)
4012            .radius(tokens::RADIUS_LG)
4013            .shadow(tokens::SHADOW_MD)
4014            .width(Size::Fixed(80.0))
4015            .height(Size::Fixed(40.0))]);
4016        let mut state = UiState::new();
4017        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4018        let ops = draw_ops(&root, &state);
4019        let (painted, inner) = ops
4020            .iter()
4021            .find_map(|op| match op {
4022                DrawOp::Quad {
4023                    id, rect, uniforms, ..
4024                } if id.contains("c") => {
4025                    let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4026                        return None;
4027                    };
4028                    Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4029                }
4030                _ => None,
4031            })
4032            .expect("shadowed quad with inner_rect");
4033
4034        // SHADOW_MD (== 12) → l=12, r=12, t=6, b=18.
4035        let blur = tokens::SHADOW_MD;
4036        assert!(
4037            (inner.x - painted.x - blur).abs() < 0.5,
4038            "left halo == blur, painted.x={}, inner.x={}",
4039            painted.x,
4040            inner.x,
4041        );
4042        assert!(
4043            (painted.right() - inner.right() - blur).abs() < 0.5,
4044            "right halo == blur",
4045        );
4046        assert!(
4047            (inner.y - painted.y - blur * 0.5).abs() < 0.5,
4048            "top halo == blur * 0.5",
4049        );
4050        assert!(
4051            (painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
4052            "bottom halo == blur * 1.5",
4053        );
4054    }
4055
4056    #[test]
4057    fn shadow_overflow_takes_per_side_max_with_explicit_paint_overflow() {
4058        // A focus-style outset of 8 on every side combined with
4059        // SHADOW_MD (12) should resolve to: l=12, r=12, t=8, b=18 —
4060        // shadow wins on left/right/bottom, paint_overflow wins on top.
4061        let combined =
4062            super::combined_overflow(crate::tree::Sides::all(8.0), tokens::SHADOW_MD, 0.0, 0.0);
4063        assert!((combined.left - 12.0).abs() < f32::EPSILON);
4064        assert!((combined.right - 12.0).abs() < f32::EPSILON);
4065        assert!((combined.top - 8.0).abs() < f32::EPSILON);
4066        assert!((combined.bottom - 18.0).abs() < f32::EPSILON);
4067    }
4068
4069    #[test]
4070    fn shadow_overflow_is_zero_when_shadow_is_zero() {
4071        let combined = super::combined_overflow(crate::tree::Sides::zero(), 0.0, 0.0, 0.0);
4072        assert_eq!(combined, crate::tree::Sides::zero());
4073    }
4074
4075    #[test]
4076    fn focus_overflow_outsets_painted_rect_by_ring_width() {
4077        let combined =
4078            super::combined_overflow(crate::tree::Sides::zero(), 0.0, 0.0, tokens::RING_WIDTH);
4079        assert_eq!(combined, crate::tree::Sides::all(tokens::RING_WIDTH));
4080    }
4081
4082    #[test]
4083    fn inside_focus_ring_does_not_outset_painted_rect() {
4084        use crate::layout::layout;
4085
4086        let mut tree = column([crate::menu_item("Open")
4087            .key("item")
4088            .width(Size::Fixed(100.0))])
4089        .padding(20.0);
4090        let mut state = UiState::new();
4091        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4092        let target = state.target_of_key(&tree, "item").expect("item target");
4093        state.focused = Some(target);
4094        state.focus_visible = true;
4095        state.apply_to_state();
4096        state.set_animation_mode(crate::state::AnimationMode::Settled);
4097        state.tick_visual_animations(&mut tree, web_time::Instant::now());
4098
4099        let item_rect = state.rect_of_key(&tree, "item").expect("item rect");
4100        let ops = draw_ops(&tree, &state);
4101        let DrawOp::Quad { rect, uniforms, .. } =
4102            find_quad(&ops, "menu_item[item]").expect("menu item quad")
4103        else {
4104            panic!("expected menu item quad");
4105        };
4106        assert_eq!(*rect, item_rect);
4107        assert_eq!(
4108            uniforms.get("focus_width"),
4109            Some(&UniformValue::F32(-tokens::RING_WIDTH))
4110        );
4111    }
4112
4113    #[test]
4114    fn stroke_overflow_outsets_painted_rect_by_half_width_plus_aa_tail() {
4115        // Stroke straddles the boundary; without any outset, cardinal
4116        // pixels of curved boundaries (radio indicator, switch thumb)
4117        // get clipped because the outside half of the band falls
4118        // outside the layout rect. Auto-widen by stroke_width/2 + 1px
4119        // (AA tail) so the full band rasterises symmetrically.
4120        let combined = super::combined_overflow(crate::tree::Sides::zero(), 0.0, 1.0, 0.0);
4121        let halo = 1.0 * 0.5 + 1.0;
4122        assert!((combined.left - halo).abs() < f32::EPSILON);
4123        assert!((combined.right - halo).abs() < f32::EPSILON);
4124        assert!((combined.top - halo).abs() < f32::EPSILON);
4125        assert!((combined.bottom - halo).abs() < f32::EPSILON);
4126    }
4127
4128    #[test]
4129    fn stroke_and_shadow_take_per_side_max() {
4130        // Shadow's bottom halo (blur*1.5 = 18) beats stroke (1.5) on
4131        // the bottom; stroke beats shadow on the top (blur*0.5 = 6 vs
4132        // 1.5? no — shadow wins there too). Use a small shadow so the
4133        // stroke halo wins on the top.
4134        let combined = super::combined_overflow(crate::tree::Sides::zero(), 1.0, 4.0, 0.0);
4135        // Stroke halo = 4*0.5 + 1 = 3. Shadow blur = 1 → top = 0.5,
4136        // bottom = 1.5, l/r = 1. Stroke wins on every side.
4137        assert!(
4138            (combined.top - 3.0).abs() < f32::EPSILON,
4139            "top = {}",
4140            combined.top
4141        );
4142        assert!((combined.left - 3.0).abs() < f32::EPSILON);
4143        assert!((combined.right - 3.0).abs() < f32::EPSILON);
4144        // Bottom: max(stroke=3, shadow*1.5=1.5) → stroke wins.
4145        assert!((combined.bottom - 3.0).abs() < f32::EPSILON);
4146    }
4147
4148    #[test]
4149    fn stroked_indicator_painted_rect_outsets_layout_rect() {
4150        // Regression for the radio indicator: a small stroked circle
4151        // looked flattened at the cardinal directions because its
4152        // painted quad equalled the 16×16 layout rect, clipping the
4153        // outside half of the stroke band on the top/bottom/left/right.
4154        // After the fix, the quad outsets by stroke_width/2 + 1 on each
4155        // side so the AA tail rasterises cleanly.
4156        let mut root = column([El::new(Kind::Custom("radio-indicator"))
4157            .key("indicator")
4158            .width(Size::Fixed(16.0))
4159            .height(Size::Fixed(16.0))
4160            .radius(tokens::RADIUS_PILL)
4161            .fill(tokens::CARD)
4162            .stroke(tokens::INPUT)]);
4163        let mut state = UiState::new();
4164        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4165
4166        let ops = draw_ops(&root, &state);
4167        let (painted, inner) = ops
4168            .iter()
4169            .find_map(|op| match op {
4170                DrawOp::Quad {
4171                    id, rect, uniforms, ..
4172                } if id.contains("indicator") => {
4173                    let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4174                        return None;
4175                    };
4176                    Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4177                }
4178                _ => None,
4179            })
4180            .expect("stroked indicator quad with inner_rect");
4181
4182        // stroke_width default = 1 → halo = 0.5 + 1 = 1.5 on each side.
4183        let halo = 1.5;
4184        assert!(
4185            (inner.x - painted.x - halo).abs() < 1e-3,
4186            "left halo, painted.x={}, inner.x={}",
4187            painted.x,
4188            inner.x,
4189        );
4190        assert!(
4191            (painted.right() - inner.right() - halo).abs() < 1e-3,
4192            "right halo",
4193        );
4194        assert!((inner.y - painted.y - halo).abs() < 1e-3, "top halo",);
4195        assert!(
4196            (painted.bottom() - inner.bottom() - halo).abs() < 1e-3,
4197            "bottom halo",
4198        );
4199        // Layout rect itself is unchanged — only the painted quad
4200        // grows; the SDF still anchors to the original 16×16 box.
4201        assert!((inner.w - 16.0).abs() < 1e-3);
4202        assert!((inner.h - 16.0).abs() < 1e-3);
4203    }
4204
4205    #[test]
4206    fn shadow_uniform_is_set_when_n_shadow_is_nonzero() {
4207        let mut root = column([El::new(Kind::Group)
4208            .key("c")
4209            .fill(tokens::CARD)
4210            .radius(tokens::RADIUS_LG)
4211            .shadow(tokens::SHADOW_MD)
4212            .width(Size::Fixed(80.0))
4213            .height(Size::Fixed(40.0))]);
4214        let mut state = UiState::new();
4215        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4216        let ops = draw_ops(&root, &state);
4217        let uniforms = ops
4218            .iter()
4219            .find_map(|op| match op {
4220                DrawOp::Quad { id, uniforms, .. } if id.contains("c") => Some(uniforms.clone()),
4221                _ => None,
4222            })
4223            .expect("shadowed quad");
4224        assert_eq!(
4225            uniforms.get("shadow"),
4226            Some(&UniformValue::F32(tokens::SHADOW_MD)),
4227            ".shadow(SHADOW_MD) on a node without surface_role must reach the shader unchanged",
4228        );
4229    }
4230
4231    #[test]
4232    fn theme_role_override_propagates_to_painted_rect() {
4233        // The card widget binds SurfaceRole::Panel, which forces the
4234        // shadow uniform to SHADOW_SM regardless of the El's own
4235        // `.shadow(SHADOW_MD)` setting. The painted rect should track
4236        // the *effective* shadow (SM = 4), not the larger MD the
4237        // builder requested — over-expanding wastes overdraw budget.
4238        let mut root = column([crate::titled_card("Card", [crate::text("Body")]).key("c")]);
4239        let mut state = UiState::new();
4240        crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4241        let ops = draw_ops(&root, &state);
4242        let (painted, inner) = ops
4243            .iter()
4244            .find_map(|op| match op {
4245                DrawOp::Quad {
4246                    id, rect, uniforms, ..
4247                } if id.contains("c") => {
4248                    let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
4249                        return None;
4250                    };
4251                    Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
4252                }
4253                _ => None,
4254            })
4255            .expect("card quad with inner_rect");
4256
4257        let blur = tokens::SHADOW_SM;
4258        assert!(
4259            (inner.x - painted.x - blur).abs() < 0.5,
4260            "left halo == effective (theme-resolved) shadow, painted.x={}, inner.x={}",
4261            painted.x,
4262            inner.x,
4263        );
4264        assert!(
4265            (painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
4266            "bottom halo == effective shadow * 1.5",
4267        );
4268    }
4269
4270    /// Read the painted layout rect (== quad's `inner_rect` uniform) for
4271    /// the first quad whose id contains `key`. Falls back to the quad's
4272    /// `rect` for shaders that don't carry an `inner_rect` uniform.
4273    fn inner_rect_quad_for(root: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
4274        use crate::shader::UniformValue;
4275        let ops = draw_ops(root, ui_state);
4276        for op in ops {
4277            if let DrawOp::Quad {
4278                id, rect, uniforms, ..
4279            } = op
4280                && id.contains(key)
4281            {
4282                if let Some(UniformValue::Vec4(v)) = uniforms.get("inner_rect") {
4283                    return Some(Rect::new(v[0], v[1], v[2], v[3]));
4284                }
4285                return Some(rect);
4286            }
4287        }
4288        None
4289    }
4290
4291    fn find_computed(node: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
4292        if node.key.as_deref() == Some(key) {
4293            return Some(ui_state.rect(&node.computed_id));
4294        }
4295        node.children
4296            .iter()
4297            .find_map(|c| find_computed(c, ui_state, key))
4298    }
4299}