Skip to main content

valo_text/
paragraph.rs

1use std::collections::HashMap;
2use std::ops::Range;
3
4use unicode_bidi::BidiInfo;
5use valo_geometry::{Color, Rect};
6
7use crate::font::{FaceSet, FontCollection, FontDemand, FontId};
8use crate::shape::{shape_runs, ShapedRun};
9use crate::style::{ParagraphStyle, TextDirection, TextStyle};
10use crate::wrap::{place_lines, wrap_lines, Wrapped};
11
12/// One positioned glyph: `x`/`y` in paragraph-local px, `y` on the baseline.
13#[derive(Clone, Copy, Debug)]
14pub struct PlacedGlyph {
15    pub id: u32,
16    pub x: f32,
17    pub y: f32,
18    /// Byte offset of this glyph's cluster in the paragraph text.
19    pub cluster: usize,
20    /// The cursor delta this glyph consumed (justify stretch included).
21    pub advance: f32,
22}
23
24/// A line's worth of one font+size+color — exactly what one `GlyphRun`
25/// display-list op carries.
26#[derive(Clone, Debug)]
27pub struct PlacedRun {
28    pub font: FontId,
29    pub size: f32,
30    pub color: Color,
31    pub decoration: Option<crate::style::Decoration>,
32    pub shadows: Vec<crate::style::Shadow>,
33    pub glyphs: Vec<PlacedGlyph>,
34    /// The run reads right-to-left: a glyph's LOGICAL start is its visual
35    /// right edge (`x + advance`), its end the left (skparagraph's
36    /// `Run::leftToRight()` from the bidi level).
37    pub rtl: bool,
38    /// Paragraph-local ADVANCE box: x from the cursor, y from ascent/
39    /// descent. Decorations and selection geometry live here.
40    pub bounds: Rect,
41    /// Paragraph-local INK bounds: the advance box widened by the font's
42    /// ink box (bearings, italic overhang, mark excursions) — what the
43    /// renderer must treat as the run's pixel extent (culling, layer
44    /// sizing, the opacity-elision disjointness proof).
45    pub ink: Rect,
46}
47
48#[derive(Clone, Debug)]
49pub struct Line {
50    pub runs: Vec<PlacedRun>,
51    /// y of the baseline, paragraph-local.
52    pub baseline: f32,
53    /// Max ascent/descent over the line's runs (px above/below baseline).
54    pub ascent: f32,
55    pub descent: f32,
56    /// x where content starts (alignment shift included).
57    pub left: f32,
58    /// Content width (trailing whitespace excluded).
59    pub width: f32,
60    /// Byte range of the paragraph text this line covers.
61    pub range: Range<usize>,
62}
63
64/// A finished `layout(width)`: the placed lines plus what they were placed
65/// against (the cache key for the re-layout tier).
66#[derive(Clone, Debug)]
67pub(crate) struct Layout {
68    pub max_width: f32,
69    pub lines: Vec<Line>,
70    pub width: f32,
71    pub height: f32,
72    /// `max_lines` cut content off (the ellipsis case).
73    pub truncated: bool,
74    /// The chosen line ranges — re-placing (recolor) skips re-wrapping.
75    pub wrapped: Wrapped,
76}
77
78/// The retained paragraph, with Skia's state tiers made explicit
79/// (SkParagraph's kShaped/kWrapped/kFormatted ladder):
80/// - `ParagraphBuilder::build()` runs the EXPENSIVE tier once — fallback
81///   segmentation + harfrust shaping — and retains it.
82/// - `layout(width)` re-wraps and places from the retained shaping (cheap;
83///   cached when the width doesn't change).
84/// - `update_color(span, color)` re-places only (no reshape, no rewrap) —
85///   the text-editing hot path.
86///
87/// Cloning duplicates the shaped and laid-out data (no re-shaping) — a cheap
88/// way for hosts to snapshot a layout before re-wrapping in place.
89#[derive(Clone)]
90pub struct Paragraph {
91    faces: FaceSet,
92    text: String,
93    style: ParagraphStyle,
94    spans: Vec<(Range<usize>, TextStyle)>,
95    shaped: Vec<ShapedRun>,
96    layout: Option<Layout>,
97    /// Precomputed at build (glyphless lines measure by the first span's
98    /// style) — the paragraph needs no collection afterwards.
99    empty_metrics: Option<(f32, f32, f32)>,
100    /// What THIS text could not resolve, even after the collection asked
101    /// its sources — the per-paragraph half of the async loop.
102    demand: FontDemand,
103}
104
105impl Paragraph {
106    /// Wrap and place against `max_width` (`f32::INFINITY` = never wrap).
107    /// Same width twice = cache hit; shaping is NEVER redone here.
108    pub fn layout(&mut self, max_width: f32) {
109        if self
110            .layout
111            .as_ref()
112            .is_some_and(|l| l.max_width == max_width)
113        {
114            return;
115        }
116        let bidi = BidiInfo::new(&self.text, base_level(&self.style));
117        let wrapped = wrap_lines(
118            &self.text,
119            &self.shaped,
120            max_width,
121            self.style.max_lines,
122            self.style.preserve_trailing_whitespace,
123        );
124        self.layout = Some(self.place(&bidi, wrapped, max_width));
125    }
126
127    /// The repaint tier: recolor one styled span and re-place — shaping and
128    /// line breaks are untouched (color never moves a glyph).
129    pub fn update_color(&mut self, span: usize, color: Color) {
130        let Some((range, style)) = self.spans.get_mut(span) else {
131            return;
132        };
133        style.color = color;
134        let range = range.clone();
135        for run in &mut self.shaped {
136            if run.range.start >= range.start && run.range.end <= range.end {
137                run.color = color;
138            }
139        }
140        if let Some(prior) = self.layout.take() {
141            let bidi = BidiInfo::new(&self.text, base_level(&self.style));
142            self.layout = Some(self.place(&bidi, prior.wrapped, prior.max_width));
143        }
144    }
145
146    fn place(&self, bidi: &BidiInfo, wrapped: Wrapped, max_width: f32) -> Layout {
147        place_lines(
148            &self.faces,
149            &self.text,
150            &self.shaped,
151            bidi,
152            wrapped,
153            max_width,
154            &self.style,
155            self.empty_line_metrics(),
156        )
157    }
158
159    /// skparagraph's computeEmptyMetrics: glyphless lines (blank first line,
160    /// trailing newline, empty paragraph) measure as the FIRST span's style.
161    fn empty_line_metrics(&self) -> Option<(f32, f32, f32)> {
162        self.empty_metrics
163    }
164
165    /// Placed lines of the most recent `layout` (empty before one).
166    pub fn lines(&self) -> &[Line] {
167        self.layout.as_ref().map_or(&[], |l| &l.lines)
168    }
169
170    /// Widest line's content width after `layout`.
171    pub fn width(&self) -> f32 {
172        self.layout.as_ref().map_or(0.0, |l| l.width)
173    }
174
175    /// How far the pen actually travelled — the widest line's signed advance.
176    ///
177    /// This is NOT [`Paragraph::width`]. A width is a layout box, so it has a
178    /// floor at zero: wrapping, alignment and `bounds()` are all defined on a
179    /// rectangle, and one narrower than nothing means nothing. An advance has
180    /// no such floor. Letter and word spacing tighter than the glyphs are wide
181    /// walks the pen backwards, and callers that report a pen position rather
182    /// than a box — Canvas2D's `TextMetrics.width` — need the negative.
183    pub fn advance(&self) -> f32 {
184        self.lines()
185            .iter()
186            .map(|line| line.width)
187            .reduce(f32::max)
188            .unwrap_or(0.0)
189    }
190
191    /// Paragraph-local pen x of the last glyph placed, or `None` when nothing
192    /// was placed at all. Distinct from the advance whenever that last glyph
193    /// carries one of its own.
194    pub fn last_glyph_origin(&self) -> Option<f32> {
195        self.lines()
196            .iter()
197            .flat_map(|line| &line.runs)
198            .flat_map(|run| &run.glyphs)
199            .next_back()
200            .map(|glyph| glyph.x)
201    }
202
203    pub fn height(&self) -> f32 {
204        self.layout.as_ref().map_or(0.0, |l| l.height)
205    }
206
207    /// Tight visible ink bounds in paragraph coordinates. Vector glyphs use
208    /// Bézier extrema; color/bitmap glyphs use their non-transparent pixels.
209    /// This deliberate slower query path is for Canvas-style text metrics;
210    /// frame recording keeps using precomputed conservative run bounds.
211    pub fn ink_bounds(&self) -> Option<Rect> {
212        let mut result: Option<Rect> = None;
213        let mut rasterizer = crate::raster::Rasterizer::new();
214        let mut color_bounds = HashMap::<(FontId, u32, u32), Option<Rect>>::new();
215        for run in self.lines().iter().flat_map(|line| &line.runs) {
216            let font = self.faces.get(run.font);
217            for glyph in &run.glyphs {
218                let key = (run.font, glyph.id, run.size.to_bits());
219                let color = *color_bounds
220                    .entry(key)
221                    .or_insert_with(|| rasterizer.color_bounds(font, glyph.id, run.size));
222                let bounds = if let Some(bounds) = color {
223                    bounds
224                } else if let Some(path) = crate::raster::glyph_path(font, glyph.id, run.size) {
225                    path.tight_bounds()
226                } else {
227                    continue;
228                };
229                let placed = Rect::new(
230                    bounds.x + glyph.x,
231                    bounds.y + glyph.y,
232                    bounds.width,
233                    bounds.height,
234                );
235                result = Some(result.map_or(placed, |current| current.union(&placed)));
236            }
237        }
238        result
239    }
240
241    /// Primary face and size even when the paragraph contains no glyphs.
242    pub fn primary_font(&self) -> Option<(&crate::font::Font, f32)> {
243        if let Some(run) = self.lines().first().and_then(|line| line.runs.first()) {
244            return Some((self.faces.get(run.font), run.size));
245        }
246        let (_, style) = self.spans.first()?;
247        if self.faces.is_empty() {
248            return None;
249        }
250        let attributes = style.font_attrs();
251        let identifier = self.faces.resolve(&style.families, attributes, ' ');
252        Some((self.faces.get(identifier), style.size))
253    }
254
255    pub fn bounds(&self) -> Rect {
256        Rect::new(0.0, 0.0, self.width(), self.height())
257    }
258
259    /// `max_lines` dropped content (what an ellipsis marks).
260    pub fn truncated(&self) -> bool {
261        self.layout.as_ref().is_some_and(|l| l.truncated)
262    }
263
264    /// Widest unbreakable segment — the narrowest useful layout width.
265    pub fn min_intrinsic_width(&self) -> f32 {
266        self.layout
267            .as_ref()
268            .map_or(0.0, |l| l.wrapped.min_intrinsic)
269    }
270
271    /// Width when nothing wraps (widest hard-break line).
272    pub fn max_intrinsic_width(&self) -> f32 {
273        self.layout
274            .as_ref()
275            .map_or(0.0, |l| l.wrapped.max_intrinsic)
276    }
277
278    /// Widest laid-out line's content width.
279    pub fn longest_line(&self) -> f32 {
280        self.width()
281    }
282}
283
284/// Collects styled spans; `build()` runs fallback segmentation + shaping
285/// once and hands back the retained [`Paragraph`].
286pub struct ParagraphBuilder<'a> {
287    fonts: &'a mut FontCollection,
288    style: ParagraphStyle,
289    text: String,
290    spans: Vec<(Range<usize>, TextStyle)>,
291}
292
293impl<'a> ParagraphBuilder<'a> {
294    /// Skia's `ParagraphBuilder::make(style, fontCollection, unicode)`:
295    /// the collection comes in at construction and answers misses itself.
296    pub fn new(fonts: &'a mut FontCollection) -> Self {
297        Self {
298            fonts,
299            style: ParagraphStyle::default(),
300            text: String::new(),
301            spans: Vec::new(),
302        }
303    }
304
305    pub fn style(&mut self, style: ParagraphStyle) -> &mut Self {
306        self.style = style;
307        self
308    }
309
310    pub fn add_text(&mut self, text: &str, style: &TextStyle) -> &mut Self {
311        let start = self.text.len();
312        self.text.push_str(text);
313        self.spans.push((start..self.text.len(), style.clone()));
314        self
315    }
316
317    /// The expensive tier: segment + shape, consulting the collection's
318    /// sources at every miss (Skia's one `build()`; what no source could
319    /// answer waits on the collection as `take_unanswered`). The paragraph
320    /// keeps the faces it resolved, so it is self-contained afterwards.
321    pub fn build(&mut self) -> Paragraph {
322        let bidi = BidiInfo::new(&self.text, base_level(&self.style));
323        let mut demand = FontDemand::default();
324        let shaped = shape_runs(self.fonts, &self.text, &self.spans, &bidi, &mut demand);
325        let faces = self.fonts.faces().clone();
326        let empty_metrics = empty_line_metrics(&faces, self.spans.first());
327        Paragraph {
328            faces,
329            text: std::mem::take(&mut self.text),
330            style: std::mem::take(&mut self.style),
331            spans: std::mem::take(&mut self.spans),
332            shaped,
333            layout: None,
334            empty_metrics,
335            demand,
336        }
337    }
338}
339
340// ── the editor surface (skparagraph's Paragraph.h queries) ─────────────────
341
342/// A byte offset plus which side of it the position leans (SkParagraph's
343/// PositionWithAffinity): `downstream` = the caret belongs to the glyph
344/// AFTER the offset.
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub struct PositionWithAffinity {
347    pub offset: usize,
348    pub downstream: bool,
349}
350
351/// Per-line metrics for caret/selection UIs (skparagraph's LineMetrics).
352#[derive(Clone, Debug)]
353pub struct LineMetrics {
354    pub range: Range<usize>,
355    pub baseline: f32,
356    pub ascent: f32,
357    pub descent: f32,
358    pub left: f32,
359    pub width: f32,
360}
361
362impl Paragraph {
363    pub fn text(&self) -> &str {
364        &self.text
365    }
366
367    /// What this paragraph could not resolve (families and codepoints no
368    /// source answered) — a host fetches them and re-registers.
369    pub fn demand(&self) -> &FontDemand {
370        &self.demand
371    }
372
373    /// The faces this paragraph resolved (decoration metrics, glyph
374    /// lookup at record time).
375    pub fn faces(&self) -> &FaceSet {
376        &self.faces
377    }
378
379    pub fn line_metrics(&self) -> Vec<LineMetrics> {
380        self.lines()
381            .iter()
382            .map(|line| LineMetrics {
383                range: line.range.clone(),
384                baseline: line.baseline,
385                ascent: line.ascent,
386                descent: line.descent,
387                left: line.left,
388                width: line.width,
389            })
390            .collect()
391    }
392
393    /// The caret rectangle (zero width) for a byte offset — the leading
394    /// edge of the cluster at `offset`, or the trailing edge of the last
395    /// cluster before it.
396    pub fn caret_for_offset(&self, offset: usize) -> Rect {
397        let Some(line) = self.line_for_offset(offset) else {
398            return Rect::default();
399        };
400        let x = caret_x(line, offset);
401        Rect::new(
402            x,
403            line.baseline - line.ascent,
404            0.0,
405            line.ascent + line.descent,
406        )
407    }
408
409    /// The text position under a point (SkParagraph's
410    /// getGlyphPositionAtCoordinate): nearest line by y, nearest cluster
411    /// edge by x.
412    pub fn glyph_position_at(&self, p: valo_geometry::Point) -> PositionWithAffinity {
413        let Some(line) = self.line_at_y(p.y) else {
414            return PositionWithAffinity {
415                offset: 0,
416                downstream: true,
417            };
418        };
419        let mut best = PositionWithAffinity {
420            offset: line.range.start,
421            downstream: true,
422        };
423        let mut best_dx = f32::MAX;
424        for run in &line.runs {
425            for g in &run.glyphs {
426                // An RTL cluster's LOGICAL start is its visual right edge.
427                let (lead_x, trail_x) = if run.rtl {
428                    (g.x + g.advance, g.x)
429                } else {
430                    (g.x, g.x + g.advance)
431                };
432                let leading = (p.x - lead_x).abs();
433                if leading < best_dx {
434                    best_dx = leading;
435                    best = PositionWithAffinity {
436                        offset: g.cluster,
437                        downstream: true,
438                    };
439                }
440                let trailing = (p.x - trail_x).abs();
441                if trailing < best_dx {
442                    best_dx = trailing;
443                    best = PositionWithAffinity {
444                        offset: self.cluster_end(g.cluster),
445                        downstream: false,
446                    };
447                }
448            }
449        }
450        best
451    }
452
453    /// Selection boxes for a byte range: one rect per (line, run) span —
454    /// bidi ranges yield multiple boxes naturally, like SkParagraph's
455    /// getRectsForRange.
456    pub fn rects_for_range(&self, range: Range<usize>) -> Vec<Rect> {
457        let mut out = Vec::new();
458        for line in self.lines() {
459            if range.end <= line.range.start || range.start >= line.range.end {
460                continue;
461            }
462            for run in &line.runs {
463                let cells: Vec<&PlacedGlyph> = run
464                    .glyphs
465                    .iter()
466                    .filter(|g| g.cluster >= range.start && g.cluster < range.end)
467                    .collect();
468                let Some(first) = cells.first() else {
469                    continue;
470                };
471                let x0 = cells.iter().map(|g| g.x).fold(first.x, f32::min);
472                let x1 = cells
473                    .iter()
474                    .map(|g| g.x + g.advance)
475                    .fold(first.x + first.advance, f32::max);
476                out.push(Rect::from_ltrb(
477                    x0,
478                    line.baseline - line.ascent,
479                    x1,
480                    line.baseline + line.descent,
481                ));
482            }
483        }
484        out
485    }
486
487    /// The word containing `offset` (UAX #29 word boundaries).
488    pub fn word_boundary(&self, offset: usize) -> Range<usize> {
489        use unicode_segmentation::UnicodeSegmentation;
490        for (start, word) in self.text.split_word_bound_indices() {
491            if offset < start + word.len() {
492                return start..start + word.len();
493            }
494        }
495        self.text.len()..self.text.len()
496    }
497
498    fn line_for_offset(&self, offset: usize) -> Option<&Line> {
499        let lines = self.lines();
500        lines
501            .iter()
502            .find(|l| l.range.contains(&offset))
503            .or(lines.last())
504    }
505
506    fn line_at_y(&self, y: f32) -> Option<&Line> {
507        let lines = self.lines();
508        lines
509            .iter()
510            .find(|l| y <= l.baseline + l.descent)
511            .or(lines.last())
512    }
513
514    /// The cluster's trailing offset: the NEXT shaped cluster on the same
515    /// line (shaping already groups combining marks — one char would land
516    /// a caret INSIDE `e + U+0301`), else the next grapheme boundary.
517    fn cluster_end(&self, cluster: usize) -> usize {
518        let next_on_line = self
519            .line_for_offset(cluster)
520            .into_iter()
521            .flat_map(|l| l.runs.iter())
522            .flat_map(|r| r.glyphs.iter())
523            .map(|g| g.cluster)
524            .filter(|&c| c > cluster)
525            .min();
526        next_on_line.unwrap_or_else(|| self.next_grapheme(cluster))
527    }
528
529    fn next_grapheme(&self, offset: usize) -> usize {
530        use unicode_segmentation::UnicodeSegmentation;
531        self.text[offset..]
532            .graphemes(true)
533            .next()
534            .map_or(self.text.len(), |g| offset + g.len())
535    }
536}
537
538/// Leading edge of the cluster at `offset`, else the nearest trailing edge
539/// before it, else the line's left edge. Edges flip per run direction.
540fn caret_x(line: &Line, offset: usize) -> f32 {
541    let mut before: Option<(usize, f32)> = None;
542    for run in &line.runs {
543        for g in &run.glyphs {
544            let (lead_x, trail_x) = if run.rtl {
545                (g.x + g.advance, g.x)
546            } else {
547                (g.x, g.x + g.advance)
548            };
549            if g.cluster == offset {
550                return lead_x;
551            }
552            if g.cluster < offset && before.is_none_or(|(c, _)| g.cluster > c) {
553                before = Some((g.cluster, trail_x));
554            }
555        }
556    }
557    before.map_or(line.left, |(_, x)| x)
558}
559
560/// The paragraph's bidi base level, or `None` to let the content pick it.
561fn base_level(style: &ParagraphStyle) -> Option<unicode_bidi::Level> {
562    style.direction.map(|direction| match direction {
563        TextDirection::Ltr => unicode_bidi::Level::ltr(),
564        TextDirection::Rtl => unicode_bidi::Level::rtl(),
565    })
566}
567
568/// skparagraph's computeEmptyMetrics, resolved once at build: glyphless
569/// lines (blank first line, trailing newline, empty paragraph) measure as
570/// the FIRST span's style.
571fn empty_line_metrics(
572    faces: &FaceSet,
573    first_span: Option<&(std::ops::Range<usize>, TextStyle)>,
574) -> Option<(f32, f32, f32)> {
575    let (_, style) = first_span?;
576    if faces.is_empty() {
577        return None;
578    }
579    let attrs = style.font_attrs();
580    let id = faces.resolve(&style.families, attrs, ' ');
581    Some(crate::wrap::style_heights(
582        faces.get(id),
583        style.size,
584        style.height,
585    ))
586}