mirror of
https://github.com/penpot/penpot.git
synced 2026-09-02 01:59:17 +00:00
⚡ Reuse cached Skia paragraphs when painting text
Add try_paint_from_layout_cache to paint from TextContent.layout when versions match, skipping ParagraphBuilder rebuild and layout on each frame. Wire into the layered text path for plain fills without strokes or effects.
This commit is contained in:
parent
cbd5c1344a
commit
91fbb3fed8
@ -19,6 +19,16 @@
|
||||
|
||||
- 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).
|
||||
- Plain text fill paint reuses `TextContent.layout` paragraphs when
|
||||
`has_usable_paint_layout` (paragraphs present + version match; during
|
||||
interactive transforms rotation/move skips width check via
|
||||
`modifier_changes_text_layout`, resize falls back to `layout_width` vs
|
||||
`get_width(selrect.width())`), via `text::try_paint_from_layout_cache`.
|
||||
The walker computes `text_layout_cache_rotation_only` from `tree` and
|
||||
passes it into `render_shape`; stroke/shadow paths pass `false`.
|
||||
- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones
|
||||
(rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only.
|
||||
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.
|
||||
|
||||
@ -29,8 +29,8 @@ pub use surfaces::{SurfaceId, Surfaces};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::math;
|
||||
use crate::shapes::{
|
||||
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
|
||||
Stroke, StrokeKind, TextContent, Type,
|
||||
all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners,
|
||||
Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, Type,
|
||||
};
|
||||
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
|
||||
use crate::tiles::{self, PendingTiles, TileRect};
|
||||
@ -291,6 +291,12 @@ impl FocusMode {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_layout_cache_rotation_only(tree: ShapesPoolRef, shape: &Shape) -> bool {
|
||||
tree.get_raw(&shape.id)
|
||||
.zip(tree.get_layout_modifier(&shape.id))
|
||||
.is_some_and(|(base, modifier)| !modifier_changes_text_layout(base, &modifier))
|
||||
}
|
||||
|
||||
/*
|
||||
* Sort by z_index descending (higher z renders on top).
|
||||
* The sort is stable so if the values are equal the index for the children
|
||||
@ -1376,6 +1382,7 @@ impl RenderState {
|
||||
parent_shadows: Option<Vec<skia_safe::Paint>>,
|
||||
outset: Option<f32>,
|
||||
target_surface: SurfaceId,
|
||||
text_layout_cache_rotation_only: bool,
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "stats")]
|
||||
self.stats.count(shape.id);
|
||||
@ -1614,141 +1621,38 @@ 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
|
||||
text_layout_cache_rotation_only,
|
||||
)?)
|
||||
{
|
||||
let rebound_text_content =
|
||||
stored_text_content.paint_content_for_selrect(shape.selrect());
|
||||
let text_content = rebound_text_content.as_ref();
|
||||
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 +1660,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 +1673,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 +1693,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 +1704,7 @@ impl RenderState {
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
@ -1874,7 +1716,7 @@ impl RenderState {
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
None,
|
||||
*layer_opacity,
|
||||
@ -1892,41 +1734,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| {
|
||||
@ -2995,6 +3015,7 @@ impl RenderState {
|
||||
None,
|
||||
None,
|
||||
target_surface,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
|
||||
@ -3146,6 +3167,7 @@ impl RenderState {
|
||||
Some(vec![new_shadow_paint.clone()]),
|
||||
None,
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
@ -3250,6 +3272,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3293,6 +3316,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3356,6 +3380,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3813,6 +3838,9 @@ impl RenderState {
|
||||
Cow::Borrowed(element)
|
||||
};
|
||||
|
||||
let text_layout_cache_rotation_only = self.options.is_interactive_transform()
|
||||
&& text_layout_cache_rotation_only(tree, element);
|
||||
|
||||
self.render_shape(
|
||||
&element_for_inline,
|
||||
clip_bounds.clone(),
|
||||
@ -3825,6 +3853,7 @@ impl RenderState {
|
||||
None,
|
||||
None,
|
||||
target_surface,
|
||||
text_layout_cache_rotation_only,
|
||||
)?;
|
||||
|
||||
self.surfaces
|
||||
|
||||
@ -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,141 @@ 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>,
|
||||
layout_cache_rotation_only: bool,
|
||||
) -> Result<bool> {
|
||||
let text_content = shape.get_text_content();
|
||||
let cache_usable = if layout_cache_rotation_only {
|
||||
text_content.layout_cache_versions_match()
|
||||
} else {
|
||||
text_content.has_usable_paint_layout(shape)
|
||||
};
|
||||
if !cache_usable {
|
||||
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,
|
||||
@ -391,8 +526,6 @@ fn render_text_on_canvas(
|
||||
if blur.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// Lays out and paints paragraph builders without any layer management.
|
||||
@ -739,6 +872,8 @@ pub fn render_emoji_overlay(
|
||||
if blur.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
fn draw_text(
|
||||
|
||||
@ -21,19 +21,57 @@ 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;
|
||||
use crate::shapes::{self, merge_fills, Shape, VerticalAlign};
|
||||
use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign};
|
||||
use crate::utils::{get_fallback_fonts, get_font_collection};
|
||||
use crate::Uuid;
|
||||
|
||||
// TODO: maybe move this to the wasm module?
|
||||
pub type ParagraphBuilderGroup = Vec<ParagraphBuilder>;
|
||||
|
||||
/// True when the modifier changes the text layout container (resize), as opposed
|
||||
/// to rotation/move where glyph layout can be reused.
|
||||
pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool {
|
||||
let Type::Text(text_content) = &base.shape_type else {
|
||||
return false;
|
||||
};
|
||||
let before = oriented_container_bounds(base);
|
||||
let after = before.transform(modifier);
|
||||
match text_content.grow_type() {
|
||||
GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()),
|
||||
GrowType::AutoHeight | GrowType::Fixed => {
|
||||
!crate::math::is_close_to(before.width(), after.width())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn oriented_container_bounds(shape: &Shape) -> Bounds {
|
||||
let selrect = shape.selrect();
|
||||
let mut bounds = Bounds::new(
|
||||
Point::new(selrect.x(), selrect.y()),
|
||||
Point::new(selrect.x() + selrect.width(), selrect.y()),
|
||||
Point::new(
|
||||
selrect.x() + selrect.width(),
|
||||
selrect.y() + selrect.height(),
|
||||
),
|
||||
Point::new(selrect.x(), selrect.y() + selrect.height()),
|
||||
);
|
||||
if !shape.transform.is_identity() {
|
||||
let mut matrix = shape.transform;
|
||||
let center = shape.center();
|
||||
matrix.post_translate(center);
|
||||
matrix.pre_translate(-center);
|
||||
bounds.transform_mut(&matrix);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
|
||||
pub enum GrowType {
|
||||
@ -399,6 +437,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);
|
||||
}
|
||||
@ -880,6 +932,41 @@ impl TextContent {
|
||||
self.layout.needs_update()
|
||||
}
|
||||
|
||||
/// True when cached Skia paragraphs can be painted as-is (no rebuild/layout).
|
||||
pub fn has_usable_paint_layout(&self, shape: &Shape) -> bool {
|
||||
if self.layout.needs_update() || self.layout_version != self.content_version {
|
||||
return false;
|
||||
}
|
||||
self.layout_matches_paint_container(shape)
|
||||
}
|
||||
|
||||
pub(crate) fn layout_cache_versions_match(&self) -> bool {
|
||||
!self.layout.needs_update() && self.layout_version == self.content_version
|
||||
}
|
||||
|
||||
pub(crate) fn layout_matches_paint_container(&self, shape: &Shape) -> bool {
|
||||
if self.grow_type() == GrowType::AutoWidth {
|
||||
return true;
|
||||
}
|
||||
let Some(layout_w) = self.layout_width else {
|
||||
return false;
|
||||
};
|
||||
let container_w = self.get_width(shape.selrect().width());
|
||||
(layout_w - container_w).abs() < f32::EPSILON
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@ -1881,6 +1968,115 @@ mod tests {
|
||||
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);
|
||||
let shape = Shape::new(Uuid::nil());
|
||||
assert!(!content.has_usable_paint_layout(&shape));
|
||||
}
|
||||
|
||||
#[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_width = Some(200.0);
|
||||
content.layout_version = 1;
|
||||
content.content_version = 2;
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_selrect(0.0, 0.0, 200.0, 100.0);
|
||||
assert!(!content.has_usable_paint_layout(&shape));
|
||||
}
|
||||
|
||||
#[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_width = Some(200.0);
|
||||
content.layout_version = 3;
|
||||
content.content_version = 3;
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_selrect(0.0, 0.0, 200.0, 100.0);
|
||||
assert!(content.has_usable_paint_layout(&shape));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_usable_paint_layout_false_when_selrect_width_changed() {
|
||||
let mut content = sample_text_content();
|
||||
content.layout.paragraphs = Rc::new(vec![vec![]]);
|
||||
content.layout_width = Some(200.0);
|
||||
content.layout_version = 3;
|
||||
content.content_version = 3;
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_selrect(0.0, 0.0, 300.0, 100.0);
|
||||
assert!(!content.has_usable_paint_layout(&shape));
|
||||
}
|
||||
|
||||
fn text_shape_with_cached_layout(content: TextContent) -> Shape {
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_shape_type(shapes::Type::Text(content));
|
||||
shape.set_selrect(0.0, 0.0, 200.0, 100.0);
|
||||
shape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_usable_paint_layout_false_when_rotated_and_resized() {
|
||||
let mut content = sample_text_content();
|
||||
content.layout.paragraphs = Rc::new(vec![vec![]]);
|
||||
content.layout_width = Some(200.0);
|
||||
content.layout_version = 3;
|
||||
content.content_version = 3;
|
||||
let base_shape = text_shape_with_cached_layout(content);
|
||||
let rotate = Matrix::rotate_deg(45.0);
|
||||
let resize = Matrix::scale((1.5, 1.0));
|
||||
let mut modifier = rotate;
|
||||
modifier.pre_concat(&resize);
|
||||
assert!(modifier_changes_text_layout(&base_shape, &modifier));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@ -153,6 +153,15 @@ impl ShapesPoolImpl {
|
||||
self.modifiers.get(&idx)
|
||||
}
|
||||
|
||||
/// Modifier applied to `id`, including one inherited from an ancestor.
|
||||
pub fn get_layout_modifier(&self, id: &Uuid) -> Option<skia::Matrix> {
|
||||
if let Some(matrix) = self.get_modifier(id) {
|
||||
return Some(*matrix);
|
||||
}
|
||||
let idx = *self.uuid_to_idx.get(id)?;
|
||||
self.find_nearest_ancestor_modifier(idx)
|
||||
}
|
||||
|
||||
/// Get a shape by UUID without applying modifiers/structure/scale-content.
|
||||
pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> {
|
||||
let idx = *self.uuid_to_idx.get(id)?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user