1use crate::{GlyphPosition, ShapedText, TextPipeline, TextStyle};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TextAlign {
14 #[default]
16 Left,
17 Center,
19 Right,
21 Justify,
23}
24
25pub struct TextLayout {
29 pub shaped: ShapedText,
31 pub align: TextAlign,
33 pub bounds: (f32, f32),
35}
36
37impl TextLayout {
38 pub fn new(
43 pipeline: &mut TextPipeline,
44 text: &str,
45 style: &TextStyle,
46 max_width: f32,
47 align: TextAlign,
48 ) -> Result<Self, crate::TextError> {
49 let mut style_with_width = style.clone();
50 style_with_width.max_width = max_width;
51 let shaped = pipeline.shape(text, &style_with_width)?;
52 let total_height = shaped.total_height;
53 Ok(Self {
54 shaped,
55 align,
56 bounds: (max_width, total_height),
57 })
58 }
59
60 pub fn align_glyphs(&self) -> Vec<Vec<GlyphPosition>> {
64 let max_w = self.bounds.0;
65 self.shaped
66 .lines
67 .iter()
68 .map(|line| {
69 if line.is_empty() {
70 return line.clone();
71 }
72
73 let line_w = line.iter().map(|g| g.x + g.width).fold(0.0_f32, f32::max);
74
75 let offset_x = match self.align {
76 TextAlign::Left => 0.0,
77 TextAlign::Right => (max_w - line_w).max(0.0),
78 TextAlign::Center => ((max_w - line_w) / 2.0).max(0.0),
79 TextAlign::Justify => 0.0, };
81
82 if matches!(self.align, TextAlign::Justify) {
83 let gap = (max_w - line_w) / (line.len().saturating_sub(1).max(1)) as f32;
85 line.iter()
86 .enumerate()
87 .map(|(i, g)| GlyphPosition {
88 x: g.x + gap * i as f32,
89 ..g.clone()
90 })
91 .collect()
92 } else {
93 line.iter()
94 .map(|g| GlyphPosition {
95 x: g.x + offset_x,
96 ..g.clone()
97 })
98 .collect()
99 }
100 })
101 .collect()
102 }
103
104 pub fn hit_test_fast(&self, x: f32) -> usize {
116 let positions: Vec<f32> = self
118 .shaped
119 .lines
120 .iter()
121 .flat_map(|line| line.iter().map(|g| g.x))
122 .collect();
123
124 if positions.is_empty() {
125 return 0;
126 }
127
128 let insert = positions.partition_point(|&pos| pos < x);
132
133 if insert == 0 {
134 return 0;
135 }
136 if insert >= positions.len() {
137 return positions.len() - 1;
138 }
139
140 let prev = insert - 1;
142 if (x - positions[prev]).abs() <= (x - positions[insert]).abs() {
143 prev
144 } else {
145 insert
146 }
147 }
148
149 pub fn hit_test(&self, x: f32, y: f32) -> usize {
153 if self.shaped.lines.is_empty() {
154 return 0;
155 }
156
157 let line = {
159 let mut best_line: &Vec<GlyphPosition> = &self.shaped.lines[0];
160 let mut best_dist = f32::MAX;
161 for line in &self.shaped.lines {
162 if line.is_empty() {
163 continue;
164 }
165 let top = line[0].y;
166 let bottom = top + line[0].height;
167 let mid = (top + bottom) * 0.5;
168 let dist = (y - mid).abs();
169 if dist < best_dist {
170 best_dist = dist;
171 best_line = line;
172 }
173 }
174 best_line
175 };
176
177 if line.is_empty() {
178 return 0;
179 }
180
181 let mut best_offset = line[0].byte_offset;
183 let mut best_dist = f32::MAX;
184 for g in line {
185 let mid = g.x + g.width * 0.5;
186 let dist = (x - mid).abs();
187 if dist < best_dist {
188 best_dist = dist;
189 best_offset = g.byte_offset;
190 }
191 }
192 best_offset
193 }
194}
195
196#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::GlyphPosition;
202
203 fn fake_shaped(lines: Vec<Vec<GlyphPosition>>) -> ShapedText {
204 let total_width = lines
205 .iter()
206 .flat_map(|l| l.iter())
207 .map(|g| g.x + g.width)
208 .fold(0.0_f32, f32::max);
209 let total_height = lines
210 .iter()
211 .flat_map(|l| l.iter())
212 .map(|g| g.y + g.height)
213 .fold(0.0_f32, f32::max);
214 ShapedText {
215 lines,
216 total_width,
217 total_height,
218 }
219 }
220
221 fn single_line_layout(align: TextAlign, max_w: f32) -> TextLayout {
222 let line = vec![
223 GlyphPosition {
224 byte_offset: 0,
225 x: 0.0,
226 y: 0.0,
227 width: 10.0,
228 height: 16.0,
229 },
230 GlyphPosition {
231 byte_offset: 1,
232 x: 10.0,
233 y: 0.0,
234 width: 10.0,
235 height: 16.0,
236 },
237 ];
238 let shaped = fake_shaped(vec![line]);
239 TextLayout {
240 shaped,
241 align,
242 bounds: (max_w, 16.0),
243 }
244 }
245
246 #[test]
247 fn layout_left_align_starts_at_zero() {
248 let layout = single_line_layout(TextAlign::Left, 200.0);
249 let aligned = layout.align_glyphs();
250 let first_x = aligned[0][0].x;
251 assert!(
252 (first_x - 0.0).abs() < f32::EPSILON,
253 "left-aligned glyph should start at x=0"
254 );
255 }
256
257 #[test]
258 fn layout_right_align_ends_at_max_width() {
259 let layout = single_line_layout(TextAlign::Right, 200.0);
260 let aligned = layout.align_glyphs();
261 let last = aligned[0].last().unwrap();
262 let end_x = last.x + last.width;
263 assert!(
264 (end_x - 200.0).abs() < f32::EPSILON,
265 "right-aligned line should end at max_width"
266 );
267 }
268
269 #[test]
270 fn layout_center_align_midpoint() {
271 let layout = single_line_layout(TextAlign::Center, 100.0);
272 let aligned = layout.align_glyphs();
273 let first_x = aligned[0][0].x;
275 assert!(
276 (first_x - 40.0).abs() < f32::EPSILON,
277 "center first glyph x should be 40"
278 );
279 }
280
281 #[test]
282 fn layout_hit_test_basic() {
283 let layout = single_line_layout(TextAlign::Left, 100.0);
284 let offset = layout.hit_test(5.0, 8.0);
286 assert_eq!(offset, 0);
287 let offset2 = layout.hit_test(15.0, 8.0);
289 assert_eq!(offset2, 1);
290 }
291
292 #[test]
295 fn hit_test_fast_returns_index_zero_for_leftmost() {
296 let layout = single_line_layout(TextAlign::Left, 100.0);
297 let idx = layout.hit_test_fast(0.0);
299 assert_eq!(idx, 0);
300 }
301
302 #[test]
303 fn hit_test_fast_returns_last_index_for_far_right() {
304 let layout = single_line_layout(TextAlign::Left, 100.0);
305 let idx = layout.hit_test_fast(100.0);
307 assert_eq!(idx, 1);
308 }
309
310 #[test]
311 fn hit_test_fast_midpoint_tie_breaks_to_left() {
312 let layout = single_line_layout(TextAlign::Left, 100.0);
315 let idx = layout.hit_test_fast(5.0);
316 assert_eq!(idx, 0);
317 }
318
319 #[test]
320 fn hit_test_fast_empty_layout_returns_zero() {
321 let shaped = ShapedText {
322 lines: Vec::new(),
323 total_width: 0.0,
324 total_height: 0.0,
325 };
326 let layout = TextLayout {
327 shaped,
328 align: TextAlign::Left,
329 bounds: (100.0, 0.0),
330 };
331 assert_eq!(layout.hit_test_fast(50.0), 0);
332 }
333}