mirror of
https://github.com/penpot/penpot.git
synced 2026-09-29 15:26:19 +00:00
This makes RTL texts in auto-width to grow towards their left side in the text editor v3. AI-assisted-by: claude-opus-5
2582 lines
86 KiB
Rust
2582 lines
86 KiB
Rust
use crate::render::text::decoration_segments;
|
|
use crate::{
|
|
math::{Bounds, Matrix, Rect},
|
|
render::{default_font, DEFAULT_EMOJI_FONT},
|
|
utils::Browser,
|
|
};
|
|
|
|
use core::f32;
|
|
use macros::ToJs;
|
|
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
|
use skia_safe::{
|
|
self as skia,
|
|
paint::{self, Paint},
|
|
textlayout::Affinity,
|
|
textlayout::ParagraphBuilder,
|
|
textlayout::ParagraphStyle,
|
|
textlayout::PlaceholderAlignment,
|
|
textlayout::PlaceholderStyle,
|
|
textlayout::PositionWithAffinity,
|
|
textlayout::TextBaseline,
|
|
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, 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 {
|
|
Fixed = 0,
|
|
AutoWidth = 1,
|
|
AutoHeight = 2,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Copy, Clone)]
|
|
pub struct TextContentSize {
|
|
pub width: f32,
|
|
pub height: f32,
|
|
pub max_width: f32,
|
|
pub normalized_line_height: f32,
|
|
}
|
|
|
|
const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01;
|
|
|
|
/// Matches `marginRight: "1px"` on `.paragraph-set` in the HTML text renderer
|
|
/// (`frontend/src/app/main/ui/shapes/text/styles.cljs`). DOM `getBoundingClientRect`
|
|
/// includes that margin in auto-width measurements; Skia `longest_line()` does not.
|
|
const PARAGRAPH_SET_MARGIN_RIGHT: f32 = 1.0;
|
|
|
|
impl TextContentSize {
|
|
pub fn default() -> Self {
|
|
Self {
|
|
width: DEFAULT_TEXT_CONTENT_SIZE,
|
|
height: DEFAULT_TEXT_CONTENT_SIZE,
|
|
max_width: DEFAULT_TEXT_CONTENT_SIZE,
|
|
normalized_line_height: 0.0,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_size(width: f32, height: f32) -> Self {
|
|
Self {
|
|
width,
|
|
height,
|
|
max_width: DEFAULT_TEXT_CONTENT_SIZE,
|
|
normalized_line_height: 0.0,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_normalized_line_height(
|
|
width: f32,
|
|
height: f32,
|
|
max_width: f32,
|
|
normalized_line_height: f32,
|
|
) -> Self {
|
|
Self {
|
|
width,
|
|
height,
|
|
max_width,
|
|
normalized_line_height,
|
|
}
|
|
}
|
|
|
|
pub fn set_size(&mut self, width: f32, height: f32) {
|
|
self.width = width;
|
|
self.height = height;
|
|
}
|
|
|
|
pub fn copy_finite_size(
|
|
&mut self,
|
|
size: TextContentSize,
|
|
default_height: f32,
|
|
default_width: f32,
|
|
) {
|
|
if f32::is_finite(size.width) {
|
|
self.width = size.width;
|
|
} else {
|
|
self.width = default_width;
|
|
}
|
|
if f32::is_finite(size.max_width) {
|
|
self.max_width = size.max_width;
|
|
} else {
|
|
self.max_width = default_width
|
|
}
|
|
if f32::is_finite(size.height) {
|
|
self.height = size.height;
|
|
} else {
|
|
self.height = default_height;
|
|
}
|
|
if f32::is_finite(size.normalized_line_height) {
|
|
self.normalized_line_height = size.normalized_line_height;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct TextPositionWithAffinity {
|
|
pub position_with_affinity: PositionWithAffinity,
|
|
pub paragraph: usize,
|
|
pub offset: usize,
|
|
}
|
|
|
|
impl PartialEq for TextPositionWithAffinity {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.paragraph == other.paragraph && self.offset == other.offset
|
|
}
|
|
}
|
|
|
|
impl TextPositionWithAffinity {
|
|
pub fn new(
|
|
position_with_affinity: PositionWithAffinity,
|
|
paragraph: usize,
|
|
offset: usize,
|
|
) -> Self {
|
|
Self {
|
|
position_with_affinity,
|
|
paragraph,
|
|
offset,
|
|
}
|
|
}
|
|
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
position_with_affinity: PositionWithAffinity {
|
|
position: 0,
|
|
affinity: Affinity::Downstream,
|
|
},
|
|
paragraph: 0,
|
|
offset: 0,
|
|
}
|
|
}
|
|
|
|
pub fn new_downstream_affinity(paragraph: usize, offset: usize) -> Self {
|
|
Self {
|
|
position_with_affinity: PositionWithAffinity {
|
|
position: offset as i32,
|
|
affinity: Affinity::Downstream,
|
|
},
|
|
paragraph,
|
|
offset,
|
|
}
|
|
}
|
|
|
|
pub fn new_upstream_affinity(paragraph: usize, offset: usize) -> Self {
|
|
Self {
|
|
position_with_affinity: PositionWithAffinity {
|
|
position: offset as i32,
|
|
affinity: Affinity::Upstream,
|
|
},
|
|
paragraph,
|
|
offset,
|
|
}
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.position_with_affinity.position = 0;
|
|
self.position_with_affinity.affinity = Affinity::Downstream;
|
|
self.paragraph = 0;
|
|
self.offset = 0;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TextContentLayoutResult(
|
|
Vec<ParagraphBuilderGroup>,
|
|
Vec<Vec<skia::textlayout::Paragraph>>,
|
|
TextContentSize,
|
|
);
|
|
|
|
/// Cached extrect stored as offsets from the selrect origin,
|
|
/// keyed by the selrect dimensions (width, height) and vertical alignment
|
|
/// used to compute it.
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct CachedExtrect {
|
|
selrect_width: f32,
|
|
selrect_height: f32,
|
|
valign: u8,
|
|
left: f32,
|
|
top: f32,
|
|
right: f32,
|
|
bottom: f32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TextContentLayout {
|
|
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
|
|
/// 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>>,
|
|
}
|
|
|
|
impl Default for TextContentLayout {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Clone for TextContentLayout {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
paragraph_builders: vec![],
|
|
paragraphs: Rc::clone(&self.paragraphs),
|
|
cached_extrect: Cell::new(self.cached_extrect.get()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PartialEq for TextContentLayout {
|
|
fn eq(&self, _other: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
impl TextContentLayout {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
paragraph_builders: vec![],
|
|
paragraphs: Rc::new(Vec::new()),
|
|
cached_extrect: Cell::new(None),
|
|
}
|
|
}
|
|
|
|
pub fn set(
|
|
&mut self,
|
|
paragraph_builders: Vec<ParagraphBuilderGroup>,
|
|
paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
|
|
) {
|
|
self.paragraph_builders = paragraph_builders;
|
|
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.paragraphs.is_empty()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TextDecorationSegment {
|
|
#[allow(dead_code)]
|
|
pub kind: skia::textlayout::TextDecoration,
|
|
pub text_style: skia::textlayout::TextStyle,
|
|
pub y: f32,
|
|
pub thickness: f32,
|
|
pub left: f32,
|
|
pub width: f32,
|
|
}
|
|
|
|
impl TextDecorationSegment {
|
|
/// The bar to paint, centered on `y`.
|
|
pub fn rect(&self) -> Rect {
|
|
Rect::new(
|
|
self.left,
|
|
self.y - self.thickness / 2.0,
|
|
self.left + self.width,
|
|
self.y + self.thickness / 2.0,
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
|
|
match valign {
|
|
VerticalAlign::Center => (container_h - content_h) / 2.0,
|
|
VerticalAlign::Bottom => container_h - content_h,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> bool {
|
|
if y < 0.0 || y > paragraph.height() {
|
|
return false;
|
|
}
|
|
|
|
let pos = paragraph.get_glyph_position_at_coordinate((x, y));
|
|
let idx = pos.position as usize;
|
|
|
|
let rects =
|
|
paragraph.get_rects_for_range(0..idx + 1, RectHeightStyle::Tight, RectWidthStyle::Tight);
|
|
|
|
rects.iter().any(|r| r.rect.contains(&Point::new(x, y)))
|
|
}
|
|
|
|
fn paragraph_intersects<'a>(
|
|
paragraphs: impl Iterator<Item = &'a skia::textlayout::Paragraph>,
|
|
x_pos: f32,
|
|
y_pos: f32,
|
|
) -> bool {
|
|
paragraphs
|
|
.scan(0.0_f32, |height, p| {
|
|
let prev_height = *height;
|
|
*height += p.height();
|
|
Some((prev_height, p))
|
|
})
|
|
.any(|(height, p)| intersects(p, x_pos, y_pos - height))
|
|
}
|
|
|
|
// Performs a text auto layout without width limits.
|
|
// This should be the same as text_auto_layout.
|
|
pub fn build_paragraphs_from_paragraph_builders(
|
|
paragraph_builders: &mut [ParagraphBuilderGroup],
|
|
width: f32,
|
|
) -> Vec<Vec<skia::textlayout::Paragraph>> {
|
|
let paragraphs = paragraph_builders
|
|
.iter_mut()
|
|
.map(|builders| {
|
|
builders
|
|
.iter_mut()
|
|
.map(|builder| {
|
|
let mut paragraph = builder.build();
|
|
// For auto-width, always layout with infinite width first to get intrinsic width
|
|
paragraph.layout(width);
|
|
paragraph
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
paragraphs
|
|
}
|
|
|
|
/// Calculate the normalized line height from paragraph builders
|
|
pub fn calculate_normalized_line_height(
|
|
paragraph_builders: &mut [ParagraphBuilderGroup],
|
|
width: f32,
|
|
) -> f32 {
|
|
let mut normalized_line_height = 0.0;
|
|
for paragraph_builder_group in paragraph_builders.iter_mut() {
|
|
for paragraph_builder in paragraph_builder_group.iter_mut() {
|
|
let mut paragraph = paragraph_builder.build();
|
|
paragraph.layout(width);
|
|
let baseline = paragraph.ideographic_baseline();
|
|
if baseline > normalized_line_height {
|
|
normalized_line_height = baseline;
|
|
}
|
|
}
|
|
}
|
|
normalized_line_height
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TextContent {
|
|
pub paragraphs: Vec<Paragraph>,
|
|
pub bounds: Rect,
|
|
pub grow_type: GrowType,
|
|
pub size: TextContentSize,
|
|
pub layout: TextContentLayout,
|
|
content_version: u64,
|
|
layout_version: u64,
|
|
layout_width: Option<f32>,
|
|
/// Canvas origin used when absolute fill shaders (image/gradient) were baked
|
|
/// into cached Skia paragraphs. Kept across move clones so paint can
|
|
/// translate glyphs + shaders together. See `cached_layout_paint_offset`.
|
|
layout_paint_origin: Option<Point>,
|
|
}
|
|
|
|
impl PartialEq for TextContent {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.paragraphs == other.paragraphs
|
|
&& self.bounds == other.bounds
|
|
&& self.grow_type == other.grow_type
|
|
&& self.size == other.size
|
|
&& self.layout == other.layout
|
|
}
|
|
}
|
|
|
|
impl TextContent {
|
|
pub fn new(bounds: Rect, grow_type: GrowType) -> Self {
|
|
Self {
|
|
paragraphs: Vec::new(),
|
|
bounds,
|
|
grow_type,
|
|
size: TextContentSize::default(),
|
|
layout: TextContentLayout::new(),
|
|
content_version: 0,
|
|
layout_version: 0,
|
|
layout_width: None,
|
|
layout_paint_origin: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_bounds(&self, bounds: Rect) -> Self {
|
|
let paragraphs = self.paragraphs.clone();
|
|
let grow_type = self.grow_type;
|
|
Self {
|
|
paragraphs,
|
|
bounds,
|
|
grow_type,
|
|
size: TextContentSize::new_with_size(bounds.width(), bounds.height()),
|
|
layout: TextContentLayout::new(),
|
|
content_version: 0,
|
|
layout_version: 0,
|
|
layout_width: None,
|
|
layout_paint_origin: None,
|
|
}
|
|
}
|
|
|
|
pub fn bounds(&self) -> Rect {
|
|
self.bounds
|
|
}
|
|
|
|
/// Anchor used when painting from the layout cache. Absolute image/gradient
|
|
/// shaders were built in this coordinate space; paint glyphs here and apply
|
|
/// [`cached_layout_paint_offset`] on the canvas so both move together.
|
|
pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point {
|
|
self.layout_paint_origin
|
|
.unwrap_or_else(|| Point::new(self.selrect_origin_x(selrect), selrect.y()))
|
|
}
|
|
|
|
/// Canvas translation from the baked paint origin to the current selrect.
|
|
/// Zero when there is no recorded origin (fall back to painting at selrect).
|
|
pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point {
|
|
match self.layout_paint_origin {
|
|
Some(origin) => Point::new(
|
|
self.selrect_origin_x(selrect) - origin.x,
|
|
selrect.y() - origin.y,
|
|
),
|
|
None => Point::new(0.0, 0.0),
|
|
}
|
|
}
|
|
|
|
/// [`layout_origin_x`] for this content's own measured width.
|
|
fn selrect_origin_x(&self, selrect: &Rect) -> f32 {
|
|
self.layout_origin_x(selrect, self.size.width)
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// Distinct font families referenced by this content's spans, in first-seen
|
|
/// order.
|
|
pub fn font_families(&self) -> Vec<FontFamily> {
|
|
let mut seen: Vec<FontFamily> = Vec::new();
|
|
for paragraph in &self.paragraphs {
|
|
for span in paragraph.children() {
|
|
if !seen.contains(&span.font_family) {
|
|
seen.push(span.font_family);
|
|
}
|
|
}
|
|
}
|
|
seen
|
|
}
|
|
|
|
pub fn add_paragraph(&mut self, mut paragraph: Paragraph) {
|
|
let index = self.paragraphs.len() as u32;
|
|
paragraph.set_span_positions(index);
|
|
self.paragraphs.push(paragraph);
|
|
self.content_version = self.content_version.wrapping_add(1);
|
|
}
|
|
|
|
pub fn reset_span_positions(&mut self) {
|
|
for (index, paragraph) in self.paragraphs.iter_mut().enumerate() {
|
|
paragraph.set_span_positions(index as u32);
|
|
}
|
|
}
|
|
|
|
pub fn paragraphs(&self) -> &[Paragraph] {
|
|
&self.paragraphs
|
|
}
|
|
|
|
pub fn has_non_ascii(&self) -> bool {
|
|
self.paragraphs
|
|
.iter()
|
|
.flat_map(|p| p.children())
|
|
.any(|span| !span.text.is_ascii())
|
|
}
|
|
|
|
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
|
|
self.content_version = self.content_version.wrapping_add(1);
|
|
&mut self.paragraphs
|
|
}
|
|
|
|
pub fn width(&self) -> f32 {
|
|
self.size.width
|
|
}
|
|
|
|
pub fn normalized_line_height(&self) -> f32 {
|
|
self.size.normalized_line_height
|
|
}
|
|
|
|
pub fn grow_type(&self) -> GrowType {
|
|
self.grow_type
|
|
}
|
|
|
|
pub fn set_grow_type(&mut self, grow_type: GrowType) {
|
|
if self.grow_type != grow_type {
|
|
self.grow_type = grow_type;
|
|
self.content_version = self.content_version.wrapping_add(1);
|
|
}
|
|
}
|
|
|
|
/// Non-empty and every paragraph RTL. Mixed content counts as LTR, mirroring
|
|
/// `app.common.types.text/rtl-content?` on the ClojureScript side.
|
|
pub fn is_rtl(&self) -> bool {
|
|
!self.paragraphs.is_empty()
|
|
&& self
|
|
.paragraphs
|
|
.iter()
|
|
.all(|paragraph| paragraph.text_direction() == TextDirection::RTL)
|
|
}
|
|
|
|
/// Left edge of the laid-out text of `width` inside `selrect`. RTL auto-width
|
|
/// text anchors right so the box extends leftward as it grows.
|
|
pub fn layout_origin_x(&self, selrect: &Rect, width: f32) -> f32 {
|
|
if self.grow_type() == GrowType::AutoWidth && self.is_rtl() {
|
|
selrect.right() - width
|
|
} else {
|
|
selrect.x()
|
|
}
|
|
}
|
|
|
|
/// Compute a tight text rect from laid-out Skia paragraphs using glyph
|
|
/// metrics (fm.top for overshoot, line descent for bottom, line left/width
|
|
/// for horizontal extent).
|
|
fn rect_from_paragraphs(&self, selrect: &Rect, valign: VerticalAlign) -> Option<Rect> {
|
|
let paragraphs = &self.layout.paragraphs;
|
|
let x = selrect.x();
|
|
let base_y = selrect.y();
|
|
|
|
let total_height: f32 = paragraphs
|
|
.iter()
|
|
.filter_map(|group| group.first())
|
|
.map(|p| p.height())
|
|
.sum();
|
|
|
|
let vertical_offset = vertical_align_offset(selrect.height(), total_height, valign);
|
|
|
|
let mut min_x = f32::MAX;
|
|
let mut min_y = f32::MAX;
|
|
let mut max_x = f32::MIN;
|
|
let mut max_y = f32::MIN;
|
|
let mut has_lines = false;
|
|
let mut y_accum = base_y + vertical_offset;
|
|
|
|
for group in paragraphs.iter() {
|
|
if let Some(paragraph) = group.first() {
|
|
let line_metrics = paragraph.get_line_metrics();
|
|
for line in &line_metrics {
|
|
let line_baseline = y_accum + line.baseline as f32;
|
|
|
|
// Use per-glyph fm.top for tighter vertical bounds when
|
|
// available; fall back to line-level ascent for empty lines
|
|
// (where get_style_metrics returns nothing).
|
|
let style_metrics = line.get_style_metrics(line.start_index..line.end_index);
|
|
if style_metrics.is_empty() {
|
|
min_y = min_y.min(line_baseline - line.ascent as f32);
|
|
} else {
|
|
for (_start, style_metric) in &style_metrics {
|
|
let fm = &style_metric.font_metrics;
|
|
min_y = min_y.min(line_baseline + fm.top);
|
|
}
|
|
}
|
|
|
|
// Bottom uses line-level descent (includes descender space
|
|
// for the whole line, not just present glyphs).
|
|
max_y = max_y.max(line_baseline + line.descent as f32);
|
|
min_x = min_x.min(x + line.left as f32);
|
|
max_x = max_x.max(x + line.left as f32 + line.width as f32);
|
|
has_lines = true;
|
|
}
|
|
y_accum += paragraph.height();
|
|
}
|
|
}
|
|
|
|
if has_lines {
|
|
Some(Rect::from_ltrb(min_x, min_y, max_x, max_y))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn compute_and_cache_extrect(
|
|
&self,
|
|
shape: &Shape,
|
|
selrect: &Rect,
|
|
valign: VerticalAlign,
|
|
) -> Rect {
|
|
// AutoWidth paragraphs are laid out with f32::MAX, so line metrics
|
|
// (line.left) reflect alignment within that huge width and are
|
|
// unusable for tight bounds. Fall back to content_rect.
|
|
if self.grow_type() == GrowType::AutoWidth {
|
|
return self.content_rect(selrect, valign);
|
|
}
|
|
|
|
let layout_matches_container = self
|
|
.layout_width
|
|
.is_some_and(|w| w.ceil() == self.get_width(selrect.width()).ceil());
|
|
|
|
let tight = if !self.layout.paragraphs.is_empty() && layout_matches_container {
|
|
self.rect_from_paragraphs(selrect, valign)
|
|
} else {
|
|
let mut text_content = self.clone();
|
|
text_content.update_layout(shape.selrect);
|
|
text_content.rect_from_paragraphs(selrect, valign)
|
|
}
|
|
.unwrap_or_else(|| self.content_rect(selrect, valign));
|
|
|
|
// Cache as offsets from selrect origin so it's position-independent.
|
|
let sx = selrect.x();
|
|
let sy = selrect.y();
|
|
self.layout.cached_extrect.set(Some(CachedExtrect {
|
|
selrect_width: selrect.width(),
|
|
selrect_height: selrect.height(),
|
|
valign: valign as u8,
|
|
left: tight.left() - sx,
|
|
top: tight.top() - sy,
|
|
right: tight.right() - sx,
|
|
bottom: tight.bottom() - sy,
|
|
}));
|
|
|
|
tight
|
|
}
|
|
|
|
pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds {
|
|
let transform = &shape.transform;
|
|
let center = &shape.center();
|
|
let selrect = shape.selrect();
|
|
let valign = shape.vertical_align();
|
|
let sw = selrect.width();
|
|
let sh = selrect.height();
|
|
let sx = selrect.x();
|
|
let sy = selrect.y();
|
|
|
|
// Try the cache first: if dimensions and valign match, just apply position offset.
|
|
let text_rect = if let Some(cached) = self.layout.cached_extrect.get() {
|
|
if (cached.selrect_width - sw).abs() < 0.1
|
|
&& (cached.selrect_height - sh).abs() < 0.1
|
|
&& cached.valign == valign as u8
|
|
{
|
|
Rect::from_ltrb(
|
|
sx + cached.left,
|
|
sy + cached.top,
|
|
sx + cached.right,
|
|
sy + cached.bottom,
|
|
)
|
|
} else {
|
|
self.compute_and_cache_extrect(shape, &selrect, valign)
|
|
}
|
|
} else {
|
|
self.compute_and_cache_extrect(shape, &selrect, valign)
|
|
};
|
|
|
|
let mut bounds = Bounds::new(
|
|
Point::new(text_rect.x(), text_rect.y()),
|
|
Point::new(text_rect.x() + text_rect.width(), text_rect.y()),
|
|
Point::new(
|
|
text_rect.x() + text_rect.width(),
|
|
text_rect.y() + text_rect.height(),
|
|
),
|
|
Point::new(text_rect.x(), text_rect.y() + text_rect.height()),
|
|
);
|
|
|
|
if apply_transform && !transform.is_identity() {
|
|
let mut matrix = *transform;
|
|
matrix.post_translate(*center);
|
|
matrix.pre_translate(-*center);
|
|
bounds.transform_mut(&matrix);
|
|
}
|
|
|
|
bounds
|
|
}
|
|
|
|
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
|
|
let mut y = selrect.y();
|
|
|
|
let width = if self.grow_type() == GrowType::AutoWidth {
|
|
self.size.width
|
|
} else {
|
|
selrect.width()
|
|
};
|
|
|
|
let x = self.layout_origin_x(selrect, width);
|
|
|
|
let height = if self.size.width.round() != width.round() {
|
|
self.get_height(width)
|
|
} else {
|
|
self.size.height
|
|
};
|
|
|
|
let offset_y = vertical_align_offset(selrect.height(), height, valign);
|
|
y += offset_y;
|
|
|
|
Rect::from_xywh(x, y, width, height)
|
|
}
|
|
|
|
pub fn transform(&mut self, transform: &Matrix) {
|
|
let left = self.bounds.left();
|
|
let right = self.bounds.right();
|
|
let top = self.bounds.top();
|
|
let bottom = self.bounds.bottom();
|
|
let p1 = transform.map_point(skia::Point::new(left, top));
|
|
let p2 = transform.map_point(skia::Point::new(right, bottom));
|
|
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
|
|
}
|
|
|
|
pub fn get_caret_position_from_shape_coords(
|
|
&self,
|
|
point: &Point,
|
|
) -> Option<TextPositionWithAffinity> {
|
|
let mut offset_y = 0.0;
|
|
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
|
|
|
|
for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
|
|
let start_y = offset_y;
|
|
let end_y = offset_y + layout_paragraph.height();
|
|
|
|
// We only test against paragraphs that can contain the current y
|
|
// coordinate. Use >= for start and handle zero-height paragraphs.
|
|
let paragraph_height = layout_paragraph.height();
|
|
let matches = if paragraph_height > 0.0 {
|
|
point.y >= start_y && point.y < end_y
|
|
} else {
|
|
// For zero-height paragraphs (empty lines), match if we're at the start position
|
|
point.y >= start_y && point.y <= start_y + 1.0
|
|
};
|
|
|
|
if matches {
|
|
// Skia's get_glyph_position_at_coordinate expects coordinates relative to
|
|
// the paragraph's top-left. For multi-paragraph or wrapped text, each
|
|
// paragraph has its own origin; subtract start_y so we pass paragraph-local coords.
|
|
let para_pt = Point::new(point.x, point.y - start_y);
|
|
let position_with_affinity =
|
|
layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y));
|
|
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
|
|
// Skia reports UTF-16 code units, the model counts characters.
|
|
let offset =
|
|
paragraph.utf16_offset_to_char(position_with_affinity.position as usize);
|
|
|
|
return Some(TextPositionWithAffinity::new(
|
|
position_with_affinity,
|
|
paragraph_index,
|
|
offset,
|
|
));
|
|
}
|
|
}
|
|
offset_y += layout_paragraph.height();
|
|
}
|
|
|
|
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
|
|
// are empty, and the click is within the text shape bounds, return a default position
|
|
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
|
|
&& self.bounds.contains(*point)
|
|
{
|
|
// Create a default position at the start of the text
|
|
use skia_safe::textlayout::Affinity;
|
|
let default_position = PositionWithAffinity {
|
|
position: 0,
|
|
affinity: Affinity::Downstream,
|
|
};
|
|
return Some(TextPositionWithAffinity::new(
|
|
default_position,
|
|
0, // paragraph 0
|
|
0, // offset 0
|
|
));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub fn get_caret_position_from_screen_coords(
|
|
&self,
|
|
point: &Point,
|
|
view_matrix: &Matrix,
|
|
shape_matrix: &Matrix,
|
|
) -> Option<TextPositionWithAffinity> {
|
|
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
|
|
self.get_caret_position_from_shape_coords(&shape_rel_point)
|
|
}
|
|
|
|
/// Builds the ParagraphBuilders necessary to render
|
|
/// this text.
|
|
pub fn paragraph_builder_group_from_text(
|
|
&self,
|
|
use_shadow: Option<bool>,
|
|
) -> Vec<ParagraphBuilderGroup> {
|
|
self.paragraph_builders(use_shadow, false, None, None, None, None)
|
|
}
|
|
|
|
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
|
|
/// Used as a clip mask for inner stroke rendering.
|
|
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
|
|
self.paragraph_builders(None, true, None, None, None, None)
|
|
}
|
|
|
|
/// Maximum number of stacked fills across every span in this text block.
|
|
pub fn max_fill_layers(&self) -> usize {
|
|
self.paragraphs()
|
|
.iter()
|
|
.flat_map(|p| p.children())
|
|
.map(|s| s.fills.len())
|
|
.max()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Builds paragraph builders that paint a single fill layer per span for SVG
|
|
/// export. `layer_from_bottom` is 0 for the bottommost fill (fills[last]).
|
|
pub fn paragraph_builder_group_for_fill_layer(
|
|
&self,
|
|
layer_from_bottom: usize,
|
|
) -> Vec<ParagraphBuilderGroup> {
|
|
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
|
|
}
|
|
|
|
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
|
|
/// this layer is an image in `skip_image_ids` get transparent paint (those
|
|
/// fills are re-emitted as linked SVG `<image>` elements).
|
|
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
|
|
&self,
|
|
layer_from_bottom: usize,
|
|
skip_image_ids: &HashSet<Uuid>,
|
|
) -> Vec<ParagraphBuilderGroup> {
|
|
self.paragraph_builders(
|
|
None,
|
|
false,
|
|
None,
|
|
Some(layer_from_bottom),
|
|
None,
|
|
Some(skip_image_ids),
|
|
)
|
|
}
|
|
|
|
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
|
|
/// the given image — used as an SVG `<clipPath>` for linked image fills.
|
|
pub fn paragraph_builder_group_opaque_for_image_layer(
|
|
&self,
|
|
layer_from_bottom: usize,
|
|
image_id: Uuid,
|
|
) -> Vec<ParagraphBuilderGroup> {
|
|
self.paragraph_builders(
|
|
None,
|
|
false,
|
|
None,
|
|
None,
|
|
Some((layer_from_bottom, image_id)),
|
|
None,
|
|
)
|
|
}
|
|
|
|
fn paragraph_builders(
|
|
&self,
|
|
use_shadow: Option<bool>,
|
|
opaque: bool,
|
|
align_override: Option<skia::textlayout::TextAlign>,
|
|
fill_layer: Option<usize>,
|
|
opaque_image_layer: Option<(usize, Uuid)>,
|
|
skip_image_ids: Option<&HashSet<Uuid>>,
|
|
) -> Vec<ParagraphBuilderGroup> {
|
|
let fonts = get_font_collection();
|
|
let fallback_fonts = get_fallback_fonts();
|
|
let mut paragraph_group = Vec::new();
|
|
|
|
for paragraph in self.paragraphs() {
|
|
let mut paragraph_style = paragraph.paragraph_to_style();
|
|
if let Some(align) = align_override {
|
|
paragraph_style.set_text_align(align);
|
|
}
|
|
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
|
let mut has_text = false;
|
|
for span in paragraph.children() {
|
|
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
|
|
let mut style = span.to_style(
|
|
&self.bounds(),
|
|
fallback_fonts,
|
|
false,
|
|
paragraph.line_height(),
|
|
);
|
|
let mut paint = paint::Paint::default();
|
|
match span.fills_from_bottom(layer) {
|
|
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
|
|
paint.set_color(skia::Color::BLACK);
|
|
paint.set_alpha(255);
|
|
}
|
|
_ => {
|
|
paint.set_color(skia::Color::TRANSPARENT);
|
|
}
|
|
}
|
|
style.set_foreground_paint(&paint);
|
|
style
|
|
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
|
|
let skip_span = matches!(
|
|
span.fills_from_bottom(layer),
|
|
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
|
|
);
|
|
if skip_span {
|
|
let mut style = span.to_style(
|
|
&self.bounds(),
|
|
fallback_fonts,
|
|
false,
|
|
paragraph.line_height(),
|
|
);
|
|
let mut paint = paint::Paint::default();
|
|
paint.set_color(skia::Color::TRANSPARENT);
|
|
style.set_foreground_paint(&paint);
|
|
style
|
|
} else {
|
|
let remove_alpha =
|
|
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
|
span.to_style_with_paint(
|
|
&self.bounds(),
|
|
fallback_fonts,
|
|
remove_alpha,
|
|
paragraph.line_height(),
|
|
fill_layer,
|
|
)
|
|
}
|
|
} else {
|
|
let remove_alpha =
|
|
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
|
|
span.to_style_with_paint(
|
|
&self.bounds(),
|
|
fallback_fonts,
|
|
remove_alpha,
|
|
paragraph.line_height(),
|
|
fill_layer,
|
|
)
|
|
};
|
|
let text: String = span.apply_text_transform();
|
|
if !text.is_empty() {
|
|
has_text = true;
|
|
}
|
|
builder.push_style(&text_style);
|
|
add_text_with_tabs(&mut builder, &text, span.font_size);
|
|
}
|
|
if !has_text {
|
|
builder.add_text(" ");
|
|
}
|
|
paragraph_group.push(vec![builder]);
|
|
}
|
|
|
|
paragraph_group
|
|
}
|
|
|
|
/// Performs an Auto Width text layout.
|
|
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
|
|
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
|
|
let mut measure_builders = self.paragraph_builders(
|
|
None,
|
|
false,
|
|
Some(skia::textlayout::TextAlign::Left),
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
|
|
let normalized_line_height =
|
|
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
|
|
|
|
let measure_paragraphs =
|
|
build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX);
|
|
|
|
let content_width = measure_paragraphs
|
|
.iter()
|
|
.flatten()
|
|
.fold(0.0_f32, |auto_width, paragraph| {
|
|
f32::max(paragraph.longest_line(), auto_width)
|
|
})
|
|
.ceil();
|
|
|
|
// Re-layout at the intrinsic width (without the HTML margin slack).
|
|
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
|
let paragraphs =
|
|
build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width);
|
|
let height = paragraphs
|
|
.iter()
|
|
.flatten()
|
|
.fold(0.0_f32, |auto_height, paragraph| {
|
|
auto_height + paragraph.height()
|
|
});
|
|
|
|
let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT;
|
|
let size = TextContentSize::new_with_normalized_line_height(
|
|
reported_width,
|
|
height.ceil(),
|
|
reported_width,
|
|
normalized_line_height,
|
|
);
|
|
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
|
}
|
|
|
|
/// Private function that performs
|
|
/// Performs an Auto Height text layout.
|
|
fn text_layout_auto_height(&self) -> TextContentLayoutResult {
|
|
let width = self.width();
|
|
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
|
|
|
let normalized_line_height =
|
|
calculate_normalized_line_height(&mut paragraph_builders, width);
|
|
|
|
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
|
let height = paragraphs
|
|
.iter()
|
|
.flatten()
|
|
.fold(0.0, |auto_height, paragraph| {
|
|
auto_height + paragraph.height()
|
|
});
|
|
let size = TextContentSize::new_with_normalized_line_height(
|
|
width,
|
|
height.ceil(),
|
|
DEFAULT_TEXT_CONTENT_SIZE,
|
|
normalized_line_height,
|
|
);
|
|
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
|
}
|
|
|
|
/// Performs a Fixed text layout.
|
|
fn text_layout_fixed(&self) -> TextContentLayoutResult {
|
|
let width = self.width();
|
|
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
|
|
|
let normalized_line_height =
|
|
calculate_normalized_line_height(&mut paragraph_builders, width);
|
|
|
|
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
|
let paragraph_height = paragraphs
|
|
.iter()
|
|
.flatten()
|
|
.fold(0.0, |auto_height, paragraph| {
|
|
auto_height + paragraph.height()
|
|
});
|
|
|
|
let size = TextContentSize::new_with_normalized_line_height(
|
|
width.ceil(),
|
|
paragraph_height.ceil(),
|
|
DEFAULT_TEXT_CONTENT_SIZE,
|
|
normalized_line_height,
|
|
);
|
|
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
|
}
|
|
|
|
pub fn get_width(&self, width: f32) -> f32 {
|
|
if self.grow_type() == GrowType::AutoWidth {
|
|
self.size.width
|
|
} else {
|
|
width
|
|
}
|
|
}
|
|
|
|
pub fn get_height(&self, width: f32) -> f32 {
|
|
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
|
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
|
let paragraph_height = paragraphs
|
|
.iter()
|
|
.flatten()
|
|
.fold(0.0, |auto_height, paragraph| {
|
|
auto_height + paragraph.height()
|
|
});
|
|
paragraph_height
|
|
}
|
|
|
|
pub fn needs_update_layout(&self) -> bool {
|
|
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,
|
|
default_width: f32,
|
|
default_height: f32,
|
|
) {
|
|
self.layout.set(result.0, result.1);
|
|
self.size
|
|
.copy_finite_size(result.2, default_width, default_height);
|
|
// Absolute image/gradient shaders are built against `self.bounds()`, so the
|
|
// origin matches them and `cached_layout_paint_offset` carries any rtl shift.
|
|
self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y()));
|
|
}
|
|
|
|
pub fn force_next_layout_update(&mut self) {
|
|
self.layout_width = None;
|
|
self.layout_paint_origin = None;
|
|
self.layout.cached_extrect.set(None);
|
|
// Bump the content version so update_layout can't early-return: auto-width
|
|
// shapes always match their container and clearing the cache above doesn't
|
|
// flip needs_update(), so a late font resolution would otherwise be skipped.
|
|
self.content_version = self.content_version.wrapping_add(1);
|
|
}
|
|
|
|
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
|
|
// Keep bounds in sync before building paints so absolute fill shaders
|
|
// match the container we are laying out for.
|
|
self.set_xywh(selrect.x(), selrect.y(), selrect.width(), selrect.height());
|
|
|
|
// Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout.
|
|
let layout_matches_container = self.grow_type() == GrowType::AutoWidth
|
|
|| self
|
|
.layout_width
|
|
.is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON);
|
|
|
|
if !self.layout.needs_update()
|
|
&& self.layout_version == self.content_version
|
|
&& layout_matches_container
|
|
{
|
|
return self.size;
|
|
}
|
|
|
|
self.size.set_size(selrect.width(), selrect.height());
|
|
|
|
match self.grow_type() {
|
|
GrowType::AutoHeight => {
|
|
let result = self.text_layout_auto_height();
|
|
self.layout_width = Some(result.2.width);
|
|
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
|
}
|
|
GrowType::AutoWidth => {
|
|
let result = self.text_layout_auto_width();
|
|
self.layout_width = Some(result.2.width);
|
|
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
|
}
|
|
GrowType::Fixed => {
|
|
let result = self.text_layout_fixed();
|
|
self.layout_width = Some(result.2.width);
|
|
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
|
}
|
|
}
|
|
|
|
if self.is_empty() {
|
|
let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect);
|
|
self.size.width = placeholder_width;
|
|
self.size.height = placeholder_height;
|
|
self.size.max_width = placeholder_width;
|
|
}
|
|
|
|
self.layout_version = self.content_version;
|
|
self.size
|
|
}
|
|
|
|
/// Return true when the content represents a freshly created empty text.
|
|
/// We consider it empty only if there is exactly one paragraph with a single
|
|
/// span whose text buffer is empty. Any additional paragraphs or characters
|
|
/// mean the user has already entered content.
|
|
fn is_empty(&self) -> bool {
|
|
if self.paragraphs.len() != 1 {
|
|
return false;
|
|
}
|
|
|
|
let paragraph = match self.paragraphs.first() {
|
|
Some(paragraph) => paragraph,
|
|
None => return true,
|
|
};
|
|
if paragraph.children().len() != 1 {
|
|
return false;
|
|
}
|
|
|
|
let span = match paragraph.children().first() {
|
|
Some(span) => span,
|
|
None => return true,
|
|
};
|
|
|
|
span.text.is_empty()
|
|
}
|
|
|
|
/// Compute the placeholder size used while the text is still empty. We ask
|
|
/// Skia to measure a single glyph using the span's typography so the editor
|
|
/// shows a caret-sized box that reflects the selected font, size and spacing.
|
|
/// If that fails we fall back to the previous WASM size or the incoming
|
|
/// selrect dimensions.
|
|
fn placeholder_dimensions(&self, selrect: Rect) -> (f32, f32) {
|
|
if let Some(paragraph) = self.paragraphs.first() {
|
|
if let Some(span) = paragraph.children().first() {
|
|
let fonts = get_font_collection();
|
|
let fallback_fonts = get_fallback_fonts();
|
|
let paragraph_style = paragraph.paragraph_to_style();
|
|
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
|
|
|
let text_style = span.to_style(
|
|
&self.bounds(),
|
|
fallback_fonts,
|
|
false,
|
|
paragraph.line_height(),
|
|
);
|
|
|
|
builder.push_style(&text_style);
|
|
builder.add_text("0");
|
|
|
|
let mut paragraph_layout = builder.build();
|
|
paragraph_layout.layout(f32::MAX);
|
|
|
|
let width = paragraph_layout.max_intrinsic_width();
|
|
let height = paragraph_layout.height();
|
|
|
|
return (width, height);
|
|
}
|
|
}
|
|
|
|
let fallback_width = selrect.width().max(self.size.width);
|
|
let fallback_height = selrect.height().max(self.size.height);
|
|
|
|
(fallback_width, fallback_height)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn intersect_position_in_shape(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool {
|
|
let rect = shape.selrect;
|
|
let mut matrix = Matrix::new_identity();
|
|
let center = shape.center();
|
|
let Some(inv_transform) = &shape.transform.invert() else {
|
|
return false;
|
|
};
|
|
matrix.pre_translate(center);
|
|
matrix.pre_concat(inv_transform);
|
|
matrix.pre_translate(-center);
|
|
|
|
let result = matrix.map_point((x_pos, y_pos));
|
|
|
|
let x_pos = result.x;
|
|
let y_pos = result.y;
|
|
|
|
x_pos >= rect.x() && x_pos <= rect.right() && y_pos >= rect.y() && y_pos <= rect.bottom()
|
|
}
|
|
|
|
pub fn intersect_position_in_text(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool {
|
|
let rect = self.content_rect(&shape.selrect, shape.vertical_align);
|
|
let mut matrix = Matrix::new_identity();
|
|
let center = shape.center();
|
|
let Some(inv_transform) = &shape.transform.invert() else {
|
|
return false;
|
|
};
|
|
matrix.pre_translate(center);
|
|
matrix.pre_concat(inv_transform);
|
|
matrix.pre_translate(-center);
|
|
|
|
let result = matrix.map_point((x_pos, y_pos));
|
|
|
|
// Change coords to content space
|
|
let x_pos = result.x - rect.x();
|
|
let y_pos = result.y - rect.y();
|
|
|
|
if !self.layout.paragraphs.is_empty() {
|
|
// Reuse stored laid-out paragraphs
|
|
paragraph_intersects(
|
|
self.layout
|
|
.paragraphs
|
|
.iter()
|
|
.flat_map(|group| group.first()),
|
|
x_pos,
|
|
y_pos,
|
|
)
|
|
} else {
|
|
let width = self.width();
|
|
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
|
let paragraphs =
|
|
build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
|
|
|
paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TextContent {
|
|
fn default() -> Self {
|
|
Self {
|
|
paragraphs: vec![],
|
|
bounds: Rect::default(),
|
|
grow_type: GrowType::Fixed,
|
|
size: TextContentSize::default(),
|
|
layout: TextContentLayout::new(),
|
|
content_version: 0,
|
|
layout_version: 0,
|
|
layout_width: None,
|
|
layout_paint_origin: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type TextAlign = skia::textlayout::TextAlign;
|
|
pub type TextDirection = skia::textlayout::TextDirection;
|
|
pub type TextDecoration = skia::textlayout::TextDecoration;
|
|
|
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
|
pub enum TextTransform {
|
|
Lowercase,
|
|
Uppercase,
|
|
Capitalize,
|
|
}
|
|
|
|
// FIXME: Rethink this type. We'll probably need to move the serialization to the
|
|
// wasm module and store here meaningful model values (and/or skia type aliases)
|
|
#[derive(Debug, PartialEq, Clone)]
|
|
pub struct Paragraph {
|
|
text_align: TextAlign,
|
|
text_direction: TextDirection,
|
|
text_decoration: Option<TextDecoration>,
|
|
text_transform: Option<TextTransform>,
|
|
line_height: f32,
|
|
letter_spacing: f32,
|
|
children: Vec<TextSpan>,
|
|
}
|
|
|
|
impl Default for Paragraph {
|
|
fn default() -> Self {
|
|
Self {
|
|
text_align: TextAlign::default(),
|
|
text_direction: TextDirection::LTR,
|
|
text_decoration: None,
|
|
text_transform: None,
|
|
line_height: 1.0,
|
|
letter_spacing: 0.0,
|
|
children: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Paragraph {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
text_align: TextAlign,
|
|
text_direction: TextDirection,
|
|
text_decoration: Option<TextDecoration>,
|
|
text_transform: Option<TextTransform>,
|
|
line_height: f32,
|
|
letter_spacing: f32,
|
|
children: Vec<TextSpan>,
|
|
) -> Self {
|
|
Self {
|
|
text_align,
|
|
text_direction,
|
|
text_decoration,
|
|
text_transform,
|
|
line_height,
|
|
letter_spacing,
|
|
children,
|
|
}
|
|
}
|
|
|
|
pub fn children(&self) -> &[TextSpan] {
|
|
&self.children
|
|
}
|
|
|
|
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
|
|
&mut self.children
|
|
}
|
|
|
|
fn set_span_positions(&mut self, index: u32) {
|
|
for (span_index, span) in self.children.iter_mut().enumerate() {
|
|
span.set_position(index, span_index as u32);
|
|
}
|
|
}
|
|
|
|
fn char_count(&self) -> usize {
|
|
self.children
|
|
.iter()
|
|
.map(|span| span.text.chars().count())
|
|
.sum()
|
|
}
|
|
|
|
/// Translate a character offset into the UTF-16 offset Skia indexes by.
|
|
/// Both differ on astral-plane characters (emoji) and whenever a text
|
|
/// transform changes the length of the laid out text (`ß` -> `SS`).
|
|
pub fn char_offset_to_utf16(&self, char_offset: usize) -> usize {
|
|
let mut remaining = char_offset;
|
|
let mut utf16 = 0;
|
|
for span in &self.children {
|
|
if remaining == 0 {
|
|
break;
|
|
}
|
|
let span_len = span.text.chars().count();
|
|
let take = remaining.min(span_len);
|
|
let prefix: String = span.text.chars().take(take).collect();
|
|
utf16 += span.transform_text(&prefix).encode_utf16().count();
|
|
remaining -= take;
|
|
}
|
|
utf16
|
|
}
|
|
|
|
/// Translate a UTF-16 offset coming from Skia into a character offset.
|
|
/// An offset inside a character rounds up, so it never splits a glyph.
|
|
pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize {
|
|
let (mut low, mut high) = (0, self.char_count());
|
|
while low < high {
|
|
let middle = (low + high) / 2;
|
|
if self.char_offset_to_utf16(middle) < utf16_offset {
|
|
low = middle + 1;
|
|
} else {
|
|
high = middle;
|
|
}
|
|
}
|
|
low
|
|
}
|
|
|
|
/// UTF-16 length of the character at `char_offset`, so a caret range covers
|
|
/// the whole glyph.
|
|
pub fn char_utf16_len_at(&self, char_offset: usize) -> usize {
|
|
self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset)
|
|
}
|
|
|
|
pub fn line_height(&self) -> f32 {
|
|
self.line_height
|
|
}
|
|
|
|
pub fn letter_spacing(&self) -> f32 {
|
|
self.letter_spacing
|
|
}
|
|
|
|
pub fn text_align(&self) -> TextAlign {
|
|
self.text_align
|
|
}
|
|
|
|
pub fn text_direction(&self) -> TextDirection {
|
|
self.text_direction
|
|
}
|
|
|
|
pub fn text_decoration(&self) -> Option<TextDecoration> {
|
|
self.text_decoration
|
|
}
|
|
|
|
pub fn text_transform(&self) -> Option<TextTransform> {
|
|
self.text_transform
|
|
}
|
|
|
|
pub fn paragraph_to_style(&self) -> ParagraphStyle {
|
|
let mut style = ParagraphStyle::default();
|
|
|
|
style.set_height(self.line_height);
|
|
style.set_text_align(self.text_align);
|
|
style.set_text_direction(self.text_direction);
|
|
style.set_replace_tab_characters(false);
|
|
style.set_apply_rounding_hack(true);
|
|
style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All);
|
|
style
|
|
}
|
|
|
|
pub fn scale_content(&mut self, value: f32) {
|
|
self.letter_spacing *= value;
|
|
self.children
|
|
.iter_mut()
|
|
.for_each(|l| l.scale_content(value));
|
|
}
|
|
}
|
|
|
|
/// Capitalize the first letter of each word, preserving all original whitespace.
|
|
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
|
|
/// any non-letter character (whitespace, punctuation, digits, symbols).
|
|
fn capitalize_words(text: &str) -> String {
|
|
let mut result = String::with_capacity(text.len());
|
|
let mut capitalize_next = true;
|
|
for c in text.chars() {
|
|
if c.is_alphabetic() {
|
|
if capitalize_next {
|
|
result.extend(c.to_uppercase());
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
capitalize_next = false;
|
|
} else {
|
|
result.push(c);
|
|
capitalize_next = true;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Add `text`, pushing every '\t' as a one em wide placeholder.
|
|
pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) {
|
|
let tab = PlaceholderStyle::new(
|
|
font_size,
|
|
0.0,
|
|
PlaceholderAlignment::Baseline,
|
|
TextBaseline::Alphabetic,
|
|
0.0,
|
|
);
|
|
|
|
for (index, segment) in text.split('\t').enumerate() {
|
|
if index > 0 {
|
|
builder.add_placeholder(&tab);
|
|
}
|
|
builder.add_text(segment);
|
|
}
|
|
}
|
|
|
|
/// Filter control characters below U+0020, preserving tabs and line breaks.
|
|
/// Browser-dependent: Firefox drops them, others replace with space.
|
|
fn process_ignored_chars(text: &str, browser: u8) -> String {
|
|
text.chars()
|
|
.filter_map(|c| {
|
|
if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' {
|
|
return Some(c);
|
|
}
|
|
if c < '\u{0020}' {
|
|
if browser == Browser::Firefox as u8 {
|
|
None
|
|
} else {
|
|
Some(' ')
|
|
}
|
|
} else {
|
|
Some(c)
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Clone)]
|
|
pub struct TextSpan {
|
|
pub text: String,
|
|
pub font_family: FontFamily,
|
|
pub font_size: f32,
|
|
pub line_height: f32,
|
|
pub letter_spacing: f32,
|
|
pub font_weight: i32,
|
|
pub font_variant_id: Uuid,
|
|
pub text_decoration: Option<TextDecoration>,
|
|
pub text_transform: Option<TextTransform>,
|
|
pub text_direction: TextDirection,
|
|
pub fills: Vec<shapes::Fill>,
|
|
pub paragraph_position: u32,
|
|
pub span_position: u32,
|
|
}
|
|
|
|
impl TextSpan {
|
|
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
|
|
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
|
|
if layer < self.fills.len() {
|
|
Some(&self.fills[self.fills.len() - 1 - layer])
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
text: String,
|
|
font_family: FontFamily,
|
|
font_size: f32,
|
|
line_height: f32,
|
|
letter_spacing: f32,
|
|
text_decoration: Option<TextDecoration>,
|
|
text_transform: Option<TextTransform>,
|
|
text_direction: TextDirection,
|
|
font_weight: i32,
|
|
font_variant_id: Uuid,
|
|
fills: Vec<shapes::Fill>,
|
|
) -> Self {
|
|
Self {
|
|
text,
|
|
font_family,
|
|
font_size,
|
|
line_height,
|
|
letter_spacing,
|
|
text_decoration,
|
|
text_transform,
|
|
text_direction,
|
|
font_weight,
|
|
font_variant_id,
|
|
fills,
|
|
paragraph_position: u32::MAX,
|
|
span_position: u32::MAX,
|
|
}
|
|
}
|
|
|
|
pub fn set_text(&mut self, text: String) {
|
|
self.text = text;
|
|
}
|
|
|
|
pub fn set_position(&mut self, paragraph: u32, span: u32) {
|
|
self.paragraph_position = paragraph;
|
|
self.span_position = span;
|
|
}
|
|
|
|
pub fn to_style(
|
|
&self,
|
|
content_bounds: &Rect,
|
|
fallback_fonts: &HashSet<String>,
|
|
remove_alpha: bool,
|
|
paragraph_line_height: f32,
|
|
) -> skia::textlayout::TextStyle {
|
|
self.to_style_with_paint(
|
|
content_bounds,
|
|
fallback_fonts,
|
|
remove_alpha,
|
|
paragraph_line_height,
|
|
None,
|
|
)
|
|
}
|
|
|
|
fn to_style_with_paint(
|
|
&self,
|
|
content_bounds: &Rect,
|
|
fallback_fonts: &HashSet<String>,
|
|
remove_alpha: bool,
|
|
paragraph_line_height: f32,
|
|
fill_layer_from_bottom: Option<usize>,
|
|
) -> skia::textlayout::TextStyle {
|
|
let mut style = skia::textlayout::TextStyle::default();
|
|
let paint = if remove_alpha {
|
|
let mut paint = paint::Paint::default();
|
|
paint.set_color(skia::Color::BLACK);
|
|
paint.set_alpha(255);
|
|
paint
|
|
} else if let Some(layer) = fill_layer_from_bottom {
|
|
if layer < self.fills.len() {
|
|
let fill_idx = self.fills.len() - 1 - layer;
|
|
self.fills[fill_idx].to_paint(content_bounds, true)
|
|
} else {
|
|
let mut paint = paint::Paint::default();
|
|
paint.set_color(skia::Color::TRANSPARENT);
|
|
paint
|
|
}
|
|
} else {
|
|
merge_fills(&self.fills, *content_bounds)
|
|
};
|
|
|
|
let max_line_height = f32::max(paragraph_line_height, self.line_height);
|
|
style.set_height(max_line_height);
|
|
style.set_height_override(true);
|
|
style.set_foreground_paint(&paint);
|
|
style.set_decoration_type(match self.text_decoration {
|
|
Some(text_decoration) => text_decoration,
|
|
None => skia::textlayout::TextDecoration::NO_DECORATION,
|
|
});
|
|
|
|
// Trick to avoid showing the text decoration
|
|
style.set_decoration_thickness_multiplier(0.0);
|
|
|
|
let mut font_families = vec![
|
|
self.serialized_font_family(),
|
|
default_font(),
|
|
DEFAULT_EMOJI_FONT.to_string(),
|
|
];
|
|
|
|
font_families.extend(fallback_fonts.iter().cloned());
|
|
style.set_font_families(&font_families);
|
|
style.set_font_size(self.font_size);
|
|
style.set_letter_spacing(self.letter_spacing);
|
|
style.set_half_leading(true);
|
|
|
|
style
|
|
}
|
|
|
|
pub fn to_stroke_style(
|
|
&self,
|
|
stroke_paint: &Paint,
|
|
fallback_fonts: &HashSet<String>,
|
|
remove_alpha: bool,
|
|
paragraph_line_height: f32,
|
|
) -> skia::textlayout::TextStyle {
|
|
let mut style = self.to_style(
|
|
&Rect::default(),
|
|
fallback_fonts,
|
|
remove_alpha,
|
|
paragraph_line_height,
|
|
);
|
|
if remove_alpha {
|
|
let mut paint = skia::Paint::default();
|
|
paint.set_style(stroke_paint.style());
|
|
paint.set_stroke_width(stroke_paint.stroke_width());
|
|
paint.set_color(skia::Color::BLACK);
|
|
paint.set_alpha(255);
|
|
style.set_foreground_paint(&paint);
|
|
} else {
|
|
style.set_foreground_paint(stroke_paint);
|
|
}
|
|
|
|
style.set_font_size(self.font_size);
|
|
style.set_letter_spacing(self.letter_spacing);
|
|
style.set_decoration_type(match self.text_decoration {
|
|
Some(text_decoration) => text_decoration,
|
|
None => skia::textlayout::TextDecoration::NO_DECORATION,
|
|
});
|
|
style
|
|
}
|
|
|
|
fn serialized_font_family(&self) -> String {
|
|
format!("{}", self.font_family)
|
|
}
|
|
|
|
pub fn transform_text(&self, text: &str) -> String {
|
|
let text = process_ignored_chars(text, crate::globals::current_browser());
|
|
match self.text_transform {
|
|
Some(TextTransform::Uppercase) => text.to_uppercase(),
|
|
Some(TextTransform::Lowercase) => text.to_lowercase(),
|
|
Some(TextTransform::Capitalize) => capitalize_words(&text),
|
|
None => text,
|
|
}
|
|
}
|
|
|
|
pub fn apply_text_transform(&self) -> String {
|
|
self.transform_text(&self.text)
|
|
}
|
|
|
|
pub fn scale_content(&mut self, value: f32) {
|
|
self.font_size *= value;
|
|
}
|
|
|
|
pub fn is_transparent(&self) -> bool {
|
|
self.fills.iter().all(|fill| match fill {
|
|
shapes::Fill::Solid(shapes::SolidColor(color)) => color.a() == 0,
|
|
_ => false,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Copy, Clone)]
|
|
pub struct PositionData {
|
|
pub paragraph: u32,
|
|
pub span: u32,
|
|
pub start_pos: u32,
|
|
pub end_pos: u32,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub width: f32,
|
|
pub height: f32,
|
|
pub direction: u32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ParagraphLayout {
|
|
pub paragraph: skia::textlayout::Paragraph,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub decorations: Vec<TextDecorationSegment>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TextLayoutData {
|
|
pub position_data: Vec<PositionData>,
|
|
pub paragraphs: Vec<ParagraphLayout>,
|
|
}
|
|
|
|
fn direction_to_int(direction: TextDirection) -> u32 {
|
|
match direction {
|
|
TextDirection::RTL => 0,
|
|
TextDirection::LTR => 1,
|
|
}
|
|
}
|
|
|
|
pub fn calculate_text_layout_data(
|
|
shape: &Shape,
|
|
text_content: &TextContent,
|
|
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
|
|
skip_position_data: bool,
|
|
) -> TextLayoutData {
|
|
let selrect_width = shape.selrect().width();
|
|
let text_width = text_content.get_width(selrect_width);
|
|
let selrect_height = shape.selrect().height();
|
|
let x = text_content.layout_origin_x(&shape.selrect, text_width);
|
|
let base_y = shape.selrect.y();
|
|
let mut position_data: Vec<PositionData> = Vec::new();
|
|
let mut previous_line_height = text_content.normalized_line_height();
|
|
let text_paragraphs = text_content.paragraphs();
|
|
|
|
// 1. Build + layout each paragraph once, recording heights as we go.
|
|
let mut paragraph_heights: Vec<f32> = Vec::new();
|
|
let mut built_groups: Vec<Vec<skia::textlayout::Paragraph>> =
|
|
Vec::with_capacity(paragraph_builder_groups.len());
|
|
for paragraph_builder_group in paragraph_builder_groups.iter_mut() {
|
|
let group_len = paragraph_builder_group.len();
|
|
let mut paragraph_offset_y = previous_line_height;
|
|
let mut group_paragraphs: Vec<skia::textlayout::Paragraph> = Vec::with_capacity(group_len);
|
|
for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
|
|
let mut skia_paragraph = paragraph_builder.build();
|
|
skia_paragraph.layout(text_width);
|
|
if builder_index == group_len - 1 {
|
|
if skia_paragraph.get_line_metrics().is_empty() {
|
|
paragraph_offset_y = skia_paragraph.ideographic_baseline();
|
|
} else {
|
|
paragraph_offset_y = skia_paragraph.height();
|
|
}
|
|
}
|
|
if builder_index == 0 {
|
|
paragraph_heights.push(skia_paragraph.height());
|
|
}
|
|
group_paragraphs.push(skia_paragraph);
|
|
}
|
|
previous_line_height = paragraph_offset_y;
|
|
built_groups.push(group_paragraphs);
|
|
}
|
|
|
|
// 2. Position each built paragraph using the heights from step 1.
|
|
let total_text_height: f32 = paragraph_heights.iter().sum();
|
|
let vertical_offset =
|
|
vertical_align_offset(selrect_height, total_text_height, shape.vertical_align());
|
|
let mut paragraph_layouts: Vec<ParagraphLayout> = Vec::new();
|
|
let mut y_accum = base_y + vertical_offset;
|
|
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
|
|
// For each paragraph in the group (e.g., fill, stroke, etc.)
|
|
for skia_paragraph in group_paragraphs.into_iter() {
|
|
let decorations = text_paragraphs
|
|
.get(i)
|
|
.map(|text_paragraph| {
|
|
decoration_segments(&skia_paragraph, text_paragraph, x, y_accum)
|
|
})
|
|
.unwrap_or_default();
|
|
paragraph_layouts.push(ParagraphLayout {
|
|
paragraph: skia_paragraph,
|
|
x,
|
|
y: y_accum,
|
|
decorations,
|
|
});
|
|
}
|
|
y_accum += paragraph_heights[i];
|
|
}
|
|
|
|
// Calculate position data from paragraph_layouts
|
|
if !skip_position_data {
|
|
for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() {
|
|
let current_y = para_layout.y;
|
|
let text_paragraph = text_paragraphs.get(paragraph_index);
|
|
if let Some(text_para) = text_paragraph {
|
|
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
|
|
let mut cur = 0;
|
|
for (span_index, span) in text_para.children().iter().enumerate() {
|
|
let text: String = span.apply_text_transform();
|
|
let text_len = text.encode_utf16().count();
|
|
span_ranges.push((cur, cur + text_len, span_index));
|
|
cur += text_len;
|
|
}
|
|
for (start, end, span_index) in span_ranges {
|
|
let rects = para_layout.paragraph.get_rects_for_range(
|
|
start..end,
|
|
RectHeightStyle::Tight,
|
|
RectWidthStyle::Tight,
|
|
);
|
|
|
|
for textbox in rects {
|
|
let direction = textbox.direct;
|
|
let mut rect = textbox.rect;
|
|
let cy = rect.top + rect.height() / 2.0;
|
|
|
|
// Get byte positions from Skia's transformed text layout
|
|
let start_pos = para_layout
|
|
.paragraph
|
|
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
|
|
.position as usize
|
|
- start;
|
|
|
|
let end_pos = para_layout
|
|
.paragraph
|
|
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
|
|
.position as usize
|
|
- start;
|
|
|
|
rect.offset((x, current_y));
|
|
position_data.push(PositionData {
|
|
paragraph: paragraph_index as u32,
|
|
span: span_index as u32,
|
|
start_pos: start_pos as u32,
|
|
end_pos: end_pos as u32,
|
|
x: rect.x(),
|
|
y: rect.y(),
|
|
width: rect.width(),
|
|
height: rect.height(),
|
|
direction: direction_to_int(direction),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TextLayoutData {
|
|
position_data,
|
|
paragraphs: paragraph_layouts,
|
|
}
|
|
}
|
|
|
|
pub fn calculate_position_data(
|
|
shape: &Shape,
|
|
text_content: &TextContent,
|
|
skip_position_data: bool,
|
|
) -> Vec<PositionData> {
|
|
let mut text_content = text_content.clone();
|
|
text_content.update_layout(shape.selrect);
|
|
|
|
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
|
let layout_info = calculate_text_layout_data(
|
|
shape,
|
|
&text_content,
|
|
&mut paragraph_builders,
|
|
skip_position_data,
|
|
);
|
|
|
|
layout_info.position_data
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn vertical_align_top_keeps_the_content_at_the_origin() {
|
|
assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn vertical_align_center_takes_half_the_slack() {
|
|
assert_eq!(
|
|
vertical_align_offset(200.0, 60.0, VerticalAlign::Center),
|
|
70.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vertical_align_bottom_takes_all_the_slack() {
|
|
assert_eq!(
|
|
vertical_align_offset(200.0, 60.0, VerticalAlign::Bottom),
|
|
140.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vertical_align_offset_is_negative_when_content_overflows() {
|
|
assert_eq!(
|
|
vertical_align_offset(60.0, 200.0, VerticalAlign::Center),
|
|
-70.0
|
|
);
|
|
assert_eq!(
|
|
vertical_align_offset(60.0, 200.0, VerticalAlign::Bottom),
|
|
-140.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_basic_words() {
|
|
assert_eq!(capitalize_words("hello world"), "Hello World");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_preserves_leading_whitespace() {
|
|
assert_eq!(capitalize_words(" hello"), " Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_preserves_trailing_whitespace() {
|
|
assert_eq!(capitalize_words("hello "), "Hello ");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_preserves_multiple_spaces() {
|
|
assert_eq!(capitalize_words("hello world"), "Hello World");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_whitespace_only() {
|
|
assert_eq!(capitalize_words(" "), " ");
|
|
assert_eq!(capitalize_words(" "), " ");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_empty_string() {
|
|
assert_eq!(capitalize_words(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_single_char() {
|
|
assert_eq!(capitalize_words("a"), "A");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_already_uppercase() {
|
|
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_preserves_tabs_and_newlines() {
|
|
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld");
|
|
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_after_punctuation() {
|
|
assert_eq!(capitalize_words("(readonly)"), "(Readonly)");
|
|
assert_eq!(capitalize_words("hello-world"), "Hello-World");
|
|
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three");
|
|
}
|
|
|
|
#[test]
|
|
fn capitalize_after_digits() {
|
|
assert_eq!(capitalize_words("item1name"), "Item1Name");
|
|
}
|
|
|
|
#[test]
|
|
fn process_ignored_chars_preserves_spaces() {
|
|
assert_eq!(process_ignored_chars("hello world", 0), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn process_ignored_chars_preserves_line_breaks() {
|
|
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld");
|
|
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld");
|
|
}
|
|
|
|
#[test]
|
|
fn process_ignored_chars_preserves_tabs() {
|
|
assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld");
|
|
assert_eq!(
|
|
process_ignored_chars("hello\tworld", Browser::Firefox as u8),
|
|
"hello\tworld"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_ignored_chars_replaces_control_chars_chrome() {
|
|
// U+0001 (SOH) should become space in non-Firefox
|
|
assert_eq!(
|
|
process_ignored_chars("a\x01b", Browser::Chrome as u8),
|
|
"a b"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_ignored_chars_removes_control_chars_firefox() {
|
|
assert_eq!(
|
|
process_ignored_chars("a\x01b", Browser::Firefox as u8),
|
|
"ab"
|
|
);
|
|
}
|
|
|
|
fn test_paragraph(texts: &[&str]) -> Paragraph {
|
|
let spans = texts
|
|
.iter()
|
|
.map(|text| {
|
|
TextSpan::new(
|
|
text.to_string(),
|
|
FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal),
|
|
14.0,
|
|
1.2,
|
|
0.0,
|
|
None,
|
|
None,
|
|
TextDirection::LTR,
|
|
400,
|
|
Uuid::nil(),
|
|
vec![],
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
Paragraph::new(
|
|
TextAlign::Left,
|
|
TextDirection::LTR,
|
|
None,
|
|
None,
|
|
1.2,
|
|
0.0,
|
|
spans,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn char_offsets_match_utf16_offsets_for_bmp_text() {
|
|
let para = test_paragraph(&["Añadir"]);
|
|
for offset in 0..=6 {
|
|
assert_eq!(para.char_offset_to_utf16(offset), offset);
|
|
assert_eq!(para.utf16_offset_to_char(offset), offset);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn char_offsets_account_for_astral_characters() {
|
|
let para = test_paragraph(&["a", "😀b"]);
|
|
|
|
assert_eq!(para.char_offset_to_utf16(0), 0);
|
|
assert_eq!(para.char_offset_to_utf16(1), 1);
|
|
assert_eq!(para.char_offset_to_utf16(2), 3);
|
|
assert_eq!(para.char_offset_to_utf16(3), 4);
|
|
|
|
assert_eq!(para.utf16_offset_to_char(0), 0);
|
|
assert_eq!(para.utf16_offset_to_char(1), 1);
|
|
assert_eq!(para.utf16_offset_to_char(3), 2);
|
|
assert_eq!(para.utf16_offset_to_char(4), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() {
|
|
let para = test_paragraph(&["a😀b"]);
|
|
assert_eq!(para.utf16_offset_to_char(2), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn char_offsets_account_for_text_transforms() {
|
|
// Skia lays out the transformed text, where "Straße" is "STRASSE".
|
|
let mut para = test_paragraph(&["Straße"]);
|
|
para.children_mut()[0].text_transform = Some(TextTransform::Uppercase);
|
|
|
|
assert_eq!(para.char_offset_to_utf16(4), 4);
|
|
assert_eq!(para.char_offset_to_utf16(6), 7);
|
|
assert_eq!(para.char_utf16_len_at(4), 2);
|
|
assert_eq!(para.utf16_offset_to_char(7), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn char_utf16_len_at_covers_the_whole_glyph() {
|
|
let para = test_paragraph(&["a😀b"]);
|
|
assert_eq!(para.char_utf16_len_at(0), 1);
|
|
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_paint_origin_set_when_layout_result_applied() {
|
|
let mut content = sample_text_content();
|
|
content.set_xywh(40.0, 60.0, 200.0, 100.0);
|
|
let empty =
|
|
TextContentLayoutResult(vec![], vec![], TextContentSize::new_with_size(200.0, 100.0));
|
|
content.set_layout_from_result(empty, 200.0, 100.0);
|
|
let selrect = Rect::from_xywh(40.0, 60.0, 200.0, 100.0);
|
|
assert_eq!(
|
|
content.cached_layout_paint_anchor(&selrect),
|
|
Point::new(40.0, 60.0)
|
|
);
|
|
assert_eq!(
|
|
content.cached_layout_paint_offset(&selrect),
|
|
Point::new(0.0, 0.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cached_layout_paint_offset_tracks_selrect_move() {
|
|
let mut content = sample_text_content();
|
|
content.layout_paint_origin = Some(Point::new(10.0, 20.0));
|
|
// Simulate a move clone: bounds follow the new selrect, origin stays.
|
|
content.set_xywh(110.0, 220.0, 200.0, 100.0);
|
|
let selrect = Rect::from_xywh(110.0, 220.0, 200.0, 100.0);
|
|
let offset = content.cached_layout_paint_offset(&selrect);
|
|
assert_eq!(offset, Point::new(100.0, 200.0));
|
|
assert_eq!(
|
|
content.cached_layout_paint_anchor(&selrect),
|
|
Point::new(10.0, 20.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cached_layout_paint_offset_zero_without_origin() {
|
|
let content = sample_text_content();
|
|
let selrect = Rect::from_xywh(50.0, 75.0, 200.0, 100.0);
|
|
assert_eq!(
|
|
content.cached_layout_paint_offset(&selrect),
|
|
Point::new(0.0, 0.0)
|
|
);
|
|
assert_eq!(
|
|
content.cached_layout_paint_anchor(&selrect),
|
|
Point::new(50.0, 75.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_paint_origin_survives_bounds_transform_on_clone() {
|
|
let mut content = sample_text_content();
|
|
content.set_xywh(10.0, 20.0, 200.0, 100.0);
|
|
content.layout_paint_origin = Some(Point::new(10.0, 20.0));
|
|
content.layout.paragraphs = Rc::new(vec![vec![]]);
|
|
content.layout_width = Some(200.0);
|
|
content.layout_version = 1;
|
|
content.content_version = 1;
|
|
|
|
let mut moved = content.clone();
|
|
let mut move_matrix = Matrix::new_identity();
|
|
move_matrix.set_translate_x(50.0);
|
|
move_matrix.set_translate_y(30.0);
|
|
moved.transform(&move_matrix);
|
|
|
|
assert_eq!(moved.bounds().x(), 60.0);
|
|
assert_eq!(moved.bounds().y(), 50.0);
|
|
assert!(Rc::ptr_eq(
|
|
&content.layout.paragraphs,
|
|
&moved.layout.paragraphs
|
|
));
|
|
let selrect = Rect::from_xywh(60.0, 50.0, 200.0, 100.0);
|
|
assert_eq!(
|
|
moved.cached_layout_paint_anchor(&selrect),
|
|
Point::new(10.0, 20.0)
|
|
);
|
|
assert_eq!(
|
|
moved.cached_layout_paint_offset(&selrect),
|
|
Point::new(50.0, 30.0)
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RTL auto-width growth anchor
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn directed_paragraph(direction: TextDirection) -> Paragraph {
|
|
let span = TextSpan::new(
|
|
"hello".to_string(),
|
|
FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal),
|
|
14.0,
|
|
1.2,
|
|
0.0,
|
|
None,
|
|
None,
|
|
direction,
|
|
400,
|
|
Uuid::nil(),
|
|
vec![],
|
|
);
|
|
Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span])
|
|
}
|
|
|
|
/// Auto-width content measured wider than its stale selrect: mid-edit, where
|
|
/// the anchor is observable.
|
|
fn grown_content(directions: &[TextDirection], measured_width: f32) -> TextContent {
|
|
let bounds = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let mut content = TextContent::new(bounds, GrowType::AutoWidth);
|
|
for direction in directions {
|
|
content.add_paragraph(directed_paragraph(*direction));
|
|
}
|
|
content.size.width = measured_width;
|
|
content.size.height = 20.0;
|
|
content
|
|
}
|
|
|
|
#[test]
|
|
fn is_rtl_false_when_no_paragraphs() {
|
|
let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), GrowType::AutoWidth);
|
|
assert!(!content.is_rtl());
|
|
}
|
|
|
|
#[test]
|
|
fn is_rtl_true_when_every_paragraph_is_rtl() {
|
|
let content = grown_content(&[TextDirection::RTL, TextDirection::RTL], 120.0);
|
|
assert!(content.is_rtl());
|
|
}
|
|
|
|
#[test]
|
|
fn is_rtl_false_when_directions_are_mixed() {
|
|
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
|
|
assert!(!content.is_rtl());
|
|
}
|
|
|
|
#[test]
|
|
fn is_rtl_false_when_every_paragraph_is_ltr() {
|
|
let content = grown_content(&[TextDirection::LTR], 120.0);
|
|
assert!(!content.is_rtl());
|
|
}
|
|
|
|
#[test]
|
|
fn layout_origin_x_right_anchors_rtl_auto_width() {
|
|
let content = grown_content(&[TextDirection::RTL], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
// right edge (160) minus the measured width (120)
|
|
assert_eq!(content.layout_origin_x(&selrect, 120.0), 40.0);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_origin_x_left_anchors_ltr_auto_width() {
|
|
let content = grown_content(&[TextDirection::LTR], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_origin_x_left_anchors_mixed_direction_auto_width() {
|
|
let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_origin_x_ignores_direction_for_fixed_and_auto_height() {
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
for grow_type in [GrowType::Fixed, GrowType::AutoHeight] {
|
|
let mut content = grown_content(&[TextDirection::RTL], 120.0);
|
|
content.set_grow_type(grow_type);
|
|
assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn layout_origin_x_is_selrect_x_once_the_selrect_is_committed() {
|
|
let content = grown_content(&[TextDirection::RTL], 60.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
assert_eq!(content.layout_origin_x(&selrect, 60.0), selrect.x());
|
|
}
|
|
|
|
#[test]
|
|
fn content_rect_right_anchors_grown_rtl_auto_width() {
|
|
let content = grown_content(&[TextDirection::RTL], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let rect = content.content_rect(&selrect, VerticalAlign::Top);
|
|
assert_eq!(rect.x(), 40.0);
|
|
assert_eq!(rect.right(), selrect.right());
|
|
assert_eq!(rect.width(), 120.0);
|
|
assert_eq!(rect.y(), selrect.y());
|
|
}
|
|
|
|
#[test]
|
|
fn content_rect_left_anchors_grown_ltr_auto_width() {
|
|
let content = grown_content(&[TextDirection::LTR], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let rect = content.content_rect(&selrect, VerticalAlign::Top);
|
|
assert_eq!(rect.x(), selrect.x());
|
|
assert_eq!(rect.width(), 120.0);
|
|
}
|
|
|
|
#[test]
|
|
fn content_rect_unchanged_for_fixed_rtl_content() {
|
|
// A mismatch sends `content_rect` into `get_height`, which needs global
|
|
// font state tests do not have.
|
|
let mut content = grown_content(&[TextDirection::RTL], 60.0);
|
|
content.set_grow_type(GrowType::Fixed);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let rect = content.content_rect(&selrect, VerticalAlign::Top);
|
|
assert_eq!(rect.x(), selrect.x());
|
|
assert_eq!(rect.width(), selrect.width());
|
|
}
|
|
|
|
#[test]
|
|
fn cached_paint_anchor_plus_offset_lands_on_the_layout_origin() {
|
|
let mut content = grown_content(&[TextDirection::RTL], 120.0);
|
|
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let anchor = content.cached_layout_paint_anchor(&selrect);
|
|
let offset = content.cached_layout_paint_offset(&selrect);
|
|
assert_eq!(
|
|
anchor.x + offset.x,
|
|
content.layout_origin_x(&selrect, 120.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cached_paint_offset_stays_a_pure_translation_when_the_shape_moves() {
|
|
let mut content = grown_content(&[TextDirection::RTL], 120.0);
|
|
content.layout_paint_origin = Some(Point::new(100.0, 50.0));
|
|
let before = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
let after = Rect::from_xywh(130.0, 70.0, 60.0, 20.0);
|
|
let moved_by = Point::new(
|
|
content.cached_layout_paint_offset(&after).x
|
|
- content.cached_layout_paint_offset(&before).x,
|
|
content.cached_layout_paint_offset(&after).y
|
|
- content.cached_layout_paint_offset(&before).y,
|
|
);
|
|
assert_eq!(moved_by, Point::new(30.0, 20.0));
|
|
}
|
|
|
|
#[test]
|
|
fn cached_paint_anchor_right_anchors_when_no_origin_was_baked() {
|
|
let content = grown_content(&[TextDirection::RTL], 120.0);
|
|
let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0);
|
|
assert_eq!(content.layout_paint_origin, None);
|
|
assert_eq!(content.cached_layout_paint_anchor(&selrect).x, 40.0);
|
|
assert_eq!(content.cached_layout_paint_offset(&selrect).x, 0.0);
|
|
}
|
|
}
|