mirror of
https://github.com/penpot/penpot.git
synced 2026-09-03 18:49:45 +00:00
⚡ Paint plain text directly onto Current (#11355)
* ♻️ Share text layout paragraphs across modifier clones Store Skia paragraphs in Rc so TextContentLayout::clone keeps the cached layout for rotate/pan modifiers. Add layout.clear() and treat needs_update as paragraphs-empty only. * ⚡ 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. * ⚡ Paint plain text directly onto Current Extend can_render_directly for stroke-free text and skip the empty save_layer in draw_text when no stroke-group opacity is set. Plain text paints into Current without the Fills/Strokes blit.
This commit is contained in:
parent
cb80ea98ba
commit
c5897bc50a
@ -19,6 +19,20 @@
|
||||
|
||||
- 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; 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
|
||||
@ -1345,6 +1351,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);
|
||||
@ -1405,6 +1412,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()
|
||||
@ -1415,11 +1433,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 {
|
||||
@ -1448,21 +1462,50 @@ 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,
|
||||
)?;
|
||||
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 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| {
|
||||
@ -1583,141 +1626,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| {
|
||||
@ -1725,60 +1665,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,
|
||||
@ -1786,25 +1678,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())
|
||||
@ -1820,7 +1698,7 @@ impl RenderState {
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
@ -1831,7 +1709,7 @@ impl RenderState {
|
||||
&shape,
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
*layer_opacity,
|
||||
)?;
|
||||
@ -1843,7 +1721,7 @@ impl RenderState {
|
||||
stroke_paragraphs,
|
||||
Some(strokes_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
text_stroke_blur_outset,
|
||||
None,
|
||||
*layer_opacity,
|
||||
@ -1861,41 +1739,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| {
|
||||
@ -2964,6 +3020,7 @@ impl RenderState {
|
||||
None,
|
||||
None,
|
||||
target_surface,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
|
||||
@ -3115,6 +3172,7 @@ impl RenderState {
|
||||
Some(vec![new_shadow_paint.clone()]),
|
||||
None,
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
@ -3219,6 +3277,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3262,6 +3321,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3325,6 +3385,7 @@ impl RenderState {
|
||||
None,
|
||||
Some(shadow.spread),
|
||||
target_surface,
|
||||
false,
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -3782,6 +3843,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(),
|
||||
@ -3794,6 +3858,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,
|
||||
@ -331,6 +466,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 +528,6 @@ fn render_text_on_canvas(
|
||||
if blur.is_some() {
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
|
||||
@ -766,20 +901,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.
|
||||
|
||||
@ -21,18 +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 {
|
||||
@ -196,7 +235,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 +251,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 +267,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 +278,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 +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);
|
||||
}
|
||||
@ -471,7 +532,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 {
|
||||
@ -892,6 +953,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,
|
||||
@ -1918,4 +2014,130 @@ 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);
|
||||
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();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user