Skip to main content

fission_layout/
paragraph.rs

1use crate::{LayoutPoint, LayoutRect, LayoutSize, LayoutUnit};
2use serde::{Deserialize, Serialize};
3
4/// Per-line metrics from the same shaped paragraph used for paint and hit testing.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub struct LineMetric {
7    /// Byte index where this line starts in the source string.
8    pub start_index: usize,
9    /// Byte index where this line ends in the source string (exclusive).
10    pub end_index: usize,
11    /// Distance from the top of the paragraph to the alphabetic baseline.
12    pub baseline: LayoutUnit,
13    /// Total line height, including leading.
14    pub height: LayoutUnit,
15    /// Shaped width of the line.
16    pub width: LayoutUnit,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct RichTextInlineBox {
21    pub id: u64,
22    pub x: LayoutUnit,
23    pub y: LayoutUnit,
24    pub width: LayoutUnit,
25    pub height: LayoutUnit,
26}
27
28/// One visually positioned shaping cluster. Byte ranges always fall on UTF-8
29/// boundaries and may contain more than one scalar value (for example a
30/// ligature, combining sequence, or emoji ZWJ sequence).
31#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
32pub struct ParagraphCluster {
33    pub start_index: usize,
34    pub end_index: usize,
35    pub line_index: usize,
36    pub rect: LayoutRect,
37    pub is_rtl: bool,
38}
39
40/// One shaped glyph and its association with a logical cluster.
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
42pub struct ParagraphGlyph {
43    pub id: u32,
44    pub style_index: usize,
45    pub cluster_index: usize,
46    pub position: LayoutPoint,
47    pub advance: LayoutUnit,
48}
49
50/// An atomic selectable visual box associated with a logical source range.
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
52pub struct ParagraphSelectionBox {
53    pub start_index: usize,
54    pub end_index: usize,
55    pub line_index: usize,
56    pub rect: LayoutRect,
57}
58
59/// A legal caret stop resolved by the shaping backend.
60#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
61pub struct ParagraphCaretStop {
62    pub index: usize,
63    pub upstream: bool,
64    pub position: LayoutPoint,
65    pub height: LayoutUnit,
66}
67
68/// Immutable, backend-neutral summary of one resolved paragraph.
69///
70/// The width is the exact wrapping constraint. Layout snapshots retain this
71/// result so paint, hit testing, caret/selection geometry, accessibility, and
72/// IME positioning can consume the same paragraph decision rather than derive
73/// another width from an ancestor.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct ResolvedParagraphLayout {
76    pub constraint_width: Option<LayoutUnit>,
77    pub size: LayoutSize,
78    pub lines: Vec<LineMetric>,
79    pub inline_boxes: Vec<RichTextInlineBox>,
80    /// Visual-order glyph/cluster mapping used by hit testing and selection.
81    pub clusters: Vec<ParagraphCluster>,
82    /// Shaped glyph positions mapped back to entries in `clusters`.
83    pub glyphs: Vec<ParagraphGlyph>,
84    /// Legal caret positions, including bidi-affinity alternatives.
85    pub caret_stops: Vec<ParagraphCaretStop>,
86    /// Atomic boxes from which arbitrary selection geometry is formed.
87    pub selection_boxes: Vec<ParagraphSelectionBox>,
88}
89
90impl ResolvedParagraphLayout {
91    pub fn empty(constraint_width: Option<LayoutUnit>) -> Self {
92        Self {
93            constraint_width,
94            size: LayoutSize::ZERO,
95            lines: Vec::new(),
96            inline_boxes: Vec::new(),
97            clusters: Vec::new(),
98            glyphs: Vec::new(),
99            caret_stops: Vec::new(),
100            selection_boxes: Vec::new(),
101        }
102    }
103
104    /// Resolve a point to the nearest shaped cluster boundary.
105    pub fn hit_test(&self, point: LayoutPoint) -> usize {
106        let Some(cluster) = self.clusters.iter().min_by(|left, right| {
107            distance_to_rect(point, left.rect).total_cmp(&distance_to_rect(point, right.rect))
108        }) else {
109            return 0;
110        };
111        let after_midpoint = point.x >= cluster.rect.x() + cluster.rect.width() * 0.5;
112        match (cluster.is_rtl, after_midpoint) {
113            (false, false) | (true, true) => cluster.start_index,
114            (false, true) | (true, false) => cluster.end_index,
115        }
116    }
117
118    /// Return the resolved caret geometry for a byte index and affinity.
119    pub fn caret(&self, index: usize, upstream: bool) -> Option<ParagraphCaretStop> {
120        self.caret_stops
121            .iter()
122            .find(|stop| stop.index == index && stop.upstream == upstream)
123            .copied()
124            .or_else(|| {
125                self.caret_stops
126                    .iter()
127                    .filter(|stop| stop.upstream == upstream)
128                    .min_by_key(|stop| stop.index.abs_diff(index))
129                    .copied()
130            })
131    }
132
133    /// Return visual rectangles for a logical byte range. Disjoint rectangles
134    /// are preserved for bidi text; adjacent clusters on one line are merged.
135    pub fn selection_rects(&self, start: usize, end: usize) -> Vec<LayoutRect> {
136        let range = start.min(end)..start.max(end);
137        let mut selected = self
138            .selection_boxes
139            .iter()
140            .filter(|selection| {
141                selection.start_index < range.end && selection.end_index > range.start
142            })
143            .copied()
144            .collect::<Vec<_>>();
145        selected.sort_by(|left, right| {
146            left.line_index
147                .cmp(&right.line_index)
148                .then_with(|| left.rect.x().total_cmp(&right.rect.x()))
149        });
150        let mut rects: Vec<LayoutRect> = Vec::new();
151        for selection in selected {
152            if let Some(last) = rects.last_mut() {
153                let same_line = (last.y() - selection.rect.y()).abs() < 0.5
154                    && (last.height() - selection.rect.height()).abs() < 0.5;
155                if same_line && selection.rect.x() <= last.right() + 0.5 {
156                    let right = last.right().max(selection.rect.right());
157                    last.size.width = right - last.x();
158                    continue;
159                }
160            }
161            rects.push(selection.rect);
162        }
163        rects
164    }
165}
166
167fn distance_to_rect(point: LayoutPoint, rect: LayoutRect) -> LayoutUnit {
168    let dx = if point.x < rect.x() {
169        rect.x() - point.x
170    } else if point.x > rect.right() {
171        point.x - rect.right()
172    } else {
173        0.0
174    };
175    let dy = if point.y < rect.y() {
176        rect.y() - point.y
177    } else if point.y > rect.bottom() {
178        point.y - rect.bottom()
179    } else {
180        0.0
181    };
182    dx * dx + dy * dy
183}
184
185/// Compatibility view for callers interested only in size and inline boxes.
186#[derive(Debug, Clone, PartialEq)]
187pub struct RichTextLayoutInfo {
188    pub width: LayoutUnit,
189    pub height: LayoutUnit,
190    pub inline_boxes: Vec<RichTextInlineBox>,
191}
192
193impl From<ResolvedParagraphLayout> for RichTextLayoutInfo {
194    fn from(layout: ResolvedParagraphLayout) -> Self {
195        Self {
196            width: layout.size.width,
197            height: layout.size.height,
198            inline_boxes: layout.inline_boxes,
199        }
200    }
201}