Skip to main content

ferroui_pretext/
pretext_layout_rich_inline.rs

1//! Rich inline text and atomic items, prepared once for repeated layout.
2
3use std::collections::HashMap;
4use std::rc::Rc;
5
6use crate::{
7    LayoutCursor, LayoutLineRange, PrepareOptions, PreparedTextWithSegments, PretextLayout,
8};
9use ferroui_pretext_contracts::PretextError;
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum RichInlineBreakMode {
13    #[default]
14    Normal,
15    Never,
16}
17
18#[derive(Clone, Debug, PartialEq)]
19pub struct RichInlineItem {
20    pub text: String,
21    pub font: String,
22    pub break_mode: RichInlineBreakMode,
23    pub extra_width: f64,
24}
25
26impl RichInlineItem {
27    pub fn new(text: impl Into<String>, font: impl Into<String>) -> Self {
28        Self {
29            text: text.into(),
30            font: font.into(),
31            break_mode: RichInlineBreakMode::Normal,
32            extra_width: 0.0,
33        }
34    }
35
36    pub fn with_break_mode(mut self, break_mode: RichInlineBreakMode) -> Self {
37        self.break_mode = break_mode;
38        self
39    }
40
41    pub fn with_extra_width(mut self, extra_width: f64) -> Self {
42        self.extra_width = extra_width;
43        self
44    }
45}
46
47/// The item index addresses the prepared flow; fragment item indices address the
48/// original source item list, which may contain empty or whitespace-only items.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
50pub struct RichInlineCursor {
51    pub item_index: usize,
52    pub segment_index: usize,
53    pub grapheme_index: usize,
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub struct RichInlineStats {
58    pub line_count: usize,
59    pub max_line_width: f64,
60}
61
62#[derive(Clone, Debug, PartialEq)]
63pub struct RichInlineFragment {
64    pub item_index: usize,
65    pub text: String,
66    pub gap_before: f64,
67    pub occupied_width: f64,
68    pub start: LayoutCursor,
69    pub end: LayoutCursor,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct RichInlineFragmentRange {
74    pub item_index: usize,
75    pub gap_before: f64,
76    pub occupied_width: f64,
77    pub start: LayoutCursor,
78    pub end: LayoutCursor,
79}
80
81#[derive(Clone, Debug, PartialEq)]
82pub struct RichInlineLine {
83    pub fragments: Vec<RichInlineFragment>,
84    pub width: f64,
85    pub end: RichInlineCursor,
86}
87
88#[derive(Clone, Debug, PartialEq)]
89pub struct RichInlineLineRange {
90    pub fragments: Vec<RichInlineFragmentRange>,
91    pub width: f64,
92    pub end: RichInlineCursor,
93}
94
95/// Immutable, reusable rich inline preparation. Keep this value alive while
96/// retaining any geometry-only ranges which refer to its source item indices.
97#[derive(Debug)]
98pub struct PreparedRichInline {
99    pub(crate) items: Vec<PreparedRichInlineItem>,
100    pub(crate) items_by_source_item_index: Vec<Option<usize>>,
101}
102
103impl PreparedRichInline {
104    pub fn item_count(&self) -> usize {
105        self.items.len()
106    }
107
108    pub fn source_item_count(&self) -> usize {
109        self.items_by_source_item_index.len()
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.items.is_empty()
114    }
115
116    /// Obtain the prepared text for an original source item. Whitespace-only
117    /// source items have no prepared text, but may contribute a collapsed gap.
118    pub fn prepared_item(&self, source_item_index: usize) -> Option<&Rc<PreparedTextWithSegments>> {
119        self.items_by_source_item_index
120            .get(source_item_index)
121            .copied()
122            .flatten()
123            .map(|index| &self.items[index].prepared)
124    }
125}
126
127#[derive(Debug)]
128pub(crate) struct PreparedRichInlineItem {
129    pub(crate) break_mode: RichInlineBreakMode,
130    pub(crate) end_grapheme_index: usize,
131    pub(crate) end_segment_index: usize,
132    pub(crate) extra_width: f64,
133    pub(crate) gap_before: f64,
134    pub(crate) natural_width: f64,
135    pub(crate) prepared: Rc<PreparedTextWithSegments>,
136    pub(crate) source_item_index: usize,
137}
138
139impl PreparedRichInlineItem {
140    fn end(&self) -> LayoutCursor {
141        LayoutCursor {
142            segment_index: self.end_segment_index,
143            grapheme_index: self.end_grapheme_index,
144        }
145    }
146}
147
148const EMPTY_LAYOUT_CURSOR: LayoutCursor = LayoutCursor {
149    segment_index: 0,
150    grapheme_index: 0,
151};
152
153impl PretextLayout {
154    pub fn prepare_rich_inline(
155        &self,
156        items: &[RichInlineItem],
157    ) -> Result<Rc<PreparedRichInline>, PretextError> {
158        let mut prepared_items = Vec::with_capacity(items.len());
159        let mut items_by_source_item_index = vec![None; items.len()];
160        let mut space_width_cache = HashMap::new();
161        let mut pending_gap_width = 0.0;
162
163        for (index, item) in items.iter().enumerate() {
164            let trimmed = trim_collapsible_boundary_text(&item.text);
165            if trimmed.text.is_empty() {
166                if (trimmed.has_leading_whitespace || trimmed.has_trailing_whitespace)
167                    && pending_gap_width == 0.0
168                {
169                    pending_gap_width =
170                        self.collapsed_space_width(&item.font, &mut space_width_cache)?;
171                }
172                continue;
173            }
174
175            let gap_before = if pending_gap_width > 0.0 {
176                pending_gap_width
177            } else if trimmed.has_leading_whitespace {
178                self.collapsed_space_width(&item.font, &mut space_width_cache)?
179            } else {
180                0.0
181            };
182            let prepared =
183                self.prepare_with_segments(trimmed.text, &item.font, PrepareOptions::default())?;
184            let whole_line =
185                Self::layout_next_line_range(&prepared, EMPTY_LAYOUT_CURSOR, f64::INFINITY);
186            if let Some(whole_line) = whole_line {
187                items_by_source_item_index[index] = Some(prepared_items.len());
188                prepared_items.push(PreparedRichInlineItem {
189                    break_mode: item.break_mode,
190                    end_grapheme_index: whole_line.end.grapheme_index,
191                    end_segment_index: whole_line.end.segment_index,
192                    extra_width: item.extra_width,
193                    gap_before,
194                    natural_width: whole_line.width,
195                    prepared,
196                    source_item_index: index,
197                });
198            }
199
200            pending_gap_width = if trimmed.has_trailing_whitespace {
201                self.collapsed_space_width(&item.font, &mut space_width_cache)?
202            } else {
203                0.0
204            };
205        }
206
207        Ok(Rc::new(PreparedRichInline {
208            items: prepared_items,
209            items_by_source_item_index,
210        }))
211    }
212
213    pub fn layout_next_rich_inline_line_range(
214        prepared: &PreparedRichInline,
215        max_width: f64,
216        start: Option<RichInlineCursor>,
217    ) -> Option<RichInlineLineRange> {
218        let mut end = start.unwrap_or_default();
219        let mut fragments = Vec::new();
220        let width = step_rich_inline_line(
221            prepared,
222            max_width,
223            &mut end,
224            |item, gap_before, occupied_width, start, end| {
225                fragments.push(RichInlineFragmentRange {
226                    item_index: item.source_item_index,
227                    gap_before,
228                    occupied_width,
229                    start,
230                    end,
231                });
232            },
233        )?;
234        Some(RichInlineLineRange {
235            fragments,
236            width,
237            end,
238        })
239    }
240
241    /// Materialize a range produced from this same prepared flow.
242    ///
243    /// Panics if a caller fabricates a fragment referencing a missing source item,
244    /// matching the source's invalid-operation contract for mismatched ranges.
245    pub fn materialize_rich_inline_line_range(
246        prepared: &PreparedRichInline,
247        line: &RichInlineLineRange,
248    ) -> RichInlineLine {
249        let fragments = line
250            .fragments
251            .iter()
252            .map(|fragment| {
253                let item = prepared
254                    .prepared_item(fragment.item_index)
255                    .expect("rich inline range references a missing source item");
256                let materialized = Self::materialize_line_range(
257                    item,
258                    &LayoutLineRange {
259                        width: fragment.occupied_width,
260                        start: fragment.start,
261                        end: fragment.end,
262                    },
263                );
264                RichInlineFragment {
265                    item_index: fragment.item_index,
266                    text: materialized.text,
267                    gap_before: fragment.gap_before,
268                    occupied_width: fragment.occupied_width,
269                    start: fragment.start,
270                    end: fragment.end,
271                }
272            })
273            .collect();
274        RichInlineLine {
275            fragments,
276            width: line.width,
277            end: line.end,
278        }
279    }
280
281    pub fn walk_rich_inline_line_ranges(
282        prepared: &PreparedRichInline,
283        max_width: f64,
284        mut on_line: impl FnMut(RichInlineLineRange),
285    ) -> usize {
286        let mut cursor = RichInlineCursor::default();
287        let mut line_count = 0;
288        while let Some(line) =
289            Self::layout_next_rich_inline_line_range(prepared, max_width, Some(cursor))
290        {
291            cursor = line.end;
292            on_line(line);
293            line_count += 1;
294        }
295        line_count
296    }
297
298    /// Compute metrics without allocating fragment vectors or materialized text.
299    pub fn measure_rich_inline_stats(
300        prepared: &PreparedRichInline,
301        max_width: f64,
302    ) -> RichInlineStats {
303        let mut cursor = RichInlineCursor::default();
304        let mut stats = RichInlineStats::default();
305        while let Some(width) =
306            step_rich_inline_line(prepared, max_width, &mut cursor, |_, _, _, _, _| {})
307        {
308            stats.line_count += 1;
309            stats.max_line_width = stats.max_line_width.max(width);
310        }
311        stats
312    }
313
314    fn collapsed_space_width(
315        &self,
316        font: &str,
317        cache: &mut HashMap<String, f64>,
318    ) -> Result<f64, PretextError> {
319        if let Some(width) = cache.get(font) {
320            return Ok(*width);
321        }
322        let joined = self.prepare_with_segments("A A", font, PrepareOptions::default())?;
323        let compact = self.prepare_with_segments("AA", font, PrepareOptions::default())?;
324        let width =
325            (Self::measure_natural_width(&joined) - Self::measure_natural_width(&compact)).max(0.0);
326        cache.insert(font.to_owned(), width);
327        Ok(width)
328    }
329}
330
331struct BoundaryTrimResult<'a> {
332    text: &'a str,
333    has_leading_whitespace: bool,
334    has_trailing_whitespace: bool,
335}
336
337fn trim_collapsible_boundary_text(text: &str) -> BoundaryTrimResult<'_> {
338    let without_start = text.trim_start_matches(is_collapsible_boundary_char);
339    let trimmed = without_start.trim_end_matches(is_collapsible_boundary_char);
340    BoundaryTrimResult {
341        text: trimmed,
342        has_leading_whitespace: without_start.len() < text.len(),
343        // Whitespace-only source items contribute a pending gap on either edge.
344        has_trailing_whitespace: text.ends_with(is_collapsible_boundary_char),
345    }
346}
347
348fn is_collapsible_boundary_char(ch: char) -> bool {
349    matches!(ch, ' ' | '\t' | '\n' | '\u{000c}' | '\r')
350}
351
352/// Shared stepping engine. The no-op metrics callback monomorphizes away and
353/// therefore shares the exact wrapping decisions without constructing fragments.
354fn step_rich_inline_line(
355    prepared: &PreparedRichInline,
356    max_width: f64,
357    cursor: &mut RichInlineCursor,
358    mut on_fragment: impl FnMut(&PreparedRichInlineItem, f64, f64, LayoutCursor, LayoutCursor),
359) -> Option<f64> {
360    let items = &prepared.items;
361    if cursor.item_index >= items.len() {
362        return None;
363    }
364    let safe_width = if max_width > 1.0 { max_width } else { 1.0 };
365    let mut line_width = 0.0;
366    let mut has_fragment = false;
367    let mut remaining_width = safe_width;
368    let mut item_index = cursor.item_index;
369    let mut text_cursor = LayoutCursor {
370        segment_index: cursor.segment_index,
371        grapheme_index: cursor.grapheme_index,
372    };
373
374    while let Some(item) = items.get(item_index) {
375        if text_cursor != EMPTY_LAYOUT_CURSOR && text_cursor == item.end() {
376            item_index += 1;
377            text_cursor = EMPTY_LAYOUT_CURSOR;
378            continue;
379        }
380
381        let gap_before = if line_width == 0.0 {
382            0.0
383        } else {
384            item.gap_before
385        };
386        let at_item_start = text_cursor == EMPTY_LAYOUT_CURSOR;
387        if item.break_mode == RichInlineBreakMode::Never {
388            if !at_item_start {
389                item_index += 1;
390                text_cursor = EMPTY_LAYOUT_CURSOR;
391                continue;
392            }
393            let occupied_width = item.natural_width + item.extra_width;
394            let total_width = gap_before + occupied_width;
395            if line_width > 0.0 && total_width > remaining_width {
396                break;
397            }
398            on_fragment(
399                item,
400                gap_before,
401                occupied_width,
402                EMPTY_LAYOUT_CURSOR,
403                item.end(),
404            );
405            has_fragment = true;
406            line_width += total_width;
407            remaining_width = (safe_width - line_width).max(0.0);
408            item_index += 1;
409            text_cursor = EMPTY_LAYOUT_CURSOR;
410            continue;
411        }
412
413        let reserved_width = gap_before + item.extra_width;
414        if line_width > 0.0 && reserved_width >= remaining_width {
415            break;
416        }
417
418        if at_item_start {
419            let total_width = reserved_width + item.natural_width;
420            if total_width <= remaining_width {
421                on_fragment(
422                    item,
423                    gap_before,
424                    item.natural_width + item.extra_width,
425                    EMPTY_LAYOUT_CURSOR,
426                    item.end(),
427                );
428                has_fragment = true;
429                line_width += total_width;
430                remaining_width = (safe_width - line_width).max(0.0);
431                item_index += 1;
432                text_cursor = EMPTY_LAYOUT_CURSOR;
433                continue;
434            }
435        }
436
437        let available_width = (remaining_width - reserved_width).max(1.0);
438        let Some(line) =
439            PretextLayout::layout_next_line_range(&item.prepared, text_cursor, available_width)
440        else {
441            item_index += 1;
442            text_cursor = EMPTY_LAYOUT_CURSOR;
443            continue;
444        };
445        if text_cursor == line.end {
446            item_index += 1;
447            text_cursor = EMPTY_LAYOUT_CURSOR;
448            continue;
449        }
450
451        if line_width > 0.0
452            && at_item_start
453            && gap_before > 0.0
454            && line.end.segment_index == 0
455            && line.end.grapheme_index > 0
456        {
457            let fresh_line = PretextLayout::layout_next_line_range(
458                &item.prepared,
459                EMPTY_LAYOUT_CURSOR,
460                (safe_width - item.extra_width).max(1.0),
461            );
462            if fresh_line.is_some_and(|fresh| fresh.end > line.end) {
463                break;
464            }
465        }
466
467        on_fragment(
468            item,
469            gap_before,
470            line.width + item.extra_width,
471            text_cursor,
472            line.end,
473        );
474        has_fragment = true;
475        line_width += gap_before + line.width + item.extra_width;
476        remaining_width = (safe_width - line_width).max(0.0);
477        if line.end == item.end() {
478            item_index += 1;
479            text_cursor = EMPTY_LAYOUT_CURSOR;
480            continue;
481        }
482        text_cursor = line.end;
483        break;
484    }
485
486    // Visible source text can legitimately measure zero. The source tested
487    // width == 0 here, which discarded that text; fragment presence determines
488    // whether a line exists independently of its painted advance.
489    if !has_fragment {
490        return None;
491    }
492    *cursor = RichInlineCursor {
493        item_index,
494        segment_index: text_cursor.segment_index,
495        grapheme_index: text_cursor.grapheme_index,
496    };
497    Some(line_width)
498}