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/// `PlacedGlyph` describes one shaped glyph positioned within a paragraph.
13#[derive(Clone, Copy, Debug)]
14pub struct PlacedGlyph {
15    /// `id` is the font-specific glyph identifier.
16    pub id: u32,
17    /// `x` is the paragraph-local horizontal origin in logical pixels.
18    pub x: f32,
19    /// `y` is the paragraph-local baseline origin in logical pixels.
20    pub y: f32,
21    /// `cluster` is the UTF-8 byte offset of the glyph's text cluster.
22    pub cluster: usize,
23    /// `advance` is the signed cursor movement, including justification.
24    pub advance: f32,
25}
26
27/// `PlacedRun` groups positioned glyphs sharing one font and paint style.
28#[derive(Clone, Debug)]
29pub struct PlacedRun {
30    /// `font` identifies the font within the paragraph's [`FaceSet`].
31    pub font: FontId,
32    /// `size` is the font size in logical pixels.
33    pub size: f32,
34    /// `color` is the glyph fill color.
35    pub color: Color,
36    /// `decoration` optionally adds a line relative to the run.
37    pub decoration: Option<crate::style::Decoration>,
38    /// `shadows` are painted back-to-front beneath the glyphs.
39    pub shadows: Vec<crate::style::Shadow>,
40    /// `glyphs` contains the run's glyphs in visual order.
41    pub glyphs: Vec<PlacedGlyph>,
42    /// `rtl` indicates that logical text order runs from right to left.
43    pub rtl: bool,
44    /// `bounds` is the paragraph-local advance box used for layout geometry.
45    pub bounds: Rect,
46    /// `ink` conservatively bounds visible glyph pixels in paragraph coordinates.
47    pub ink: Rect,
48}
49
50/// `Line` contains the visually ordered runs and metrics of one laid-out line.
51#[derive(Clone, Debug)]
52pub struct Line {
53    /// `runs` contains the line's visually ordered glyph runs.
54    pub runs: Vec<PlacedRun>,
55    /// `baseline` is the paragraph-local y coordinate of the text baseline.
56    pub baseline: f32,
57    /// `ascent` is the maximum distance above the baseline in logical pixels.
58    pub ascent: f32,
59    /// `descent` is the maximum distance below the baseline in logical pixels.
60    pub descent: f32,
61    /// `left` is the paragraph-local x coordinate after alignment.
62    pub left: f32,
63    /// `width` is the signed content advance in logical pixels.
64    ///
65    /// Trailing whitespace contributes only when requested by [`ParagraphStyle`].
66    pub width: f32,
67    /// `range` is the UTF-8 byte range of paragraph text covered by the line.
68    pub range: Range<usize>,
69}
70
71/// A finished `layout(width)`: the placed lines plus what they were placed
72/// against (the cache key for the re-layout tier).
73#[derive(Clone, Debug)]
74pub(crate) struct Layout {
75    pub max_width: f32,
76    pub lines: Vec<Line>,
77    pub width: f32,
78    pub height: f32,
79    /// `max_lines` cut content off (the ellipsis case).
80    pub truncated: bool,
81    /// The chosen line ranges — re-placing (recolor) skips re-wrapping.
82    pub wrapped: Wrapped,
83}
84
85/// `Paragraph` is the primary API for laying out and drawing text.
86///
87/// [`ParagraphBuilder::build`] shapes the text, selecting fonts and converting
88/// characters into positioned glyph sequences. [`Self::layout`] then wraps
89/// those glyphs into lines and positions them within a width. Call `layout`
90/// before drawing or reading layout metrics. It can be called again at another
91/// width without repeating shaping.
92#[derive(Clone)]
93pub struct Paragraph {
94    faces: FaceSet,
95    text: String,
96    style: ParagraphStyle,
97    spans: Vec<(Range<usize>, TextStyle)>,
98    shaped: Vec<ShapedRun>,
99    layout: Option<Layout>,
100    /// Precomputed at build (glyphless lines measure by the first span's
101    /// style) — the paragraph needs no collection afterwards.
102    empty_metrics: Option<(f32, f32, f32)>,
103    /// What THIS text could not resolve, even after the collection asked
104    /// its sources — the per-paragraph half of the async loop.
105    demand: FontDemand,
106}
107
108impl Paragraph {
109    /// `layout` prepares the paragraph for drawing within `max_width`.
110    ///
111    /// It wraps the shaped glyphs into lines and computes their positions and
112    /// metrics. Call it before drawing the paragraph. Use `f32::INFINITY` to
113    /// disable soft wrapping. Repeating the same width reuses the existing
114    /// layout; shaping is never repeated.
115    pub fn layout(&mut self, max_width: f32) {
116        if self
117            .layout
118            .as_ref()
119            .is_some_and(|l| l.max_width == max_width)
120        {
121            return;
122        }
123        let bidi = BidiInfo::new(&self.text, base_level(&self.style));
124        let wrapped = wrap_lines(
125            &self.text,
126            &self.shaped,
127            max_width,
128            self.style.max_lines,
129            self.style.preserve_trailing_whitespace,
130        );
131        self.layout = Some(self.place(&bidi, wrapped, max_width));
132    }
133
134    /// `update_color` changes one added span without reshaping or rewrapping.
135    ///
136    /// An out-of-range span index has no effect.
137    pub fn update_color(&mut self, span: usize, color: Color) {
138        let Some((range, style)) = self.spans.get_mut(span) else {
139            return;
140        };
141        style.color = color;
142        let range = range.clone();
143        for run in &mut self.shaped {
144            if run.range.start >= range.start && run.range.end <= range.end {
145                run.color = color;
146            }
147        }
148        if let Some(prior) = self.layout.take() {
149            let bidi = BidiInfo::new(&self.text, base_level(&self.style));
150            self.layout = Some(self.place(&bidi, prior.wrapped, prior.max_width));
151        }
152    }
153
154    fn place(&self, bidi: &BidiInfo, wrapped: Wrapped, max_width: f32) -> Layout {
155        place_lines(
156            &self.faces,
157            &self.text,
158            &self.shaped,
159            bidi,
160            wrapped,
161            max_width,
162            &self.style,
163            self.empty_line_metrics(),
164        )
165    }
166
167    /// skparagraph's computeEmptyMetrics: glyphless lines (blank first line,
168    /// trailing newline, empty paragraph) measure as the FIRST span's style.
169    fn empty_line_metrics(&self) -> Option<(f32, f32, f32)> {
170        self.empty_metrics
171    }
172
173    /// `lines` returns the most recently laid-out lines.
174    ///
175    /// It is empty before [`Self::layout`] is called.
176    pub fn lines(&self) -> &[Line] {
177        self.layout.as_ref().map_or(&[], |l| &l.lines)
178    }
179
180    /// `width` returns the nonnegative width of the widest laid-out line.
181    ///
182    /// It is zero before [`Self::layout`] is called.
183    pub fn width(&self) -> f32 {
184        self.layout.as_ref().map_or(0.0, |l| l.width)
185    }
186
187    /// `advance` returns the greatest signed line advance.
188    ///
189    /// This is NOT [`Paragraph::width`]. A width is a layout box, so it has a
190    /// floor at zero: wrapping, alignment and `bounds()` are all defined on a
191    /// rectangle, and one narrower than nothing means nothing. An advance has
192    /// no such floor. Letter and word spacing tighter than the glyphs are wide
193    /// walks the pen backwards, and callers that report a pen position rather
194    /// than a box — Canvas2D's `TextMetrics.width` — need the negative.
195    pub fn advance(&self) -> f32 {
196        self.lines()
197            .iter()
198            .map(|line| line.width)
199            .reduce(f32::max)
200            .unwrap_or(0.0)
201    }
202
203    /// `last_glyph_origin` returns the paragraph-local x origin of the final glyph.
204    ///
205    /// It returns `None` before layout or when no glyph was placed.
206    pub fn last_glyph_origin(&self) -> Option<f32> {
207        self.lines()
208            .iter()
209            .flat_map(|line| &line.runs)
210            .flat_map(|run| &run.glyphs)
211            .next_back()
212            .map(|glyph| glyph.x)
213    }
214
215    /// `height` returns the laid-out paragraph height in logical pixels.
216    ///
217    /// It is zero before [`Self::layout`] is called.
218    pub fn height(&self) -> f32 {
219        self.layout.as_ref().map_or(0.0, |l| l.height)
220    }
221
222    /// `ink_bounds` returns tight visible glyph bounds in paragraph coordinates.
223    ///
224    /// It returns `None` before layout or when the paragraph has no visible
225    /// glyphs. This query may rasterize color glyphs.
226    pub fn ink_bounds(&self) -> Option<Rect> {
227        let mut result: Option<Rect> = None;
228        let mut rasterizer = crate::raster::Rasterizer::new();
229        let mut color_bounds = HashMap::<(FontId, u32, u32), Option<Rect>>::new();
230        for run in self.lines().iter().flat_map(|line| &line.runs) {
231            let font = self.faces.get(run.font);
232            for glyph in &run.glyphs {
233                let key = (run.font, glyph.id, run.size.to_bits());
234                let color = *color_bounds
235                    .entry(key)
236                    .or_insert_with(|| rasterizer.color_bounds(font, glyph.id, run.size));
237                let bounds = if let Some(bounds) = color {
238                    bounds
239                } else if let Some(path) = crate::raster::glyph_path(font, glyph.id, run.size) {
240                    path.tight_bounds()
241                } else {
242                    continue;
243                };
244                let placed = Rect::new(
245                    bounds.x + glyph.x,
246                    bounds.y + glyph.y,
247                    bounds.width,
248                    bounds.height,
249                );
250                result = Some(result.map_or(placed, |current| current.union(&placed)));
251            }
252        }
253        result
254    }
255
256    /// `primary_font` returns the first run's font and size.
257    ///
258    /// For glyphless text it resolves the first styled span instead. It returns
259    /// `None` when no span or font is available.
260    pub fn primary_font(&self) -> Option<(&crate::font::Font, f32)> {
261        if let Some(run) = self.lines().first().and_then(|line| line.runs.first()) {
262            return Some((self.faces.get(run.font), run.size));
263        }
264        let (_, style) = self.spans.first()?;
265        if self.faces.is_empty() {
266            return None;
267        }
268        let attributes = style.font_attrs();
269        let identifier = self.faces.resolve(&style.families, attributes, ' ');
270        Some((self.faces.get(identifier), style.size))
271    }
272
273    /// `bounds` returns the paragraph's layout box at the origin.
274    pub fn bounds(&self) -> Rect {
275        Rect::new(0.0, 0.0, self.width(), self.height())
276    }
277
278    /// `truncated` reports whether the line limit omitted content.
279    pub fn truncated(&self) -> bool {
280        self.layout.as_ref().is_some_and(|l| l.truncated)
281    }
282
283    /// `min_intrinsic_width` returns the widest unbreakable segment.
284    ///
285    /// It is zero before [`Self::layout`] is called.
286    pub fn min_intrinsic_width(&self) -> f32 {
287        self.layout
288            .as_ref()
289            .map_or(0.0, |l| l.wrapped.min_intrinsic)
290    }
291
292    /// `max_intrinsic_width` returns the width required to avoid soft wrapping.
293    ///
294    /// It is zero before [`Self::layout`] is called.
295    pub fn max_intrinsic_width(&self) -> f32 {
296        self.layout
297            .as_ref()
298            .map_or(0.0, |l| l.wrapped.max_intrinsic)
299    }
300
301    /// `longest_line` returns the width of the widest laid-out line.
302    pub fn longest_line(&self) -> f32 {
303        self.width()
304    }
305}
306
307/// `ParagraphBuilder` assembles styled text into a [`Paragraph`] for layout and drawing.
308///
309/// Each added span can use a different [`TextStyle`]. Building selects fonts
310/// from the borrowed [`FontCollection`] and shapes the text into glyphs.
311pub struct ParagraphBuilder<'a> {
312    fonts: &'a mut FontCollection,
313    style: ParagraphStyle,
314    text: String,
315    spans: Vec<(Range<usize>, TextStyle)>,
316}
317
318impl<'a> ParagraphBuilder<'a> {
319    /// `new` creates an empty builder with the default [`ParagraphStyle`].
320    pub fn new(fonts: &'a mut FontCollection) -> Self {
321        Self {
322            fonts,
323            style: ParagraphStyle::default(),
324            text: String::new(),
325            spans: Vec::new(),
326        }
327    }
328
329    /// `style` replaces the paragraph-level layout style.
330    pub fn style(&mut self, style: ParagraphStyle) -> &mut Self {
331        self.style = style;
332        self
333    }
334
335    /// `add_text` appends a UTF-8 text span with its own style.
336    ///
337    /// The zero-based call order defines indices accepted by
338    /// [`Paragraph::update_color`].
339    pub fn add_text(&mut self, text: &str, style: &TextStyle) -> &mut Self {
340        let start = self.text.len();
341        self.text.push_str(text);
342        self.spans.push((start..self.text.len(), style.clone()));
343        self
344    }
345
346    /// `build` shapes the accumulated spans and drains the builder.
347    ///
348    /// Font sources are consulted for missing text. The returned paragraph
349    /// snapshots resolved faces and no longer borrows the collection. The empty
350    /// builder can be reused afterward.
351    pub fn build(&mut self) -> Paragraph {
352        let bidi = BidiInfo::new(&self.text, base_level(&self.style));
353        let mut demand = FontDemand::default();
354        let shaped = shape_runs(self.fonts, &self.text, &self.spans, &bidi, &mut demand);
355        let faces = self.fonts.faces().clone();
356        let empty_metrics = empty_line_metrics(&faces, self.spans.first());
357        Paragraph {
358            faces,
359            text: std::mem::take(&mut self.text),
360            style: std::mem::take(&mut self.style),
361            spans: std::mem::take(&mut self.spans),
362            shaped,
363            layout: None,
364            empty_metrics,
365            demand,
366        }
367    }
368}
369
370// ── the editor surface (skparagraph's Paragraph.h queries) ─────────────────
371
372/// `PositionWithAffinity` identifies where to place a caret in editable text.
373///
374/// It is returned by [`Paragraph::glyph_position_at`] when mapping a pointer
375/// position back to text. At line wraps and bidirectional boundaries, one text
376/// offset can have two visual caret positions. Affinity selects whether the
377/// caret belongs with the text before or after that offset.
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
379pub struct PositionWithAffinity {
380    /// `offset` is a UTF-8 byte offset in paragraph text.
381    pub offset: usize,
382    /// `downstream` selects the text after the offset when true, or before it when false.
383    pub downstream: bool,
384}
385
386/// `LineMetrics` describes one laid-out line for caret and selection geometry.
387#[derive(Clone, Debug)]
388pub struct LineMetrics {
389    /// `range` is the UTF-8 byte range covered by the line.
390    pub range: Range<usize>,
391    /// `baseline` is the paragraph-local y coordinate of the baseline.
392    pub baseline: f32,
393    /// `ascent` is the logical-pixel distance above the baseline.
394    pub ascent: f32,
395    /// `descent` is the logical-pixel distance below the baseline.
396    pub descent: f32,
397    /// `left` is the paragraph-local x coordinate after alignment.
398    pub left: f32,
399    /// `width` is the line's signed content advance in logical pixels.
400    pub width: f32,
401}
402
403impl Paragraph {
404    /// `text` returns the complete UTF-8 paragraph text.
405    pub fn text(&self) -> &str {
406        &self.text
407    }
408
409    /// `demand` returns font requests unresolved while building this paragraph.
410    ///
411    /// A host can load matching fonts, register them with [`FontCollection`],
412    /// and rebuild the paragraph to replace missing-glyph boxes.
413    pub fn demand(&self) -> &FontDemand {
414        &self.demand
415    }
416
417    /// `faces` returns the font snapshot retained for glyph lookup and drawing.
418    pub fn faces(&self) -> &FaceSet {
419        &self.faces
420    }
421
422    /// `line_metrics` returns measurements for every laid-out line.
423    ///
424    /// It is empty before [`Self::layout`] is called.
425    pub fn line_metrics(&self) -> Vec<LineMetrics> {
426        self.lines()
427            .iter()
428            .map(|line| LineMetrics {
429                range: line.range.clone(),
430                baseline: line.baseline,
431                ascent: line.ascent,
432                descent: line.descent,
433                left: line.left,
434                width: line.width,
435            })
436            .collect()
437    }
438
439    /// `caret_for_offset` returns a zero-width caret rectangle for a UTF-8 offset.
440    ///
441    /// The offset snaps to a cluster edge. It returns [`Rect::default`] before
442    /// layout or when no line exists.
443    pub fn caret_for_offset(&self, offset: usize) -> Rect {
444        let Some(line) = self.line_for_offset(offset) else {
445            return Rect::default();
446        };
447        let x = caret_x(line, offset);
448        Rect::new(
449            x,
450            line.baseline - line.ascent,
451            0.0,
452            line.ascent + line.descent,
453        )
454    }
455
456    /// `glyph_position_at` maps a paragraph-local point to an editable text position.
457    ///
458    /// Use it to place a caret from a pointer press. It chooses the nearest line
459    /// and glyph-cluster edge. An unlaid-out or empty paragraph returns offset
460    /// zero with downstream affinity.
461    pub fn glyph_position_at(&self, p: valo_geometry::Point) -> PositionWithAffinity {
462        let Some(line) = self.line_at_y(p.y) else {
463            return PositionWithAffinity {
464                offset: 0,
465                downstream: true,
466            };
467        };
468        let mut best = PositionWithAffinity {
469            offset: line.range.start,
470            downstream: true,
471        };
472        let mut best_dx = f32::MAX;
473        for run in &line.runs {
474            for g in &run.glyphs {
475                // An RTL cluster's LOGICAL start is its visual right edge.
476                let (lead_x, trail_x) = if run.rtl {
477                    (g.x + g.advance, g.x)
478                } else {
479                    (g.x, g.x + g.advance)
480                };
481                let leading = (p.x - lead_x).abs();
482                if leading < best_dx {
483                    best_dx = leading;
484                    best = PositionWithAffinity {
485                        offset: g.cluster,
486                        downstream: true,
487                    };
488                }
489                let trailing = (p.x - trail_x).abs();
490                if trailing < best_dx {
491                    best_dx = trailing;
492                    best = PositionWithAffinity {
493                        offset: self.cluster_end(g.cluster),
494                        downstream: false,
495                    };
496                }
497            }
498        }
499        best
500    }
501
502    /// `rects_for_range` returns boxes for painting a selected UTF-8 byte range.
503    ///
504    /// It returns one box per intersecting line and visual run, so bidirectional
505    /// text may produce multiple boxes on one line.
506    pub fn rects_for_range(&self, range: Range<usize>) -> Vec<Rect> {
507        let mut out = Vec::new();
508        for line in self.lines() {
509            if range.end <= line.range.start || range.start >= line.range.end {
510                continue;
511            }
512            for run in &line.runs {
513                let cells: Vec<&PlacedGlyph> = run
514                    .glyphs
515                    .iter()
516                    .filter(|g| g.cluster >= range.start && g.cluster < range.end)
517                    .collect();
518                let Some(first) = cells.first() else {
519                    continue;
520                };
521                let x0 = cells.iter().map(|g| g.x).fold(first.x, f32::min);
522                let x1 = cells
523                    .iter()
524                    .map(|g| g.x + g.advance)
525                    .fold(first.x + first.advance, f32::max);
526                out.push(Rect::from_ltrb(
527                    x0,
528                    line.baseline - line.ascent,
529                    x1,
530                    line.baseline + line.descent,
531                ));
532            }
533        }
534        out
535    }
536
537    /// `word_boundary` returns the text segment selected as a word at an offset.
538    ///
539    /// It follows Unicode word boundaries, making it suitable for word selection
540    /// from a double click. Offsets at or beyond the text end return an empty
541    /// range at the end.
542    pub fn word_boundary(&self, offset: usize) -> Range<usize> {
543        use unicode_segmentation::UnicodeSegmentation;
544        for (start, word) in self.text.split_word_bound_indices() {
545            if offset < start + word.len() {
546                return start..start + word.len();
547            }
548        }
549        self.text.len()..self.text.len()
550    }
551
552    fn line_for_offset(&self, offset: usize) -> Option<&Line> {
553        let lines = self.lines();
554        lines
555            .iter()
556            .find(|l| l.range.contains(&offset))
557            .or(lines.last())
558    }
559
560    fn line_at_y(&self, y: f32) -> Option<&Line> {
561        let lines = self.lines();
562        lines
563            .iter()
564            .find(|l| y <= l.baseline + l.descent)
565            .or(lines.last())
566    }
567
568    /// `cluster_end` returns the cluster's trailing UTF-8 offset.
569    ///
570    /// It uses the next shaped cluster on the same
571    /// line (shaping already groups combining marks — one char would land
572    /// a caret INSIDE `e + U+0301`), else the next grapheme boundary.
573    fn cluster_end(&self, cluster: usize) -> usize {
574        let next_on_line = self
575            .line_for_offset(cluster)
576            .into_iter()
577            .flat_map(|l| l.runs.iter())
578            .flat_map(|r| r.glyphs.iter())
579            .map(|g| g.cluster)
580            .filter(|&c| c > cluster)
581            .min();
582        next_on_line.unwrap_or_else(|| self.next_grapheme(cluster))
583    }
584
585    fn next_grapheme(&self, offset: usize) -> usize {
586        use unicode_segmentation::UnicodeSegmentation;
587        self.text[offset..]
588            .graphemes(true)
589            .next()
590            .map_or(self.text.len(), |g| offset + g.len())
591    }
592}
593
594/// `caret_x` returns the leading edge at an offset or the nearest prior edge.
595///
596/// It uses the leading edge of the cluster at `offset`, else the nearest trailing edge
597/// before it, else the line's left edge. Edges flip per run direction.
598fn caret_x(line: &Line, offset: usize) -> f32 {
599    let mut before: Option<(usize, f32)> = None;
600    for run in &line.runs {
601        for g in &run.glyphs {
602            let (lead_x, trail_x) = if run.rtl {
603                (g.x + g.advance, g.x)
604            } else {
605                (g.x, g.x + g.advance)
606            };
607            if g.cluster == offset {
608                return lead_x;
609            }
610            if g.cluster < offset && before.is_none_or(|(c, _)| g.cluster > c) {
611                before = Some((g.cluster, trail_x));
612            }
613        }
614    }
615    before.map_or(line.left, |(_, x)| x)
616}
617
618/// `base_level` returns an explicit bidi level or leaves content to choose it.
619fn base_level(style: &ParagraphStyle) -> Option<unicode_bidi::Level> {
620    style.direction.map(|direction| match direction {
621        TextDirection::Ltr => unicode_bidi::Level::ltr(),
622        TextDirection::Rtl => unicode_bidi::Level::rtl(),
623    })
624}
625
626/// `empty_line_metrics` resolves glyphless line metrics from the first span.
627fn empty_line_metrics(
628    faces: &FaceSet,
629    first_span: Option<&(std::ops::Range<usize>, TextStyle)>,
630) -> Option<(f32, f32, f32)> {
631    let (_, style) = first_span?;
632    if faces.is_empty() {
633        return None;
634    }
635    let attrs = style.font_attrs();
636    let id = faces.resolve(&style.families, attrs, ' ');
637    Some(crate::wrap::style_heights(
638        faces.get(id),
639        style.size,
640        style.height,
641    ))
642}