From 885bb710cfd896dfcbaa4ee7681cded342b7fea8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 26 Aug 2026 10:17:49 +0200 Subject: [PATCH] WIP --- .../render-wasm/ffi-rendering-subtleties.md | 10 +- render-wasm/src/render.rs | 515 +++++++++--------- render-wasm/src/render/text.rs | 131 ++++- render-wasm/src/shapes/text.rs | 45 +- render-wasm/src/state/text_editor.rs | 9 +- render-wasm/src/wasm/text_editor.rs | 9 +- 6 files changed, 454 insertions(+), 265 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index d6fcd46720..630f6846b7 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -23,7 +23,13 @@ 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. +- Plain text fill paint reuses `TextContent.layout` paragraphs when + `has_usable_paint_layout` (paragraphs present + version match; ignores AABB width), via + `text::try_paint_from_layout_cache`. Stroke/shadow builders must not use that cache. +- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones + (rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only. + Rotation expands selrect AABB without invalidating glyph layout. Decorations are + skipped when no span requests underline/strike. - 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. @@ -34,4 +40,4 @@ - Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. - Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. - Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + - blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. \ No newline at end of file + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 43ce836ce7..0aa78dcfb5 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1483,29 +1483,37 @@ impl RenderState { if let Type::Text(stored_text_content) = &shape.shape_type { self.tile_atlas_flushed = true; - 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( + if !text::try_paint_from_layout_cache( Some(self), None, shape, - &mut paragraph_builders, Some(target_surface), - None, - None, - None, - None, - )?; + )? { + 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, + &mut paragraph_builders, + Some(target_surface), + None, + None, + None, + None, + )?; + } } else { fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; @@ -1643,141 +1651,46 @@ impl RenderState { s.canvas().concat(&matrix); }); - // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. - 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 count_inner_strokes = shape.count_visible_inner_strokes(); - // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. - let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); - if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( + // Plain fill (no strokes / parent shadows): reuse cached layout + // paragraphs when valid. Skip builder rebuild + Skia layout. + let can_use_layout_cache = !shape.has_visible_strokes() + && parent_shadows.is_none() + && (skip_effects + || (shape.blur.is_none() + && !shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale(self.get_scale())) + && shape.inner_shadow_paints().is_empty())); + if !(can_use_layout_cache + && text::try_paint_from_layout_cache( Some(self), None, &shape, - &mut paragraph_builders, Some(fills_surface_id), - None, - None, - text_fill_inset, - None, - )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } - } else { - let shape_scale = self.get_scale(); - let mut drop_shadows = if skip_drop_shadows { - Vec::new() + )?) + { + // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. + 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 { - shape - .drop_shadows_visible() - .filter(|s| s.is_perceptible_at_scale(shape_scale)) - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - paint.set_image_filter(shadow.get_drop_shadow_filter()); - paint - }) - .collect() + Some(stored_text_content.new_bounds(selrect)) }; - - if !skip_drop_shadows { - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape + let text_content: &TextContent = + rebound_text_content.as_ref().unwrap_or(stored_text_content); + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -1785,60 +1698,12 @@ impl RenderState { text_content, stroke, &shape.selrect(), - Some(true), + None, ) }) .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !skip_drop_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - } - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1846,25 +1711,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1880,7 +1731,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1891,7 +1742,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1903,7 +1754,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -1921,41 +1772,219 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip(); + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_non_ascii() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } - } + } // end layout-cache miss fallback } _ => { self.surfaces.apply_mut(surface_ids, |s| { diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 2b4cd373a9..5f56564d04 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -4,7 +4,7 @@ use crate::{ math::Rect, shapes::{ add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - ParagraphLayout, Stroke, StrokeKind, TextContent, + ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -318,6 +318,135 @@ pub fn render_overlay_emoji( ) } +/// Paint fill glyphs from `TextContent.layout` when the cache is valid. +/// +/// Avoids rebuilding ParagraphBuilders and re-running Skia layout on every +/// paint. Only safe for the plain fill pass (no stroke/shadow-specific builders). +/// Returns `true` when painting was done from cache. +pub fn try_paint_from_layout_cache( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + surface_id: Option, +) -> Result { + let text_content = shape.get_text_content(); + if !text_content.has_usable_paint_layout(shape.selrect()) { + return Ok(false); + } + + if let Some(render_state) = render_state { + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + if let Some(canvas) = canvas { + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + Ok(false) +} + +fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { + let selrect = shape.selrect(); + let x = selrect.x(); + let base_y = selrect.y(); + let paragraphs = &text_content.layout.paragraphs; + let draw_decorations = text_content.has_text_decorations(); + + let total_text_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + let vertical_offset = match shape.vertical_align() { + VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0, + VerticalAlign::Bottom => selrect.height() - total_text_height, + _ => 0.0, + }; + + let mut y_accum = base_y + vertical_offset; + for group in paragraphs.iter() { + let Some(paragraph) = group.first() else { + continue; + }; + paragraph.paint(canvas, (x, y_accum)); + if draw_decorations { + paint_decorations_for_paragraph(canvas, paragraph, x, y_accum); + } + y_accum += paragraph.height(); + } +} + +fn paint_decorations_for_paragraph( + canvas: &Canvas, + paragraph: &skia::textlayout::Paragraph, + x: f32, + y_accum: f32, +) { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&style_metrics, line_baseline); + for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { + let text_style = &style_metric.text_style; + let style_end = style_metrics + .get(i + 1) + .map(|(next_i, _)| *next_i) + .unwrap_or(line.end_index); + let seg_start = (*style_start).max(line.start_index); + let seg_end = style_end.min(line.end_index); + if seg_start >= seg_end { + continue; + } + let rects = paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (segment_width, actual_x_offset) = if !rects.is_empty() { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + let skia_x_offset = rects + .first() + .map(|r| r.rect.left - line.left as f32) + .unwrap_or(0.0); + (total_width, skia_x_offset) + } else { + (0.0, 0.0) + }; + let text_left = x + line.left as f32 + actual_x_offset; + let text_width = segment_width; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + draw_text_decorations( + canvas, + text_style, + Some(underline_y.unwrap_or(line_baseline)), + max_underline_thickness, + text_left, + text_width, + ); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + draw_text_decorations( + canvas, + text_style, + Some(strike_y.unwrap_or(line_baseline)), + max_strike_thickness, + text_left, + text_width, + ); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 3bc29e96fd..40802275ef 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -23,6 +23,7 @@ use skia_safe::{ use std::cell::Cell; use std::collections::HashSet; +use std::rc::Rc; use super::FontFamily; use crate::math::Point; @@ -196,7 +197,9 @@ struct CachedExtrect { #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, - pub paragraphs: Vec>, + /// Shared across shape clones (e.g. modifier transforms) so rotation/pan + /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. + pub paragraphs: Rc>>, cached_extrect: Cell>, } @@ -210,8 +213,8 @@ impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], - cached_extrect: Cell::new(None), + paragraphs: Rc::clone(&self.paragraphs), + cached_extrect: Cell::new(self.cached_extrect.get()), } } } @@ -226,7 +229,7 @@ impl TextContentLayout { pub fn new() -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], + paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), } } @@ -237,12 +240,18 @@ impl TextContentLayout { paragraphs: Vec>, ) { self.paragraph_builders = paragraph_builders; - self.paragraphs = paragraphs; + self.paragraphs = Rc::new(paragraphs); + self.cached_extrect.set(None); + } + + pub fn clear(&mut self) { + self.paragraph_builders.clear(); + self.paragraphs = Rc::new(Vec::new()); self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { - self.paragraph_builders.is_empty() || self.paragraphs.is_empty() + self.paragraphs.is_empty() } } @@ -471,7 +480,7 @@ impl TextContent { let mut has_lines = false; let mut y_accum = base_y + vertical_offset; - for group in paragraphs { + for group in paragraphs.iter() { if let Some(paragraph) = group.first() { let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { @@ -871,6 +880,28 @@ impl TextContent { self.layout.needs_update() } + /// True when cached Skia paragraphs can be painted as-is (no rebuild/layout). + /// + /// Does **not** require `layout_width == selrect.width()`: modifier rotation + /// expands the AABB selrect without changing glyph layout. Width matching + /// stays in [`Self::update_layout`] for real resize/reflow. + /// Layout paragraphs are `Rc`-shared across modifier clones. + pub fn has_usable_paint_layout(&self, _selrect: Rect) -> bool { + !self.layout.needs_update() && self.layout_version == self.content_version + } + + /// True when any span requests underline/overline/line-through (custom draw path). + pub fn has_text_decorations(&self) -> bool { + self.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + matches!( + span.text_decoration, + Some(d) if d != skia::textlayout::TextDecoration::NO_DECORATION + ) + }) + }) + } + pub fn set_layout_from_result( &mut self, result: TextContentLayoutResult, diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index ecb792f775..0c14c69ae0 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -796,8 +796,7 @@ impl TextEditorState { } } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -822,8 +821,7 @@ impl TextEditorState { self.selection.set_caret(clamped); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -844,8 +842,7 @@ impl TextEditorState { self.selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 7f0e75465f..995722895b 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -396,8 +396,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -448,8 +447,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { let cursor = get_text_editor_state().selection.focus; text_helpers::insert_text_with_newlines(text_content, &cursor, &text); - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -517,8 +515,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(TextEditorEvent::ContentChanged);