diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index aac0201522..d6fcd46720 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,11 @@ - Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and + for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles + live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans). + `draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct. +- Text with strokes / shadows / blur stays on the layered Fills/Strokes path. - Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is compose+present only. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f435bf6697..43ce836ce7 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1430,6 +1430,17 @@ impl RenderState { // Stroke-only (fills_none) can go direct: empty fills are a no-op and // strokes paint into Current. Large files need mid-walk GPU drains so // release builds do not backlog a huge ops buffer in one Partial. + // + // Plain text (no strokes / effects) also paints into Current: span styles + // live in Skia Paragraph TextStyles, so multi-style text is fine. + // Text skips the nested_fills guard because fills are on spans, not + // shape.fills. Strokes stay layered (masking needs save_layers). + let is_direct_geometry = matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) + ) && !(shape.fills.is_empty() && has_nested_fills); + let is_direct_text = + matches!(shape.shape_type, Type::Text(_)) && !shape.has_visible_strokes(); let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1440,11 +1451,7 @@ impl RenderState { && shape.background_blur.is_none() && !has_inherited_blur && !shadows_need_layered - && matches!( - shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) - ) - && !(shape.fills.is_empty() && has_nested_fills) + && (is_direct_geometry || is_direct_text) && target_surface != SurfaceId::Export; if can_render_directly { @@ -1473,21 +1480,49 @@ impl RenderState { }); } - fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + if let Type::Text(stored_text_content) = &shape.shape_type { + self.tile_atlas_flushed = true; - // Clipped frames draw strokes in render_shape_exit over children. - let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; - if !skip_strokes { - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, + let selrect = shape.selrect(); + let stored_bounds = stored_text_content.bounds(); + let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01 + && (stored_bounds.height() - selrect.height()).abs() < 0.01; + let rebound_text_content = if bounds_match { + None + } else { + Some(stored_text_content.new_bounds(selrect)) + }; + let text_content: &TextContent = + rebound_text_content.as_ref().unwrap_or(stored_text_content); + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + text::render( + Some(self), + None, shape, - &visible_strokes, + &mut paragraph_builders, Some(target_surface), - antialias, - outset, + None, + None, + None, + None, )?; + } else { + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } } self.surfaces.apply_mut(target_surface as u32, |s| { diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index e027aebd21..2b4cd373a9 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -331,6 +331,8 @@ fn render_text_on_canvas( ) { let layer_bounds = shape.layer_bounds(); + // Layer stack is managed here (blur / shadow / inset). `draw_text` is + // self-contained and only opens a layer when stroke-group opacity needs it. if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); @@ -391,8 +393,6 @@ fn render_text_on_canvas( if blur.is_some() { canvas.restore(); } - - canvas.restore(); } /// Lays out and paints paragraph builders without any layer management. @@ -748,20 +748,22 @@ fn draw_text( layer_opacity: Option, overlay_emoji: bool, ) { - let layer_bounds = shape.layer_bounds(); - + // Multi-style spans are already encoded in each ParagraphBuilder's + // TextStyles; paragraph.paint handles them without an isolation layer. + // Only open a save_layer when stroke-group opacity must composite as one. if let Some(opacity) = layer_opacity { + let layer_bounds = shape.layer_bounds(); let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); let layer_rec = SaveLayerRec::default() .bounds(&layer_bounds) .paint(&opacity_paint); canvas.save_layer(&layer_rec); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); + canvas.restore(); } else { - canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } - - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders a text stroke masked to the glyph shape.