Skip to main content

blinc_app/
context.rs

1//! Render context for blinc_app
2//!
3//! Wraps the GPU rendering pipeline with a clean API.
4
5use blinc_core::{
6    Brush, Color, CornerRadius, DrawCommand, DrawContext, DrawContextExt, Rect, Stroke,
7};
8use blinc_gpu::{
9    FontRegistry, GenericFont as GpuGenericFont, GpuGlyph, GpuImage, GpuImageInstance,
10    GpuPaintContext, GpuPrimitive, GpuRenderer, ImageRenderingContext, PrimitiveBatch,
11    TextAlignment, TextAnchor, TextRenderingContext,
12};
13use blinc_layout::div::{FontFamily, FontWeight, GenericFont, TextAlign, TextVerticalAlign};
14use blinc_layout::prelude::*;
15use blinc_layout::render_state::Overlay;
16use blinc_layout::renderer::ElementType;
17use blinc_svg::{RasterizedSvg, SvgDocument};
18use lru::LruCache;
19use std::collections::hash_map::DefaultHasher;
20use std::hash::{Hash, Hasher};
21use std::num::NonZeroUsize;
22use std::sync::{Arc, Mutex};
23
24use crate::error::Result;
25use crate::svg_atlas::SvgAtlas;
26
27/// Maximum number of images to keep in cache (prevents unbounded memory growth)
28const IMAGE_CACHE_CAPACITY: usize = 32;
29
30/// Maximum number of parsed SVG documents to cache
31const SVG_CACHE_CAPACITY: usize = 128;
32
33/// Intersect two axis-aligned clip rects [x, y, w, h], returning their overlap.
34fn intersect_clip_rects(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
35    let x1 = a[0].max(b[0]);
36    let y1 = a[1].max(b[1]);
37    let x2 = (a[0] + a[2]).min(b[0] + b[2]);
38    let y2 = (a[1] + a[3]).min(b[1] + b[3]);
39    [x1, y1, (x2 - x1).max(0.0), (y2 - y1).max(0.0)]
40}
41
42/// Merge a new clip rect with an optional existing one via intersection.
43fn merge_scroll_clip(new_clip: [f32; 4], existing: Option<[f32; 4]>) -> Option<[f32; 4]> {
44    match existing {
45        Some(ex) => Some(intersect_clip_rects(new_clip, ex)),
46        None => Some(new_clip),
47    }
48}
49
50/// Compute effective clip for elements that support only a single clip rect (text, SVG).
51/// Intersects primary clip and scroll clip so nested scroll containers are respected.
52fn effective_single_clip(primary: Option<[f32; 4]>, scroll: Option<[f32; 4]>) -> Option<[f32; 4]> {
53    match (primary, scroll) {
54        (Some(c), Some(s)) => Some(intersect_clip_rects(c, s)),
55        (c, s) => c.or(s),
56    }
57}
58
59// Rasterized SVG textures are now packed into SvgAtlas (single shared GPU texture)
60
61/// Internal render context that manages GPU resources and rendering
62pub struct RenderContext {
63    renderer: GpuRenderer,
64    text_ctx: TextRenderingContext,
65    image_ctx: ImageRenderingContext,
66    device: Arc<wgpu::Device>,
67    queue: Arc<wgpu::Queue>,
68    sample_count: u32,
69    // Single texture for glass backdrop (rendered to and sampled from)
70    backdrop_texture: Option<CachedTexture>,
71    // Cached MSAA texture for anti-aliased rendering
72    msaa_texture: Option<CachedTexture>,
73    // LRU cache for images (prevents unbounded memory growth)
74    image_cache: LruCache<String, GpuImage>,
75    // LRU cache for parsed SVG documents (avoids re-parsing)
76    svg_cache: LruCache<u64, SvgDocument>,
77    // Texture atlas for rasterized SVGs (single shared GPU texture, shelf-packed)
78    svg_atlas: SvgAtlas,
79    // Scratch buffers for per-frame allocations (reused to avoid allocations)
80    scratch_glyphs: Vec<GpuGlyph>,
81    scratch_texts: Vec<TextElement>,
82    scratch_svgs: Vec<SvgElement>,
83    scratch_images: Vec<ImageElement>,
84    // Current cursor position in physical pixels (for @flow pointer input)
85    cursor_pos: [f32; 2],
86    // Whether the last render contained @flow shader elements (triggers continuous redraw)
87    has_active_flows: bool,
88    // Frame counter for periodic cache stats logging
89    frame_count: u64,
90}
91
92struct CachedTexture {
93    texture: wgpu::Texture,
94    view: wgpu::TextureView,
95    width: u32,
96    height: u32,
97}
98
99/// Info about a 3D-transformed ancestor layer. When text/SVGs/images are inside a parent
100/// with `perspective` + `rotate-x`/`rotate-y`, this info is used to render them to an
101/// offscreen texture and blit with the same perspective transform.
102#[derive(Clone, Debug)]
103struct Transform3DLayerInfo {
104    /// Node ID of the 3D-transformed ancestor (used as layer grouping key)
105    node_id: LayoutNodeId,
106    /// Screen-space bounds of the 3D layer [x, y, w, h] (DPI-scaled)
107    layer_bounds: [f32; 4],
108    /// Perspective transform parameters
109    transform_3d: blinc_core::Transform3DParams,
110    /// Layer opacity
111    opacity: f32,
112}
113
114/// Text element data for rendering
115#[derive(Clone)]
116struct TextElement {
117    content: String,
118    x: f32,
119    y: f32,
120    width: f32,
121    height: f32,
122    font_size: f32,
123    color: [f32; 4],
124    align: TextAlign,
125    weight: FontWeight,
126    /// Whether to use italic style
127    italic: bool,
128    /// Vertical alignment within bounding box
129    v_align: TextVerticalAlign,
130    /// Clip bounds from parent scroll container (x, y, width, height)
131    clip_bounds: Option<[f32; 4]>,
132    /// Motion opacity inherited from parent motion container
133    motion_opacity: f32,
134    /// Whether to wrap text at container bounds
135    wrap: bool,
136    /// Line height multiplier
137    line_height: f32,
138    /// Measured width (before layout constraints) - used to determine if wrap is needed
139    measured_width: f32,
140    /// Font family category
141    font_family: FontFamily,
142    /// Word spacing in pixels (0.0 = normal)
143    word_spacing: f32,
144    /// Letter spacing in pixels (0.0 = normal)
145    letter_spacing: f32,
146    /// Z-index for rendering order (higher = on top)
147    z_index: u32,
148    /// Font ascender in pixels (distance from baseline to top)
149    ascender: f32,
150    /// Whether text has strikethrough decoration
151    strikethrough: bool,
152    /// Whether text has underline decoration
153    underline: bool,
154    /// CSS text-decoration-color override (RGBA)
155    decoration_color: Option<[f32; 4]>,
156    /// CSS text-decoration-thickness override in pixels
157    decoration_thickness: Option<f32>,
158    /// Inherited CSS transform from ancestor elements (full 6-element affine in layout coords)
159    /// [a, b, c, d, tx, ty] where new_x = a*x + c*y + tx, new_y = b*x + d*y + ty
160    css_affine: Option<[f32; 6]>,
161    /// Text shadow (offset_x, offset_y, blur, color) from CSS text-shadow property
162    text_shadow: Option<blinc_core::Shadow>,
163    /// 3D layer info if this text is inside a perspective-transformed parent
164    transform_3d_layer: Option<Transform3DLayerInfo>,
165    /// Whether this text is inside a foreground-layer element (rendered after foreground primitives)
166    is_foreground: bool,
167}
168
169/// Image element data for rendering
170#[derive(Clone)]
171struct ImageElement {
172    source: String,
173    x: f32,
174    y: f32,
175    width: f32,
176    height: f32,
177    object_fit: u8,
178    object_position: [f32; 2],
179    opacity: f32,
180    border_radius: f32,
181    tint: [f32; 4],
182    /// Clip bounds from parent (x, y, width, height)
183    clip_bounds: Option<[f32; 4]>,
184    /// Clip corner radii (tl, tr, br, bl)
185    clip_radius: [f32; 4],
186    /// Which layer this image renders in
187    layer: RenderLayer,
188    /// Loading strategy: 0 = Eager (load immediately), 1 = Lazy (load when visible)
189    loading_strategy: u8,
190    /// Placeholder type: 0 = None, 1 = Color, 2 = Image, 3 = Skeleton
191    placeholder_type: u8,
192    /// Placeholder color [r, g, b, a]
193    placeholder_color: [f32; 4],
194    /// Z-layer index for interleaved rendering with primitives
195    z_index: u32,
196    /// Border width (0 = no border)
197    border_width: f32,
198    /// Border color
199    border_color: blinc_core::Color,
200    /// CSS transform as 6-element affine [a, b, c, d, tx, ty] (None = no transform)
201    css_affine: Option<[f32; 6]>,
202    /// Drop shadow from CSS
203    shadow: Option<blinc_core::Shadow>,
204    /// CSS filter A (grayscale, invert, sepia, hue_rotate_rad) — identity = [0,0,0,0]
205    filter_a: [f32; 4],
206    /// CSS filter B (brightness, contrast, saturate, unused) — identity = [1,1,1,0]
207    filter_b: [f32; 4],
208    /// Secondary clip (scroll container boundary) — sharp rect, no radius.
209    /// Kept separate from primary clip_bounds so rounded corners don't morph
210    /// when the primary clip rect shrinks at scroll boundaries.
211    scroll_clip: Option<[f32; 4]>,
212    /// Mask gradient params: linear=(x1,y1,x2,y2), radial=(cx,cy,r,0) in OBB space
213    mask_params: [f32; 4],
214    /// Mask info: [mask_type, start_alpha, end_alpha, 0] (0=none, 1=linear, 2=radial)
215    mask_info: [f32; 4],
216    /// 3D layer info if this image is inside a perspective-transformed parent
217    transform_3d_layer: Option<Transform3DLayerInfo>,
218}
219
220/// SVG element data for rendering
221#[derive(Clone)]
222struct SvgElement {
223    source: Arc<str>,
224    x: f32,
225    y: f32,
226    width: f32,
227    height: f32,
228    /// Tint color to apply to SVG fill/stroke (from CSS `color`)
229    tint: Option<blinc_core::Color>,
230    /// CSS `fill` override for SVG
231    fill: Option<blinc_core::Color>,
232    /// CSS `stroke` override for SVG
233    stroke: Option<blinc_core::Color>,
234    /// CSS `stroke-width` override for SVG
235    stroke_width: Option<f32>,
236    /// CSS `stroke-dasharray` pattern for SVG
237    stroke_dasharray: Option<Vec<f32>>,
238    /// CSS `stroke-dashoffset` for SVG
239    stroke_dashoffset: Option<f32>,
240    /// SVG path `d` attribute data (for path morphing)
241    svg_path_data: Option<String>,
242    /// Clip bounds from parent scroll container (x, y, width, height)
243    clip_bounds: Option<[f32; 4]>,
244    /// Motion opacity inherited from parent motion container
245    motion_opacity: f32,
246    /// Inherited CSS transform from ancestor elements (full 6-element affine in layout coords)
247    /// [a, b, c, d, tx, ty] where new_x = a*x + c*y + tx, new_y = b*x + d*y + ty
248    css_affine: Option<[f32; 6]>,
249    /// Per-SVG-tag style overrides from CSS tag-name selectors (e.g., `path { fill: red; }`)
250    tag_overrides: std::collections::HashMap<String, blinc_layout::element::SvgTagStyle>,
251    /// 3D layer info if this SVG is inside a perspective-transformed parent
252    transform_3d_layer: Option<Transform3DLayerInfo>,
253}
254
255/// Flow shader element — an element with `flow: <name>` that renders via a custom GPU pipeline
256#[derive(Clone)]
257struct FlowElement {
258    /// Name referencing a @flow DAG in the stylesheet
259    flow_name: String,
260    /// Direct FlowGraph (from `flow!` macro), bypasses stylesheet lookup
261    flow_graph: Option<std::sync::Arc<blinc_core::FlowGraph>>,
262    /// Bounds in physical pixels (DPI-scaled)
263    x: f32,
264    y: f32,
265    width: f32,
266    height: f32,
267    /// Z-layer for rendering order
268    z_index: u32,
269    /// Corner radius in physical pixels
270    corner_radius: f32,
271}
272
273/// Debug bounds element for layout visualization
274#[derive(Clone)]
275struct DebugBoundsElement {
276    x: f32,
277    y: f32,
278    width: f32,
279    height: f32,
280    /// Element type name for labeling
281    element_type: String,
282    /// Depth in the tree (for color coding)
283    depth: u32,
284}
285
286impl RenderContext {
287    /// Create a new render context
288    pub(crate) fn new(
289        renderer: GpuRenderer,
290        text_ctx: TextRenderingContext,
291        device: Arc<wgpu::Device>,
292        queue: Arc<wgpu::Queue>,
293        sample_count: u32,
294    ) -> Self {
295        let image_ctx = ImageRenderingContext::new(device.clone(), queue.clone());
296        let svg_atlas = SvgAtlas::new(&device);
297        Self {
298            renderer,
299            text_ctx,
300            image_ctx,
301            device,
302            queue,
303            sample_count,
304            backdrop_texture: None,
305            msaa_texture: None,
306            image_cache: LruCache::new(NonZeroUsize::new(IMAGE_CACHE_CAPACITY).unwrap()),
307            svg_cache: LruCache::new(NonZeroUsize::new(SVG_CACHE_CAPACITY).unwrap()),
308            svg_atlas,
309            scratch_glyphs: Vec::with_capacity(1024), // Pre-allocate for typical text
310            scratch_texts: Vec::with_capacity(64),    // Pre-allocate for text elements
311            scratch_svgs: Vec::with_capacity(32),     // Pre-allocate for SVG elements
312            scratch_images: Vec::with_capacity(32),   // Pre-allocate for image elements
313            cursor_pos: [0.0; 2],
314            has_active_flows: false,
315            frame_count: 0,
316        }
317    }
318
319    /// Update the current cursor position in physical pixels (for @flow pointer input)
320    pub fn set_cursor_position(&mut self, x: f32, y: f32) {
321        self.cursor_pos = [x, y];
322    }
323
324    /// Whether the last render frame contained @flow shader elements.
325    /// Used to trigger continuous redraws for animated flow shaders.
326    pub fn has_active_flows(&self) -> bool {
327        self.has_active_flows
328    }
329
330    /// Set the current render target texture for blend mode two-pass compositing.
331    /// Must be called before rendering when the batch may use non-Normal blend modes.
332    pub fn set_blend_target(&mut self, texture: &wgpu::Texture) {
333        self.renderer.set_blend_target(texture);
334    }
335
336    /// Clear the blend target texture reference after rendering.
337    pub fn clear_blend_target(&mut self) {
338        self.renderer.clear_blend_target();
339    }
340
341    /// Load font data into the text rendering registry
342    ///
343    /// This adds fonts that will be available for text rendering.
344    /// Returns the number of font faces loaded.
345    pub fn load_font_data_to_registry(&mut self, data: Vec<u8>) -> usize {
346        self.text_ctx.load_font_data_to_registry(data)
347    }
348
349    /// Render a layout tree to a texture view
350    ///
351    /// Handles everything automatically - glass, text, SVG, MSAA.
352    pub fn render_tree(
353        &mut self,
354        tree: &RenderTree,
355        width: u32,
356        height: u32,
357        target: &wgpu::TextureView,
358    ) -> Result<()> {
359        // Get scale factor for HiDPI rendering
360        let scale_factor = tree.scale_factor();
361
362        // Create paint contexts for each layer with text rendering support
363        let mut bg_ctx =
364            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
365
366        // Render layout layers (background and glass go to bg_ctx)
367        tree.render_to_layer(&mut bg_ctx, RenderLayer::Background);
368        tree.render_to_layer(&mut bg_ctx, RenderLayer::Glass);
369
370        // Take the batch from bg_ctx before we can reuse text_ctx for fg_ctx
371        let mut bg_batch = bg_ctx.take_batch();
372
373        // Create foreground context with text rendering support
374        let mut fg_ctx =
375            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
376        tree.render_to_layer(&mut fg_ctx, RenderLayer::Foreground);
377
378        // Take the batch from fg_ctx before reusing text_ctx for text elements
379        let mut fg_batch = fg_ctx.take_batch();
380
381        // Collect text, SVG, image, and flow elements
382        let (texts, svgs, images, _flows) = self.collect_render_elements(tree);
383
384        // Pre-load all images into cache before rendering
385        self.preload_images(&images, width as f32, height as f32);
386
387        // Prepare text glyphs
388        let mut all_glyphs = Vec::new();
389        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
390        for text in &texts {
391            // Convert layout TextAlign to GPU TextAlignment
392            let alignment = match text.align {
393                TextAlign::Left => TextAlignment::Left,
394                TextAlign::Center => TextAlignment::Center,
395                TextAlign::Right => TextAlignment::Right,
396            };
397
398            // Vertical alignment:
399            // - Center: Use TextAnchor::Center with y at vertical center of bounds.
400            //   This ensures text appears visually centered (by cap-height) rather than
401            //   mathematically centered by the full bounding box (which includes descenders).
402            // - Top: Text is centered within its layout box (items_center works).
403            // - Baseline: Position text so baseline aligns at the font's actual baseline.
404            //   Using the actual ascender from font metrics ensures all fonts align by
405            //   their true baseline regardless of font family.
406            let (anchor, y_pos, use_layout_height) = match text.v_align {
407                TextVerticalAlign::Center => {
408                    (TextAnchor::Center, text.y + text.height / 2.0, false)
409                }
410                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
411                TextVerticalAlign::Baseline => {
412                    // Use the actual font ascender for baseline positioning.
413                    // This ensures each font aligns by its true baseline.
414                    let baseline_y = text.y + text.ascender;
415                    (TextAnchor::Baseline, baseline_y, false)
416                }
417            };
418
419            // Determine wrap width: use clip bounds if available (parent constraint),
420            // otherwise use the text element's own layout width
421            let wrap_width = if text.wrap {
422                if let Some(clip) = text.clip_bounds {
423                    // clip[2] is the clip width - use it if smaller than text width
424                    clip[2].min(text.width)
425                } else {
426                    text.width
427                }
428            } else {
429                text.width
430            };
431
432            // Convert font family to GPU types
433            let font_name = text.font_family.name.as_deref();
434            let generic = to_gpu_generic_font(text.font_family.generic);
435            let font_weight = text.weight.weight();
436
437            // Only pass layout_height when we want centering within the box
438            let layout_height = if use_layout_height {
439                Some(text.height)
440            } else {
441                None
442            };
443
444            match self.text_ctx.prepare_text_with_style(
445                &text.content,
446                text.x,
447                y_pos,
448                text.font_size,
449                text.color,
450                anchor,
451                alignment,
452                Some(wrap_width),
453                text.wrap,
454                font_name,
455                generic,
456                font_weight,
457                text.italic,
458                layout_height,
459                text.letter_spacing,
460            ) {
461                Ok(mut glyphs) => {
462                    tracing::trace!(
463                        "Prepared {} glyphs for text '{}' (font={:?}, generic={:?})",
464                        glyphs.len(),
465                        text.content,
466                        font_name,
467                        generic
468                    );
469                    // Apply clip bounds to all glyphs if the text element has clip bounds
470                    if let Some(clip) = text.clip_bounds {
471                        for glyph in &mut glyphs {
472                            glyph.clip_bounds = clip;
473                        }
474                    }
475
476                    if let Some(affine) = text.css_affine {
477                        // CSS-transformed text: convert to SDF primitives with local_affine
478                        let [a, b, c, d, tx, ty] = affine;
479                        let tx_scaled = tx * scale_factor;
480                        let ty_scaled = ty * scale_factor;
481                        for glyph in &glyphs {
482                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
483                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
484                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
485                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
486                            let mut prim = GpuPrimitive::from_glyph(glyph);
487                            prim.bounds = [
488                                new_gc_x - glyph.bounds[2] / 2.0,
489                                new_gc_y - glyph.bounds[3] / 2.0,
490                                glyph.bounds[2],
491                                glyph.bounds[3],
492                            ];
493                            prim.local_affine = [a, b, c, d];
494                            css_transformed_text_prims.push(prim);
495                        }
496                    } else {
497                        all_glyphs.extend(glyphs);
498                    }
499                }
500                Err(e) => {
501                    tracing::warn!("Failed to prepare text '{}': {:?}", text.content, e);
502                }
503            }
504        }
505
506        tracing::trace!(
507            "Text rendering: {} texts collected, {} total glyphs prepared",
508            texts.len(),
509            all_glyphs.len()
510        );
511
512        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
513        // They will be rendered later via render_rasterized_svgs
514
515        self.renderer.resize(width, height);
516
517        // If we have CSS-transformed text, push text prims into the background batch
518        // and bind the real glyph atlas to the SDF pipeline for ALL render paths.
519        if !css_transformed_text_prims.is_empty() {
520            if let (Some(atlas), Some(color_atlas)) =
521                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
522            {
523                bg_batch.primitives.append(&mut css_transformed_text_prims);
524                self.renderer.set_glyph_atlas(atlas, color_atlas);
525            }
526        }
527
528        let has_glass = bg_batch.glass_count() > 0;
529
530        // Only allocate glass textures when glass is actually used
531        if has_glass {
532            self.ensure_glass_textures(width, height);
533        }
534        let use_msaa_overlay = self.sample_count > 1;
535
536        // Background layer uses SDF rendering (shader-based AA, no MSAA needed)
537        // Foreground layer (SVGs as tessellated paths) uses MSAA for smooth edges
538
539        if has_glass {
540            // Split images by layer: background images go behind glass (get blurred),
541            // glass/foreground images render on top of glass (not blurred)
542            let (bg_images, fg_images): (Vec<_>, Vec<_>) = images
543                .iter()
544                .partition(|img| img.layer == RenderLayer::Background);
545
546            // Pre-render background images to both backdrop and target so glass can blur them
547            let has_bg_images = !bg_images.is_empty();
548            if has_bg_images {
549                // Take backdrop temporarily to avoid borrow conflict with render_images_ref(&mut self)
550                let backdrop_tex = self.backdrop_texture.take().unwrap();
551                self.renderer
552                    .clear_target(&backdrop_tex.view, wgpu::Color::TRANSPARENT);
553                self.renderer.clear_target(target, wgpu::Color::BLACK);
554                self.render_images_ref(&backdrop_tex.view, &bg_images);
555                self.render_images_ref(target, &bg_images);
556                self.backdrop_texture = Some(backdrop_tex);
557            }
558
559            // Glass path - batched rendering for reduced command buffer overhead:
560            // Steps 1-3 are batched into a single encoder submission
561            {
562                let backdrop = self.backdrop_texture.as_ref().unwrap();
563                self.renderer.render_glass_frame(
564                    target,
565                    &backdrop.view,
566                    (backdrop.width, backdrop.height),
567                    &bg_batch,
568                    has_bg_images,
569                );
570            }
571
572            // Render background paths with MSAA for smooth edges on curved shapes like notch
573            // (render_glass_frame uses 1x sampled path rendering, so we need MSAA overlay)
574            if use_msaa_overlay && bg_batch.has_paths() {
575                self.renderer
576                    .render_paths_overlay_msaa(target, &bg_batch, self.sample_count);
577            }
578
579            // Render remaining bg images to target (only if not already pre-rendered)
580            if !has_bg_images {
581                self.render_images_ref(target, &bg_images);
582            }
583
584            // Step 5: Render glass/foreground-layer images (on top of glass, NOT blurred)
585            self.render_images_ref(target, &fg_images);
586
587            // Step 6: Render foreground and text
588            // Use batch-based rendering when layer effects are present
589            let has_layer_effects = fg_batch.has_layer_effects();
590            if has_layer_effects {
591                // Layer effects require batch-based rendering to process layer commands
592                fg_batch.convert_glyphs_to_primitives();
593                if !fg_batch.is_empty() {
594                    // Pre-load any mask images referenced by layer effects
595                    self.preload_mask_images(&fg_batch);
596                    self.renderer.render_overlay(target, &fg_batch);
597                }
598                // Render SVGs as rasterized images for high-quality anti-aliasing
599                if !svgs.is_empty() {
600                    self.render_rasterized_svgs(target, &svgs, scale_factor);
601                }
602            } else if self.renderer.unified_text_rendering() {
603                // Unified rendering: combine text glyphs with foreground primitives
604                let unified_primitives = fg_batch.get_unified_foreground_primitives();
605                if !unified_primitives.is_empty() {
606                    self.render_unified(target, &unified_primitives);
607                }
608
609                // Render paths with MSAA for smooth edges (paths are not included in unified primitives)
610                if use_msaa_overlay && fg_batch.has_paths() {
611                    self.renderer
612                        .render_paths_overlay_msaa(target, &fg_batch, self.sample_count);
613                }
614
615                // Render SVGs as rasterized images for high-quality anti-aliasing
616                if !svgs.is_empty() {
617                    self.render_rasterized_svgs(target, &svgs, scale_factor);
618                }
619            } else {
620                // Legacy rendering: separate foreground and text passes
621                if !fg_batch.is_empty() {
622                    if use_msaa_overlay {
623                        self.renderer
624                            .render_overlay_msaa(target, &fg_batch, self.sample_count);
625                    } else {
626                        self.renderer.render_overlay(target, &fg_batch);
627                    }
628                }
629
630                // Step 7: Render text
631                if !all_glyphs.is_empty() {
632                    self.render_text(target, &all_glyphs);
633                }
634
635                // Render SVGs as rasterized images for high-quality anti-aliasing
636                if !svgs.is_empty() {
637                    self.render_rasterized_svgs(target, &svgs, scale_factor);
638                }
639            }
640
641            // Step 8: Render text decorations (strikethrough, underline)
642            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
643            for primitives in decorations_by_layer.values() {
644                if !primitives.is_empty() {
645                    self.render_unified(target, primitives);
646                }
647            }
648        } else {
649            // Simple path (no glass):
650            // Background uses SDF rendering (no MSAA needed)
651            // Foreground uses MSAA for smooth SVG edges
652
653            // Render background directly to target
654            // Use opaque black clear - transparent clear can cause issues with window surfaces
655            self.renderer
656                .render_with_clear(target, &bg_batch, [0.0, 0.0, 0.0, 1.0]);
657
658            // Render background paths with MSAA for smooth edges on curved shapes like notch
659            if use_msaa_overlay && bg_batch.has_paths() {
660                self.renderer
661                    .render_paths_overlay_msaa(target, &bg_batch, self.sample_count);
662            }
663
664            // Render images after background primitives
665            self.render_images(target, &images, width as f32, height as f32, scale_factor);
666
667            // Render foreground and text
668            // Use batch-based rendering when layer effects are present to preserve
669            // layer commands for effect processing
670            let has_layer_effects = fg_batch.has_layer_effects();
671            if has_layer_effects {
672                // Layer effects require batch-based rendering to process layer commands
673                // First convert glyphs to primitives so they're included in the batch
674                fg_batch.convert_glyphs_to_primitives();
675
676                // Use render_overlay which supports layer effect processing
677                if !fg_batch.is_empty() {
678                    self.renderer.render_overlay(target, &fg_batch);
679                }
680                // Render SVGs as rasterized images for high-quality anti-aliasing
681                if !svgs.is_empty() {
682                    self.render_rasterized_svgs(target, &svgs, scale_factor);
683                }
684            } else if self.renderer.unified_text_rendering() {
685                // Unified rendering: combine text glyphs with foreground primitives
686                // This ensures text and shapes transform together during animations
687                let unified_primitives = fg_batch.get_unified_foreground_primitives();
688                if !unified_primitives.is_empty() {
689                    self.render_unified(target, &unified_primitives);
690                }
691
692                // Render paths with MSAA for smooth edges (paths are not included in unified primitives)
693                if use_msaa_overlay && fg_batch.has_paths() {
694                    self.renderer
695                        .render_paths_overlay_msaa(target, &fg_batch, self.sample_count);
696                }
697
698                // Render SVGs as rasterized images for high-quality anti-aliasing
699                if !svgs.is_empty() {
700                    self.render_rasterized_svgs(target, &svgs, scale_factor);
701                }
702            } else {
703                // Legacy rendering: separate foreground and text passes
704                if !fg_batch.is_empty() {
705                    if use_msaa_overlay {
706                        self.renderer
707                            .render_overlay_msaa(target, &fg_batch, self.sample_count);
708                    } else {
709                        self.renderer.render_overlay(target, &fg_batch);
710                    }
711                }
712
713                // Render text
714                if !all_glyphs.is_empty() {
715                    self.render_text(target, &all_glyphs);
716                }
717
718                // Render SVGs as rasterized images for high-quality anti-aliasing
719                if !svgs.is_empty() {
720                    self.render_rasterized_svgs(target, &svgs, scale_factor);
721                }
722            }
723
724            // Render text decorations (strikethrough, underline)
725            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
726            for primitives in decorations_by_layer.values() {
727                if !primitives.is_empty() {
728                    self.render_unified(target, primitives);
729                }
730            }
731        }
732
733        // Return scratch buffers for reuse on next frame
734        self.return_scratch_elements(texts, svgs, images);
735
736        // Poll the device to free completed command buffers and prevent memory accumulation
737        self.renderer.poll();
738
739        Ok(())
740    }
741
742    /// Return element vectors to scratch pool for reuse
743    #[inline]
744    fn return_scratch_elements(
745        &mut self,
746        mut texts: Vec<TextElement>,
747        mut svgs: Vec<SvgElement>,
748        mut images: Vec<ImageElement>,
749    ) {
750        // Clear and keep capacity for reuse
751        texts.clear();
752        svgs.clear();
753        images.clear();
754        self.scratch_texts = texts;
755        self.scratch_svgs = svgs;
756        self.scratch_images = images;
757    }
758
759    /// Log cache statistics (called every 300 frames, ~5 seconds at 60fps).
760    /// Visible at RUST_LOG=blinc_app=debug level.
761    fn log_cache_stats(&mut self) {
762        self.frame_count += 1;
763        if self.frame_count % 300 != 1 {
764            return;
765        }
766        let (aw, ah) = self.text_ctx.atlas_dimensions();
767        let (caw, cah) = self.text_ctx.color_atlas_dimensions();
768        let atlas_glyphs = self.text_ctx.atlas().glyph_count();
769        let atlas_util = self.text_ctx.atlas().utilization();
770        let color_glyphs = self.text_ctx.color_atlas().glyph_count();
771        let color_util = self.text_ctx.color_atlas().utilization();
772        let glyph_cache = self.text_ctx.glyph_cache_len();
773        let glyph_cap = self.text_ctx.glyph_cache_capacity();
774        let color_cache = self.text_ctx.color_glyph_cache_len();
775        let color_cap = self.text_ctx.color_glyph_cache_capacity();
776        let img_cache = self.image_cache.len();
777        let svg_cache = self.svg_cache.len();
778        let svg_atlas_entries = self.svg_atlas.entry_count();
779        let svg_atlas_util = self.svg_atlas.utilization();
780        let (svg_aw, svg_ah) = (self.svg_atlas.width(), self.svg_atlas.height());
781
782        tracing::info!(
783            "Cache stats [frame {}]: \
784             atlas={}x{} ({} glyphs, {:.1}% used), \
785             color_atlas={}x{} ({} glyphs, {:.1}% used), \
786             glyph_lru={}/{}, color_glyph_lru={}/{}, \
787             image={}/{}, svg_doc={}/{}, svg_atlas={}x{} ({} entries, {:.1}% used)",
788            self.frame_count,
789            aw,
790            ah,
791            atlas_glyphs,
792            atlas_util * 100.0,
793            caw,
794            cah,
795            color_glyphs,
796            color_util * 100.0,
797            glyph_cache,
798            glyph_cap,
799            color_cache,
800            color_cap,
801            img_cache,
802            IMAGE_CACHE_CAPACITY,
803            svg_cache,
804            SVG_CACHE_CAPACITY,
805            svg_aw,
806            svg_ah,
807            svg_atlas_entries,
808            svg_atlas_util * 100.0,
809        );
810    }
811
812    /// Ensure glass-related textures exist and are the right size.
813    /// Only called when glass elements are present in the scene.
814    ///
815    /// We use a single texture for both rendering and sampling (backdrop_texture).
816    /// The texture is rendered at half resolution to save memory (blur doesn't need full res).
817    fn ensure_glass_textures(&mut self, width: u32, height: u32) {
818        // Use the same texture format as the renderer's pipelines
819        let format = self.renderer.texture_format();
820
821        // Use half resolution for glass backdrop - blur effect doesn't need full resolution
822        // This saves 75% of texture memory (e.g., 2.5MB -> 0.6MB for 900x700 window)
823        let backdrop_width = (width / 2).max(1);
824        let backdrop_height = (height / 2).max(1);
825
826        let needs_backdrop = self
827            .backdrop_texture
828            .as_ref()
829            .map(|t| t.width != backdrop_width || t.height != backdrop_height)
830            .unwrap_or(true);
831
832        if needs_backdrop {
833            // Single texture that can be both rendered to AND sampled from
834            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
835                label: Some("Glass Backdrop"),
836                size: wgpu::Extent3d {
837                    width: backdrop_width,
838                    height: backdrop_height,
839                    depth_or_array_layers: 1,
840                },
841                mip_level_count: 1,
842                sample_count: 1,
843                dimension: wgpu::TextureDimension::D2,
844                format,
845                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
846                    | wgpu::TextureUsages::TEXTURE_BINDING,
847                view_formats: &[],
848            });
849            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
850            self.backdrop_texture = Some(CachedTexture {
851                texture,
852                view,
853                width: backdrop_width,
854                height: backdrop_height,
855            });
856        }
857    }
858
859    /// Render text glyphs
860    fn render_text(&mut self, target: &wgpu::TextureView, glyphs: &[GpuGlyph]) {
861        if let (Some(atlas_view), Some(color_atlas_view)) =
862            (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
863        {
864            self.renderer.render_text(
865                target,
866                glyphs,
867                atlas_view,
868                color_atlas_view,
869                self.text_ctx.sampler(),
870            );
871        }
872    }
873
874    /// Render SDF primitives and text glyphs in a unified pass
875    ///
876    /// This ensures text and shapes transform together during animations,
877    /// preventing visual lag when parent containers have motion transforms.
878    fn render_unified(&mut self, target: &wgpu::TextureView, primitives: &[GpuPrimitive]) {
879        if primitives.is_empty() {
880            return;
881        }
882
883        self.renderer.render_primitives_overlay(target, primitives);
884    }
885
886    /// Render text decorations for a specific z-layer
887    fn render_text_decorations_for_layer(
888        &mut self,
889        target: &wgpu::TextureView,
890        decorations_by_layer: &std::collections::HashMap<u32, Vec<GpuPrimitive>>,
891        z_layer: u32,
892    ) {
893        if let Some(primitives) = decorations_by_layer.get(&z_layer) {
894            if !primitives.is_empty() {
895                self.renderer.render_primitives_overlay(target, primitives);
896            }
897        }
898    }
899
900    /// Render debug visualization overlays for text elements
901    ///
902    /// When `BLINC_DEBUG=text` (or `1`, `all`, `true`) is set, this renders:
903    /// - Cyan: Text bounding box outline
904    /// - Magenta: Baseline position
905    /// - Green: Top of bounding box (ascender reference)
906    /// - Yellow: Bottom of bounding box (descender reference)
907    fn render_text_debug(&mut self, target: &wgpu::TextureView, texts: &[TextElement]) {
908        let debug_primitives = generate_text_debug_primitives(texts);
909        if !debug_primitives.is_empty() {
910            self.renderer
911                .render_primitives_overlay(target, &debug_primitives);
912        }
913    }
914
915    /// Render debug visualization overlays for all layout elements
916    ///
917    /// When `BLINC_DEBUG=layout` (or `all`) is set, this renders:
918    /// - Semi-transparent colored rectangles for each element's bounding box
919    /// - Colors cycle based on tree depth to distinguish nested elements
920    fn render_layout_debug(&mut self, target: &wgpu::TextureView, tree: &RenderTree, scale: f32) {
921        let debug_bounds = collect_debug_bounds(tree, scale);
922        let debug_primitives = generate_layout_debug_primitives(&debug_bounds);
923        if !debug_primitives.is_empty() {
924            self.renderer
925                .render_primitives_overlay(target, &debug_primitives);
926        }
927    }
928
929    /// Render debug visualization for motion/animations
930    ///
931    /// When `BLINC_DEBUG=motion` (or `all`) is set, this renders:
932    /// - Top-right corner overlay showing animation stats
933    /// - Number of active visual animations, layout animations, etc.
934    fn render_motion_debug(
935        &mut self,
936        target: &wgpu::TextureView,
937        tree: &RenderTree,
938        width: u32,
939        _height: u32,
940    ) {
941        let stats = tree.debug_stats();
942        let mut debug_primitives = Vec::new();
943
944        // Background for the debug panel
945        let panel_width = 200.0;
946        let panel_height = 100.0;
947        let panel_x = width as f32 - panel_width - 10.0;
948        let panel_y = 10.0;
949
950        // Semi-transparent dark background
951        debug_primitives.push(
952            GpuPrimitive::rect(panel_x, panel_y, panel_width, panel_height)
953                .with_color(0.1, 0.1, 0.15, 0.85)
954                .with_corner_radius(6.0),
955        );
956
957        // Status indicator - green if any animations active
958        let has_active = stats.visual_animation_count > 0
959            || stats.layout_animation_count > 0
960            || stats.animated_bounds_count > 0;
961
962        let (r, g, b, a) = if has_active {
963            (0.2, 0.9, 0.3, 1.0) // Green when animating
964        } else {
965            (0.4, 0.4, 0.5, 1.0) // Gray when idle
966        };
967
968        debug_primitives.push(
969            GpuPrimitive::rect(panel_x + 10.0, panel_y + 12.0, 10.0, 10.0)
970                .with_color(r, g, b, a)
971                .with_corner_radius(5.0),
972        );
973
974        // Visual bars showing animation counts
975        let bar_x = panel_x + 12.0;
976        let bar_width = panel_width - 24.0;
977        let bar_height = 6.0;
978
979        // Visual animations bar (cyan)
980        let visual_ratio = (stats.visual_animation_count as f32).min(10.0) / 10.0;
981        if visual_ratio > 0.0 {
982            debug_primitives.push(
983                GpuPrimitive::rect(bar_x, panel_y + 35.0, bar_width * visual_ratio, bar_height)
984                    .with_color(0.0, 0.8, 0.9, 0.9)
985                    .with_corner_radius(3.0),
986            );
987        }
988
989        // Layout animations bar (magenta)
990        let layout_ratio = (stats.layout_animation_count as f32).min(10.0) / 10.0;
991        if layout_ratio > 0.0 {
992            debug_primitives.push(
993                GpuPrimitive::rect(bar_x, panel_y + 50.0, bar_width * layout_ratio, bar_height)
994                    .with_color(0.9, 0.2, 0.8, 0.9)
995                    .with_corner_radius(3.0),
996            );
997        }
998
999        // Animated bounds bar (yellow)
1000        let bounds_ratio = (stats.animated_bounds_count as f32).min(50.0) / 50.0;
1001        if bounds_ratio > 0.0 {
1002            debug_primitives.push(
1003                GpuPrimitive::rect(bar_x, panel_y + 65.0, bar_width * bounds_ratio, bar_height)
1004                    .with_color(0.95, 0.85, 0.2, 0.9)
1005                    .with_corner_radius(3.0),
1006            );
1007        }
1008
1009        // Scroll physics indicator (orange dots)
1010        let scroll_count = stats.scroll_physics_count.min(8);
1011        for i in 0..scroll_count {
1012            debug_primitives.push(
1013                GpuPrimitive::rect(bar_x + (i as f32 * 14.0), panel_y + 80.0, 8.0, 8.0)
1014                    .with_color(1.0, 0.6, 0.2, 0.9)
1015                    .with_corner_radius(4.0),
1016            );
1017        }
1018
1019        if !debug_primitives.is_empty() {
1020            self.renderer
1021                .render_primitives_overlay(target, &debug_primitives);
1022        }
1023    }
1024
1025    /// Render images to the backdrop texture (for images that should be blurred by glass)
1026    fn render_images_to_backdrop(&mut self, images: &[&ImageElement]) {
1027        let Some(ref backdrop) = self.backdrop_texture else {
1028            return;
1029        };
1030        // Create a new view to avoid borrow conflicts
1031        let target = backdrop
1032            .texture
1033            .create_view(&wgpu::TextureViewDescriptor::default());
1034        self.render_images_ref(&target, images);
1035    }
1036
1037    /// Pre-load images into cache (call before rendering)
1038    ///
1039    /// Images with lazy loading strategy are only loaded when visible in the viewport.
1040    /// A buffer zone extends the viewport to preload images that are about to become visible.
1041    fn preload_images(
1042        &mut self,
1043        images: &[ImageElement],
1044        viewport_width: f32,
1045        viewport_height: f32,
1046    ) {
1047        // Buffer zone: load images that are within 100px of becoming visible
1048        const VISIBILITY_BUFFER: f32 = 100.0;
1049
1050        for image in images {
1051            // LruCache::contains also promotes to most-recently-used
1052            if self.image_cache.contains(&image.source) {
1053                continue;
1054            }
1055
1056            // Check if lazy loading is enabled (loading_strategy == 1)
1057            if image.loading_strategy == 1 {
1058                // If image has clip bounds from a scroll container, use those for visibility check
1059                // The clip bounds represent the visible area of the parent scroll container
1060                let is_visible = if let Some([clip_x, clip_y, clip_w, clip_h]) = image.clip_bounds {
1061                    // Check if image intersects with its clip region (+ buffer for prefetching)
1062                    let clip_left = clip_x - VISIBILITY_BUFFER;
1063                    let clip_top = clip_y - VISIBILITY_BUFFER;
1064                    let clip_right = clip_x + clip_w + VISIBILITY_BUFFER;
1065                    let clip_bottom = clip_y + clip_h + VISIBILITY_BUFFER;
1066
1067                    let image_right = image.x + image.width;
1068                    let image_bottom = image.y + image.height;
1069
1070                    image.x < clip_right
1071                        && image_right > clip_left
1072                        && image.y < clip_bottom
1073                        && image_bottom > clip_top
1074                } else {
1075                    // No clip bounds - check against viewport
1076                    let viewport_left = -VISIBILITY_BUFFER;
1077                    let viewport_top = -VISIBILITY_BUFFER;
1078                    let viewport_right = viewport_width + VISIBILITY_BUFFER;
1079                    let viewport_bottom = viewport_height + VISIBILITY_BUFFER;
1080
1081                    let image_right = image.x + image.width;
1082                    let image_bottom = image.y + image.height;
1083
1084                    image.x < viewport_right
1085                        && image_right > viewport_left
1086                        && image.y < viewport_bottom
1087                        && image_bottom > viewport_top
1088                };
1089
1090                if !is_visible {
1091                    // Skip loading - image is not yet visible
1092                    continue;
1093                }
1094            }
1095
1096            // Try to load the image - use from_uri to handle emoji://, data:, and file paths
1097            let source = blinc_image::ImageSource::from_uri(&image.source);
1098            let image_data = match blinc_image::ImageData::load(source) {
1099                Ok(data) => data,
1100                Err(e) => {
1101                    tracing::trace!("Failed to load image '{}': {:?}", image.source, e);
1102                    continue; // Skip images that fail to load
1103                }
1104            };
1105
1106            // Create GPU texture
1107            let gpu_image = self.image_ctx.create_image_labeled(
1108                image_data.pixels(),
1109                image_data.width(),
1110                image_data.height(),
1111                &image.source,
1112            );
1113
1114            // LruCache::put evicts oldest entry if at capacity
1115            self.image_cache.put(image.source.clone(), gpu_image);
1116        }
1117    }
1118
1119    /// Pre-load mask images referenced in a primitive batch's layer effects
1120    fn preload_mask_images(&mut self, batch: &PrimitiveBatch) {
1121        use blinc_core::LayerEffect;
1122        for entry in &batch.layer_commands {
1123            if let blinc_gpu::primitives::LayerCommand::Push { config } = &entry.command {
1124                for effect in &config.effects {
1125                    if let LayerEffect::MaskImage { image_url, .. } = effect {
1126                        if self.renderer.has_mask_image(image_url) {
1127                            continue;
1128                        }
1129                        let source = blinc_image::ImageSource::from_uri(image_url);
1130                        if let Ok(data) = blinc_image::ImageData::load(source) {
1131                            self.renderer.load_mask_image_rgba(
1132                                image_url,
1133                                data.pixels(),
1134                                data.width(),
1135                                data.height(),
1136                            );
1137                        }
1138                    }
1139                }
1140            }
1141        }
1142    }
1143
1144    /// Convert a CssFilter into filter_a/filter_b arrays for the image shader.
1145    /// Returns (filter_a, filter_b) where identity = ([0,0,0,0], [1,1,1,0]).
1146    /// Extract mask gradient params and info from a MaskImage gradient.
1147    /// Returns ([mask_params], [mask_info]) or zero arrays if not a gradient.
1148    fn mask_image_to_arrays(mask: Option<&blinc_core::MaskImage>) -> ([f32; 4], [f32; 4]) {
1149        match mask {
1150            Some(blinc_core::MaskImage::Gradient(gradient)) => match gradient {
1151                blinc_core::Gradient::Linear {
1152                    start, end, stops, ..
1153                } => {
1154                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1155                    ([start.x, start.y, end.x, end.y], [1.0, sa, ea, 0.0])
1156                }
1157                blinc_core::Gradient::Radial {
1158                    center,
1159                    radius,
1160                    stops,
1161                    ..
1162                } => {
1163                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1164                    ([center.x, center.y, *radius, 0.0], [2.0, sa, ea, 0.0])
1165                }
1166                blinc_core::Gradient::Conic { center, stops, .. } => {
1167                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1168                    ([center.x, center.y, 0.5, 0.0], [2.0, sa, ea, 0.0])
1169                }
1170            },
1171            _ => ([0.0; 4], [0.0; 4]),
1172        }
1173    }
1174
1175    fn extract_mask_alphas_from_stops(stops: &[blinc_core::GradientStop]) -> (f32, f32) {
1176        if stops.is_empty() {
1177            return (1.0, 0.0);
1178        }
1179        (stops[0].color.a, stops[stops.len() - 1].color.a)
1180    }
1181
1182    fn css_filter_to_arrays(
1183        filter: &blinc_layout::element_style::CssFilter,
1184    ) -> ([f32; 4], [f32; 4]) {
1185        (
1186            [
1187                filter.grayscale,
1188                filter.invert,
1189                filter.sepia,
1190                filter.hue_rotate.to_radians(),
1191            ],
1192            [filter.brightness, filter.contrast, filter.saturate, 0.0],
1193        )
1194    }
1195
1196    /// Transform clip bounds and radii by a CSS affine.
1197    /// When a parent div has a CSS transform (e.g. `scale(1.08)` on hover), the image
1198    /// clip must follow the same transform so the image fills the visually-scaled parent.
1199    fn transform_clip_by_affine(
1200        clip: [f32; 4],
1201        clip_radius: [f32; 4],
1202        affine: [f32; 6],
1203        scale_factor: f32,
1204    ) -> ([f32; 4], [f32; 4]) {
1205        let [a, b, c, d, tx, ty] = affine;
1206        let tx_s = tx * scale_factor;
1207        let ty_s = ty * scale_factor;
1208        // Transform clip center through the affine
1209        let ccx = clip[0] + clip[2] * 0.5;
1210        let ccy = clip[1] + clip[3] * 0.5;
1211        let new_cx = a * ccx + c * ccy + tx_s;
1212        let new_cy = b * ccx + d * ccy + ty_s;
1213        // Uniform scale for dimensions
1214        let s = (a * d - b * c).abs().sqrt().max(1e-6);
1215        let new_clip = [
1216            new_cx - clip[2] * s * 0.5,
1217            new_cy - clip[3] * s * 0.5,
1218            clip[2] * s,
1219            clip[3] * s,
1220        ];
1221        let new_radius = [
1222            clip_radius[0] * s,
1223            clip_radius[1] * s,
1224            clip_radius[2] * s,
1225            clip_radius[3] * s,
1226        ];
1227        (new_clip, new_radius)
1228    }
1229
1230    /// Decompose a CSS affine [a,b,c,d,tx,ty] into position and 2x2 transform for image rendering.
1231    /// Input: original rect (already DPI-scaled), affine (layout coords), scale_factor.
1232    /// Returns: (draw_x, draw_y, draw_w, draw_h, transform_a, transform_b, transform_c, transform_d)
1233    /// The 2x2 matrix [a, b, c, d] is passed to the shader for full affine support (rotation, scale, skew).
1234    fn decompose_image_affine(
1235        x: f32,
1236        y: f32,
1237        w: f32,
1238        h: f32,
1239        affine: [f32; 6],
1240        scale_factor: f32,
1241    ) -> (f32, f32, f32, f32, f32, f32, f32, f32) {
1242        let [a, b, c, d, tx, ty] = affine;
1243        // DPI-scale the translation components
1244        let tx_s = tx * scale_factor;
1245        let ty_s = ty * scale_factor;
1246        // Transform center through the affine (positions are already in screen space)
1247        let cx = x + w * 0.5;
1248        let cy = y + h * 0.5;
1249        let new_cx = a * cx + c * cy + tx_s;
1250        let new_cy = b * cx + d * cy + ty_s;
1251        // Pass original bounds — the 2x2 transform is applied in the shader around the center
1252        (new_cx - w * 0.5, new_cy - h * 0.5, w, h, a, b, c, d)
1253    }
1254
1255    /// Render images to target (images must be preloaded first)
1256    fn render_images(
1257        &mut self,
1258        target: &wgpu::TextureView,
1259        images: &[ImageElement],
1260        viewport_width: f32,
1261        viewport_height: f32,
1262        scale_factor: f32,
1263    ) {
1264        use blinc_image::{calculate_fit_rects, src_rect_to_uv, ObjectFit, ObjectPosition};
1265
1266        for image in images {
1267            // Get cached GPU image
1268            let gpu_image = self.image_cache.get(&image.source);
1269
1270            // If image is not loaded and has a placeholder, render placeholder
1271            if gpu_image.is_none() && image.placeholder_type != 0 {
1272                // Placeholder type 1 = Color
1273                if image.placeholder_type == 1 {
1274                    // Render a solid color rectangle as placeholder
1275                    let color = blinc_core::Color::rgba(
1276                        image.placeholder_color[0],
1277                        image.placeholder_color[1],
1278                        image.placeholder_color[2],
1279                        image.placeholder_color[3],
1280                    );
1281
1282                    // Create a simple rectangle for the placeholder
1283                    let mut ctx = GpuPaintContext::new(viewport_width, viewport_height);
1284
1285                    let rect = blinc_core::Rect::new(image.x, image.y, image.width, image.height);
1286
1287                    ctx.fill_rounded_rect(
1288                        rect,
1289                        blinc_core::CornerRadius::uniform(image.border_radius),
1290                        color,
1291                    );
1292
1293                    let batch = ctx.take_batch();
1294                    self.renderer.render_overlay(target, &batch);
1295                }
1296                // TODO: Placeholder type 2 = Image (thumbnail), 3 = Skeleton (shimmer)
1297                continue;
1298            }
1299
1300            let Some(gpu_image) = gpu_image else {
1301                continue; // Skip images that failed to load
1302            };
1303
1304            // Convert object_fit byte to ObjectFit enum
1305            let object_fit = match image.object_fit {
1306                0 => ObjectFit::Cover,
1307                1 => ObjectFit::Contain,
1308                2 => ObjectFit::Fill,
1309                3 => ObjectFit::ScaleDown,
1310                4 => ObjectFit::None,
1311                _ => ObjectFit::Cover,
1312            };
1313
1314            // Create ObjectPosition from array
1315            let object_position =
1316                ObjectPosition::new(image.object_position[0], image.object_position[1]);
1317
1318            // Calculate fit rectangles
1319            let (src_rect, dst_rect) = calculate_fit_rects(
1320                gpu_image.width(),
1321                gpu_image.height(),
1322                image.width,
1323                image.height,
1324                object_fit,
1325                object_position,
1326            );
1327
1328            // Convert src_rect to UV coordinates
1329            let src_uv = src_rect_to_uv(src_rect, gpu_image.width(), gpu_image.height());
1330
1331            // Apply CSS affine transform if present
1332            let base_x = image.x + dst_rect[0];
1333            let base_y = image.y + dst_rect[1];
1334            let base_w = dst_rect[2];
1335            let base_h = dst_rect[3];
1336
1337            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) = if let Some(affine) =
1338                image.css_affine
1339            {
1340                Self::decompose_image_affine(base_x, base_y, base_w, base_h, affine, scale_factor)
1341            } else {
1342                (base_x, base_y, base_w, base_h, 1.0, 0.0, 0.0, 1.0)
1343            };
1344
1345            // Pre-compute effective clip (transformed by CSS affine if present)
1346            let effective_clip = image.clip_bounds.map(|clip| {
1347                if let Some(affine) = image.css_affine {
1348                    Self::transform_clip_by_affine(clip, image.clip_radius, affine, scale_factor)
1349                } else {
1350                    (clip, image.clip_radius)
1351                }
1352            });
1353
1354            // Render shadow before image if present
1355            if let Some(ref shadow) = image.shadow {
1356                let mut shadow_ctx = GpuPaintContext::new(viewport_width, viewport_height);
1357                // Push scroll/parent clip so shadow doesn't escape the container
1358                if let Some(clip) = image.clip_bounds {
1359                    shadow_ctx.push_clip(blinc_core::ClipShape::RoundedRect {
1360                        rect: blinc_core::Rect::new(clip[0], clip[1], clip[2], clip[3]),
1361                        corner_radius: blinc_core::CornerRadius {
1362                            top_left: image.clip_radius[0],
1363                            top_right: image.clip_radius[1],
1364                            bottom_right: image.clip_radius[2],
1365                            bottom_left: image.clip_radius[3],
1366                        },
1367                    });
1368                }
1369                let shadow_rect =
1370                    blinc_core::Rect::new(image.x, image.y, image.width, image.height);
1371                let shadow_radius = blinc_core::CornerRadius::uniform(image.border_radius);
1372                shadow_ctx.draw_shadow(shadow_rect, shadow_radius, *shadow);
1373                let shadow_batch = shadow_ctx.take_batch();
1374                self.renderer.render_overlay(target, &shadow_batch);
1375            }
1376
1377            // Create GPU instance with proper positioning
1378            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
1379                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
1380                .with_tint(image.tint[0], image.tint[1], image.tint[2], image.tint[3])
1381                .with_border_radius(image.border_radius)
1382                .with_opacity(image.opacity)
1383                .with_transform(ta, tb, tc, td)
1384                .with_filter(image.filter_a, image.filter_b);
1385
1386            // Render border inside the image shader (same SDF, perfect transform alignment)
1387            if image.border_width > 0.0 {
1388                instance = instance.with_image_border(
1389                    image.border_width,
1390                    image.border_color.r,
1391                    image.border_color.g,
1392                    image.border_color.b,
1393                    image.border_color.a,
1394                );
1395            }
1396
1397            // Apply mask gradient
1398            if image.mask_info[0] > 0.5 {
1399                instance.mask_params = image.mask_params;
1400                instance.mask_info = image.mask_info;
1401            }
1402
1403            // Apply clip bounds (primary rounded clip)
1404            if let Some((clip, clip_r)) = effective_clip {
1405                instance = instance.with_clip_rounded_rect_corners(
1406                    clip[0], clip[1], clip[2], clip[3], clip_r[0], clip_r[1], clip_r[2], clip_r[3],
1407                );
1408            }
1409            // Apply secondary scroll clip (sharp rect)
1410            if let Some(sc) = image.scroll_clip {
1411                instance = instance.with_clip2_rect(sc[0], sc[1], sc[2], sc[3]);
1412            }
1413
1414            // Render the image
1415            self.renderer
1416                .render_images(target, gpu_image.view(), &[instance]);
1417        }
1418    }
1419
1420    /// Render images to target from references (images must be preloaded first)
1421    fn render_images_ref(&mut self, target: &wgpu::TextureView, images: &[&ImageElement]) {
1422        use blinc_image::{calculate_fit_rects, src_rect_to_uv, ObjectFit, ObjectPosition};
1423
1424        for image in images {
1425            // Get cached GPU image
1426            let Some(gpu_image) = self.image_cache.get(&image.source) else {
1427                continue; // Skip images that failed to load
1428            };
1429
1430            // Convert object_fit byte to ObjectFit enum
1431            let object_fit = match image.object_fit {
1432                0 => ObjectFit::Cover,
1433                1 => ObjectFit::Contain,
1434                2 => ObjectFit::Fill,
1435                3 => ObjectFit::ScaleDown,
1436                4 => ObjectFit::None,
1437                _ => ObjectFit::Cover,
1438            };
1439
1440            // Create ObjectPosition from array
1441            let object_position =
1442                ObjectPosition::new(image.object_position[0], image.object_position[1]);
1443
1444            // Calculate fit rectangles
1445            let (src_rect, dst_rect) = calculate_fit_rects(
1446                gpu_image.width(),
1447                gpu_image.height(),
1448                image.width,
1449                image.height,
1450                object_fit,
1451                object_position,
1452            );
1453
1454            // Convert src_rect to UV coordinates
1455            let src_uv = src_rect_to_uv(src_rect, gpu_image.width(), gpu_image.height());
1456
1457            // Apply CSS affine transform if present
1458            let base_x = image.x + dst_rect[0];
1459            let base_y = image.y + dst_rect[1];
1460            let base_w = dst_rect[2];
1461            let base_h = dst_rect[3];
1462
1463            // render_images_ref is called for backdrop images; no scale_factor available,
1464            // but affine translation is already in screen coords for backdrop path
1465            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) =
1466                if let Some(affine) = image.css_affine {
1467                    Self::decompose_image_affine(base_x, base_y, base_w, base_h, affine, 1.0)
1468                } else {
1469                    (base_x, base_y, base_w, base_h, 1.0, 0.0, 0.0, 1.0)
1470                };
1471
1472            // Pre-compute effective clip (transformed by CSS affine if present)
1473            let effective_clip = image.clip_bounds.map(|clip| {
1474                if let Some(affine) = image.css_affine {
1475                    Self::transform_clip_by_affine(clip, image.clip_radius, affine, 1.0)
1476                } else {
1477                    (clip, image.clip_radius)
1478                }
1479            });
1480
1481            // Create GPU instance with proper positioning
1482            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
1483                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
1484                .with_tint(image.tint[0], image.tint[1], image.tint[2], image.tint[3])
1485                .with_border_radius(image.border_radius)
1486                .with_opacity(image.opacity)
1487                .with_transform(ta, tb, tc, td)
1488                .with_filter(image.filter_a, image.filter_b);
1489
1490            // Render border inside the image shader (same SDF, perfect transform alignment)
1491            if image.border_width > 0.0 {
1492                instance = instance.with_image_border(
1493                    image.border_width,
1494                    image.border_color.r,
1495                    image.border_color.g,
1496                    image.border_color.b,
1497                    image.border_color.a,
1498                );
1499            }
1500
1501            // Apply mask gradient
1502            if image.mask_info[0] > 0.5 {
1503                instance.mask_params = image.mask_params;
1504                instance.mask_info = image.mask_info;
1505            }
1506
1507            // Apply clip bounds (primary rounded clip)
1508            if let Some((clip, clip_r)) = effective_clip {
1509                instance = instance.with_clip_rounded_rect_corners(
1510                    clip[0], clip[1], clip[2], clip[3], clip_r[0], clip_r[1], clip_r[2], clip_r[3],
1511                );
1512            }
1513            // Apply secondary scroll clip (sharp rect)
1514            if let Some(sc) = image.scroll_clip {
1515                instance = instance.with_clip2_rect(sc[0], sc[1], sc[2], sc[3]);
1516            }
1517
1518            // Render the image
1519            self.renderer
1520                .render_images(target, gpu_image.view(), &[instance]);
1521        }
1522    }
1523
1524    /// Render an SVG element with clipping and opacity support
1525    fn render_svg_element(&mut self, ctx: &mut GpuPaintContext, svg: &SvgElement) {
1526        // Skip completely transparent SVGs
1527        if svg.motion_opacity <= 0.001 {
1528            return;
1529        }
1530
1531        // Skip SVGs completely outside their clip bounds
1532        if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1533            let svg_right = svg.x + svg.width;
1534            let svg_bottom = svg.y + svg.height;
1535            let clip_right = clip_x + clip_w;
1536            let clip_bottom = clip_y + clip_h;
1537
1538            // Check if SVG is completely outside clip bounds
1539            if svg.x >= clip_right
1540                || svg_right <= clip_x
1541                || svg.y >= clip_bottom
1542                || svg_bottom <= clip_y
1543            {
1544                return;
1545            }
1546        }
1547
1548        // Hash the SVG source for cache lookup (faster than using string as key)
1549        let svg_hash = {
1550            let mut hasher = DefaultHasher::new();
1551            svg.source.hash(&mut hasher);
1552            hasher.finish()
1553        };
1554
1555        // Try cache lookup first, parse only on miss
1556        let doc = if let Some(cached) = self.svg_cache.get(&svg_hash) {
1557            cached.clone()
1558        } else {
1559            let Ok(parsed) = SvgDocument::from_str(&svg.source) else {
1560                return;
1561            };
1562            self.svg_cache.put(svg_hash, parsed.clone());
1563            parsed
1564        };
1565
1566        // Apply clipping if present
1567        if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1568            ctx.push_clip(blinc_core::ClipShape::rect(Rect::new(
1569                clip_x, clip_y, clip_w, clip_h,
1570            )));
1571        }
1572
1573        // Apply opacity if not fully opaque
1574        if svg.motion_opacity < 1.0 {
1575            ctx.push_opacity(svg.motion_opacity);
1576        }
1577
1578        // Render the SVG with optional CSS overrides
1579        let has_css_overrides = svg.tint.is_some()
1580            || svg.fill.is_some()
1581            || svg.stroke.is_some()
1582            || svg.stroke_width.is_some();
1583        if has_css_overrides {
1584            self.render_svg_with_overrides(
1585                ctx,
1586                &doc,
1587                svg.x,
1588                svg.y,
1589                svg.width,
1590                svg.height,
1591                svg.tint,
1592                svg.fill,
1593                svg.stroke,
1594                svg.stroke_width,
1595            );
1596        } else {
1597            doc.render_fit(ctx, Rect::new(svg.x, svg.y, svg.width, svg.height));
1598        }
1599
1600        // Pop opacity if applied
1601        if svg.motion_opacity < 1.0 {
1602            ctx.pop_opacity();
1603        }
1604
1605        // Pop clip if applied
1606        if svg.clip_bounds.is_some() {
1607            ctx.pop_clip();
1608        }
1609    }
1610
1611    /// Render an SVG with CSS overrides for fill, stroke, stroke-width, and tint
1612    #[allow(clippy::too_many_arguments)]
1613    fn render_svg_with_overrides(
1614        &self,
1615        ctx: &mut GpuPaintContext,
1616        doc: &SvgDocument,
1617        x: f32,
1618        y: f32,
1619        width: f32,
1620        height: f32,
1621        tint: Option<blinc_core::Color>,
1622        fill: Option<blinc_core::Color>,
1623        stroke: Option<blinc_core::Color>,
1624        stroke_width: Option<f32>,
1625    ) {
1626        use blinc_svg::SvgDrawCommand;
1627
1628        // Calculate scale to fit within bounds while maintaining aspect ratio
1629        let scale_x = width / doc.width;
1630        let scale_y = height / doc.height;
1631        let scale = scale_x.min(scale_y);
1632
1633        // Center within bounds
1634        let scaled_width = doc.width * scale;
1635        let scaled_height = doc.height * scale;
1636        let offset_x = x + (width - scaled_width) / 2.0;
1637        let offset_y = y + (height - scaled_height) / 2.0;
1638
1639        let commands = doc.commands();
1640
1641        for cmd in commands {
1642            match cmd {
1643                SvgDrawCommand::FillPath { path, brush } => {
1644                    let scaled = scale_and_translate_path(&path, offset_x, offset_y, scale);
1645                    // Priority: fill > tint > original brush
1646                    let fill_brush = if let Some(f) = fill {
1647                        Brush::Solid(f)
1648                    } else if let Some(t) = tint {
1649                        Brush::Solid(t)
1650                    } else {
1651                        brush.clone()
1652                    };
1653                    ctx.fill_path(&scaled, fill_brush);
1654                }
1655                SvgDrawCommand::StrokePath {
1656                    path,
1657                    stroke: orig_stroke,
1658                    brush,
1659                } => {
1660                    let scaled = scale_and_translate_path(&path, offset_x, offset_y, scale);
1661                    // Apply stroke-width override or scale original
1662                    let sw = stroke_width.unwrap_or(orig_stroke.width) * scale;
1663                    let scaled_stroke = Stroke::new(sw)
1664                        .with_cap(orig_stroke.cap)
1665                        .with_join(orig_stroke.join);
1666                    // Priority: stroke > tint > original brush
1667                    let stroke_brush = if let Some(s) = stroke {
1668                        Brush::Solid(s)
1669                    } else if let Some(t) = tint {
1670                        Brush::Solid(t)
1671                    } else {
1672                        brush.clone()
1673                    };
1674                    ctx.stroke_path(&scaled, &scaled_stroke, stroke_brush);
1675                }
1676            }
1677        }
1678    }
1679
1680    /// Render SVG elements using CPU rasterization for high-quality anti-aliased output
1681    ///
1682    /// This method rasterizes SVGs using resvg/tiny-skia and renders them as textures,
1683    /// providing much better anti-aliasing than tessellation-based path rendering.
1684    ///
1685    /// The `scale_factor` parameter is the display's DPI scale (e.g., 2.0 for Retina).
1686    /// SVGs are rasterized at physical pixel resolution for crisp rendering on HiDPI displays.
1687    fn render_rasterized_svgs(
1688        &mut self,
1689        target: &wgpu::TextureView,
1690        svgs: &[SvgElement],
1691        scale_factor: f32,
1692    ) {
1693        // Collect all instances for a single batched draw call
1694        let mut instances: Vec<GpuImageInstance> = Vec::with_capacity(svgs.len());
1695
1696        for svg in svgs {
1697            // Skip completely transparent SVGs
1698            if svg.motion_opacity <= 0.001 {
1699                continue;
1700            }
1701
1702            // Skip SVGs completely outside their clip bounds
1703            if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1704                let svg_right = svg.x + svg.width;
1705                let svg_bottom = svg.y + svg.height;
1706                let clip_right = clip_x + clip_w;
1707                let clip_bottom = clip_y + clip_h;
1708
1709                if svg.x >= clip_right
1710                    || svg_right <= clip_x
1711                    || svg.y >= clip_bottom
1712                    || svg_bottom <= clip_y
1713                {
1714                    continue;
1715                }
1716            }
1717
1718            // Rasterize at physical pixel resolution for HiDPI displays
1719            // svg.width/height are logical sizes, multiply by scale_factor for physical pixels
1720            let raster_width = ((svg.width * scale_factor).ceil() as u32).max(1);
1721            let raster_height = ((svg.height * scale_factor).ceil() as u32).max(1);
1722
1723            // Detect tintable SVGs: simple currentColor icons that can use shader tinting
1724            // instead of CPU re-rasterization per color variant.
1725            // Tintable = has tint, no other overrides, source uses currentColor.
1726            let is_tintable = svg.tint.is_some()
1727                && svg.fill.is_none()
1728                && svg.stroke.is_none()
1729                && svg.stroke_width.is_none()
1730                && svg.stroke_dasharray.is_none()
1731                && svg.stroke_dashoffset.is_none()
1732                && svg.svg_path_data.is_none()
1733                && svg.tag_overrides.is_empty()
1734                && svg.source.contains("currentColor");
1735
1736            // Compute cache key: hash of (svg_source, width, height, scale, tint, fill, stroke, stroke_width)
1737            // For tintable SVGs, exclude tint from hash so all color variants share one texture.
1738            let cache_key = {
1739                let mut hasher = DefaultHasher::new();
1740                svg.source.hash(&mut hasher);
1741                raster_width.hash(&mut hasher);
1742                raster_height.hash(&mut hasher);
1743                if is_tintable {
1744                    // Sentinel byte to distinguish from non-tintable hashes
1745                    255u8.hash(&mut hasher);
1746                } else if let Some(tint) = &svg.tint {
1747                    tint.r.to_bits().hash(&mut hasher);
1748                    tint.g.to_bits().hash(&mut hasher);
1749                    tint.b.to_bits().hash(&mut hasher);
1750                    tint.a.to_bits().hash(&mut hasher);
1751                }
1752                if let Some(fill) = &svg.fill {
1753                    1u8.hash(&mut hasher);
1754                    fill.r.to_bits().hash(&mut hasher);
1755                    fill.g.to_bits().hash(&mut hasher);
1756                    fill.b.to_bits().hash(&mut hasher);
1757                    fill.a.to_bits().hash(&mut hasher);
1758                }
1759                if let Some(stroke) = &svg.stroke {
1760                    2u8.hash(&mut hasher);
1761                    stroke.r.to_bits().hash(&mut hasher);
1762                    stroke.g.to_bits().hash(&mut hasher);
1763                    stroke.b.to_bits().hash(&mut hasher);
1764                    stroke.a.to_bits().hash(&mut hasher);
1765                }
1766                if let Some(sw) = &svg.stroke_width {
1767                    3u8.hash(&mut hasher);
1768                    sw.to_bits().hash(&mut hasher);
1769                }
1770                if let Some(ref da) = svg.stroke_dasharray {
1771                    4u8.hash(&mut hasher);
1772                    for v in da {
1773                        v.to_bits().hash(&mut hasher);
1774                    }
1775                }
1776                if let Some(offset) = &svg.stroke_dashoffset {
1777                    5u8.hash(&mut hasher);
1778                    offset.to_bits().hash(&mut hasher);
1779                }
1780                if let Some(ref path_data) = svg.svg_path_data {
1781                    6u8.hash(&mut hasher);
1782                    path_data.hash(&mut hasher);
1783                }
1784                // Hash per-tag style overrides
1785                if !svg.tag_overrides.is_empty() {
1786                    7u8.hash(&mut hasher);
1787                    // Sort keys for deterministic hashing
1788                    let mut keys: Vec<&String> = svg.tag_overrides.keys().collect();
1789                    keys.sort();
1790                    for key in keys {
1791                        key.hash(&mut hasher);
1792                        if let Some(ts) = svg.tag_overrides.get(key) {
1793                            if let Some(f) = &ts.fill {
1794                                for v in f {
1795                                    v.to_bits().hash(&mut hasher);
1796                                }
1797                            }
1798                            if let Some(s) = &ts.stroke {
1799                                for v in s {
1800                                    v.to_bits().hash(&mut hasher);
1801                                }
1802                            }
1803                            if let Some(sw) = &ts.stroke_width {
1804                                sw.to_bits().hash(&mut hasher);
1805                            }
1806                            if let Some(op) = &ts.opacity {
1807                                op.to_bits().hash(&mut hasher);
1808                            }
1809                        }
1810                    }
1811                }
1812                hasher.finish()
1813            };
1814
1815            // Check atlas first — skip string manipulation entirely on cache hit
1816            if self.svg_atlas.get(cache_key).is_none() {
1817                // Cache miss: build SVG source with inline attribute overrides
1818                let has_overrides = svg.tint.is_some()
1819                    || svg.fill.is_some()
1820                    || svg.stroke.is_some()
1821                    || svg.stroke_width.is_some()
1822                    || svg.stroke_dasharray.is_some()
1823                    || svg.stroke_dashoffset.is_some()
1824                    || svg.svg_path_data.is_some()
1825                    || !svg.tag_overrides.is_empty();
1826
1827                fn color_val(c: blinc_core::Color) -> String {
1828                    if c.a < 1.0 {
1829                        format!(
1830                            "rgba({},{},{},{})",
1831                            (c.r * 255.0) as u8,
1832                            (c.g * 255.0) as u8,
1833                            (c.b * 255.0) as u8,
1834                            c.a
1835                        )
1836                    } else {
1837                        format!(
1838                            "#{:02x}{:02x}{:02x}",
1839                            (c.r * 255.0) as u8,
1840                            (c.g * 255.0) as u8,
1841                            (c.b * 255.0) as u8
1842                        )
1843                    }
1844                }
1845
1846                let effective_source = if has_overrides {
1847                    // Build attribute string to inject into the root <svg> tag
1848                    let mut svg_attrs = String::new();
1849                    if let Some(fill) = svg.fill {
1850                        svg_attrs.push_str(&format!(r#" fill="{}""#, color_val(fill)));
1851                    }
1852                    if let Some(stroke) = svg.stroke {
1853                        svg_attrs.push_str(&format!(r#" stroke="{}""#, color_val(stroke)));
1854                    }
1855                    if let Some(sw) = svg.stroke_width {
1856                        svg_attrs.push_str(&format!(r#" stroke-width="{}""#, sw));
1857                    }
1858                    if let Some(ref da) = svg.stroke_dasharray {
1859                        let da_str = da
1860                            .iter()
1861                            .map(|v| v.to_string())
1862                            .collect::<Vec<_>>()
1863                            .join(",");
1864                        svg_attrs.push_str(&format!(r#" stroke-dasharray="{}""#, da_str));
1865                    }
1866                    if let Some(offset) = svg.stroke_dashoffset {
1867                        svg_attrs.push_str(&format!(r#" stroke-dashoffset="{}""#, offset));
1868                    }
1869
1870                    // Strip existing attribute from a tag region in the SVG string.
1871                    fn strip_attr(s: &mut String, tag_start: usize, tag_end: usize, attr: &str) {
1872                        let region = &s[tag_start..tag_end];
1873                        let attr_eq = format!("{}=", attr);
1874                        if let Some(attr_offset) = region.find(&attr_eq) {
1875                            let abs_attr = tag_start + attr_offset;
1876                            let after_eq = abs_attr + attr.len() + 1;
1877                            if after_eq < s.len() {
1878                                let quote = s.as_bytes()[after_eq];
1879                                if quote == b'"' || quote == b'\'' {
1880                                    if let Some(end_quote) = s[after_eq + 1..].find(quote as char) {
1881                                        let remove_end = after_eq + 1 + end_quote + 1;
1882                                        let remove_start =
1883                                            if abs_attr > 0 && s.as_bytes()[abs_attr - 1] == b' ' {
1884                                                abs_attr - 1
1885                                            } else {
1886                                                abs_attr
1887                                            };
1888                                        s.replace_range(remove_start..remove_end, "");
1889                                    }
1890                                }
1891                            }
1892                        }
1893                    }
1894
1895                    let mut modified = String::from(&*svg.source);
1896
1897                    // Strip existing attributes from the <svg> tag
1898                    if let Some(svg_close) = modified.find('>') {
1899                        if svg.stroke.is_some() {
1900                            strip_attr(&mut modified, 0, svg_close, "stroke");
1901                        }
1902                        if svg.fill.is_some() {
1903                            let svg_close = modified.find('>').unwrap_or(0);
1904                            strip_attr(&mut modified, 0, svg_close, "fill");
1905                        }
1906                        if svg.stroke_width.is_some() {
1907                            let svg_close = modified.find('>').unwrap_or(0);
1908                            strip_attr(&mut modified, 0, svg_close, "stroke-width");
1909                        }
1910                        if svg.stroke_dasharray.is_some() {
1911                            let svg_close = modified.find('>').unwrap_or(0);
1912                            strip_attr(&mut modified, 0, svg_close, "stroke-dasharray");
1913                        }
1914                        if svg.stroke_dashoffset.is_some() {
1915                            let svg_close = modified.find('>').unwrap_or(0);
1916                            strip_attr(&mut modified, 0, svg_close, "stroke-dashoffset");
1917                        }
1918                    }
1919
1920                    // Insert new attributes into the opening <svg tag
1921                    if !svg_attrs.is_empty() {
1922                        if let Some(pos) = modified.find('>') {
1923                            let insert_pos = if pos > 0 && modified.as_bytes()[pos - 1] == b'/' {
1924                                pos - 1
1925                            } else {
1926                                pos
1927                            };
1928                            modified.insert_str(insert_pos, &svg_attrs);
1929                        }
1930                    }
1931
1932                    // Override fill/stroke on individual shape elements
1933                    let shape_tags = [
1934                        "<path",
1935                        "<circle",
1936                        "<rect",
1937                        "<polygon",
1938                        "<line",
1939                        "<ellipse",
1940                        "<polyline",
1941                    ];
1942                    for tag in &shape_tags {
1943                        let tag_name = tag.trim_start_matches('<');
1944                        let tag_style = svg.tag_overrides.get(tag_name);
1945
1946                        // Per-tag overrides take priority over global element-level overrides
1947                        let effective_fill: Option<blinc_core::Color> = tag_style
1948                            .and_then(|ts| ts.fill)
1949                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
1950                            .or(svg.fill);
1951                        let effective_stroke: Option<blinc_core::Color> = tag_style
1952                            .and_then(|ts| ts.stroke)
1953                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
1954                            .or(svg.stroke);
1955                        let effective_stroke_width: Option<f32> = tag_style
1956                            .and_then(|ts| ts.stroke_width)
1957                            .or(svg.stroke_width);
1958                        let effective_dasharray: Option<Vec<f32>> = tag_style
1959                            .and_then(|ts| ts.stroke_dasharray.clone())
1960                            .or_else(|| svg.stroke_dasharray.clone());
1961                        let effective_dashoffset: Option<f32> = tag_style
1962                            .and_then(|ts| ts.stroke_dashoffset)
1963                            .or(svg.stroke_dashoffset);
1964                        let effective_opacity: Option<f32> = tag_style.and_then(|ts| ts.opacity);
1965
1966                        let mut search_from = 0;
1967                        while let Some(tag_start) = modified[search_from..].find(tag) {
1968                            let abs_tag = search_from + tag_start;
1969                            let abs_start = abs_tag + tag.len();
1970                            if let Some(close) = modified[abs_start..].find('>') {
1971                                let abs_close = abs_start + close;
1972
1973                                if effective_stroke.is_some() {
1974                                    strip_attr(&mut modified, abs_tag, abs_close, "stroke-width");
1975                                    let new_close = abs_start
1976                                        + modified[abs_start..].find('>').unwrap_or(close);
1977                                    strip_attr(&mut modified, abs_tag, new_close, "stroke");
1978                                }
1979                                if effective_fill.is_some() {
1980                                    let new_close = abs_start
1981                                        + modified[abs_start..].find('>').unwrap_or(close);
1982                                    strip_attr(&mut modified, abs_tag, new_close, "fill");
1983                                }
1984                                if effective_stroke_width.is_some() {
1985                                    let new_close = abs_start
1986                                        + modified[abs_start..].find('>').unwrap_or(close);
1987                                    strip_attr(&mut modified, abs_tag, new_close, "stroke-width");
1988                                }
1989                                if effective_dasharray.is_some() {
1990                                    let new_close = abs_start
1991                                        + modified[abs_start..].find('>').unwrap_or(close);
1992                                    strip_attr(
1993                                        &mut modified,
1994                                        abs_tag,
1995                                        new_close,
1996                                        "stroke-dasharray",
1997                                    );
1998                                }
1999                                if effective_dashoffset.is_some() {
2000                                    let new_close = abs_start
2001                                        + modified[abs_start..].find('>').unwrap_or(close);
2002                                    strip_attr(
2003                                        &mut modified,
2004                                        abs_tag,
2005                                        new_close,
2006                                        "stroke-dashoffset",
2007                                    );
2008                                }
2009                                if effective_opacity.is_some() {
2010                                    let new_close = abs_start
2011                                        + modified[abs_start..].find('>').unwrap_or(close);
2012                                    strip_attr(&mut modified, abs_tag, new_close, "opacity");
2013                                }
2014                                if svg.svg_path_data.is_some() && *tag == "<path" {
2015                                    let new_close = abs_start
2016                                        + modified[abs_start..].find('>').unwrap_or(close);
2017                                    strip_attr(&mut modified, abs_tag, new_close, "d");
2018                                }
2019
2020                                // Recompute close position after stripping
2021                                let abs_close =
2022                                    abs_start + modified[abs_start..].find('>').unwrap_or(0);
2023                                let is_self_close =
2024                                    abs_close > 0 && modified.as_bytes()[abs_close - 1] == b'/';
2025                                let insert_at = if is_self_close {
2026                                    abs_close - 1
2027                                } else {
2028                                    abs_close
2029                                };
2030                                let mut elem_attrs = String::new();
2031                                if let Some(fill) = effective_fill {
2032                                    elem_attrs.push_str(&format!(r#" fill="{}""#, color_val(fill)));
2033                                }
2034                                if let Some(stroke) = effective_stroke {
2035                                    elem_attrs
2036                                        .push_str(&format!(r#" stroke="{}""#, color_val(stroke)));
2037                                }
2038                                if let Some(sw) = effective_stroke_width {
2039                                    elem_attrs.push_str(&format!(r#" stroke-width="{}""#, sw));
2040                                }
2041                                if let Some(ref da) = effective_dasharray {
2042                                    let da_str = da
2043                                        .iter()
2044                                        .map(|v| v.to_string())
2045                                        .collect::<Vec<_>>()
2046                                        .join(",");
2047                                    elem_attrs
2048                                        .push_str(&format!(r#" stroke-dasharray="{}""#, da_str));
2049                                }
2050                                if let Some(offset) = effective_dashoffset {
2051                                    elem_attrs
2052                                        .push_str(&format!(r#" stroke-dashoffset="{}""#, offset));
2053                                }
2054                                if let Some(opacity) = effective_opacity {
2055                                    elem_attrs.push_str(&format!(r#" opacity="{}""#, opacity));
2056                                }
2057                                if let Some(ref path_data) = svg.svg_path_data {
2058                                    if *tag == "<path" {
2059                                        elem_attrs.push_str(&format!(r#" d="{}""#, path_data));
2060                                    }
2061                                }
2062                                modified.insert_str(insert_at, &elem_attrs);
2063                                search_from = insert_at + elem_attrs.len() + 1;
2064                            } else {
2065                                break;
2066                            }
2067                        }
2068                    }
2069
2070                    std::borrow::Cow::Owned(modified)
2071                } else {
2072                    std::borrow::Cow::Borrowed(&*svg.source)
2073                };
2074
2075                // Resolve currentColor references in SVG source.
2076                // For tintable SVGs: rasterize as white — color applied via shader tint.
2077                // For non-tintable: replace with actual tint color for CPU rasterization.
2078                let final_source = if is_tintable {
2079                    std::borrow::Cow::Owned(effective_source.replace("currentColor", "#ffffff"))
2080                } else if let Some(tint) = svg.tint {
2081                    if effective_source.contains("currentColor") {
2082                        std::borrow::Cow::Owned(
2083                            effective_source.replace("currentColor", &color_val(tint)),
2084                        )
2085                    } else {
2086                        effective_source
2087                    }
2088                } else {
2089                    effective_source
2090                };
2091
2092                let rasterized =
2093                    RasterizedSvg::from_str(&final_source, raster_width, raster_height);
2094
2095                let rasterized = match rasterized {
2096                    Ok(r) => r,
2097                    Err(e) => {
2098                        tracing::warn!("Failed to rasterize SVG: {}", e);
2099                        continue;
2100                    }
2101                };
2102
2103                // Insert into atlas (handles grow/clear internally)
2104                if self
2105                    .svg_atlas
2106                    .insert(
2107                        cache_key,
2108                        rasterized.width,
2109                        rasterized.height,
2110                        rasterized.data(),
2111                        &self.device,
2112                    )
2113                    .is_none()
2114                {
2115                    tracing::warn!(
2116                        "SVG atlas full, could not allocate {}x{}",
2117                        raster_width,
2118                        raster_height
2119                    );
2120                    continue;
2121                }
2122            }
2123
2124            // Get the atlas region for this SVG
2125            let Some(region) = self.svg_atlas.get(cache_key) else {
2126                continue;
2127            };
2128            let src_uv = region.uv_bounds(self.svg_atlas.width(), self.svg_atlas.height());
2129
2130            // Apply CSS affine transform to SVG bounds if present.
2131            // Pass full 2x2 affine to shader for rotation, scale, and skew support.
2132            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) =
2133                if let Some([a, b, c, d, tx, ty]) = svg.css_affine {
2134                    // DPI-scale the translation components
2135                    let tx_s = tx * scale_factor;
2136                    let ty_s = ty * scale_factor;
2137
2138                    // Transform center through the affine (in screen space)
2139                    let cx = svg.x + svg.width * 0.5;
2140                    let cy = svg.y + svg.height * 0.5;
2141                    let new_cx = a * cx + c * cy + tx_s;
2142                    let new_cy = b * cx + d * cy + ty_s;
2143
2144                    // Pass original bounds — the 2x2 transform is applied in the shader
2145                    (
2146                        new_cx - svg.width * 0.5,
2147                        new_cy - svg.height * 0.5,
2148                        svg.width,
2149                        svg.height,
2150                        a,
2151                        b,
2152                        c,
2153                        d,
2154                    )
2155                } else {
2156                    (svg.x, svg.y, svg.width, svg.height, 1.0, 0.0, 0.0, 1.0)
2157                };
2158
2159            // Create instance with atlas UV coordinates
2160            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
2161                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
2162                .with_opacity(svg.motion_opacity)
2163                .with_transform(ta, tb, tc, td);
2164
2165            // For tintable SVGs, apply color via shader tint multiplication
2166            // (white texture * tint = correctly colored output)
2167            if is_tintable {
2168                if let Some(tint) = svg.tint {
2169                    instance = instance.with_tint(tint.r, tint.g, tint.b, tint.a);
2170                }
2171            }
2172
2173            // Apply clip bounds if specified
2174            if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
2175                instance = instance.with_clip_rect(clip_x, clip_y, clip_w, clip_h);
2176            }
2177
2178            instances.push(instance);
2179        }
2180
2181        // Upload atlas to GPU if dirty, then batch-render all SVG instances
2182        if !instances.is_empty() {
2183            self.svg_atlas.upload(&self.queue);
2184            self.renderer
2185                .render_images(target, self.svg_atlas.view(), &instances);
2186        }
2187    }
2188
2189    /// Collect text, SVG, and image elements from the render tree
2190    fn collect_render_elements(
2191        &mut self,
2192        tree: &RenderTree,
2193    ) -> (
2194        Vec<TextElement>,
2195        Vec<SvgElement>,
2196        Vec<ImageElement>,
2197        Vec<FlowElement>,
2198    ) {
2199        self.collect_render_elements_with_state(tree, None)
2200    }
2201
2202    /// Collect text, SVG, and image elements with motion state
2203    fn collect_render_elements_with_state(
2204        &mut self,
2205        tree: &RenderTree,
2206        render_state: Option<&blinc_layout::RenderState>,
2207    ) -> (
2208        Vec<TextElement>,
2209        Vec<SvgElement>,
2210        Vec<ImageElement>,
2211        Vec<FlowElement>,
2212    ) {
2213        // Reuse scratch buffers - take them, clear, populate, and return
2214        // On next call they'll be reallocated if not returned
2215        let mut texts = std::mem::take(&mut self.scratch_texts);
2216        let mut svgs = std::mem::take(&mut self.scratch_svgs);
2217        let mut images = std::mem::take(&mut self.scratch_images);
2218        let mut flows = Vec::new();
2219        texts.clear();
2220        svgs.clear();
2221        images.clear();
2222
2223        // Get the scale factor from the tree for DPI scaling
2224        let scale = tree.scale_factor();
2225
2226        if let Some(root) = tree.root() {
2227            let mut z_layer = 0u32;
2228            self.collect_elements_recursive(
2229                tree,
2230                root,
2231                (0.0, 0.0),
2232                false,      // inside_glass
2233                false,      // inside_foreground
2234                None,       // No initial clip bounds
2235                None,       // No initial clip radius
2236                1.0,        // Initial motion opacity
2237                (0.0, 0.0), // Initial motion translate offset
2238                (1.0, 1.0), // Initial motion scale
2239                None,       // No initial motion scale center
2240                render_state,
2241                scale,
2242                &mut z_layer,
2243                &mut texts,
2244                &mut svgs,
2245                &mut images,
2246                &mut flows,
2247                None, // No initial CSS transform
2248                1.0,  // Initial inherited CSS opacity
2249                None, // No parent node
2250                None, // No initial scroll clip
2251                None, // No 3D layer ancestor
2252            );
2253        }
2254
2255        // Sort texts by z_index (z_layer) to ensure correct rendering order with primitives
2256        texts.sort_by_key(|t| t.z_index);
2257
2258        (texts, svgs, images, flows)
2259    }
2260
2261    #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
2262    fn collect_elements_recursive(
2263        &self,
2264        tree: &RenderTree,
2265        node: LayoutNodeId,
2266        parent_offset: (f32, f32),
2267        inside_glass: bool,
2268        inside_foreground: bool,
2269        current_clip: Option<[f32; 4]>,
2270        current_clip_radius: Option<[f32; 4]>,
2271        inherited_motion_opacity: f32,
2272        inherited_motion_translate: (f32, f32),
2273        inherited_motion_scale: (f32, f32),
2274        // Center point for motion scale (in layout coordinates, before DPI scaling)
2275        // When a parent has motion scale, children should scale around the parent's center
2276        inherited_motion_scale_center: Option<(f32, f32)>,
2277        render_state: Option<&blinc_layout::RenderState>,
2278        scale: f32,
2279        z_layer: &mut u32,
2280        texts: &mut Vec<TextElement>,
2281        svgs: &mut Vec<SvgElement>,
2282        images: &mut Vec<ImageElement>,
2283        flows: &mut Vec<FlowElement>,
2284        // Accumulated CSS transform from ancestors as a 6-element affine [a,b,c,d,tx,ty]
2285        // in layout coordinates. Maps pre-transform coords to post-transform visual coords.
2286        inherited_css_affine: Option<[f32; 6]>,
2287        // Accumulated CSS opacity from ancestors (compounds multiplicatively).
2288        // CSS `opacity` applies to the element and its entire visual subtree.
2289        inherited_css_opacity: f32,
2290        // Parent node ID for inheriting non-cascading CSS props (border, shadow, filter)
2291        // to child images that render separately from the SDF pipeline.
2292        parent_node: Option<LayoutNodeId>,
2293        // Scroll container clip — sharp rect kept separate from the primary rounded clip.
2294        // This prevents corner radius morphing when a rounded element (card) is partially
2295        // scrolled past a sharp scroll boundary.
2296        current_scroll_clip: Option<[f32; 4]>,
2297        // 3D layer info if inside a perspective-transformed ancestor.
2298        // Text/SVGs/images inside 3D layers are rendered to offscreen textures
2299        // and blitted with the same perspective transform.
2300        inside_3d_layer: Option<Transform3DLayerInfo>,
2301    ) {
2302        use blinc_layout::Material;
2303
2304        // Use animated bounds if this node has layout animation, otherwise use layout bounds
2305        // This ensures children are positioned correctly during layout animation transitions
2306        let Some(bounds) = tree.get_render_bounds(node, parent_offset) else {
2307            return;
2308        };
2309
2310        let abs_x = bounds.x;
2311        let abs_y = bounds.y;
2312
2313        // Get motion values for this node from RenderState (entry/exit animations)
2314        let motion_values = render_state.and_then(|rs| {
2315            // Try stable motion first, then node-based
2316            if let Some(render_node) = tree.get_render_node(node) {
2317                if let Some(ref stable_key) = render_node.props.motion_stable_id {
2318                    return rs.get_stable_motion_values(stable_key);
2319                }
2320            }
2321            rs.get_motion_values(node)
2322        });
2323
2324        // Get motion bindings from RenderTree (continuous AnimatedValue animations)
2325        // NOTE: binding_transform (translate) is NOT added to effective_motion_translate
2326        // because it's already included in new_offset for child positioning (see line ~1250).
2327        // Only RenderState motion values need to be inherited through effective_motion_translate.
2328        let binding_scale = tree.get_motion_scale(node);
2329        let binding_opacity = tree.get_motion_opacity(node);
2330
2331        // Calculate motion opacity for this node (combine both sources)
2332        let node_motion_opacity = motion_values
2333            .and_then(|m| m.opacity)
2334            .unwrap_or_else(|| binding_opacity.unwrap_or(1.0));
2335
2336        // Get motion translate for this node from RenderState only
2337        // (binding translate is handled via new_offset in recursive calls)
2338        let node_motion_translate = motion_values
2339            .map(|m| m.resolved_translate())
2340            .unwrap_or((0.0, 0.0));
2341
2342        // Get motion scale for this node from RenderState
2343        let node_motion_scale = motion_values
2344            .map(|m| m.resolved_scale())
2345            .unwrap_or((1.0, 1.0));
2346
2347        // Combine with binding scale
2348        let binding_scale_values = binding_scale.unwrap_or((1.0, 1.0));
2349
2350        // Combine with inherited values
2351        // NOTE: effective_motion_translate only includes RenderState motion values,
2352        // NOT binding transforms (which are already in the position via new_offset)
2353        let effective_motion_opacity = inherited_motion_opacity * node_motion_opacity;
2354        let effective_motion_translate = (
2355            inherited_motion_translate.0 + node_motion_translate.0,
2356            inherited_motion_translate.1 + node_motion_translate.1,
2357        );
2358        // Scale compounds multiplicatively (including binding scale)
2359        let effective_motion_scale = (
2360            inherited_motion_scale.0 * node_motion_scale.0 * binding_scale_values.0,
2361            inherited_motion_scale.1 * node_motion_scale.1 * binding_scale_values.1,
2362        );
2363
2364        // Determine the motion scale center for children
2365        // If this node has motion scale (from RenderState or binding), use its center as the scale center
2366        // Otherwise, inherit the parent's scale center
2367        let this_node_has_scale = (node_motion_scale.0 - 1.0).abs() > 0.001
2368            || (node_motion_scale.1 - 1.0).abs() > 0.001
2369            || (binding_scale_values.0 - 1.0).abs() > 0.001
2370            || (binding_scale_values.1 - 1.0).abs() > 0.001;
2371
2372        let effective_motion_scale_center = if this_node_has_scale {
2373            // This node has motion scale - compute its center in absolute layout coordinates
2374            let center_x = abs_x + bounds.width / 2.0;
2375            let center_y = abs_y + bounds.height / 2.0;
2376            Some((center_x, center_y))
2377        } else {
2378            // No scale on this node - inherit the parent's scale center
2379            inherited_motion_scale_center
2380        };
2381
2382        // Skip if completely transparent
2383        if effective_motion_opacity <= 0.001 {
2384            return;
2385        }
2386
2387        // CSS visibility: hidden — skip rendering but preserve layout space
2388        if let Some(render_node) = tree.get_render_node(node) {
2389            if !render_node.props.visible {
2390                return;
2391            }
2392        }
2393
2394        // Determine if this node is a glass element
2395        let is_glass = tree
2396            .get_render_node(node)
2397            .map(|n| matches!(n.props.material, Some(Material::Glass(_))))
2398            .unwrap_or(false);
2399
2400        // Track if children should be considered inside glass
2401        let children_inside_glass = inside_glass || is_glass;
2402
2403        // Track if we're inside a foreground-layer element
2404        let is_foreground_node = tree
2405            .get_render_node(node)
2406            .map(|n| n.props.layer == RenderLayer::Foreground)
2407            .unwrap_or(false);
2408        let children_inside_foreground = inside_foreground || is_foreground_node;
2409
2410        // Check if this node clips its children (e.g., scroll containers)
2411        let clips_content = tree
2412            .get_render_node(node)
2413            .map(|n| n.props.clips_content)
2414            .unwrap_or(false);
2415
2416        // Check if this node has an active layout animation (also needs clipping)
2417        // Layout animations need to clip children to animated bounds
2418        let has_layout_animation = tree.is_layout_animating(node);
2419
2420        // Check if this is a Stack layer - if so, increment z_layer for proper z-ordering
2421        let is_stack_layer = tree
2422            .get_render_node(node)
2423            .map(|n| n.props.is_stack_layer)
2424            .unwrap_or(false);
2425        if is_stack_layer {
2426            *z_layer += 1;
2427        }
2428
2429        // Apply CSS z-index to z_layer for stacking order
2430        let saved_z_layer = *z_layer;
2431        let node_z_index = tree
2432            .get_render_node(node)
2433            .map(|n| n.props.z_index)
2434            .unwrap_or(0);
2435        if node_z_index > 0 {
2436            *z_layer = node_z_index as u32;
2437        }
2438
2439        // Update clip bounds for children if this node clips (either via clips_content or layout animation)
2440        // When a node clips, we INTERSECT its bounds with any existing clip
2441        // This ensures nested clipping works correctly (inner clips can't expand outer clips)
2442        let should_clip = clips_content || has_layout_animation;
2443        let (child_clip, child_clip_radius, child_scroll_clip) = if should_clip {
2444            // For layout animation, use animated bounds for clipping
2445            // This ensures content is clipped to the animating size during transition
2446            let clip_bounds = if has_layout_animation {
2447                // Get animated bounds - these are the interpolated bounds during animation
2448                tree.get_render_bounds(node, parent_offset)
2449                    .map(|b| [b.x, b.y, b.width, b.height])
2450                    .unwrap_or([abs_x, abs_y, bounds.width, bounds.height])
2451            } else {
2452                [abs_x, abs_y, bounds.width, bounds.height]
2453            };
2454            // Inset clip by border-width only.  Per CSS spec, overflow clips
2455            // at the padding box (inside border, but padding area is visible).
2456            // Padding affects layout positioning, not clipping.
2457            let bw = tree
2458                .get_render_node(node)
2459                .map(|n| n.props.border_width)
2460                .unwrap_or(0.0);
2461            let this_clip = [
2462                clip_bounds[0] + bw,
2463                clip_bounds[1] + bw,
2464                (clip_bounds[2] - bw * 2.0).max(0.0),
2465                (clip_bounds[3] - bw * 2.0).max(0.0),
2466            ];
2467
2468            // Extract border radius from this node for rounded clipping.
2469            // Inner corner radius = max(outer_radius − border_width, 0)
2470            let this_clip_radius = tree.get_render_node(node).map(|n| {
2471                let r = &n.props.border_radius;
2472                [
2473                    (r.top_left - bw).max(0.0),
2474                    (r.top_right - bw).max(0.0),
2475                    (r.bottom_right - bw).max(0.0),
2476                    (r.bottom_left - bw).max(0.0),
2477                ]
2478            });
2479
2480            let this_has_radius = this_clip_radius
2481                .map(|r| r.iter().any(|&v| v > 0.5))
2482                .unwrap_or(false);
2483            let parent_has_radius = current_clip_radius
2484                .map(|r| r.iter().any(|&v| v > 0.5))
2485                .unwrap_or(false);
2486
2487            if let Some(parent_clip) = current_clip {
2488                if this_has_radius && !parent_has_radius {
2489                    // This node is rounded (card), parent is sharp (scroll container).
2490                    // Keep them separate to avoid SDF radius clamping/morphing.
2491                    // Primary clip = this node's rounded clip (full card bounds).
2492                    // Scroll clip = parent's sharp clip intersected with any existing scroll clip.
2493                    (
2494                        Some(this_clip),
2495                        this_clip_radius,
2496                        merge_scroll_clip(parent_clip, current_scroll_clip),
2497                    )
2498                } else if !this_has_radius && parent_has_radius {
2499                    // This node is sharp (scroll), parent is rounded (card).
2500                    // Keep parent as primary rounded clip, this as scroll clip
2501                    // intersected with any existing scroll clip.
2502                    (
2503                        current_clip,
2504                        current_clip_radius,
2505                        merge_scroll_clip(this_clip, current_scroll_clip),
2506                    )
2507                } else {
2508                    // Both have same kind of radius — intersect normally.
2509                    let x1 = parent_clip[0].max(this_clip[0]);
2510                    let y1 = parent_clip[1].max(this_clip[1]);
2511                    let parent_right = parent_clip[0] + parent_clip[2];
2512                    let parent_bottom = parent_clip[1] + parent_clip[3];
2513                    let this_right = this_clip[0] + this_clip[2];
2514                    let this_bottom = this_clip[1] + this_clip[3];
2515                    let x2 = parent_right.min(this_right);
2516                    let y2 = parent_bottom.min(this_bottom);
2517                    let w = (x2 - x1).max(0.0);
2518                    let h = (y2 - y1).max(0.0);
2519                    let clip = Some([x1, y1, w, h]);
2520
2521                    let child_r = this_clip_radius.unwrap_or([0.0; 4]);
2522                    let parent_r = current_clip_radius.unwrap_or([0.0; 4]);
2523                    let radius = Some([
2524                        child_r[0].max(parent_r[0]),
2525                        child_r[1].max(parent_r[1]),
2526                        child_r[2].max(parent_r[2]),
2527                        child_r[3].max(parent_r[3]),
2528                    ]);
2529
2530                    (clip, radius, current_scroll_clip)
2531                }
2532            } else {
2533                // No parent clip — this is the first clip level.
2534                if this_has_radius {
2535                    // Rounded clip becomes primary, scroll clip passes through.
2536                    (Some(this_clip), this_clip_radius, current_scroll_clip)
2537                } else {
2538                    // Sharp clip becomes scroll clip; intersect with existing scroll clip
2539                    // so nested sharp clips (scroll + stack wrapper) don't lose the outer boundary.
2540                    let new_scroll_clip = if let Some(existing) = current_scroll_clip {
2541                        let x1 = existing[0].max(this_clip[0]);
2542                        let y1 = existing[1].max(this_clip[1]);
2543                        let x2 = (existing[0] + existing[2]).min(this_clip[0] + this_clip[2]);
2544                        let y2 = (existing[1] + existing[3]).min(this_clip[1] + this_clip[3]);
2545                        [x1, y1, (x2 - x1).max(0.0), (y2 - y1).max(0.0)]
2546                    } else {
2547                        this_clip
2548                    };
2549                    (None, None, Some(new_scroll_clip))
2550                }
2551            }
2552        } else {
2553            (current_clip, current_clip_radius, current_scroll_clip)
2554        };
2555
2556        // Compute this node's CSS affine: compose its own CSS transform with inherited.
2557        // This must happen BEFORE the element-type match block so that SVGs, text, and images
2558        // get their own transform applied (not just the parent's inherited transform).
2559        // NOTE: 3D rotations (rotate-x/rotate-y/perspective) are NOT included here — they
2560        // can't be accurately represented as a 2D affine (perspective is projective, not linear).
2561        // Proper 3D text compositing requires layer-based rendering (render to texture, then
2562        // apply 3D transform to the composite). For now, text stays flat under 3D parents.
2563        let node_css_affine = if let Some(render_node) = tree.get_render_node(node) {
2564            let has_non_identity = if let Some(blinc_core::Transform::Affine2D(affine)) =
2565                &render_node.props.transform
2566            {
2567                let [a, b, c, d, tx, ty] = affine.elements;
2568                !((a - 1.0).abs() < 0.0001
2569                    && b.abs() < 0.0001
2570                    && c.abs() < 0.0001
2571                    && (d - 1.0).abs() < 0.0001
2572                    && tx.abs() < 0.0001
2573                    && ty.abs() < 0.0001)
2574            } else {
2575                false
2576            };
2577
2578            if has_non_identity {
2579                let affine = match &render_node.props.transform {
2580                    Some(blinc_core::Transform::Affine2D(a)) => a.elements,
2581                    _ => unreachable!(),
2582                };
2583                let [a, b, c, d, tx, ty] = affine;
2584                // Compute transform center in absolute layout coords
2585                let (cx, cy) = if let Some([ox_pct, oy_pct]) = render_node.props.transform_origin {
2586                    (
2587                        abs_x + bounds.width * ox_pct / 100.0,
2588                        abs_y + bounds.height * oy_pct / 100.0,
2589                    )
2590                } else {
2591                    (abs_x + bounds.width / 2.0, abs_y + bounds.height / 2.0)
2592                };
2593                // Build full 6-element affine: T(center) * [a,b,c,d,tx,ty] * T(-center)
2594                // = [a, b, c, d, cx*(1-a) - cy*c + tx, cy*(1-d) - cx*b + ty]
2595                let this_affine = [
2596                    a,
2597                    b,
2598                    c,
2599                    d,
2600                    cx * (1.0 - a) - cy * c + tx,
2601                    cy * (1.0 - d) - cx * b + ty,
2602                ];
2603                match inherited_css_affine {
2604                    Some(parent) => {
2605                        let [pa, pb, pc, pd, ptx, pty] = parent;
2606                        Some([
2607                            a * pa + c * pb,
2608                            b * pa + d * pb,
2609                            a * pc + c * pd,
2610                            b * pc + d * pd,
2611                            a * ptx + c * pty + this_affine[4],
2612                            b * ptx + d * pty + this_affine[5],
2613                        ])
2614                    }
2615                    None => Some(this_affine),
2616                }
2617            } else {
2618                inherited_css_affine
2619            }
2620        } else {
2621            inherited_css_affine
2622        };
2623
2624        if let Some(render_node) = tree.get_render_node(node) {
2625            // Determine effective layer: children inside glass render in Foreground
2626            let effective_layer = if inside_glass && !is_glass {
2627                RenderLayer::Foreground
2628            } else if is_glass {
2629                RenderLayer::Glass
2630            } else {
2631                render_node.props.layer
2632            };
2633
2634            match &render_node.element_type {
2635                ElementType::Text(text_data) => {
2636                    // Apply DPI scale factor FIRST to match shape rendering order
2637                    // In render_with_motion, DPI scale is pushed at root level before any other transforms
2638                    // So we must: scale base positions first, then apply motion transforms
2639                    let base_x = abs_x * scale;
2640                    let base_y = abs_y * scale;
2641                    let base_width = bounds.width * scale;
2642                    let base_height = bounds.height * scale;
2643
2644                    // Scale motion translate by DPI factor (motion values are in layout coordinates)
2645                    let scaled_motion_tx = effective_motion_translate.0 * scale;
2646                    let scaled_motion_ty = effective_motion_translate.1 * scale;
2647
2648                    // Apply motion scale and translation
2649                    // When there's a motion scale center (from parent Motion container),
2650                    // we must scale around THAT center, not the text element's own center.
2651                    // This matches how shapes are rendered - the scale transform is pushed
2652                    // at the Motion container level and affects all children relative to
2653                    // the container's center.
2654                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
2655                        if let Some((motion_center_x, motion_center_y)) =
2656                            effective_motion_scale_center
2657                        {
2658                            // Scale position around the motion container's center (in DPI-scaled coordinates)
2659                            let motion_center_x_scaled = motion_center_x * scale;
2660                            let motion_center_y_scaled = motion_center_y * scale;
2661
2662                            // Calculate position relative to motion center
2663                            let rel_x = base_x - motion_center_x_scaled;
2664                            let rel_y = base_y - motion_center_y_scaled;
2665
2666                            // Apply scale to relative position and size
2667                            let scaled_rel_x = rel_x * effective_motion_scale.0;
2668                            let scaled_rel_y = rel_y * effective_motion_scale.1;
2669                            let scaled_w = base_width * effective_motion_scale.0;
2670                            let scaled_h = base_height * effective_motion_scale.1;
2671
2672                            // Apply motion translation and convert back to absolute position
2673                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
2674                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
2675
2676                            (final_x, final_y, scaled_w, scaled_h)
2677                        } else {
2678                            // No motion scale center - just apply translation (no scale effect)
2679                            let final_x = base_x + scaled_motion_tx;
2680                            let final_y = base_y + scaled_motion_ty;
2681                            (final_x, final_y, base_width, base_height)
2682                        };
2683
2684                    // Use CSS-overridden font size if available (from stylesheet/animation/transition)
2685                    let base_font_size = render_node.props.font_size.unwrap_or(text_data.font_size);
2686                    let scaled_font_size = base_font_size * effective_motion_scale.1 * scale;
2687                    let scaled_measured_width =
2688                        text_data.measured_width * effective_motion_scale.0 * scale;
2689
2690                    // Intersect primary clip with scroll clip — text only supports
2691                    // a single clip rect so we must merge both boundaries.
2692                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
2693                    let scaled_clip = effective_clip
2694                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
2695
2696                    // Log motion values if non-trivial (for debugging text/shape sync issues)
2697                    if effective_motion_translate.0.abs() > 0.1
2698                        || effective_motion_translate.1.abs() > 0.1
2699                        || (effective_motion_scale.0 - 1.0).abs() > 0.01
2700                        || (effective_motion_scale.1 - 1.0).abs() > 0.01
2701                    {
2702                        tracing::trace!(
2703                            "Text '{}': motion_translate=({:.1}, {:.1}), motion_scale=({:.2}, {:.2}), base=({:.1}, {:.1}), final=({:.1}, {:.1})",
2704                            text_data.content,
2705                            effective_motion_translate.0,
2706                            effective_motion_translate.1,
2707                            effective_motion_scale.0,
2708                            effective_motion_scale.1,
2709                            base_x,
2710                            base_y,
2711                            scaled_x,
2712                            scaled_y,
2713                        );
2714                    }
2715                    tracing::trace!(
2716                        "Text '{}': abs=({:.1}, {:.1}), size=({:.1}x{:.1}), font={:.1}, align={:?}, v_align={:?}, z_layer={}",
2717                        text_data.content,
2718                        scaled_x,
2719                        scaled_y,
2720                        scaled_width,
2721                        scaled_height,
2722                        scaled_font_size,
2723                        text_data.align,
2724                        text_data.v_align,
2725                        *z_layer
2726                    );
2727
2728                    // Apply text-overflow: ellipsis truncation if needed.
2729                    // Check both text_data.wrap (set at build time) and render_node.props.white_space
2730                    // (set by CSS after build). CSS white-space: nowrap overrides the builder wrap setting.
2731                    let is_nowrap = !text_data.wrap
2732                        || matches!(
2733                            render_node.props.white_space,
2734                            Some(blinc_layout::element_style::WhiteSpace::Nowrap)
2735                                | Some(blinc_layout::element_style::WhiteSpace::Pre)
2736                        );
2737                    let content = if is_nowrap
2738                        && matches!(
2739                            render_node.props.text_overflow,
2740                            Some(blinc_layout::element_style::TextOverflow::Ellipsis)
2741                        )
2742                        && scaled_measured_width > scaled_width
2743                        && scaled_width > 0.0
2744                    {
2745                        // Measure with the same options used for layout
2746                        let mut options = blinc_layout::text_measure::TextLayoutOptions::new();
2747                        options.font_name = text_data.font_family.name.clone();
2748                        options.generic_font = text_data.font_family.generic;
2749                        options.font_weight =
2750                            match render_node.props.font_weight.unwrap_or(text_data.weight) {
2751                                FontWeight::Bold => 700,
2752                                FontWeight::Normal => 400,
2753                                FontWeight::Light => 300,
2754                                _ => 400,
2755                            };
2756                        options.letter_spacing = render_node
2757                            .props
2758                            .letter_spacing
2759                            .unwrap_or(text_data.letter_spacing);
2760
2761                        // Measure "..." to know reserved width
2762                        let ellipsis = "\u{2026}";
2763                        let ellipsis_w = blinc_layout::text_measure::measure_text_with_options(
2764                            ellipsis,
2765                            scaled_font_size / scale,
2766                            &options,
2767                        )
2768                        .width
2769                            * scale;
2770                        let target_width = scaled_width - ellipsis_w;
2771
2772                        if target_width > 0.0 {
2773                            // Binary search for the right truncation point
2774                            let chars: Vec<char> = text_data.content.chars().collect();
2775                            let mut lo = 0usize;
2776                            let mut hi = chars.len();
2777                            while lo < hi {
2778                                #[allow(clippy::manual_div_ceil)]
2779                                let mid = (lo + hi + 1) / 2;
2780                                let sub: String = chars[..mid].iter().collect();
2781                                let w = blinc_layout::text_measure::measure_text_with_options(
2782                                    &sub,
2783                                    scaled_font_size / scale,
2784                                    &options,
2785                                )
2786                                .width
2787                                    * scale;
2788                                if w <= target_width {
2789                                    lo = mid;
2790                                } else {
2791                                    hi = mid - 1;
2792                                }
2793                            }
2794                            let truncated: String = chars[..lo].iter().collect();
2795                            format!("{}{}", truncated.trim_end(), ellipsis)
2796                        } else {
2797                            ellipsis.to_string()
2798                        }
2799                    } else {
2800                        text_data.content.clone()
2801                    };
2802
2803                    texts.push(TextElement {
2804                        content,
2805                        x: scaled_x,
2806                        y: scaled_y,
2807                        width: scaled_width,
2808                        height: scaled_height,
2809                        font_size: scaled_font_size,
2810                        color: render_node.props.text_color.unwrap_or(text_data.color),
2811                        align: text_data.align,
2812                        weight: render_node.props.font_weight.unwrap_or(text_data.weight),
2813                        italic: text_data.italic,
2814                        v_align: text_data.v_align,
2815                        clip_bounds: scaled_clip,
2816                        motion_opacity: effective_motion_opacity
2817                            * render_node.props.opacity
2818                            * inherited_css_opacity,
2819                        wrap: !is_nowrap && text_data.wrap,
2820                        line_height: text_data.line_height,
2821                        measured_width: scaled_measured_width,
2822                        font_family: text_data.font_family.clone(),
2823                        word_spacing: text_data.word_spacing,
2824                        letter_spacing: render_node
2825                            .props
2826                            .letter_spacing
2827                            .unwrap_or(text_data.letter_spacing),
2828                        z_index: *z_layer,
2829                        ascender: text_data.ascender * effective_motion_scale.1 * scale,
2830                        strikethrough: render_node.props.text_decoration.map_or(
2831                            text_data.strikethrough,
2832                            |td| {
2833                                matches!(
2834                                    td,
2835                                    blinc_layout::element_style::TextDecoration::LineThrough
2836                                )
2837                            },
2838                        ),
2839                        underline: render_node.props.text_decoration.map_or(
2840                            text_data.underline,
2841                            |td| {
2842                                matches!(td, blinc_layout::element_style::TextDecoration::Underline)
2843                            },
2844                        ),
2845                        decoration_color: render_node.props.text_decoration_color,
2846                        decoration_thickness: render_node.props.text_decoration_thickness,
2847                        css_affine: node_css_affine,
2848                        text_shadow: render_node.props.text_shadow,
2849                        transform_3d_layer: inside_3d_layer.clone(),
2850                        is_foreground: children_inside_foreground,
2851                    });
2852                }
2853                ElementType::Svg(svg_data) => {
2854                    // Apply DPI scale factor FIRST to match shape rendering order
2855                    let base_x = abs_x * scale;
2856                    let base_y = abs_y * scale;
2857                    let base_width = bounds.width * scale;
2858                    let base_height = bounds.height * scale;
2859
2860                    // Scale motion translate by DPI factor
2861                    let scaled_motion_tx = effective_motion_translate.0 * scale;
2862                    let scaled_motion_ty = effective_motion_translate.1 * scale;
2863
2864                    // Apply motion scale and translation (same logic as Text)
2865                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
2866                        if let Some((motion_center_x, motion_center_y)) =
2867                            effective_motion_scale_center
2868                        {
2869                            let motion_center_x_scaled = motion_center_x * scale;
2870                            let motion_center_y_scaled = motion_center_y * scale;
2871
2872                            let rel_x = base_x - motion_center_x_scaled;
2873                            let rel_y = base_y - motion_center_y_scaled;
2874
2875                            let scaled_rel_x = rel_x * effective_motion_scale.0;
2876                            let scaled_rel_y = rel_y * effective_motion_scale.1;
2877                            let scaled_w = base_width * effective_motion_scale.0;
2878                            let scaled_h = base_height * effective_motion_scale.1;
2879
2880                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
2881                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
2882
2883                            (final_x, final_y, scaled_w, scaled_h)
2884                        } else {
2885                            let final_x = base_x + scaled_motion_tx;
2886                            let final_y = base_y + scaled_motion_ty;
2887                            (final_x, final_y, base_width, base_height)
2888                        };
2889
2890                    // Intersect primary clip with scroll clip — text/SVG only support
2891                    // a single clip rect so we must merge both boundaries.
2892                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
2893                    let scaled_clip = effective_clip
2894                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
2895
2896                    // Tint resolves `currentColor` references in SVG source.
2897                    // CSS fill/stroke are explicit overrides injected as SVG attributes.
2898                    // Both can coexist: tint handles currentColor, CSS handles specifics.
2899                    svgs.push(SvgElement {
2900                        source: svg_data.source.clone(),
2901                        x: scaled_x,
2902                        y: scaled_y,
2903                        width: scaled_width,
2904                        height: scaled_height,
2905                        tint: svg_data.tint.or_else(|| {
2906                            render_node
2907                                .props
2908                                .text_color
2909                                .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
2910                        }),
2911                        fill: render_node
2912                            .props
2913                            .fill
2914                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
2915                            .or(svg_data.fill),
2916                        stroke: render_node
2917                            .props
2918                            .stroke
2919                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
2920                            .or(svg_data.stroke),
2921                        stroke_width: render_node.props.stroke_width.or(svg_data.stroke_width),
2922                        stroke_dasharray: render_node.props.stroke_dasharray.clone(),
2923                        stroke_dashoffset: render_node.props.stroke_dashoffset,
2924                        svg_path_data: render_node.props.svg_path_data.clone(),
2925                        clip_bounds: scaled_clip,
2926                        motion_opacity: effective_motion_opacity
2927                            * render_node.props.opacity
2928                            * inherited_css_opacity,
2929                        css_affine: node_css_affine,
2930                        tag_overrides: render_node.props.svg_tag_styles.clone(),
2931                        transform_3d_layer: inside_3d_layer.clone(),
2932                    });
2933                }
2934                ElementType::Image(image_data) => {
2935                    // Apply DPI scale factor to image positions and sizes
2936                    let scaled_clip = current_clip
2937                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
2938
2939                    // Scale clip radius by DPI factor (radius values are in layout coordinates)
2940                    let scaled_clip_radius = current_clip_radius
2941                        .map(|[tl, tr, br, bl]| [tl * scale, tr * scale, br * scale, bl * scale])
2942                        .unwrap_or([0.0; 4]);
2943
2944                    // Scale scroll clip by DPI factor
2945                    let scaled_scroll_clip = current_scroll_clip
2946                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
2947
2948                    // Look up parent render props for CSS property inheritance.
2949                    // Images render via a separate pipeline and don't inherit parent CSS
2950                    // properties automatically — we must propagate them explicitly.
2951                    let parent_props = parent_node
2952                        .and_then(|pid| tree.get_render_node(pid))
2953                        .map(|pn| &pn.props);
2954
2955                    // Opacity: own CSS opacity * inherited CSS opacity chain * builder * motion
2956                    let own_css_opacity = render_node.props.opacity;
2957                    let final_opacity = image_data.opacity
2958                        * own_css_opacity
2959                        * inherited_css_opacity
2960                        * effective_motion_opacity;
2961
2962                    // Border-radius: prefer own CSS, then builder.
2963                    // Parent clip (now at content-box) handles corner rounding.
2964                    let own_br = render_node.props.border_radius.top_left;
2965                    let final_border_radius = if own_br > 0.0 {
2966                        own_br * scale
2967                    } else {
2968                        image_data.border_radius * scale
2969                    };
2970
2971                    // Border: use image's own CSS border (parent border renders via SDF,
2972                    // visible because clip now insets by border-width)
2973                    let border_width = render_node.props.border_width * scale;
2974                    let border_color = render_node
2975                        .props
2976                        .border_color
2977                        .unwrap_or(blinc_core::Color::TRANSPARENT);
2978
2979                    // Shadow: use image's own (parent shadow renders via SDF)
2980                    let shadow = render_node.props.shadow;
2981
2982                    // Filter: prefer own, fall back to parent
2983                    let own_filter = &render_node.props.filter;
2984                    let parent_filter = parent_props.and_then(|p| p.filter.as_ref());
2985                    let effective_filter = own_filter.as_ref().or(parent_filter);
2986                    let filter_a = effective_filter
2987                        .map(|f| Self::css_filter_to_arrays(f).0)
2988                        .unwrap_or([0.0, 0.0, 0.0, 0.0]);
2989                    let filter_b = effective_filter
2990                        .map(|f| Self::css_filter_to_arrays(f).1)
2991                        .unwrap_or([1.0, 1.0, 1.0, 0.0]);
2992
2993                    // object-fit / object-position: CSS overrides builder values
2994                    let final_object_fit = render_node
2995                        .props
2996                        .object_fit
2997                        .unwrap_or(image_data.object_fit);
2998                    let final_object_position = render_node
2999                        .props
3000                        .object_position
3001                        .unwrap_or(image_data.object_position);
3002
3003                    // Mask: prefer own, fall back to parent
3004                    let own_mask = render_node.props.mask_image.as_ref();
3005                    let parent_mask = parent_props.and_then(|p| p.mask_image.as_ref());
3006                    let effective_mask = own_mask.or(parent_mask);
3007                    let (mask_params, mask_info) = Self::mask_image_to_arrays(effective_mask);
3008
3009                    images.push(ImageElement {
3010                        source: image_data.source.clone(),
3011                        x: abs_x * scale,
3012                        y: abs_y * scale,
3013                        width: bounds.width * scale,
3014                        height: bounds.height * scale,
3015                        object_fit: final_object_fit,
3016                        object_position: final_object_position,
3017                        opacity: final_opacity,
3018                        border_radius: final_border_radius,
3019                        tint: image_data.tint,
3020                        clip_bounds: scaled_clip,
3021                        clip_radius: scaled_clip_radius,
3022                        layer: effective_layer,
3023                        loading_strategy: image_data.loading_strategy,
3024                        placeholder_type: image_data.placeholder_type,
3025                        placeholder_color: image_data.placeholder_color,
3026                        z_index: *z_layer,
3027                        border_width,
3028                        border_color,
3029                        css_affine: node_css_affine,
3030                        shadow,
3031                        filter_a,
3032                        filter_b,
3033                        scroll_clip: scaled_scroll_clip,
3034                        mask_params,
3035                        mask_info,
3036                        transform_3d_layer: inside_3d_layer.clone(),
3037                    });
3038                }
3039                // Canvas elements are rendered inline during tree traversal (in render_layer)
3040                ElementType::Canvas(_) => {}
3041                ElementType::Div => {
3042                    // Check if this div has a background image brush
3043                    if let Some(blinc_core::Brush::Image(ref img_brush)) =
3044                        render_node.props.background
3045                    {
3046                        let scaled_clip = current_clip.map(|[cx, cy, cw, ch]| {
3047                            [cx * scale, cy * scale, cw * scale, ch * scale]
3048                        });
3049                        let scaled_clip_radius = current_clip_radius
3050                            .map(|[tl, tr, br, bl]| {
3051                                [tl * scale, tr * scale, br * scale, bl * scale]
3052                            })
3053                            .unwrap_or([0.0; 4]);
3054                        let scaled_scroll_clip_bg = current_scroll_clip.map(|[cx, cy, cw, ch]| {
3055                            [cx * scale, cy * scale, cw * scale, ch * scale]
3056                        });
3057
3058                        images.push(ImageElement {
3059                            source: img_brush.source.clone(),
3060                            x: abs_x * scale,
3061                            y: abs_y * scale,
3062                            width: bounds.width * scale,
3063                            height: bounds.height * scale,
3064                            object_fit: match img_brush.fit {
3065                                blinc_core::ImageFit::Cover => 0,
3066                                blinc_core::ImageFit::Contain => 1,
3067                                blinc_core::ImageFit::Fill => 2,
3068                                blinc_core::ImageFit::Tile => 0,
3069                            },
3070                            object_position: [img_brush.position.x, img_brush.position.y],
3071                            opacity: img_brush.opacity
3072                                * render_node.props.opacity
3073                                * inherited_css_opacity
3074                                * effective_motion_opacity,
3075                            border_radius: render_node.props.border_radius.top_left * scale,
3076                            tint: [
3077                                img_brush.tint.r,
3078                                img_brush.tint.g,
3079                                img_brush.tint.b,
3080                                img_brush.tint.a,
3081                            ],
3082                            clip_bounds: scaled_clip,
3083                            clip_radius: scaled_clip_radius,
3084                            layer: effective_layer,
3085                            loading_strategy: 0, // Eager
3086                            placeholder_type: 0, // None
3087                            placeholder_color: [0.0; 4],
3088                            z_index: *z_layer,
3089                            border_width: 0.0,
3090                            border_color: blinc_core::Color::TRANSPARENT,
3091                            css_affine: node_css_affine,
3092                            shadow: render_node.props.shadow,
3093                            filter_a: render_node
3094                                .props
3095                                .filter
3096                                .as_ref()
3097                                .map(|f| Self::css_filter_to_arrays(f).0)
3098                                .unwrap_or([0.0, 0.0, 0.0, 0.0]),
3099                            filter_b: render_node
3100                                .props
3101                                .filter
3102                                .as_ref()
3103                                .map(|f| Self::css_filter_to_arrays(f).1)
3104                                .unwrap_or([1.0, 1.0, 1.0, 0.0]),
3105                            scroll_clip: scaled_scroll_clip_bg,
3106                            mask_params: {
3107                                let (mp, _) = Self::mask_image_to_arrays(
3108                                    render_node.props.mask_image.as_ref(),
3109                                );
3110                                mp
3111                            },
3112                            mask_info: {
3113                                let (_, mi) = Self::mask_image_to_arrays(
3114                                    render_node.props.mask_image.as_ref(),
3115                                );
3116                                mi
3117                            },
3118                            transform_3d_layer: inside_3d_layer.clone(),
3119                        });
3120                    }
3121                }
3122                // StyledText: render text with inline styling using multiple TextElements
3123                ElementType::StyledText(styled_data) => {
3124                    // Apply DPI scale factor first
3125                    let base_x = abs_x * scale;
3126                    let base_y = abs_y * scale;
3127                    let base_width = bounds.width * scale;
3128                    let base_height = bounds.height * scale;
3129
3130                    // Scale motion translate by DPI factor
3131                    let scaled_motion_tx = effective_motion_translate.0 * scale;
3132                    let scaled_motion_ty = effective_motion_translate.1 * scale;
3133
3134                    // Apply motion scale and translation (same logic as Text)
3135                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
3136                        if let Some((motion_center_x, motion_center_y)) =
3137                            effective_motion_scale_center
3138                        {
3139                            let motion_center_x_scaled = motion_center_x * scale;
3140                            let motion_center_y_scaled = motion_center_y * scale;
3141
3142                            let rel_x = base_x - motion_center_x_scaled;
3143                            let rel_y = base_y - motion_center_y_scaled;
3144
3145                            let scaled_rel_x = rel_x * effective_motion_scale.0;
3146                            let scaled_rel_y = rel_y * effective_motion_scale.1;
3147                            let scaled_w = base_width * effective_motion_scale.0;
3148                            let scaled_h = base_height * effective_motion_scale.1;
3149
3150                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
3151                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
3152
3153                            (final_x, final_y, scaled_w, scaled_h)
3154                        } else {
3155                            let final_x = base_x + scaled_motion_tx;
3156                            let final_y = base_y + scaled_motion_ty;
3157                            (final_x, final_y, base_width, base_height)
3158                        };
3159
3160                    // Use CSS-overridden font size if available (from stylesheet/animation/transition)
3161                    let base_styled_font_size =
3162                        render_node.props.font_size.unwrap_or(styled_data.font_size);
3163                    let scaled_font_size = base_styled_font_size * effective_motion_scale.1 * scale;
3164                    // Intersect primary clip with scroll clip for styled text
3165                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
3166                    let scaled_clip = effective_clip
3167                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
3168
3169                    // Build non-overlapping segments from potentially overlapping spans
3170                    // This handles nested tags like <span color="red"><b>text</b></span>
3171                    let content = &styled_data.content;
3172                    let content_len = content.len();
3173
3174                    // Get default styles from element config
3175                    let default_bold = styled_data.weight == FontWeight::Bold;
3176                    let default_italic = styled_data.italic;
3177
3178                    // Collect all boundary positions where style might change
3179                    let mut boundaries: Vec<usize> = vec![0, content_len];
3180                    for span in &styled_data.spans {
3181                        if span.start < content_len {
3182                            boundaries.push(span.start);
3183                        }
3184                        if span.end <= content_len {
3185                            boundaries.push(span.end);
3186                        }
3187                    }
3188                    boundaries.sort();
3189                    boundaries.dedup();
3190
3191                    // Build segments between boundaries
3192                    #[allow(clippy::type_complexity)]
3193                    let mut segments: Vec<(
3194                        usize,
3195                        usize,
3196                        [f32; 4],
3197                        bool,
3198                        bool,
3199                        bool,
3200                        bool,
3201                    )> = Vec::new();
3202
3203                    for window in boundaries.windows(2) {
3204                        let seg_start = window[0];
3205                        let seg_end = window[1];
3206                        if seg_start >= seg_end {
3207                            continue;
3208                        }
3209
3210                        // Determine style for this segment by merging all overlapping spans
3211                        let mut color: Option<[f32; 4]> = None;
3212                        let mut bold = default_bold;
3213                        let mut italic = default_italic;
3214                        let mut underline = false;
3215                        let mut strikethrough = false;
3216
3217                        for span in &styled_data.spans {
3218                            // Check if span overlaps this segment
3219                            if span.start <= seg_start && span.end >= seg_end {
3220                                // This span covers this segment - merge styles
3221                                if span.bold {
3222                                    bold = true;
3223                                }
3224                                if span.italic {
3225                                    italic = true;
3226                                }
3227                                if span.underline {
3228                                    underline = true;
3229                                }
3230                                if span.strikethrough {
3231                                    strikethrough = true;
3232                                }
3233                                // Use color if span has explicit color (not transparent)
3234                                if span.color[3] > 0.0 {
3235                                    color = Some(span.color);
3236                                }
3237                            }
3238                        }
3239
3240                        // CSS text_color override takes precedence over span colors
3241                        let default_color = render_node
3242                            .props
3243                            .text_color
3244                            .unwrap_or(styled_data.default_color);
3245                        let final_color = color.unwrap_or(default_color);
3246                        segments.push((
3247                            seg_start,
3248                            seg_end,
3249                            final_color,
3250                            bold,
3251                            italic,
3252                            underline,
3253                            strikethrough,
3254                        ));
3255                    }
3256
3257                    // Use consistent ascender from element for baseline alignment
3258                    let scaled_ascender = styled_data.ascender * scale;
3259
3260                    // Calculate x offsets for each segment and push as TextElements
3261                    let mut x_offset = 0.0f32;
3262                    for (start, end, color, bold, italic, underline, strikethrough) in segments {
3263                        if start >= end || start >= content.len() {
3264                            continue;
3265                        }
3266                        let segment_text = &content[start..end.min(content.len())];
3267                        if segment_text.is_empty() {
3268                            continue;
3269                        }
3270
3271                        // Measure segment width for positioning
3272                        let mut options = blinc_layout::text_measure::TextLayoutOptions::new();
3273                        options.font_name = styled_data.font_family.name.clone();
3274                        options.generic_font = styled_data.font_family.generic;
3275                        options.font_weight = if bold { 700 } else { 400 };
3276                        options.italic = italic;
3277                        let metrics = blinc_layout::text_measure::measure_text_with_options(
3278                            segment_text,
3279                            styled_data.font_size,
3280                            &options,
3281                        );
3282                        // Apply both DPI scale and motion scale to segment width
3283                        let segment_width = metrics.width * scale * effective_motion_scale.0;
3284
3285                        texts.push(TextElement {
3286                            content: segment_text.to_string(),
3287                            x: scaled_x + x_offset,
3288                            y: scaled_y,
3289                            width: segment_width,
3290                            height: scaled_height,
3291                            font_size: scaled_font_size,
3292                            color,
3293                            align: TextAlign::Left, // Always left-align segments
3294                            weight: if bold {
3295                                FontWeight::Bold
3296                            } else {
3297                                FontWeight::Normal
3298                            },
3299                            italic,
3300                            v_align: styled_data.v_align,
3301                            clip_bounds: scaled_clip,
3302                            motion_opacity: effective_motion_opacity
3303                                * render_node.props.opacity
3304                                * inherited_css_opacity,
3305                            wrap: false, // Don't wrap individual segments
3306                            line_height: styled_data.line_height,
3307                            measured_width: segment_width,
3308                            font_family: styled_data.font_family.clone(),
3309                            word_spacing: 0.0,
3310                            letter_spacing: render_node.props.letter_spacing.unwrap_or(0.0),
3311                            z_index: *z_layer,
3312                            ascender: scaled_ascender * effective_motion_scale.1, // Scale ascender with motion
3313                            strikethrough,
3314                            underline,
3315                            decoration_color: render_node.props.text_decoration_color,
3316                            decoration_thickness: render_node.props.text_decoration_thickness,
3317                            css_affine: node_css_affine,
3318                            text_shadow: render_node.props.text_shadow,
3319                            transform_3d_layer: inside_3d_layer.clone(),
3320                            is_foreground: children_inside_foreground,
3321                        });
3322
3323                        x_offset += segment_width;
3324                    }
3325                }
3326            }
3327
3328            // Collect flow element if this node has a @flow shader reference.
3329            // Flow elements render via custom GPU pipelines instead of (or on top of) the SDF path.
3330            if let Some(ref flow_name) = render_node.props.flow {
3331                flows.push(FlowElement {
3332                    flow_name: flow_name.clone(),
3333                    flow_graph: render_node.props.flow_graph.clone(),
3334                    x: abs_x * scale,
3335                    y: abs_y * scale,
3336                    width: bounds.width * scale,
3337                    height: bounds.height * scale,
3338                    z_index: *z_layer,
3339                    corner_radius: render_node.props.border_radius.top_left * scale,
3340                });
3341            }
3342        }
3343
3344        // Include scroll offset and motion offset when calculating child positions
3345        let scroll_offset = tree.get_scroll_offset(node);
3346        let static_motion_offset = tree
3347            .get_motion_transform(node)
3348            .map(|t| match t {
3349                blinc_core::Transform::Affine2D(a) => (a.elements[4], a.elements[5]),
3350                _ => (0.0, 0.0),
3351            })
3352            .unwrap_or((0.0, 0.0));
3353
3354        let new_offset = (
3355            abs_x + scroll_offset.0 + static_motion_offset.0,
3356            abs_y + scroll_offset.1 + static_motion_offset.1,
3357        );
3358
3359        // Compute inherited CSS opacity for children: compound this node's CSS opacity
3360        // CSS `opacity` applies to the element AND its visual subtree
3361        let child_css_opacity = if let Some(rn) = tree.get_render_node(node) {
3362            inherited_css_opacity * rn.props.opacity
3363        } else {
3364            inherited_css_opacity
3365        };
3366
3367        // Detect 3D layer: if this node has rotate-x/rotate-y/perspective,
3368        // create a Transform3DLayerInfo for children to inherit.
3369        let child_3d_layer = if let Some(rn) = tree.get_render_node(node) {
3370            let has_3d = rn.props.rotate_x.is_some()
3371                || rn.props.rotate_y.is_some()
3372                || rn.props.perspective.is_some();
3373            if has_3d {
3374                let rx = rn.props.rotate_x.unwrap_or(0.0).to_radians();
3375                let ry = rn.props.rotate_y.unwrap_or(0.0).to_radians();
3376                let d = rn.props.perspective.unwrap_or(800.0) * scale;
3377                Some(Transform3DLayerInfo {
3378                    node_id: node,
3379                    layer_bounds: [
3380                        abs_x * scale,
3381                        abs_y * scale,
3382                        bounds.width * scale,
3383                        bounds.height * scale,
3384                    ],
3385                    transform_3d: blinc_core::Transform3DParams {
3386                        sin_rx: rx.sin(),
3387                        cos_rx: rx.cos(),
3388                        sin_ry: ry.sin(),
3389                        cos_ry: ry.cos(),
3390                        perspective_d: d,
3391                    },
3392                    opacity: rn.props.opacity,
3393                })
3394            } else {
3395                inside_3d_layer.clone()
3396            }
3397        } else {
3398            inside_3d_layer.clone()
3399        };
3400
3401        for child_id in tree.layout().children(node) {
3402            self.collect_elements_recursive(
3403                tree,
3404                child_id,
3405                new_offset,
3406                children_inside_glass,
3407                children_inside_foreground,
3408                child_clip,
3409                child_clip_radius,
3410                effective_motion_opacity,
3411                effective_motion_translate,
3412                effective_motion_scale,
3413                effective_motion_scale_center,
3414                render_state,
3415                scale,
3416                z_layer,
3417                texts,
3418                svgs,
3419                images,
3420                flows,
3421                node_css_affine,
3422                child_css_opacity,
3423                Some(node), // pass current node as parent for children
3424                child_scroll_clip,
3425                child_3d_layer.clone(),
3426            );
3427        }
3428
3429        // Restore z_layer after this subtree
3430        if node_z_index > 0 {
3431            *z_layer = saved_z_layer;
3432        }
3433    }
3434
3435    /// Get device arc
3436    pub fn device(&self) -> &Arc<wgpu::Device> {
3437        &self.device
3438    }
3439
3440    /// Get queue arc
3441    pub fn queue(&self) -> &Arc<wgpu::Queue> {
3442        &self.queue
3443    }
3444
3445    /// Get the shared font registry
3446    ///
3447    /// This can be used to share fonts between text measurement and rendering,
3448    /// ensuring consistent font loading and metrics.
3449    pub fn font_registry(&self) -> Arc<Mutex<FontRegistry>> {
3450        self.text_ctx.font_registry()
3451    }
3452
3453    /// Get the texture format used by the renderer
3454    pub fn texture_format(&self) -> wgpu::TextureFormat {
3455        self.renderer.texture_format()
3456    }
3457
3458    /// Render a layout tree with dynamic render state overlays
3459    ///
3460    /// This method renders:
3461    /// 1. The stable RenderTree (element hierarchy and layout)
3462    /// 2. RenderState overlays (cursors, selections, focus rings)
3463    ///
3464    /// The RenderState overlays are drawn on top of the tree without requiring
3465    /// tree rebuilds. This enables smooth cursor blinking and animations.
3466    pub fn render_tree_with_state(
3467        &mut self,
3468        tree: &RenderTree,
3469        render_state: &blinc_layout::RenderState,
3470        width: u32,
3471        height: u32,
3472        target: &wgpu::TextureView,
3473    ) -> Result<()> {
3474        // First render the tree as normal
3475        self.render_tree(tree, width, height, target)?;
3476
3477        // Then render overlays from RenderState
3478        self.render_overlays(render_state, width, height, target);
3479
3480        Ok(())
3481    }
3482
3483    /// Render a layout tree with motion animations from RenderState
3484    ///
3485    /// This method renders:
3486    /// 1. The RenderTree with motion animations applied (opacity, scale, translate)
3487    /// 2. RenderState overlays (cursors, selections, focus rings)
3488    ///
3489    /// Use this method when you have elements wrapped in motion() containers
3490    /// for enter/exit animations.
3491    pub fn render_tree_with_motion(
3492        &mut self,
3493        tree: &RenderTree,
3494        render_state: &blinc_layout::RenderState,
3495        width: u32,
3496        height: u32,
3497        target: &wgpu::TextureView,
3498    ) -> Result<()> {
3499        // Get scale factor for HiDPI rendering
3500        let scale_factor = tree.scale_factor();
3501
3502        // Create a single paint context for all layers with text rendering support
3503        let mut ctx =
3504            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
3505
3506        // Render with motion animations applied (all layers to same context)
3507        tree.render_with_motion(&mut ctx, render_state);
3508
3509        // Take the batch (mutable so CSS-transformed text primitives can be added)
3510        let mut batch = ctx.take_batch();
3511
3512        // Collect text, SVG, image, and flow elements WITH motion state
3513        let (all_texts, all_svgs, all_images, flow_elements) =
3514            self.collect_render_elements_with_state(tree, Some(render_state));
3515
3516        // Partition elements into normal (no 3D ancestor) and 3D-layer groups.
3517        // Elements inside a 3D-transformed parent need to be rendered to an offscreen
3518        // texture and blitted with the same perspective transform.
3519        let mut texts = Vec::new();
3520        let mut fg_texts = Vec::new();
3521        let mut layer_3d_texts: std::collections::HashMap<
3522            LayoutNodeId,
3523            (Transform3DLayerInfo, Vec<TextElement>),
3524        > = std::collections::HashMap::new();
3525        for text in all_texts {
3526            if let Some(ref info) = text.transform_3d_layer {
3527                layer_3d_texts
3528                    .entry(info.node_id)
3529                    .or_insert_with(|| (info.clone(), Vec::new()))
3530                    .1
3531                    .push(text);
3532            } else if text.is_foreground {
3533                fg_texts.push(text);
3534            } else {
3535                texts.push(text);
3536            }
3537        }
3538
3539        let mut svgs = Vec::new();
3540        let mut layer_3d_svgs: std::collections::HashMap<LayoutNodeId, Vec<SvgElement>> =
3541            std::collections::HashMap::new();
3542        for svg in all_svgs {
3543            if let Some(ref info) = svg.transform_3d_layer {
3544                layer_3d_svgs.entry(info.node_id).or_default().push(svg);
3545            } else {
3546                svgs.push(svg);
3547            }
3548        }
3549
3550        let mut images = Vec::new();
3551        let mut layer_3d_images: std::collections::HashMap<LayoutNodeId, Vec<ImageElement>> =
3552            std::collections::HashMap::new();
3553        for image in all_images {
3554            if let Some(ref info) = image.transform_3d_layer {
3555                layer_3d_images.entry(info.node_id).or_default().push(image);
3556            } else {
3557                images.push(image);
3558            }
3559        }
3560
3561        // Collect unique 3D layer IDs for rendering
3562        let layer_3d_ids: Vec<LayoutNodeId> = layer_3d_texts.keys().cloned().collect();
3563
3564        // Pre-load all images into cache before rendering (both normal and 3D-layer)
3565        self.preload_images(&images, width as f32, height as f32);
3566        for layer_imgs in layer_3d_images.values() {
3567            self.preload_images(layer_imgs, width as f32, height as f32);
3568        }
3569
3570        // Prepare text glyphs with z_layer information
3571        // Store (z_layer, glyphs) to enable interleaved rendering
3572        let mut glyphs_by_layer: std::collections::BTreeMap<u32, Vec<GpuGlyph>> =
3573            std::collections::BTreeMap::new();
3574        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
3575        for text in &texts {
3576            // Skip text that's completely outside its clip bounds (visibility culling)
3577            // This prevents loading emoji fonts for off-screen text in scroll containers
3578            if let Some([clip_x, clip_y, clip_w, clip_h]) = text.clip_bounds {
3579                let text_right = text.x + text.width;
3580                let text_bottom = text.y + text.height;
3581                let clip_right = clip_x + clip_w;
3582                let clip_bottom = clip_y + clip_h;
3583
3584                // Check if text is completely outside clip bounds
3585                if text.x >= clip_right
3586                    || text_right <= clip_x
3587                    || text.y >= clip_bottom
3588                    || text_bottom <= clip_y
3589                {
3590                    // Text is not visible, skip rendering entirely
3591                    continue;
3592                }
3593            }
3594
3595            let alignment = match text.align {
3596                TextAlign::Left => TextAlignment::Left,
3597                TextAlign::Center => TextAlignment::Center,
3598                TextAlign::Right => TextAlignment::Right,
3599            };
3600
3601            // Apply motion opacity to text color
3602            let color = if text.motion_opacity < 1.0 {
3603                [
3604                    text.color[0],
3605                    text.color[1],
3606                    text.color[2],
3607                    text.color[3] * text.motion_opacity,
3608                ]
3609            } else {
3610                text.color
3611            };
3612
3613            // Determine wrap width:
3614            // 1. If clip bounds exist and are smaller than measured width, use clip width
3615            //    (this handles scroll containers where layout width isn't constrained)
3616            // 2. Otherwise, if layout width is smaller than measured, use layout width
3617            // 3. Otherwise, don't wrap (text fits naturally)
3618            let effective_width = if let Some(clip) = text.clip_bounds {
3619                // Use clip width if it constrains the text
3620                clip[2].min(text.width)
3621            } else {
3622                text.width
3623            };
3624
3625            // Wrap if effective width is significantly smaller than measured width
3626            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
3627
3628            // Always pass width for alignment - the layout engine needs max_width
3629            // to calculate center/right alignment offsets
3630            let wrap_width = Some(text.width);
3631
3632            // Convert font family to GPU types
3633            let font_name = text.font_family.name.as_deref();
3634            let generic = to_gpu_generic_font(text.font_family.generic);
3635            let font_weight = text.weight.weight();
3636
3637            // Map vertical alignment to text anchor
3638            let (anchor, y_pos, use_layout_height) = match text.v_align {
3639                TextVerticalAlign::Center => {
3640                    (TextAnchor::Center, text.y + text.height / 2.0, false)
3641                }
3642                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
3643                TextVerticalAlign::Baseline => {
3644                    let baseline_y = text.y + text.ascender;
3645                    (TextAnchor::Baseline, baseline_y, false)
3646                }
3647            };
3648            let layout_height = if use_layout_height {
3649                Some(text.height)
3650            } else {
3651                None
3652            };
3653
3654            // Render text shadow first (behind text) if present
3655            if let Some(shadow) = &text.text_shadow {
3656                let shadow_color = [
3657                    shadow.color.r,
3658                    shadow.color.g,
3659                    shadow.color.b,
3660                    shadow.color.a * text.motion_opacity,
3661                ];
3662                let shadow_x = text.x + shadow.offset_x * scale_factor;
3663                let shadow_y = y_pos + shadow.offset_y * scale_factor;
3664                if let Ok(mut shadow_glyphs) = self.text_ctx.prepare_text_with_style(
3665                    &text.content,
3666                    shadow_x,
3667                    shadow_y,
3668                    text.font_size,
3669                    shadow_color,
3670                    anchor,
3671                    alignment,
3672                    wrap_width,
3673                    needs_wrap,
3674                    font_name,
3675                    generic,
3676                    font_weight,
3677                    text.italic,
3678                    layout_height,
3679                    text.letter_spacing,
3680                ) {
3681                    if let Some(clip) = text.clip_bounds {
3682                        for glyph in &mut shadow_glyphs {
3683                            glyph.clip_bounds = clip;
3684                        }
3685                    }
3686                    if let Some(affine) = text.css_affine {
3687                        let [a, b, c, d, tx, ty] = affine;
3688                        let tx_scaled = tx * scale_factor;
3689                        let ty_scaled = ty * scale_factor;
3690                        for glyph in &shadow_glyphs {
3691                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
3692                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
3693                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
3694                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
3695                            let mut prim = GpuPrimitive::from_glyph(glyph);
3696                            prim.bounds = [
3697                                new_gc_x - glyph.bounds[2] / 2.0,
3698                                new_gc_y - glyph.bounds[3] / 2.0,
3699                                glyph.bounds[2],
3700                                glyph.bounds[3],
3701                            ];
3702                            prim.local_affine = [a, b, c, d];
3703                            prim.set_z_layer(text.z_index);
3704                            css_transformed_text_prims.push(prim);
3705                        }
3706                    } else {
3707                        glyphs_by_layer
3708                            .entry(text.z_index)
3709                            .or_default()
3710                            .extend(shadow_glyphs);
3711                    }
3712                }
3713            }
3714
3715            match self.text_ctx.prepare_text_with_style(
3716                &text.content,
3717                text.x,
3718                y_pos,
3719                text.font_size,
3720                color,
3721                anchor,
3722                alignment,
3723                wrap_width,
3724                needs_wrap,
3725                font_name,
3726                generic,
3727                font_weight,
3728                text.italic,
3729                layout_height,
3730                text.letter_spacing,
3731            ) {
3732                Ok(mut glyphs) => {
3733                    tracing::trace!(
3734                        "render_tree_with_motion: prepared {} glyphs for '{}' (font={:?})",
3735                        glyphs.len(),
3736                        text.content,
3737                        font_name
3738                    );
3739                    // Apply clip bounds if present
3740                    if let Some(clip) = text.clip_bounds {
3741                        for glyph in &mut glyphs {
3742                            glyph.clip_bounds = clip;
3743                        }
3744                    }
3745
3746                    if let Some(affine) = text.css_affine {
3747                        // CSS-transformed text: convert glyphs to SDF primitives with local_affine
3748                        let [a, b, c, d, tx, ty] = affine;
3749                        let tx_scaled = tx * scale_factor;
3750                        let ty_scaled = ty * scale_factor;
3751                        for glyph in &glyphs {
3752                            // Transform glyph center through the affine
3753                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
3754                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
3755                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
3756                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
3757                            let mut prim = GpuPrimitive::from_glyph(glyph);
3758                            prim.bounds = [
3759                                new_gc_x - glyph.bounds[2] / 2.0,
3760                                new_gc_y - glyph.bounds[3] / 2.0,
3761                                glyph.bounds[2],
3762                                glyph.bounds[3],
3763                            ];
3764                            prim.local_affine = [a, b, c, d];
3765                            prim.set_z_layer(text.z_index);
3766                            css_transformed_text_prims.push(prim);
3767                        }
3768                    } else {
3769                        // Normal text: add to glyph pipeline
3770                        glyphs_by_layer
3771                            .entry(text.z_index)
3772                            .or_default()
3773                            .extend(glyphs);
3774                    }
3775                }
3776                Err(e) => {
3777                    tracing::warn!(
3778                        "render_tree_with_motion: failed to prepare text '{}': {:?}",
3779                        text.content,
3780                        e
3781                    );
3782                }
3783            }
3784        }
3785
3786        // Prepare foreground text glyphs (rendered after foreground primitives)
3787        let mut fg_glyphs: Vec<GpuGlyph> = Vec::new();
3788        for text in &fg_texts {
3789            if let Some([clip_x, clip_y, clip_w, clip_h]) = text.clip_bounds {
3790                let text_right = text.x + text.width;
3791                let text_bottom = text.y + text.height;
3792                let clip_right = clip_x + clip_w;
3793                let clip_bottom = clip_y + clip_h;
3794                if text.x >= clip_right
3795                    || text_right <= clip_x
3796                    || text.y >= clip_bottom
3797                    || text_bottom <= clip_y
3798                {
3799                    continue;
3800                }
3801            }
3802
3803            let alignment = match text.align {
3804                TextAlign::Left => TextAlignment::Left,
3805                TextAlign::Center => TextAlignment::Center,
3806                TextAlign::Right => TextAlignment::Right,
3807            };
3808
3809            let color = if text.motion_opacity < 1.0 {
3810                [
3811                    text.color[0],
3812                    text.color[1],
3813                    text.color[2],
3814                    text.color[3] * text.motion_opacity,
3815                ]
3816            } else {
3817                text.color
3818            };
3819
3820            let effective_width = if let Some(clip) = text.clip_bounds {
3821                clip[2].min(text.width)
3822            } else {
3823                text.width
3824            };
3825            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
3826            let wrap_width = Some(text.width);
3827            let font_name = text.font_family.name.as_deref();
3828            let generic = to_gpu_generic_font(text.font_family.generic);
3829            let font_weight = text.weight.weight();
3830
3831            let (anchor, y_pos, use_layout_height) = match text.v_align {
3832                TextVerticalAlign::Center => {
3833                    (TextAnchor::Center, text.y + text.height / 2.0, false)
3834                }
3835                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
3836                TextVerticalAlign::Baseline => {
3837                    let baseline_y = text.y + text.ascender;
3838                    (TextAnchor::Baseline, baseline_y, false)
3839                }
3840            };
3841            let layout_height = if use_layout_height {
3842                Some(text.height)
3843            } else {
3844                None
3845            };
3846
3847            if let Ok(mut glyphs) = self.text_ctx.prepare_text_with_style(
3848                &text.content,
3849                text.x,
3850                y_pos,
3851                text.font_size,
3852                color,
3853                anchor,
3854                alignment,
3855                wrap_width,
3856                needs_wrap,
3857                font_name,
3858                generic,
3859                font_weight,
3860                text.italic,
3861                layout_height,
3862                text.letter_spacing,
3863            ) {
3864                if let Some(clip) = text.clip_bounds {
3865                    for glyph in &mut glyphs {
3866                        glyph.clip_bounds = clip;
3867                    }
3868                }
3869                fg_glyphs.extend(glyphs);
3870            }
3871        }
3872
3873        tracing::trace!(
3874            "render_tree_with_motion: {} texts, {} fg texts, {} z-layers with glyphs, {} css-transformed",
3875            texts.len(),
3876            fg_texts.len(),
3877            glyphs_by_layer.len(),
3878            css_transformed_text_prims.len()
3879        );
3880
3881        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
3882        // They will be rendered later via render_rasterized_svgs
3883
3884        self.renderer.resize(width, height);
3885
3886        // If we have CSS-transformed text, push text prims into the main batch
3887        // and bind the real glyph atlas to the SDF pipeline for ALL render paths.
3888        if !css_transformed_text_prims.is_empty() {
3889            if let (Some(atlas), Some(color_atlas)) =
3890                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
3891            {
3892                batch.primitives.append(&mut css_transformed_text_prims);
3893                self.renderer.set_glyph_atlas(atlas, color_atlas);
3894            }
3895        }
3896
3897        let has_glass = batch.glass_count() > 0;
3898        let has_layer_effects_in_batch = batch.has_layer_effects();
3899
3900        // Only allocate glass textures when glass is actually used
3901        if has_glass {
3902            self.ensure_glass_textures(width, height);
3903        }
3904        let use_msaa_overlay = self.sample_count > 1;
3905
3906        if has_glass {
3907            // Glass path with layer effects support
3908            let (bg_images, fg_images): (Vec<_>, Vec<_>) = images
3909                .iter()
3910                .partition(|img| img.layer == RenderLayer::Background);
3911
3912            // Pre-render background images to both backdrop and target so glass can blur them
3913            let has_bg_images = !bg_images.is_empty();
3914            if has_bg_images {
3915                let backdrop_tex = self.backdrop_texture.take().unwrap();
3916                self.renderer
3917                    .clear_target(&backdrop_tex.view, wgpu::Color::TRANSPARENT);
3918                self.renderer.clear_target(target, wgpu::Color::BLACK);
3919                self.render_images_ref(&backdrop_tex.view, &bg_images);
3920                self.render_images_ref(target, &bg_images);
3921                self.backdrop_texture = Some(backdrop_tex);
3922            }
3923
3924            if has_layer_effects_in_batch {
3925                // When we have layer effects, we need a more complex render path:
3926                // 1. Render backdrop for glass blur sampling (with pre-rendered images if any)
3927                // 2. Use render_with_clear which handles layer effects
3928                // 3. Render background images to target (after clear, before glass)
3929                // 4. Render glass primitives on top
3930                {
3931                    let backdrop = self.backdrop_texture.as_ref().unwrap();
3932                    self.renderer.render_to_backdrop(
3933                        &backdrop.view,
3934                        (backdrop.width, backdrop.height),
3935                        &batch,
3936                        has_bg_images,
3937                    );
3938                }
3939
3940                // Then use render_with_clear which handles layer effects
3941                self.renderer
3942                    .render_with_clear(target, &batch, [0.0, 0.0, 0.0, 1.0]);
3943
3944                // Render background images to target after clear (so they're visible behind glass)
3945                if has_bg_images {
3946                    self.render_images_ref(target, &bg_images);
3947                }
3948
3949                // Finally render glass primitives on top
3950                if batch.glass_count() > 0 {
3951                    let backdrop = self.backdrop_texture.as_ref().unwrap();
3952                    self.renderer.render_glass(target, &backdrop.view, &batch);
3953                }
3954            } else {
3955                // No layer effects, use optimized glass frame rendering
3956                let backdrop = self.backdrop_texture.as_ref().unwrap();
3957                self.renderer.render_glass_frame(
3958                    target,
3959                    &backdrop.view,
3960                    (backdrop.width, backdrop.height),
3961                    &batch,
3962                    has_bg_images,
3963                );
3964            }
3965
3966            // Render paths with MSAA for smooth edges on curved shapes like notch
3967            // (render_glass_frame uses 1x sampled path rendering)
3968            if use_msaa_overlay && batch.has_paths() {
3969                self.renderer
3970                    .render_paths_overlay_msaa(target, &batch, self.sample_count);
3971            }
3972
3973            // Render remaining bg images (only if not already pre-rendered for glass)
3974            if !has_bg_images {
3975                self.render_images_ref(target, &bg_images);
3976            }
3977            self.render_images_ref(target, &fg_images);
3978
3979            // Interleaved z-layer rendering for proper text z-ordering in glass path
3980            let max_z = batch.max_z_layer();
3981            let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
3982            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
3983            let max_decoration_z = decorations_by_layer.keys().cloned().max().unwrap_or(0);
3984            let max_glass_layer = max_z.max(max_text_z).max(max_decoration_z);
3985
3986            // Render z=0 text first (before any z>0 primitives)
3987            {
3988                let mut scratch = std::mem::take(&mut self.scratch_glyphs);
3989                scratch.clear();
3990                if let Some(glyphs) = glyphs_by_layer.get(&0) {
3991                    scratch.extend_from_slice(glyphs);
3992                }
3993                if !scratch.is_empty() {
3994                    self.render_text(target, &scratch);
3995                }
3996                self.scratch_glyphs = scratch;
3997            }
3998            self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
3999
4000            if max_glass_layer > 0 {
4001                let effect_indices = batch.effect_layer_indices();
4002                for z in 1..=max_glass_layer {
4003                    // Render primitives for this layer
4004                    let layer_primitives = if effect_indices.is_empty() {
4005                        batch.primitives_for_layer(z)
4006                    } else {
4007                        batch.primitives_for_layer_excluding_effects(z, &effect_indices)
4008                    };
4009                    if !layer_primitives.is_empty() {
4010                        self.renderer
4011                            .render_primitives_overlay(target, &layer_primitives);
4012                    }
4013
4014                    // Render text for this layer (interleaved for proper z-order)
4015                    {
4016                        let mut scratch = std::mem::take(&mut self.scratch_glyphs);
4017                        scratch.clear();
4018                        if let Some(glyphs) = glyphs_by_layer.get(&z) {
4019                            scratch.extend_from_slice(glyphs);
4020                        }
4021                        if !scratch.is_empty() {
4022                            self.render_text(target, &scratch);
4023                        }
4024                        self.scratch_glyphs = scratch;
4025                    }
4026                    self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4027                }
4028            }
4029
4030            // Render SVGs as rasterized images for high-quality anti-aliasing
4031            if !svgs.is_empty() {
4032                self.render_rasterized_svgs(target, &svgs, scale_factor);
4033            }
4034
4035            // Render foreground text (inside foreground-layer elements, after everything else)
4036            if !fg_glyphs.is_empty() {
4037                self.render_text(target, &fg_glyphs);
4038            }
4039        } else {
4040            // Simple path (no glass)
4041            // Pre-generate text decorations grouped by layer for interleaved rendering
4042            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
4043
4044            let max_z = batch.max_z_layer();
4045            let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
4046            let max_decoration_z = decorations_by_layer.keys().cloned().max().unwrap_or(0);
4047            let max_layer = max_z.max(max_text_z).max(max_decoration_z);
4048            let has_layer_effects = batch.has_layer_effects();
4049
4050            if max_layer > 0 && !has_layer_effects {
4051                // Interleaved z-layer rendering for proper Stack z-ordering
4052                // Group images by z_index for interleaved rendering
4053                let mut images_by_layer: std::collections::BTreeMap<u32, Vec<&ImageElement>> =
4054                    std::collections::BTreeMap::new();
4055                for img in &images {
4056                    images_by_layer.entry(img.z_index).or_default().push(img);
4057                }
4058                let max_image_z = images_by_layer.keys().cloned().max().unwrap_or(0);
4059                let max_layer = max_layer.max(max_image_z);
4060
4061                // First pass: render z_layer=0 primitives with clear
4062                let z0_primitives = batch.primitives_for_layer(0);
4063                // Create a temporary batch for z=0 (include paths - they don't have z-layer support)
4064                let mut z0_batch = PrimitiveBatch::new();
4065                z0_batch.primitives = z0_primitives;
4066                z0_batch.paths = batch.paths.clone();
4067                self.renderer
4068                    .render_with_clear(target, &z0_batch, [0.0, 0.0, 0.0, 1.0]);
4069
4070                // Render paths with MSAA for smooth edges on curved shapes like notch
4071                if use_msaa_overlay && z0_batch.has_paths() {
4072                    self.renderer
4073                        .render_paths_overlay_msaa(target, &z0_batch, self.sample_count);
4074                }
4075
4076                // Render z=0 images
4077                if let Some(z0_images) = images_by_layer.get(&0) {
4078                    self.render_images_ref(target, z0_images);
4079                }
4080
4081                // Render z=0 text (must render before z=1 primitives for proper z-ordering)
4082                if let Some(glyphs) = glyphs_by_layer.get(&0) {
4083                    if !glyphs.is_empty() {
4084                        self.render_text(target, glyphs);
4085                    }
4086                }
4087                self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
4088
4089                // Render subsequent layers interleaved (primitives, images, text per layer)
4090                for z in 1..=max_layer {
4091                    // Render primitives for this layer
4092                    let layer_primitives = batch.primitives_for_layer(z);
4093                    if !layer_primitives.is_empty() {
4094                        self.renderer
4095                            .render_primitives_overlay(target, &layer_primitives);
4096                    }
4097
4098                    // Render images for this layer
4099                    if let Some(layer_images) = images_by_layer.get(&z) {
4100                        self.render_images_ref(target, layer_images);
4101                    }
4102
4103                    // Render text for this layer (interleaved with primitives for proper z-order)
4104                    if let Some(glyphs) = glyphs_by_layer.get(&z) {
4105                        if !glyphs.is_empty() {
4106                            self.render_text(target, glyphs);
4107                        }
4108                    }
4109                    self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4110                }
4111
4112                // Render SVGs as rasterized images for high-quality anti-aliasing
4113                if !svgs.is_empty() {
4114                    self.render_rasterized_svgs(target, &svgs, scale_factor);
4115                }
4116
4117                // Render foreground primitives (e.g. borders on top)
4118                if !batch.foreground_primitives.is_empty() {
4119                    self.renderer
4120                        .render_primitives_overlay(target, &batch.foreground_primitives);
4121                }
4122
4123                // Render foreground text (inside foreground-layer elements, after foreground primitives)
4124                if !fg_glyphs.is_empty() {
4125                    self.render_text(target, &fg_glyphs);
4126                }
4127            } else {
4128                // Fast path: render full batch (handles layer effects like backdrop-filter)
4129                self.renderer
4130                    .render_with_clear(target, &batch, [0.0, 0.0, 0.0, 1.0]);
4131
4132                // Render paths with MSAA for smooth edges on curved shapes like notch
4133                if use_msaa_overlay && batch.has_paths() {
4134                    self.renderer
4135                        .render_paths_overlay_msaa(target, &batch, self.sample_count);
4136                }
4137
4138                self.render_images(target, &images, width as f32, height as f32, scale_factor);
4139
4140                // Render foreground primitives (e.g. borders on top)
4141                if !batch.foreground_primitives.is_empty() {
4142                    self.renderer
4143                        .render_primitives_overlay(target, &batch.foreground_primitives);
4144                }
4145
4146                // Render SVGs as rasterized images for high-quality anti-aliasing
4147                if !svgs.is_empty() {
4148                    self.render_rasterized_svgs(target, &svgs, scale_factor);
4149                }
4150
4151                // Interleaved z-layer rendering for proper text z-ordering
4152                // Render z=0 text before any z>0 primitive overlays
4153                if let Some(glyphs) = glyphs_by_layer.get(&0) {
4154                    if !glyphs.is_empty() {
4155                        self.render_text(target, glyphs);
4156                    }
4157                }
4158                self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
4159
4160                if max_layer > 0 {
4161                    let effect_indices = batch.effect_layer_indices();
4162                    for z in 1..=max_layer {
4163                        // Render primitives for this z-layer
4164                        let layer_primitives = if effect_indices.is_empty() {
4165                            batch.primitives_for_layer(z)
4166                        } else {
4167                            batch.primitives_for_layer_excluding_effects(z, &effect_indices)
4168                        };
4169                        if !layer_primitives.is_empty() {
4170                            self.renderer
4171                                .render_primitives_overlay(target, &layer_primitives);
4172                        }
4173
4174                        // Render text for this z-layer (interleaved for proper z-order)
4175                        if let Some(glyphs) = glyphs_by_layer.get(&z) {
4176                            if !glyphs.is_empty() {
4177                                self.render_text(target, glyphs);
4178                            }
4179                        }
4180                        self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4181                    }
4182                }
4183
4184                // Render foreground text (inside foreground-layer elements, after all z-layers)
4185                if !fg_glyphs.is_empty() {
4186                    self.render_text(target, &fg_glyphs);
4187                }
4188            }
4189        }
4190
4191        // Render 3D-layer text/SVGs/images: for each 3D layer group, render to an
4192        // offscreen texture and blit with the same perspective transform as the parent.
4193        for layer_id in &layer_3d_ids {
4194            if let Some((info, layer_texts)) = layer_3d_texts.get(layer_id) {
4195                let layer_svgs_vec = layer_3d_svgs.get(layer_id);
4196                let layer_images_vec = layer_3d_images.get(layer_id);
4197                self.render_3d_layer_elements(
4198                    target,
4199                    info,
4200                    layer_texts,
4201                    layer_svgs_vec.map(|v| v.as_slice()).unwrap_or(&[]),
4202                    layer_images_vec.map(|v| v.as_slice()).unwrap_or(&[]),
4203                    scale_factor,
4204                );
4205            }
4206        }
4207
4208        // Render @flow shader elements on top of their SDF base
4209        self.has_active_flows = !flow_elements.is_empty();
4210        if !flow_elements.is_empty() {
4211            let stylesheet = tree.stylesheet();
4212
4213            // Use monotonic time for smooth animation
4214            static START_TIME: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
4215            let start = START_TIME.get_or_init(std::time::Instant::now);
4216            let elapsed_secs = start.elapsed().as_secs_f32();
4217
4218            for flow_el in &flow_elements {
4219                // Resolve FlowGraph: direct graph first, then stylesheet lookup
4220                let graph = flow_el
4221                    .flow_graph
4222                    .as_deref()
4223                    .or_else(|| stylesheet.and_then(|s| s.get_flow(&flow_el.flow_name)));
4224
4225                if let Some(graph) = graph {
4226                    // Compile on first use (no-op if already cached)
4227                    if let Err(e) = self.renderer.flow_pipeline_cache().compile(graph) {
4228                        tracing::warn!("@flow '{}' compile error: {}", flow_el.flow_name, e);
4229                        continue;
4230                    }
4231
4232                    let uniforms = blinc_gpu::FlowUniformData {
4233                        viewport_size: [width as f32, height as f32],
4234                        time: elapsed_secs,
4235                        frame_index: 0.0, // TODO: track frame counter
4236                        element_bounds: [flow_el.x, flow_el.y, flow_el.width, flow_el.height],
4237                        pointer: [
4238                            (self.cursor_pos[0] - flow_el.x) / flow_el.width.max(1.0),
4239                            (self.cursor_pos[1] - flow_el.y) / flow_el.height.max(1.0),
4240                        ],
4241                        corner_radius: flow_el.corner_radius,
4242                        _padding: 0.0,
4243                    };
4244
4245                    let viewport = [flow_el.x, flow_el.y, flow_el.width, flow_el.height];
4246                    if !self.renderer.render_flow(
4247                        target,
4248                        &flow_el.flow_name,
4249                        &uniforms,
4250                        Some(viewport),
4251                    ) {
4252                        tracing::warn!("@flow '{}' render failed", flow_el.flow_name);
4253                    }
4254                }
4255            }
4256        }
4257
4258        // Poll the device to free completed command buffers
4259        self.renderer.poll();
4260
4261        // Render overlays from RenderState
4262        self.render_overlays(render_state, width, height, target);
4263
4264        // Render debug visualization if enabled (BLINC_DEBUG=text|layout|all)
4265        let debug = DebugMode::from_env();
4266        if debug.text {
4267            self.render_text_debug(target, &texts);
4268        }
4269        if debug.layout {
4270            let scale = tree.scale_factor();
4271            self.render_layout_debug(target, tree, scale);
4272        }
4273        if debug.motion {
4274            self.render_motion_debug(target, tree, width, height);
4275        }
4276
4277        // Return scratch buffers for reuse on next frame
4278        self.return_scratch_elements(texts, svgs, images);
4279
4280        // Periodic cache stats (every ~5s at 60fps)
4281        self.log_cache_stats();
4282
4283        Ok(())
4284    }
4285
4286    /// Render 3D-layer text/SVGs/images to an offscreen texture and blit with perspective.
4287    ///
4288    /// Elements inside a parent with `perspective` + `rotate-x`/`rotate-y` need to be
4289    /// rendered to a temporary offscreen texture and then blitted with the same perspective
4290    /// transform so they visually tilt with their parent's 3D transform.
4291    fn render_3d_layer_elements(
4292        &mut self,
4293        target: &wgpu::TextureView,
4294        info: &Transform3DLayerInfo,
4295        texts: &[TextElement],
4296        svgs: &[SvgElement],
4297        images: &[ImageElement],
4298        scale_factor: f32,
4299    ) {
4300        let [lx, ly, lw, lh] = info.layer_bounds;
4301        if lw <= 0.0 || lh <= 0.0 {
4302            return;
4303        }
4304
4305        let tex_w = (lw.ceil() as u32).max(1);
4306        let tex_h = (lh.ceil() as u32).max(1);
4307
4308        // Acquire offscreen texture
4309        let layer_tex = self.renderer.acquire_layer_texture((tex_w, tex_h), false);
4310        self.renderer
4311            .clear_target(&layer_tex.view, wgpu::Color::TRANSPARENT);
4312
4313        // Set viewport to offscreen texture size
4314        self.renderer.set_viewport_override((tex_w, tex_h));
4315
4316        // Render offset text glyphs
4317        if !texts.is_empty() {
4318            let mut layer_glyphs: Vec<GpuGlyph> = Vec::new();
4319            for text in texts {
4320                let alignment = match text.align {
4321                    TextAlign::Left => TextAlignment::Left,
4322                    TextAlign::Center => TextAlignment::Center,
4323                    TextAlign::Right => TextAlignment::Right,
4324                };
4325
4326                let color = if text.motion_opacity < 1.0 {
4327                    [
4328                        text.color[0],
4329                        text.color[1],
4330                        text.color[2],
4331                        text.color[3] * text.motion_opacity,
4332                    ]
4333                } else {
4334                    text.color
4335                };
4336
4337                let effective_width = if let Some(clip) = text.clip_bounds {
4338                    clip[2].min(text.width)
4339                } else {
4340                    text.width
4341                };
4342                let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
4343                let wrap_width = Some(text.width);
4344                let font_name = text.font_family.name.as_deref();
4345                let generic = to_gpu_generic_font(text.font_family.generic);
4346                let font_weight = text.weight.weight();
4347
4348                let (anchor, y_pos, use_layout_height) = match text.v_align {
4349                    TextVerticalAlign::Center => {
4350                        (TextAnchor::Center, text.y + text.height / 2.0, false)
4351                    }
4352                    TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
4353                    TextVerticalAlign::Baseline => {
4354                        let baseline_y = text.y + text.ascender;
4355                        (TextAnchor::Baseline, baseline_y, false)
4356                    }
4357                };
4358                let layout_height = if use_layout_height {
4359                    Some(text.height)
4360                } else {
4361                    None
4362                };
4363
4364                if let Ok(mut glyphs) = self.text_ctx.prepare_text_with_style(
4365                    &text.content,
4366                    text.x - lx,
4367                    y_pos - ly,
4368                    text.font_size,
4369                    color,
4370                    anchor,
4371                    alignment,
4372                    wrap_width,
4373                    needs_wrap,
4374                    font_name,
4375                    generic,
4376                    font_weight,
4377                    text.italic,
4378                    layout_height,
4379                    text.letter_spacing,
4380                ) {
4381                    // Offset clip bounds to layer-local coords
4382                    if let Some(clip) = text.clip_bounds {
4383                        for glyph in &mut glyphs {
4384                            glyph.clip_bounds = [clip[0] - lx, clip[1] - ly, clip[2], clip[3]];
4385                        }
4386                    }
4387                    layer_glyphs.extend(glyphs);
4388                }
4389            }
4390
4391            if !layer_glyphs.is_empty() {
4392                self.render_text(&layer_tex.view, &layer_glyphs);
4393            }
4394        }
4395
4396        // Render offset images (mutate in place — we own these from partition)
4397        if !images.is_empty() {
4398            let mut offset_images = images.to_vec();
4399            for img in &mut offset_images {
4400                img.x -= lx;
4401                img.y -= ly;
4402                if let Some(ref mut clip) = img.clip_bounds {
4403                    clip[0] -= lx;
4404                    clip[1] -= ly;
4405                }
4406                if let Some(ref mut scroll) = img.scroll_clip {
4407                    scroll[0] -= lx;
4408                    scroll[1] -= ly;
4409                }
4410            }
4411            self.render_images(&layer_tex.view, &offset_images, lw, lh, scale_factor);
4412        }
4413
4414        // Render offset SVGs (mutate in place — we own these from partition)
4415        if !svgs.is_empty() {
4416            let mut offset_svgs = svgs.to_vec();
4417            for svg in &mut offset_svgs {
4418                svg.x -= lx;
4419                svg.y -= ly;
4420                if let Some(ref mut clip) = svg.clip_bounds {
4421                    clip[0] -= lx;
4422                    clip[1] -= ly;
4423                }
4424            }
4425            self.render_rasterized_svgs(&layer_tex.view, &offset_svgs, scale_factor);
4426        }
4427
4428        // Restore viewport
4429        self.renderer.restore_viewport();
4430
4431        // Blit with perspective transform
4432        self.renderer.blit_tight_texture_to_target(
4433            &layer_tex.view,
4434            (tex_w, tex_h),
4435            target,
4436            (lx, ly),
4437            (lw, lh),
4438            info.opacity,
4439            blinc_core::BlendMode::Normal,
4440            None,
4441            Some(info.transform_3d),
4442        );
4443
4444        self.renderer.release_layer_texture(layer_tex);
4445    }
4446
4447    /// Render a tree on top of existing content (no clear)
4448    ///
4449    /// This is used for overlay trees (modals, toasts, dialogs) that render
4450    /// on top of the main UI without clearing it.
4451    pub fn render_overlay_tree_with_motion(
4452        &mut self,
4453        tree: &RenderTree,
4454        render_state: &blinc_layout::RenderState,
4455        width: u32,
4456        height: u32,
4457        target: &wgpu::TextureView,
4458    ) -> Result<()> {
4459        // Get scale factor for HiDPI rendering
4460        let scale_factor = tree.scale_factor();
4461
4462        // Create a single paint context for all layers with text rendering support
4463        let mut ctx =
4464            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
4465
4466        // Render with motion animations applied (all layers to same context)
4467        tree.render_with_motion(&mut ctx, render_state);
4468
4469        // Take the batch (mutable so CSS-transformed text primitives can be added)
4470        let mut batch = ctx.take_batch();
4471
4472        // Collect text, SVG, image, and flow elements WITH motion state
4473        let (texts, svgs, images, _flows) =
4474            self.collect_render_elements_with_state(tree, Some(render_state));
4475
4476        // Pre-load all images into cache before rendering
4477        self.preload_images(&images, width as f32, height as f32);
4478
4479        // Prepare text glyphs with z_layer information
4480        let mut glyphs_by_layer: std::collections::BTreeMap<u32, Vec<GpuGlyph>> =
4481            std::collections::BTreeMap::new();
4482        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
4483        for text in &texts {
4484            let alignment = match text.align {
4485                TextAlign::Left => TextAlignment::Left,
4486                TextAlign::Center => TextAlignment::Center,
4487                TextAlign::Right => TextAlignment::Right,
4488            };
4489
4490            // Apply motion opacity to text color
4491            let color = if text.motion_opacity < 1.0 {
4492                [
4493                    text.color[0],
4494                    text.color[1],
4495                    text.color[2],
4496                    text.color[3] * text.motion_opacity,
4497                ]
4498            } else {
4499                text.color
4500            };
4501
4502            // Determine wrap width
4503            let effective_width = if let Some(clip) = text.clip_bounds {
4504                clip[2].min(text.width)
4505            } else {
4506                text.width
4507            };
4508
4509            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
4510            let wrap_width = Some(text.width);
4511            let font_name = text.font_family.name.as_deref();
4512            let generic = to_gpu_generic_font(text.font_family.generic);
4513            let font_weight = text.weight.weight();
4514
4515            let (anchor, y_pos, use_layout_height) = match text.v_align {
4516                TextVerticalAlign::Center => {
4517                    (TextAnchor::Center, text.y + text.height / 2.0, false)
4518                }
4519                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
4520                TextVerticalAlign::Baseline => {
4521                    let baseline_y = text.y + text.ascender;
4522                    (TextAnchor::Baseline, baseline_y, false)
4523                }
4524            };
4525            let layout_height = if use_layout_height {
4526                Some(text.height)
4527            } else {
4528                None
4529            };
4530
4531            if let Ok(glyphs) = self.text_ctx.prepare_text_with_style(
4532                &text.content,
4533                text.x,
4534                y_pos,
4535                text.font_size,
4536                color,
4537                anchor,
4538                alignment,
4539                wrap_width,
4540                needs_wrap,
4541                font_name,
4542                generic,
4543                font_weight,
4544                text.italic,
4545                layout_height,
4546                text.letter_spacing,
4547            ) {
4548                let mut glyphs = glyphs;
4549                if let Some(clip) = text.clip_bounds {
4550                    for glyph in &mut glyphs {
4551                        glyph.clip_bounds = clip;
4552                    }
4553                }
4554
4555                if let Some(affine) = text.css_affine {
4556                    // CSS-transformed text: convert to SDF primitives with local_affine
4557                    // Pushed into fg_batch.primitives to render in the main SDF pass
4558                    let [a, b, c, d, tx, ty] = affine;
4559                    let tx_scaled = tx * scale_factor;
4560                    let ty_scaled = ty * scale_factor;
4561                    for glyph in &glyphs {
4562                        let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
4563                        let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
4564                        let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
4565                        let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
4566                        let mut prim = GpuPrimitive::from_glyph(glyph);
4567                        prim.bounds = [
4568                            new_gc_x - glyph.bounds[2] / 2.0,
4569                            new_gc_y - glyph.bounds[3] / 2.0,
4570                            glyph.bounds[2],
4571                            glyph.bounds[3],
4572                        ];
4573                        prim.local_affine = [a, b, c, d];
4574                        prim.set_z_layer(text.z_index);
4575                        css_transformed_text_prims.push(prim);
4576                    }
4577                } else {
4578                    glyphs_by_layer
4579                        .entry(text.z_index)
4580                        .or_default()
4581                        .extend(glyphs);
4582                }
4583            }
4584        }
4585
4586        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
4587        // They will be rendered later via render_rasterized_svgs
4588
4589        self.renderer.resize(width, height);
4590
4591        // If we have CSS-transformed text, push text prims into the main batch
4592        // and bind the real glyph atlas to the SDF pipeline.
4593        if !css_transformed_text_prims.is_empty() {
4594            if let (Some(atlas), Some(color_atlas)) =
4595                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
4596            {
4597                batch.primitives.append(&mut css_transformed_text_prims);
4598                self.renderer.set_glyph_atlas(atlas, color_atlas);
4599            }
4600        }
4601
4602        // For overlay rendering, we DON'T have glass effects (overlays are simple)
4603        // Render primitives without clearing (LoadOp::Load)
4604        let max_z = batch.max_z_layer();
4605        let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
4606        let max_layer = max_z.max(max_text_z);
4607
4608        tracing::trace!(
4609            "render_overlay_tree: {} primitives, {} text layers, max_layer={}",
4610            batch.primitives.len(),
4611            glyphs_by_layer.len(),
4612            max_layer
4613        );
4614
4615        // Render all layers using overlay mode (no clear)
4616        for z in 0..=max_layer {
4617            let layer_primitives = batch.primitives_for_layer(z);
4618            if !layer_primitives.is_empty() {
4619                tracing::trace!(
4620                    "render_overlay_tree: rendering {} primitives at z={}",
4621                    layer_primitives.len(),
4622                    z
4623                );
4624                self.renderer
4625                    .render_primitives_overlay(target, &layer_primitives);
4626            }
4627
4628            if let Some(glyphs) = glyphs_by_layer.get(&z) {
4629                if !glyphs.is_empty() {
4630                    tracing::trace!(
4631                        "render_overlay_tree: rendering {} glyphs at z={}",
4632                        glyphs.len(),
4633                        z
4634                    );
4635                    self.render_text(target, glyphs);
4636                }
4637            }
4638        }
4639
4640        // Images render on top
4641        self.render_images(target, &images, width as f32, height as f32, scale_factor);
4642
4643        // Render foreground primitives (e.g. borders on top)
4644        if !batch.foreground_primitives.is_empty() {
4645            self.renderer
4646                .render_primitives_overlay(target, &batch.foreground_primitives);
4647        }
4648
4649        // Poll the device to free completed command buffers
4650        self.renderer.poll();
4651
4652        // Render layout debug for overlay tree if enabled
4653        let debug = DebugMode::from_env();
4654        if debug.layout {
4655            let scale = tree.scale_factor();
4656            self.render_layout_debug(target, tree, scale);
4657        }
4658        if debug.motion {
4659            self.render_motion_debug(target, tree, width, height);
4660        }
4661
4662        // Return scratch buffers for reuse on next frame
4663        self.return_scratch_elements(texts, svgs, images);
4664
4665        Ok(())
4666    }
4667
4668    /// Render overlays from RenderState (cursors, selections, focus rings)
4669    fn render_overlays(
4670        &mut self,
4671        render_state: &blinc_layout::RenderState,
4672        width: u32,
4673        height: u32,
4674        target: &wgpu::TextureView,
4675    ) {
4676        let overlays = render_state.overlays();
4677        if overlays.is_empty() {
4678            return;
4679        }
4680
4681        // Create a paint context for overlays
4682        let mut overlay_ctx = GpuPaintContext::new(width as f32, height as f32);
4683
4684        for overlay in overlays {
4685            match overlay {
4686                Overlay::Cursor {
4687                    position,
4688                    size,
4689                    color,
4690                    opacity,
4691                } => {
4692                    if *opacity > 0.0 {
4693                        // Apply opacity to cursor color
4694                        let cursor_color =
4695                            Color::rgba(color.r, color.g, color.b, color.a * opacity);
4696                        overlay_ctx.execute_command(&DrawCommand::FillRect {
4697                            rect: Rect::new(position.0, position.1, size.0, size.1),
4698                            corner_radius: CornerRadius::default(),
4699                            brush: Brush::Solid(cursor_color),
4700                        });
4701                    }
4702                }
4703                Overlay::Selection { rects: _, color: _ } => {
4704                    // TODO: Re-enable for real-time text selection
4705                    // Disabled for now to avoid blue mask issue after modal close
4706                }
4707                Overlay::FocusRing {
4708                    position,
4709                    size,
4710                    radius,
4711                    color,
4712                    thickness,
4713                } => {
4714                    overlay_ctx.execute_command(&DrawCommand::StrokeRect {
4715                        rect: Rect::new(position.0, position.1, size.0, size.1),
4716                        corner_radius: CornerRadius::uniform(*radius),
4717                        stroke: Stroke::new(*thickness),
4718                        brush: Brush::Solid(*color),
4719                    });
4720                }
4721            }
4722        }
4723
4724        // Render overlays as an overlay pass (on top of existing content)
4725        let overlay_batch = overlay_ctx.take_batch();
4726        if !overlay_batch.is_empty() {
4727            self.renderer.render_overlay(target, &overlay_batch);
4728        }
4729    }
4730}
4731
4732/// Convert layout's GenericFont to GPU's GenericFont
4733fn to_gpu_generic_font(generic: GenericFont) -> GpuGenericFont {
4734    match generic {
4735        GenericFont::System => GpuGenericFont::System,
4736        GenericFont::Monospace => GpuGenericFont::Monospace,
4737        GenericFont::Serif => GpuGenericFont::Serif,
4738        GenericFont::SansSerif => GpuGenericFont::SansSerif,
4739    }
4740}
4741
4742/// Debug mode flags for visual debugging
4743///
4744/// Set environment variable `BLINC_DEBUG` to enable debug visualization:
4745/// - `text`: Show text bounding boxes and baselines
4746/// - `layout`: Show all element bounding boxes (useful for debugging hit-testing)
4747/// - `motion`: Show active animation stats overlay
4748/// - `all` or `1` or `true`: Show all debug visualizations
4749#[derive(Clone, Copy)]
4750pub struct DebugMode {
4751    /// Show text bounding boxes and baseline indicators
4752    pub text: bool,
4753    /// Show all element bounding boxes
4754    pub layout: bool,
4755    /// Show motion/animation debug info
4756    pub motion: bool,
4757}
4758
4759impl DebugMode {
4760    /// Check environment variable and return debug mode configuration
4761    pub fn from_env() -> Self {
4762        let debug_value = std::env::var("BLINC_DEBUG")
4763            .map(|v| v.to_lowercase())
4764            .unwrap_or_default();
4765
4766        let all = debug_value == "all" || debug_value == "1" || debug_value == "true";
4767        let text = all || debug_value == "text";
4768        let layout = all || debug_value == "layout";
4769        let motion = all || debug_value == "motion";
4770
4771        Self {
4772            text,
4773            layout,
4774            motion,
4775        }
4776    }
4777
4778    /// Check if any debug mode is enabled
4779    pub fn any_enabled(&self) -> bool {
4780        self.text || self.layout || self.motion
4781    }
4782}
4783
4784/// Generate text decoration primitives (strikethrough and underline) grouped by z-layer
4785///
4786/// Creates decoration lines for text elements that have:
4787/// - strikethrough: horizontal line through the middle of the text
4788/// - underline: horizontal line below the text baseline
4789///
4790/// Returns a HashMap of z_index -> primitives for interleaved rendering with text
4791fn generate_text_decoration_primitives_by_layer(
4792    texts: &[TextElement],
4793) -> std::collections::HashMap<u32, Vec<GpuPrimitive>> {
4794    let mut primitives_by_layer: std::collections::HashMap<u32, Vec<GpuPrimitive>> =
4795        std::collections::HashMap::new();
4796
4797    for text in texts {
4798        if !text.strikethrough && !text.underline {
4799            continue;
4800        }
4801
4802        // Calculate text width for decorations
4803        let decoration_width = if text.wrap && text.measured_width > text.width {
4804            text.width
4805        } else {
4806            text.measured_width.min(text.width)
4807        };
4808
4809        // Skip if there's no meaningful width
4810        if decoration_width <= 0.0 {
4811            continue;
4812        }
4813
4814        // Line thickness: use CSS text-decoration-thickness if set, else scale with font size
4815        let line_thickness = text
4816            .decoration_thickness
4817            .unwrap_or_else(|| (text.font_size / 14.0).clamp(1.0, 3.0));
4818
4819        // Decoration color: use CSS text-decoration-color if set, else use text color
4820        let dec_color = text.decoration_color.unwrap_or(text.color);
4821
4822        let layer_primitives = primitives_by_layer.entry(text.z_index).or_default();
4823
4824        // Calculate the actual baseline Y position based on vertical alignment
4825        // This must match the text rendering logic to position decorations correctly
4826        //
4827        // glyph_extent = ascender - descender (where descender is negative)
4828        // Typical descender is about -20% of ascender, so glyph_extent ≈ ascender * 1.2
4829        let descender_approx = -text.ascender * 0.2;
4830        let glyph_extent = text.ascender - descender_approx;
4831
4832        let baseline_y = match text.v_align {
4833            TextVerticalAlign::Center => {
4834                // GPU: y_pos = text.y + text.height / 2.0, then y_offset = y_pos - glyph_extent / 2.0
4835                // Glyph top is at: text.y + text.height/2 - glyph_extent/2
4836                // Baseline is at: glyph_top + ascender
4837                let glyph_top = text.y + text.height / 2.0 - glyph_extent / 2.0;
4838                glyph_top + text.ascender
4839            }
4840            TextVerticalAlign::Top => {
4841                // GPU: y_pos = text.y, y_offset = y + (layout_height - glyph_extent) / 2.0
4842                // Glyph top is at: text.y + (text.height - glyph_extent) / 2.0
4843                // Baseline is at: glyph_top + ascender
4844                let glyph_top = text.y + (text.height - glyph_extent) / 2.0;
4845                glyph_top + text.ascender
4846            }
4847            TextVerticalAlign::Baseline => {
4848                // GPU: y_pos = text.y + ascender, y_offset = y_pos - ascender = text.y
4849                // Glyph top is at: text.y
4850                // Baseline is at: text.y + ascender
4851                text.y + text.ascender
4852            }
4853        };
4854
4855        // Strikethrough: draw line through the center of lowercase letters (x-height center)
4856        if text.strikethrough {
4857            // x-height is typically ~50% of ascender, center of x-height is ~25% above baseline
4858            let strikethrough_y = baseline_y - text.ascender * 0.35;
4859            let mut strike_rect = GpuPrimitive::rect(
4860                text.x,
4861                strikethrough_y - line_thickness / 2.0,
4862                decoration_width,
4863                line_thickness,
4864            )
4865            .with_color(dec_color[0], dec_color[1], dec_color[2], dec_color[3]);
4866
4867            // Apply clip bounds from text element if present
4868            if let Some(clip) = text.clip_bounds {
4869                strike_rect = strike_rect.with_clip_rect(clip[0], clip[1], clip[2], clip[3]);
4870            }
4871            layer_primitives.push(strike_rect);
4872        }
4873
4874        // Underline: draw line just below the baseline (at text bottom)
4875        if text.underline {
4876            // Underline position: just below baseline, snapping to text bottom
4877            let underline_y = baseline_y + text.ascender * 0.05;
4878            let mut underline_rect = GpuPrimitive::rect(
4879                text.x,
4880                underline_y - line_thickness / 2.0,
4881                decoration_width,
4882                line_thickness,
4883            )
4884            .with_color(dec_color[0], dec_color[1], dec_color[2], dec_color[3]);
4885
4886            // Apply clip bounds from text element if present
4887            if let Some(clip) = text.clip_bounds {
4888                underline_rect = underline_rect.with_clip_rect(clip[0], clip[1], clip[2], clip[3]);
4889            }
4890            layer_primitives.push(underline_rect);
4891        }
4892    }
4893
4894    primitives_by_layer
4895}
4896
4897/// Generate debug primitives for text elements
4898///
4899/// Creates visual overlays showing:
4900/// - Bounding box outline (cyan)
4901/// - Baseline position (magenta line)
4902/// - Ascender line (green, at top of bounding box)
4903/// - Descender line (yellow, at bottom of bounding box)
4904fn generate_text_debug_primitives(texts: &[TextElement]) -> Vec<GpuPrimitive> {
4905    let mut primitives = Vec::new();
4906
4907    for text in texts {
4908        // Determine the actual text width for debug visualization:
4909        // - For non-wrapped text: use measured_width (actual rendered text width)
4910        // - For wrapped text: use layout width (container constrains the text)
4911        let debug_width = if text.wrap && text.measured_width > text.width {
4912            // Text is wrapping - use container width
4913            text.width
4914        } else {
4915            // Single line - use actual measured width (clamped to layout width)
4916            text.measured_width.min(text.width)
4917        };
4918
4919        // Bounding box outline (cyan, semi-transparent)
4920        let bbox = GpuPrimitive::rect(text.x, text.y, debug_width, text.height)
4921            .with_color(0.0, 0.0, 0.0, 0.0) // Transparent fill
4922            .with_border(1.0, 0.0, 1.0, 1.0, 0.7); // Cyan border
4923        primitives.push(bbox);
4924
4925        // Baseline indicator (magenta horizontal line)
4926        // The baseline is at y + ascender
4927        let baseline_y = text.y + text.ascender;
4928        let baseline = GpuPrimitive::rect(text.x, baseline_y - 0.5, debug_width, 1.0)
4929            .with_color(1.0, 0.0, 1.0, 0.6); // Magenta
4930        primitives.push(baseline);
4931
4932        // Ascender line indicator (green, at top of text)
4933        // For v_baseline texts, this shows where the ascender sits
4934        let ascender_line = GpuPrimitive::rect(text.x, text.y - 0.5, debug_width, 1.0)
4935            .with_color(0.0, 1.0, 0.0, 0.4); // Green, more transparent
4936        primitives.push(ascender_line);
4937
4938        // Descender line (yellow, at bottom of bounding box)
4939        let descender_y = text.y + text.height;
4940        let descender_line = GpuPrimitive::rect(text.x, descender_y - 0.5, debug_width, 1.0)
4941            .with_color(1.0, 1.0, 0.0, 0.4); // Yellow
4942        primitives.push(descender_line);
4943    }
4944
4945    primitives
4946}
4947
4948/// Collect all element bounds from the render tree for debug visualization
4949fn collect_debug_bounds(tree: &RenderTree, scale: f32) -> Vec<DebugBoundsElement> {
4950    let mut bounds = Vec::new();
4951
4952    if let Some(root) = tree.root() {
4953        collect_debug_bounds_recursive(tree, root, (0.0, 0.0), 0, scale, &mut bounds);
4954    }
4955
4956    bounds
4957}
4958
4959/// Recursively collect bounds from all nodes
4960fn collect_debug_bounds_recursive(
4961    tree: &RenderTree,
4962    node: LayoutNodeId,
4963    parent_offset: (f32, f32),
4964    depth: u32,
4965    scale: f32,
4966    bounds: &mut Vec<DebugBoundsElement>,
4967) {
4968    use blinc_layout::renderer::ElementType;
4969
4970    let Some(node_bounds) = tree.layout().get_bounds(node, parent_offset) else {
4971        return;
4972    };
4973
4974    // Determine element type name
4975    let element_type = tree
4976        .get_render_node(node)
4977        .map(|n| match &n.element_type {
4978            ElementType::Div => "Div".to_string(),
4979            ElementType::Text(_) => "Text".to_string(),
4980            ElementType::StyledText(_) => "StyledText".to_string(),
4981            ElementType::Image(_) => "Image".to_string(),
4982            ElementType::Svg(_) => "Svg".to_string(),
4983            ElementType::Canvas(_) => "Canvas".to_string(),
4984        })
4985        .unwrap_or_else(|| "Unknown".to_string());
4986
4987    // Add this element's bounds (with DPI scaling)
4988    bounds.push(DebugBoundsElement {
4989        x: node_bounds.x * scale,
4990        y: node_bounds.y * scale,
4991        width: node_bounds.width * scale,
4992        height: node_bounds.height * scale,
4993        element_type,
4994        depth,
4995    });
4996
4997    // Get scroll offset for this node (scroll containers offset their children)
4998    let scroll_offset = tree.get_scroll_offset(node);
4999
5000    // Calculate new offset for children (including scroll offset)
5001    let new_offset = (
5002        node_bounds.x + scroll_offset.0,
5003        node_bounds.y + scroll_offset.1,
5004    );
5005
5006    // Recurse into children
5007    for child in tree.layout().children(node) {
5008        collect_debug_bounds_recursive(tree, child, new_offset, depth + 1, scale, bounds);
5009    }
5010}
5011
5012/// Generate debug primitives for layout element bounds
5013///
5014/// Creates visual overlays showing:
5015/// - Colored outlines for each element's bounding box
5016/// - Colors cycle based on tree depth (red, green, blue, yellow, cyan, magenta)
5017fn generate_layout_debug_primitives(bounds: &[DebugBoundsElement]) -> Vec<GpuPrimitive> {
5018    let mut primitives = Vec::new();
5019
5020    // Color palette for different depths (cycling)
5021    let colors: [(f32, f32, f32); 6] = [
5022        (1.0, 0.3, 0.3), // Red
5023        (0.3, 1.0, 0.3), // Green
5024        (0.3, 0.3, 1.0), // Blue
5025        (1.0, 1.0, 0.3), // Yellow
5026        (0.3, 1.0, 1.0), // Cyan
5027        (1.0, 0.3, 1.0), // Magenta
5028    ];
5029
5030    for elem in bounds {
5031        // Skip very small elements (likely invisible)
5032        if elem.width < 1.0 || elem.height < 1.0 {
5033            continue;
5034        }
5035
5036        let (r, g, b) = colors[(elem.depth as usize) % colors.len()];
5037        let alpha = 0.5; // Semi-transparent outline
5038
5039        // Draw outline only (transparent fill with colored border)
5040        let rect = GpuPrimitive::rect(elem.x, elem.y, elem.width, elem.height)
5041            .with_color(0.0, 0.0, 0.0, 0.0) // Transparent fill
5042            .with_border(1.0, r, g, b, alpha); // Colored border
5043
5044        primitives.push(rect);
5045    }
5046
5047    primitives
5048}
5049
5050/// Scale and translate a path for SVG rendering with tint
5051fn scale_and_translate_path(
5052    path: &blinc_core::Path,
5053    x: f32,
5054    y: f32,
5055    scale: f32,
5056) -> blinc_core::Path {
5057    use blinc_core::{PathCommand, Point, Vec2};
5058
5059    if scale == 1.0 && x == 0.0 && y == 0.0 {
5060        return path.clone();
5061    }
5062
5063    let transform_point = |p: Point| -> Point { Point::new(p.x * scale + x, p.y * scale + y) };
5064
5065    let new_commands: Vec<PathCommand> = path
5066        .commands()
5067        .iter()
5068        .map(|cmd| match cmd {
5069            PathCommand::MoveTo(p) => PathCommand::MoveTo(transform_point(*p)),
5070            PathCommand::LineTo(p) => PathCommand::LineTo(transform_point(*p)),
5071            PathCommand::QuadTo { control, end } => PathCommand::QuadTo {
5072                control: transform_point(*control),
5073                end: transform_point(*end),
5074            },
5075            PathCommand::CubicTo {
5076                control1,
5077                control2,
5078                end,
5079            } => PathCommand::CubicTo {
5080                control1: transform_point(*control1),
5081                control2: transform_point(*control2),
5082                end: transform_point(*end),
5083            },
5084            PathCommand::ArcTo {
5085                radii,
5086                rotation,
5087                large_arc,
5088                sweep,
5089                end,
5090            } => PathCommand::ArcTo {
5091                radii: Vec2::new(radii.x * scale, radii.y * scale),
5092                rotation: *rotation,
5093                large_arc: *large_arc,
5094                sweep: *sweep,
5095                end: transform_point(*end),
5096            },
5097            PathCommand::Close => PathCommand::Close,
5098        })
5099        .collect();
5100
5101    blinc_core::Path::from_commands(new_commands)
5102}