Paint plain text onto Current and reuse layout cache

Paint stroke-free SrcOver text directly onto Current, skipping the
Fills/Strokes blit and empty save_layers. Reuse cached Skia paragraphs
during interactive transforms via Rc-shared layout and
try_paint_from_layout_cache.

AI-assisted-by: composer
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alejandro Alonso 2026-09-01 11:28:11 +02:00
parent 89a17df403
commit 88ca581317
6 changed files with 582 additions and 270 deletions

View File

@ -19,6 +19,17 @@
- 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.
- 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.

View File

@ -1436,6 +1436,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()
@ -1446,11 +1457,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 {
@ -1479,21 +1486,48 @@ 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,
if !text::try_paint_from_layout_cache(
Some(self),
None,
shape,
&visible_strokes,
Some(target_surface),
antialias,
outset,
)?;
)? {
let text_content =
stored_text_content.paint_content_for_selrect(shape.selrect());
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)?;
// 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| {
@ -1614,141 +1648,36 @@ 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<StrokeKind> =
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()
} 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()
};
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 =
stored_text_content.paint_content_for_selrect(shape.selrect());
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<StrokeKind> =
shape.visible_strokes().rev().map(|s| s.kind).collect();
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
.visible_strokes()
.rev()
.map(|stroke| {
@ -1756,60 +1685,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,
@ -1817,25 +1698,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())
@ -1851,7 +1718,7 @@ impl RenderState {
stroke_paragraphs,
&mut fill_builders,
Some(strokes_surface_id),
blur_filter.as_ref(),
None,
text_stroke_blur_outset,
*layer_opacity,
)?;
@ -1862,7 +1729,7 @@ impl RenderState {
&shape,
stroke_paragraphs,
Some(strokes_surface_id),
blur_filter.as_ref(),
None,
text_stroke_blur_outset,
*layer_opacity,
)?;
@ -1874,7 +1741,7 @@ impl RenderState {
stroke_paragraphs,
Some(strokes_surface_id),
None,
blur_filter.as_ref(),
None,
text_stroke_blur_outset,
None,
*layer_opacity,
@ -1892,41 +1759,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| {

View File

@ -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<SurfaceId>,
) -> Result<bool> {
let text_content = shape.get_text_content();
if !text_content.has_usable_paint_layout() {
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,
@ -331,6 +460,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 +522,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 +877,22 @@ fn draw_text(
layer_opacity: Option<f32>,
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.

View File

@ -21,8 +21,10 @@ use skia_safe::{
Contains,
};
use std::borrow::Cow;
use std::cell::Cell;
use std::collections::HashSet;
use std::rc::Rc;
use super::FontFamily;
use crate::math::Point;
@ -196,7 +198,9 @@ struct CachedExtrect {
#[derive(Debug)]
pub struct TextContentLayout {
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
/// 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<Vec<Vec<skia::textlayout::Paragraph>>>,
cached_extrect: Cell<Option<CachedExtrect>>,
}
@ -210,8 +214,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 +230,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 +241,18 @@ impl TextContentLayout {
paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
) {
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()
}
}
@ -390,6 +400,20 @@ impl TextContent {
self.bounds
}
/// Text content for paint when [`Rect`] size may differ from stored bounds
/// (e.g. modifier transform). Reuses `self` when width/height match; otherwise
/// clones paragraphs into a rebound copy with an empty layout cache.
pub fn paint_content_for_selrect<'a>(&'a self, selrect: Rect) -> Cow<'a, Self> {
let stored_bounds = self.bounds();
if (stored_bounds.width() - selrect.width()).abs() < 0.01
&& (stored_bounds.height() - selrect.height()).abs() < 0.01
{
Cow::Borrowed(self)
} else {
Cow::Owned(self.new_bounds(selrect))
}
}
pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.bounds = Rect::from_xywh(x, y, w, h);
}
@ -471,7 +495,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 +895,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) -> 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,
@ -1871,4 +1917,89 @@ mod tests {
assert_eq!(para.char_utf16_len_at(1), 2);
assert_eq!(para.char_utf16_len_at(2), 1);
}
fn sample_text_content() -> TextContent {
let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0);
let mut content = TextContent::new(bounds, GrowType::Fixed);
content.add_paragraph(test_paragraph(&["hello"]));
content
}
#[test]
fn has_usable_paint_layout_false_when_paragraphs_empty() {
let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 50.0), GrowType::Fixed);
assert!(!content.has_usable_paint_layout());
}
#[test]
fn has_usable_paint_layout_false_when_versions_mismatch() {
let mut content = sample_text_content();
content.layout.paragraphs = Rc::new(vec![vec![]]);
content.layout_version = 1;
content.content_version = 2;
assert!(!content.has_usable_paint_layout());
}
#[test]
fn has_usable_paint_layout_true_when_cached_and_versions_match() {
let mut content = sample_text_content();
content.layout.paragraphs = Rc::new(vec![vec![]]);
content.layout_version = 3;
content.content_version = 3;
assert!(content.has_usable_paint_layout());
}
#[test]
fn has_text_decorations_detects_underline() {
let mut content = sample_text_content();
content.paragraphs_mut()[0].children_mut()[0].text_decoration =
Some(skia::textlayout::TextDecoration::UNDERLINE);
assert!(content.has_text_decorations());
}
#[test]
fn has_text_decorations_false_for_plain_text() {
let content = sample_text_content();
assert!(!content.has_text_decorations());
}
#[test]
fn paint_content_for_selrect_borrows_when_bounds_match() {
let content = sample_text_content();
let selrect = Rect::from_xywh(10.0, 20.0, 200.0, 100.0);
match content.paint_content_for_selrect(selrect) {
Cow::Borrowed(_) => {}
Cow::Owned(_) => panic!("expected borrowed content"),
}
}
#[test]
fn paint_content_for_selrect_rebounds_when_size_differs() {
let content = sample_text_content();
let selrect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0);
match content.paint_content_for_selrect(selrect) {
Cow::Owned(rebound) => {
assert_eq!(rebound.bounds().width(), 300.0);
assert!(rebound.layout.needs_update());
}
Cow::Borrowed(_) => panic!("expected rebound content"),
}
}
#[test]
fn layout_clone_shares_skia_paragraphs() {
let mut layout = TextContentLayout::new();
layout.paragraphs = Rc::new(vec![vec![]]);
let cloned = layout.clone();
assert!(Rc::ptr_eq(&layout.paragraphs, &cloned.paragraphs));
assert!(cloned.paragraph_builders.is_empty());
}
#[test]
fn layout_clear_empties_paragraphs() {
let mut layout = TextContentLayout::new();
layout.paragraphs = Rc::new(vec![vec![]]);
layout.clear();
assert!(layout.needs_update());
}
}

View File

@ -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);

View File

@ -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);