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}