Skip to main content

blockworx_text/
rows.rs

1//! Breaking a shaped paragraph into rows.
2//!
3//! The rules are the desktop engine's (epaint's), so a run laid out here
4//! breaks where the same run breaks on screen: a row ends at the last
5//! whitespace that fits, failing that at a dash or a punctuation mark, and
6//! failing that anywhere at all — a word wider than the wrap is broken
7//! per character rather than overrun.
8
9use blockworx_geom::{Vec2, WorldPx, vec2};
10use blockworx_paint::Font;
11use blockworx_paint::text::{Glyph, Layout, Row};
12
13use crate::metrics::Metrics;
14
15/// Stack `paragraphs` — each one a shaped run placed on a single endless row —
16/// into the rows that fit `wrap`.
17pub(crate) fn rows_of(
18    paragraphs: Vec<Vec<Glyph>>,
19    wrap: WorldPx,
20    metrics: Metrics,
21    font: &Font,
22) -> Layout {
23    let fit = Fit {
24        wrap: wrap.get(),
25        height: metrics.row_height(font),
26    };
27    let last = paragraphs.len().saturating_sub(1);
28    let mut rows: Vec<Row> = Vec::with_capacity(paragraphs.len());
29    for (index, glyphs) in paragraphs.into_iter().enumerate() {
30        let ends = if index == last {
31            Break::Wrap
32        } else {
33            Break::Newline
34        };
35        if glyphs.is_empty() || width_of(&glyphs) <= fit.wrap {
36            rows.push(fit.row(glyphs, ends));
37            continue;
38        }
39        fit.break_rows(&glyphs, &mut rows);
40        if let Some(row) = rows.last_mut() {
41            row.ends_with_newline = ends.closes_a_paragraph();
42        }
43    }
44    stack(rows)
45}
46
47/// What a paragraph is broken to fit: the width a row may fill, and the height
48/// every row stands whether or not a glyph is on it.
49struct Fit {
50    wrap: f32,
51    height: f32,
52}
53
54impl Fit {
55    fn break_rows(&self, glyphs: &[Glyph], out: &mut Vec<Row>) {
56        let mut candidates = Candidates::default();
57        let mut start = 0;
58        let mut start_x = 0.0;
59        for index in 0..glyphs.len() {
60            if self.wrap < right_of(&glyphs[index]) - start_x
61                && let Some(kept) = candidates.pick()
62            {
63                out.push(self.row(shifted(&glyphs[start..=kept], start_x), Break::Wrap));
64                // A candidate is only ever recorded for an index already
65                // passed, so the row after it is never empty.
66                start = kept + 1;
67                start_x = glyphs[start].pos.x;
68                candidates.forget_before(start);
69            }
70            candidates.add(index, &glyphs[index..]);
71        }
72        if start < glyphs.len() {
73            out.push(self.row(shifted(&glyphs[start..], start_x), Break::Wrap));
74        }
75    }
76
77    fn row(&self, glyphs: Vec<Glyph>, ends: Break) -> Row {
78        Row {
79            pos: Vec2::ZERO,
80            height: self.height,
81            ends_with_newline: ends.closes_a_paragraph(),
82            glyphs,
83        }
84    }
85}
86
87/// What ended a row: the paragraph it closes, or the wrap width it filled.
88#[derive(Clone, Copy, PartialEq)]
89enum Break {
90    Newline,
91    Wrap,
92}
93
94impl Break {
95    fn closes_a_paragraph(self) -> bool {
96        self == Break::Newline
97    }
98}
99
100fn shifted(glyphs: &[Glyph], by: f32) -> Vec<Glyph> {
101    glyphs
102        .iter()
103        .map(|glyph| Glyph {
104            pos: vec2(glyph.pos.x - by, glyph.pos.y),
105            ..*glyph
106        })
107        .collect()
108}
109
110/// The rows in order, each at its own height below the one before.
111fn stack(mut rows: Vec<Row>) -> Layout {
112    let mut y = 0.0;
113    let mut width: f32 = 0.0;
114    for row in &mut rows {
115        row.pos = vec2(0.0, y);
116        y += row.height;
117        width = width.max(width_of(&row.glyphs));
118    }
119    Layout {
120        size: vec2(width, y),
121        rows,
122    }
123}
124
125fn right_of(glyph: &Glyph) -> f32 {
126    glyph.pos.x + glyph.advance
127}
128
129fn width_of(glyphs: &[Glyph]) -> f32 {
130    glyphs.last().map_or(0.0, right_of)
131}
132
133/// Where a row could end, best first. The last whitespace is the break a
134/// reader expects; the rest are what a word too wide for the wrap falls back
135/// through.
136#[derive(Default)]
137struct Candidates {
138    space: Option<usize>,
139    cjk: Option<usize>,
140    /// Before a CJK character, which may begin a row.
141    pre_cjk: Option<usize>,
142    dash: Option<usize>,
143    punctuation: Option<usize>,
144    any: Option<usize>,
145}
146
147impl Candidates {
148    fn add(&mut self, index: usize, rest: &[Glyph]) {
149        const NON_BREAKING_SPACE: char = '\u{A0}';
150        let chr = rest[0].chr;
151        if chr.is_whitespace() && chr != NON_BREAKING_SPACE {
152            self.space = Some(index);
153        } else if is_cjk(chr) && (rest.len() == 1 || may_begin_a_row(rest[1].chr)) {
154            self.cjk = Some(index);
155        } else if chr == '-' {
156            self.dash = Some(index);
157        } else if chr.is_ascii_punctuation() {
158            self.punctuation = Some(index);
159        } else if rest.len() > 1 && is_cjk(rest[1].chr) {
160            self.pre_cjk = Some(index);
161        }
162        self.any = Some(index);
163    }
164
165    fn pick(&self) -> Option<usize> {
166        [self.space, self.cjk, self.pre_cjk]
167            .into_iter()
168            .max()
169            .flatten()
170            .or(self.dash)
171            .or(self.punctuation)
172            .or(self.any)
173    }
174
175    fn forget_before(&mut self, index: usize) {
176        for candidate in [
177            &mut self.space,
178            &mut self.cjk,
179            &mut self.pre_cjk,
180            &mut self.dash,
181            &mut self.punctuation,
182            &mut self.any,
183        ] {
184            if candidate.is_some_and(|at| at < index) {
185                *candidate = None;
186            }
187        }
188    }
189}
190
191/// A logogram or kana — a character that is a word, and so a place a row may
192/// end without a space.
193fn is_cjk(chr: char) -> bool {
194    ('\u{4E00}'..='\u{9FFF}').contains(&chr)
195        || ('\u{3400}'..='\u{4DBF}').contains(&chr)
196        || ('\u{2B740}'..='\u{2B81F}').contains(&chr)
197        || ('\u{3040}'..='\u{309F}').contains(&chr)
198        || ('\u{30A0}'..='\u{30FF}').contains(&chr)
199}
200
201/// <https://en.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages>
202fn may_begin_a_row(chr: char) -> bool {
203    !")]}〕〉》」』】〙〗〟'\"⦆»ヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‐゠–〜?!‼⁇⁈⁉・、:;,。."
204        .contains(chr)
205}