text_typeset/layout/line.rs
1use std::ops::Range;
2
3use crate::shaping::run::ShapedRun;
4use crate::shaping::shaper::TextDirection;
5use crate::types::CursorAffinity;
6
7/// One place the caret can sit on a line: a logical offset, the x it
8/// renders at, and which side of its run produced it.
9///
10/// `trailing` is what makes a direction boundary resolvable. A boundary
11/// offset appears twice — once as the trailing edge of the run before it
12/// and once as the leading edge of the run after — and the two sit at
13/// different x. Recording which is which lets the caller pick by
14/// affinity instead of by whichever happened to be leftmost.
15#[derive(Clone, Copy, Debug)]
16pub(crate) struct CaretStop {
17 pub offset: usize,
18 pub x: f32,
19 /// This stop is the *end* of its run's logical extent.
20 pub trailing: bool,
21}
22
23/// Choose among the stops that share one offset.
24///
25/// With a single candidate there is nothing to disambiguate. With two —
26/// a direction boundary — `Downstream` attaches the caret to the text
27/// before the offset (the trailing stop) and `Upstream` to the text
28/// after it (the leading stop). Falls back to the first candidate if the
29/// expected side is absent, so a malformed line still yields a caret.
30fn pick_by_affinity<'a>(
31 stops: &[&'a CaretStop],
32 affinity: CursorAffinity,
33) -> Option<&'a CaretStop> {
34 if stops.len() < 2 {
35 return stops.first().copied();
36 }
37 let want_trailing = affinity == CursorAffinity::Downstream;
38 stops
39 .iter()
40 .find(|s| s.trailing == want_trailing)
41 .or_else(|| stops.first())
42 .copied()
43}
44
45#[derive(Clone)]
46pub struct LayoutLine {
47 pub runs: Vec<PositionedRun>,
48 /// Baseline y relative to block top (set by block layout).
49 pub y: f32,
50 pub ascent: f32,
51 pub descent: f32,
52 pub leading: f32,
53 /// Total line height: ascent + descent + leading.
54 pub line_height: f32,
55 /// Actual content width (sum of run advances).
56 pub width: f32,
57 /// Character range in the block's text.
58 pub char_range: Range<usize>,
59}
60
61impl LayoutLine {
62 /// End (exclusive) of the cluster starting at char offset `cluster`:
63 /// the smallest distinct glyph cluster in the line strictly greater
64 /// than `cluster`, or the line's `char_range.end` if none is larger.
65 ///
66 /// Clusters across the whole line are the complete set of logical
67 /// char-offset boundaries, so the next-larger one is exactly where
68 /// `cluster`'s char span ends — true for LTR, RTL, and multi-char
69 /// (ligature) clusters alike.
70 pub(crate) fn cluster_end(&self, cluster: usize) -> usize {
71 let mut best: Option<usize> = None;
72 for run in &self.runs {
73 for g in &run.shaped_run.glyphs {
74 let c = g.cluster as usize;
75 if c > cluster {
76 best = Some(best.map_or(c, |b| b.min(c)));
77 }
78 }
79 }
80 best.unwrap_or(self.char_range.end)
81 }
82
83 /// Find the x coordinate for a char offset within this line.
84 ///
85 /// Equivalent to [`Self::x_for_offset_with_affinity`] with the
86 /// default (downstream) affinity — the caret attaches to the text
87 /// *before* the offset.
88 pub fn x_for_offset(&self, offset: usize) -> f32 {
89 self.x_for_offset_with_affinity(offset, CursorAffinity::default())
90 }
91
92 /// Find the x coordinate for a char offset, disambiguating a
93 /// direction boundary with `affinity`.
94 ///
95 /// Builds the line's caret stops in visual order — direction-aware,
96 /// so an RTL run's caret for its lowest offset sits at its rightmost
97 /// edge — then returns the x of the stop matching `offset`.
98 ///
99 /// One offset can produce **two** stops. Where an LTR run meets an
100 /// RTL one, the boundary offset is both the trailing edge of the run
101 /// before it and the leading edge of the run after it, and those sit
102 /// at completely different x — often at opposite ends of the line.
103 /// Neither is "the" answer: which one the writer means depends on
104 /// which side they arrived from, so it is the caller's to say.
105 /// Picking the leftmost, as this used to, put the caret at the far
106 /// end of the line whenever the seam ran the other way.
107 ///
108 /// `Downstream` attaches the caret to the text before the offset (the
109 /// trailing stop), `Upstream` to the text after it (the leading
110 /// stop) — the same "before or after" question affinity already
111 /// answers at a soft-wrap boundary.
112 pub fn x_for_offset_with_affinity(&self, offset: usize, affinity: CursorAffinity) -> f32 {
113 let stops = self.caret_stops();
114 if stops.is_empty() {
115 return 0.0;
116 }
117
118 let exact: Vec<&CaretStop> = stops.iter().filter(|s| s.offset == offset).collect();
119 if let Some(stop) = pick_by_affinity(&exact, affinity) {
120 return stop.x;
121 }
122
123 // No exact stop (e.g. offset inside a multi-char cluster): snap to
124 // the nearest stop by logical distance.
125 stops
126 .iter()
127 .min_by_key(|s| s.offset.abs_diff(offset))
128 .map(|s| s.x)
129 .unwrap_or(0.0)
130 }
131
132 /// Whether `offset` sits on a direction boundary within this line —
133 /// i.e. whether [`Self::x_for_offset_with_affinity`] would return
134 /// different x for the two affinities.
135 ///
136 /// The widget layer needs this to know when moving the caret across
137 /// a seam has to flip affinity rather than leave it alone.
138 pub fn is_direction_boundary(&self, offset: usize) -> bool {
139 let stops = self.caret_stops();
140 let mut xs = stops.iter().filter(|s| s.offset == offset).map(|s| s.x);
141 let Some(first) = xs.next() else {
142 return false;
143 };
144 xs.any(|x| x != first)
145 }
146
147 /// Caret stops across the line in visual (left-to-right) order. For
148 /// an LTR run each glyph contributes a stop at its left edge (the
149 /// glyph's own cluster) plus a trailing stop at the run's right edge;
150 /// for an RTL run the leftmost edge is the trailing (highest) offset
151 /// and each glyph's right edge is its own (leading) offset.
152 pub(crate) fn caret_stops(&self) -> Vec<CaretStop> {
153 let mut stops: Vec<CaretStop> = Vec::new();
154 for run in &self.runs {
155 let glyphs = &run.shaped_run.glyphs;
156 if glyphs.is_empty() {
157 continue;
158 }
159 let mut gx = run.x;
160 if run.shaped_run.direction == TextDirection::RightToLeft {
161 // Leftmost edge: caret after the last logical char in the run.
162 stops.push(CaretStop {
163 offset: self.cluster_end(glyphs[0].cluster as usize),
164 x: gx,
165 trailing: true,
166 });
167 for g in glyphs {
168 gx += g.x_advance;
169 stops.push(CaretStop {
170 offset: g.cluster as usize,
171 x: gx,
172 trailing: false,
173 });
174 }
175 } else {
176 for g in glyphs {
177 stops.push(CaretStop {
178 offset: g.cluster as usize,
179 x: gx,
180 trailing: false,
181 });
182 gx += g.x_advance;
183 }
184 // Rightmost edge: caret after the last logical char in the run.
185 let last = glyphs.last().map(|g| g.cluster as usize).unwrap_or(0);
186 stops.push(CaretStop {
187 offset: self.cluster_end(last),
188 x: gx,
189 trailing: true,
190 });
191 }
192 }
193 stops
194 }
195}
196
197#[derive(Clone)]
198pub struct PositionedRun {
199 pub shaped_run: ShapedRun,
200 /// X offset from the left edge of the content area.
201 pub x: f32,
202 /// Decoration flags for this run.
203 pub decorations: RunDecorations,
204}
205
206/// Text decoration flags and metadata carried from the source TextFormat.
207#[derive(Clone, Debug, Default)]
208pub struct RunDecorations {
209 pub underline_style: crate::types::UnderlineStyle,
210 pub overline: bool,
211 pub strikeout: bool,
212 pub is_link: bool,
213 /// Text foreground color (RGBA). None means default (black).
214 pub foreground_color: Option<[f32; 4]>,
215 /// Underline color (RGBA). None means use foreground_color.
216 pub underline_color: Option<[f32; 4]>,
217 /// Text-level background highlight color (RGBA). None means transparent.
218 pub background_color: Option<[f32; 4]>,
219 /// Hyperlink destination URL.
220 pub anchor_href: Option<String>,
221 /// Tooltip text.
222 pub tooltip: Option<String>,
223 /// Vertical alignment (normal, superscript, subscript).
224 pub vertical_alignment: crate::types::VerticalAlignment,
225}