1use crate::parley_shaper::ShapedCluster;
2use fret_core::{CaretAffinity, HitTestResult, Point, Rect, Size, TextMetrics, geometry::Px};
3use std::ops::Range;
4
5fn utf8_grapheme_boundaries(text: &str) -> Vec<usize> {
6 use unicode_segmentation::UnicodeSegmentation as _;
7
8 let mut out: Vec<usize> = Vec::with_capacity(text.chars().count().saturating_add(2));
9 out.push(0);
10 for (i, _) in text.grapheme_indices(true) {
11 out.push(i);
12 }
13 out.push(text.len());
14 out.sort_unstable();
15 out.dedup();
16 out
17}
18
19pub fn caret_stops_for_slice(
20 slice: &str,
21 base_offset: usize,
22 clusters: &[ShapedCluster],
23 line_width_px: f32,
24 scale: f32,
25 kept_end: usize,
26) -> Vec<(usize, Px)> {
27 let mut out: Vec<(usize, Px)> = Vec::new();
28 let boundaries = utf8_grapheme_boundaries(slice);
29
30 if boundaries.is_empty() {
31 return vec![(base_offset, Px(0.0))];
32 }
33
34 if clusters.is_empty() {
35 for &b in &boundaries {
36 let idx = base_offset + b;
37 if idx > kept_end {
38 continue;
39 }
40 let x = if b >= slice.len() {
41 (line_width_px / scale).max(0.0)
42 } else {
43 0.0
44 };
45 out.push((idx, Px(x)));
46 }
47 out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.0.total_cmp(&b.1.0)));
48 out.dedup_by(|a, b| a.0 == b.0);
49 return out;
50 }
51
52 let last_cluster_end = clusters
53 .iter()
54 .map(|c| c.text_range().end)
55 .max()
56 .unwrap_or(0)
57 .min(slice.len());
58 let effective_line_width_px = clusters
59 .iter()
60 .flat_map(|c| [c.x0(), c.x1()])
61 .fold(line_width_px, |acc, x| acc.max(x.max(0.0)));
62
63 let mut cluster_i = 0usize;
64 for &b in &boundaries {
65 let idx = base_offset + b;
66 if idx > kept_end {
67 continue;
68 }
69
70 while cluster_i + 1 < clusters.len() && clusters[cluster_i].text_range().end < b {
71 cluster_i = cluster_i.saturating_add(1);
72 }
73
74 let x = if b <= clusters[0].text_range().start {
75 let first = &clusters[0];
76 if first.is_rtl() {
77 first.x1().max(0.0)
78 } else {
79 first.x0().max(0.0)
80 }
81 } else if b > last_cluster_end {
82 let last = clusters.last().unwrap_or(&clusters[0]);
83 if last.is_rtl() {
84 0.0
85 } else {
86 effective_line_width_px
87 }
88 } else if cluster_i >= clusters.len() {
89 let last = clusters.last().unwrap_or(&clusters[0]);
90 if last.is_rtl() {
91 0.0
92 } else {
93 line_width_px.max(0.0)
94 }
95 } else {
96 let c = &clusters[cluster_i];
97 let text_range = c.text_range();
98 let start = text_range.start.min(slice.len());
99 let end = text_range.end.min(slice.len());
100
101 if start == end {
102 c.x0().max(0.0)
103 } else if b <= start {
104 if c.is_rtl() {
105 c.x1().max(0.0)
106 } else {
107 c.x0().max(0.0)
108 }
109 } else if b >= end {
110 if c.is_rtl() {
111 c.x0().max(0.0)
112 } else {
113 c.x1().max(0.0)
114 }
115 } else {
116 let denom = (end - start) as f32;
117 let mut t = ((b - start) as f32 / denom).clamp(0.0, 1.0);
118 if c.is_rtl() {
119 t = 1.0 - t;
120 }
121 let w = (c.x1() - c.x0()).max(0.0);
122 (c.x0() + w * t).max(0.0)
123 }
124 };
125
126 out.push((idx, Px((x / scale).max(0.0))));
127 }
128
129 out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.0.total_cmp(&b.1.0)));
130 out.dedup_by(|a, b| a.0 == b.0);
131 out
132}
133
134pub fn caret_x_from_stops(stops: &[(usize, Px)], index: usize) -> Px {
135 if stops.is_empty() {
136 return Px(0.0);
137 }
138 if let Ok(pos) = stops.binary_search_by_key(&index, |(idx, _)| *idx) {
139 return stops[pos].1;
140 }
141 match stops.partition_point(|(idx, _)| *idx <= index) {
142 0 => stops[0].1,
143 n => stops[n.saturating_sub(1)].1,
144 }
145}
146
147pub fn hit_test_x_from_stops(stops: &[(usize, Px)], x: Px) -> usize {
148 if stops.is_empty() {
149 return 0;
150 }
151 let mut best = stops[0].0;
152 let mut best_dist = (stops[0].1.0 - x.0).abs();
153 for (idx, px) in stops {
154 let dist = (px.0 - x.0).abs();
155 if dist < best_dist {
156 best = *idx;
157 best_dist = dist;
158 }
159 }
160 best
161}
162
163pub(crate) fn shaped_line_visual_x_bounds_px(
164 line: &crate::parley_shaper::ShapedLineLayout,
165) -> (f32, f32) {
166 let fallback_max = line.width().max(0.0);
167 if line.clusters().is_empty() {
168 return (0.0, fallback_max);
169 }
170
171 let mut min_x = f32::INFINITY;
172 let mut max_x = f32::NEG_INFINITY;
173 for c in line.clusters() {
174 let a = c.x0();
175 let b = c.x1();
176 min_x = min_x.min(a.min(b));
177 max_x = max_x.max(a.max(b));
178 }
179
180 if !min_x.is_finite() || !max_x.is_finite() || max_x < min_x {
181 return (0.0, fallback_max);
182 }
183
184 (min_x, max_x.max(min_x))
185}
186
187fn shaped_line_visual_width_px(line: &crate::parley_shaper::ShapedLineLayout) -> f32 {
188 let (min_x, max_x) = shaped_line_visual_x_bounds_px(line);
189 (max_x - min_x).max(0.0)
190}
191
192pub(crate) fn metrics_from_wrapped_lines(
193 lines: &[crate::parley_shaper::ShapedLineLayout],
194 scale: f32,
195) -> TextMetrics {
196 let snap_vertical = scale.is_finite() && scale.fract().abs() > 1e-4 && scale >= 1.0;
197
198 let mut first_baseline_px = lines.first().map(|l| l.baseline().max(0.0)).unwrap_or(0.0);
199 if snap_vertical && let Some(first) = lines.first() {
200 let top_px = 0.0_f32;
201 let bottom_px = (top_px + first.line_height().max(0.0)).round().max(top_px);
202 let height_px = (bottom_px - top_px).max(0.0);
203 first_baseline_px = (top_px + first.baseline().max(0.0))
204 .round()
205 .clamp(top_px, top_px + height_px);
206 }
207
208 let mut max_w_px = 0.0_f32;
209 let mut total_h_px = 0.0_f32;
210 if snap_vertical {
211 let mut top_px = 0.0_f32;
212 for line in lines {
213 max_w_px = max_w_px.max(shaped_line_visual_width_px(line));
214 let bottom_px = (top_px + line.line_height().max(0.0)).round().max(top_px);
215 top_px = bottom_px;
216 }
217 total_h_px = top_px;
218 } else {
219 for line in lines {
220 max_w_px = max_w_px.max(shaped_line_visual_width_px(line));
221 total_h_px += line.line_height().max(0.0);
222 }
223 }
224
225 TextMetrics {
226 size: fret_core::Size::new(
227 Px((max_w_px / scale).max(0.0)),
228 Px((total_h_px / scale).max(0.0)),
229 ),
230 baseline: Px((first_baseline_px / scale).max(0.0)),
231 }
232}
233
234pub(crate) fn metrics_for_uniform_lines(
235 max_w_px: f32,
236 line_count: usize,
237 baseline_px: f32,
238 line_height_px: f32,
239 scale: f32,
240) -> TextMetrics {
241 let snap_vertical = scale.is_finite() && scale.fract().abs() > 1e-4 && scale >= 1.0;
242
243 let mut first_baseline_px = baseline_px.max(0.0);
244 if snap_vertical {
245 let top_px = 0.0_f32;
246 let bottom_px = (top_px + line_height_px.max(0.0)).round().max(top_px);
247 let height_px = (bottom_px - top_px).max(0.0);
248 first_baseline_px = (top_px + baseline_px.max(0.0))
249 .round()
250 .clamp(top_px, top_px + height_px);
251 }
252
253 let total_h_px = if snap_vertical {
254 let mut top_px = 0.0_f32;
255 for _ in 0..line_count.max(1) {
256 top_px = (top_px + line_height_px.max(0.0)).round().max(top_px);
257 }
258 top_px
259 } else {
260 line_height_px.max(0.0) * (line_count.max(1) as f32)
261 };
262
263 TextMetrics {
264 size: fret_core::Size::new(
265 Px((max_w_px.max(0.0) / scale).max(0.0)),
266 Px((total_h_px / scale).max(0.0)),
267 ),
268 baseline: Px((first_baseline_px / scale).max(0.0)),
269 }
270}
271
272#[derive(Debug, Clone)]
273pub struct TextLineCluster {
274 text_range: Range<usize>,
275 x0: Px,
276 x1: Px,
277 is_rtl: bool,
278}
279
280impl TextLineCluster {
281 pub(crate) fn new(text_range: Range<usize>, x0: Px, x1: Px, is_rtl: bool) -> Self {
282 Self {
283 text_range,
284 x0,
285 x1,
286 is_rtl,
287 }
288 }
289
290 pub fn text_range(&self) -> Range<usize> {
291 self.text_range.clone()
292 }
293
294 pub fn x0(&self) -> Px {
295 self.x0
296 }
297
298 pub fn x1(&self) -> Px {
299 self.x1
300 }
301
302 pub fn is_rtl(&self) -> bool {
303 self.is_rtl
304 }
305}
306
307pub trait TextLineGeometry {
308 fn start(&self) -> usize;
309 fn end(&self) -> usize;
310 fn y_top(&self) -> Px;
311 fn height(&self) -> Px;
312 fn caret_stops(&self) -> &[(usize, Px)];
313 fn clusters(&self) -> &[TextLineCluster];
314}
315
316pub trait TextLineDecorationGeometry: TextLineGeometry {
317 fn y_baseline(&self) -> Px;
318}
319
320pub fn caret_rect_from_lines<L: TextLineGeometry>(
321 lines: &[L],
322 index: usize,
323 affinity: CaretAffinity,
324) -> Option<Rect> {
325 if lines.is_empty() {
326 return None;
327 }
328
329 let mut candidates: Vec<usize> = Vec::new();
330 for (i, line) in lines.iter().enumerate() {
331 if index >= line.start() && index <= line.end() {
332 candidates.push(i);
333 }
334 }
335
336 let line_idx = match candidates.as_slice() {
337 [] => {
338 if index <= lines[0].start() {
339 0
340 } else {
341 lines.len().saturating_sub(1)
342 }
343 }
344 [only] => *only,
345 many => match affinity {
346 CaretAffinity::Upstream => many[0],
347 CaretAffinity::Downstream => many[many.len().saturating_sub(1)],
348 },
349 };
350
351 let line = &lines[line_idx];
352 let x = caret_x_from_stops(line.caret_stops(), index);
353 Some(Rect::new(
354 Point::new(x, line.y_top()),
355 Size::new(Px(1.0), line.height()),
356 ))
357}
358
359pub fn hit_test_point_from_lines<L: TextLineGeometry>(
360 lines: &[L],
361 point: Point,
362) -> Option<HitTestResult> {
363 if lines.is_empty() {
364 return None;
365 }
366
367 let mut line_idx = 0usize;
368 for (i, line) in lines.iter().enumerate() {
369 let y0 = line.y_top().0;
370 let y1 = (line.y_top().0 + line.height().0).max(y0);
371 if point.y.0 >= y0 && point.y.0 < y1 {
372 line_idx = i;
373 break;
374 }
375 if point.y.0 >= y1 {
376 line_idx = i;
377 }
378 }
379
380 let line = &lines[line_idx];
381 let index = hit_test_x_from_stops(line.caret_stops(), point.x);
382
383 let mut affinity = CaretAffinity::Downstream;
384 if line_idx + 1 < lines.len() && index == line.end() && lines[line_idx + 1].start() == index {
385 affinity = CaretAffinity::Upstream;
386 }
387
388 Some(HitTestResult { index, affinity })
389}
390
391pub fn selection_rects_from_lines<L: TextLineGeometry>(
392 lines: &[L],
393 range: (usize, usize),
394 out: &mut Vec<Rect>,
395) {
396 out.clear();
397 if lines.is_empty() {
398 return;
399 }
400
401 let (a, b) = (range.0.min(range.1), range.0.max(range.1));
402 if a == b {
403 return;
404 }
405
406 for line in lines {
407 let start = a.max(line.start());
408 let end = b.min(line.end());
409 if start >= end {
410 continue;
411 }
412
413 let clusters = line.clusters();
414 if !clusters.is_empty() {
415 for c in clusters.iter() {
416 let text_range = c.text_range();
417 let seg_start = start.max(text_range.start);
418 let seg_end = end.min(text_range.end);
419 if seg_start >= seg_end {
420 continue;
421 }
422
423 let x0 = cluster_x_from_range(c, seg_start);
424 let x1 = cluster_x_from_range(c, seg_end);
425 let left = x0.0.min(x1.0);
426 let right = x0.0.max(x1.0);
427 if right <= left {
428 continue;
429 }
430
431 out.push(Rect::new(
432 Point::new(Px(left), line.y_top()),
433 Size::new(Px((right - left).max(0.0)), line.height()),
434 ));
435 }
436 } else {
437 let x0 = caret_x_from_stops(line.caret_stops(), start);
438 let x1 = caret_x_from_stops(line.caret_stops(), end);
439 let left = Px(x0.0.min(x1.0));
440 let right = Px(x0.0.max(x1.0));
441
442 out.push(Rect::new(
443 Point::new(left, line.y_top()),
444 Size::new(Px((right.0 - left.0).max(0.0)), line.height()),
445 ));
446 }
447 }
448
449 coalesce_selection_rects_in_place(out);
450}
451
452pub fn selection_rects_from_lines_clipped<L: TextLineGeometry>(
453 lines: &[L],
454 range: (usize, usize),
455 clip: Rect,
456 out: &mut Vec<Rect>,
457) {
458 out.clear();
459 if lines.is_empty() {
460 return;
461 }
462
463 let clip_x0 = clip.origin.x.0;
464 let clip_y0 = clip.origin.y.0;
465 let clip_x1 = clip_x0 + clip.size.width.0;
466 let clip_y1 = clip_y0 + clip.size.height.0;
467 if clip_x1 <= clip_x0 || clip_y1 <= clip_y0 {
468 return;
469 }
470
471 let (a, b) = (range.0.min(range.1), range.0.max(range.1));
472 if a == b {
473 return;
474 }
475
476 let start_idx = lines.partition_point(|line| {
477 let y0 = line.y_top().0;
478 let y1 = (line.y_top().0 + line.height().0).max(y0);
479 y1 <= clip_y0
480 });
481 let end_idx = lines.partition_point(|line| line.y_top().0 < clip_y1);
482 let start_idx = start_idx.min(end_idx);
483 if start_idx >= end_idx {
484 return;
485 }
486
487 for line in &lines[start_idx..end_idx] {
488 let start = a.max(line.start());
489 let end = b.min(line.end());
490 if start >= end {
491 continue;
492 }
493
494 let y0 = line.y_top().0;
495 let y1 = (line.y_top().0 + line.height().0).max(y0);
496
497 let iy0 = y0.max(clip_y0);
498 let iy1 = y1.min(clip_y1);
499 if iy1 <= iy0 {
500 continue;
501 }
502
503 let clusters = line.clusters();
504 if !clusters.is_empty() {
505 for c in clusters.iter() {
506 let text_range = c.text_range();
507 let seg_start = start.max(text_range.start);
508 let seg_end = end.min(text_range.end);
509 if seg_start >= seg_end {
510 continue;
511 }
512
513 let x0 = cluster_x_from_range(c, seg_start).0;
514 let x1 = cluster_x_from_range(c, seg_end).0;
515 let left = x0.min(x1);
516 let right = x0.max(x1);
517
518 let ix0 = left.max(clip_x0);
519 let ix1 = right.min(clip_x1);
520 if ix1 <= ix0 {
521 continue;
522 }
523
524 out.push(Rect::new(
525 Point::new(Px(ix0), Px(iy0)),
526 Size::new(Px((ix1 - ix0).max(0.0)), Px((iy1 - iy0).max(0.0))),
527 ));
528 }
529 } else {
530 let x0 = caret_x_from_stops(line.caret_stops(), start).0;
531 let x1 = caret_x_from_stops(line.caret_stops(), end).0;
532 let left = x0.min(x1);
533 let right = x0.max(x1);
534
535 let ix0 = left.max(clip_x0);
536 let ix1 = right.min(clip_x1);
537 if ix1 <= ix0 {
538 continue;
539 }
540
541 out.push(Rect::new(
542 Point::new(Px(ix0), Px(iy0)),
543 Size::new(Px((ix1 - ix0).max(0.0)), Px((iy1 - iy0).max(0.0))),
544 ));
545 }
546 }
547
548 coalesce_selection_rects_in_place(out);
549}
550
551fn cluster_x_from_range(cluster: &TextLineCluster, boundary: usize) -> Px {
552 let text_range = cluster.text_range();
553 let start = text_range.start;
554 let end = text_range.end;
555 if start == end {
556 return cluster.x0();
557 }
558
559 if boundary <= start {
560 return if cluster.is_rtl() {
561 cluster.x1()
562 } else {
563 cluster.x0()
564 };
565 }
566 if boundary >= end {
567 return if cluster.is_rtl() {
568 cluster.x0()
569 } else {
570 cluster.x1()
571 };
572 }
573
574 let denom = (end - start) as f32;
575 if denom <= 0.0 {
576 return cluster.x0();
577 }
578
579 let mut t = ((boundary - start) as f32 / denom).clamp(0.0, 1.0);
580 if cluster.is_rtl() {
581 t = 1.0 - t;
582 }
583 let x0 = cluster.x0();
584 let x1 = cluster.x1();
585 let w = (x1.0 - x0.0).max(0.0);
586 Px((x0.0 + w * t).max(0.0))
587}
588
589fn coalesce_selection_rects_in_place(rects: &mut Vec<Rect>) {
590 if rects.len() <= 1 {
591 return;
592 }
593
594 rects.sort_by(|a, b| {
595 a.origin
596 .y
597 .0
598 .total_cmp(&b.origin.y.0)
599 .then_with(|| a.size.height.0.total_cmp(&b.size.height.0))
600 .then_with(|| a.origin.x.0.total_cmp(&b.origin.x.0))
601 });
602
603 let mut out: Vec<Rect> = Vec::with_capacity(rects.len());
604 for r in rects.drain(..) {
605 match out.last_mut() {
606 Some(prev)
607 if prev.origin.y == r.origin.y
608 && prev.size.height == r.size.height
609 && r.origin.x.0 <= prev.origin.x.0 + prev.size.width.0 =>
610 {
611 let x0 = prev.origin.x.0.min(r.origin.x.0);
612 let x1 = (prev.origin.x.0 + prev.size.width.0).max(r.origin.x.0 + r.size.width.0);
613 prev.origin.x = Px(x0);
614 prev.size.width = Px((x1 - x0).max(0.0));
615 }
616 _ => out.push(r),
617 }
618 }
619 *rects = out;
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::{parley_shaper::ParleyShaper, prepare_layout, wrapper};
626 use fret_core::{
627 FontId, Point, Px, Rect, Size, TextConstraints, TextInputRef, TextLineHeightPolicy,
628 TextOverflow, TextShapingStyle, TextSpan, TextStyle, TextWrap,
629 };
630 use std::sync::Arc;
631
632 fn shaper_with_bundled_fonts() -> ParleyShaper {
633 let mut shaper = ParleyShaper::new_without_system_fonts();
634 let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
635 fret_fonts::bootstrap_profile()
636 .faces
637 .iter()
638 .chain(fret_fonts_emoji::default_profile().faces.iter())
639 .chain(fret_fonts_cjk::default_profile().faces.iter()),
640 ));
641 assert!(added > 0, "expected bundled fonts to load");
642 shaper
643 }
644
645 fn prepare_layout_for_test(
646 shaper: &mut ParleyShaper,
647 text: &str,
648 style: &TextStyle,
649 constraints: TextConstraints,
650 ) -> prepare_layout::PreparedLayout {
651 let scale = crate::effective_text_scale_factor(constraints.scale_factor);
652 let snap_vertical = scale.fract().abs() > 1e-4;
653
654 let wrapped =
655 wrapper::wrap_with_constraints(shaper, TextInputRef::plain(text, style), constraints);
656 prepare_layout::prepare_layout_from_wrapped(
657 text,
658 wrapped,
659 constraints,
660 scale,
661 snap_vertical,
662 )
663 }
664
665 fn prepare_lines(
666 shaper: &mut ParleyShaper,
667 text: &str,
668 style: &TextStyle,
669 constraints: TextConstraints,
670 ) -> Vec<crate::line_layout::TextLineLayout> {
671 prepare_layout_for_test(shaper, text, style, constraints)
672 .lines()
673 .iter()
674 .map(|line| line.layout().clone())
675 .collect()
676 }
677
678 fn prepare_layout_for_attributed_test(
679 shaper: &mut ParleyShaper,
680 text: &str,
681 base: &TextStyle,
682 spans: &[TextSpan],
683 constraints: TextConstraints,
684 ) -> prepare_layout::PreparedLayout {
685 let scale = crate::effective_text_scale_factor(constraints.scale_factor);
686 let snap_vertical = scale.fract().abs() > 1e-4;
687
688 let wrapped = wrapper::wrap_with_constraints(
689 shaper,
690 TextInputRef::attributed(text, base, spans),
691 constraints,
692 );
693 prepare_layout::prepare_layout_from_wrapped(
694 text,
695 wrapped,
696 constraints,
697 scale,
698 snap_vertical,
699 )
700 }
701
702 fn prepare_lines_attributed(
703 shaper: &mut ParleyShaper,
704 text: &str,
705 base: &TextStyle,
706 spans: &[TextSpan],
707 constraints: TextConstraints,
708 ) -> Vec<crate::line_layout::TextLineLayout> {
709 prepare_layout_for_attributed_test(shaper, text, base, spans, constraints)
710 .lines()
711 .iter()
712 .map(|line| line.layout().clone())
713 .collect()
714 }
715
716 fn is_synthetic_rtl_char(ch: char) -> bool {
717 matches!(
720 ch,
721 '\u{0590}'..='\u{05FF}' | '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' )
726 }
727
728 fn synthetic_clusters_for_text(
729 text: &str,
730 advance: f32,
731 ) -> Vec<crate::parley_shaper::ShapedCluster> {
732 let mut out = Vec::new();
733 let mut x = 0.0_f32;
734 for (start, ch) in text.char_indices() {
735 let end = start + ch.len_utf8();
736 out.push(crate::parley_shaper::ShapedCluster::new(
737 start..end,
738 x,
739 x + advance,
740 is_synthetic_rtl_char(ch),
741 ));
742 x += advance;
743 }
744 out
745 }
746
747 fn line_clusters_from_shaped(
748 base_offset: usize,
749 clusters: &[crate::parley_shaper::ShapedCluster],
750 ) -> Arc<[TextLineCluster]> {
751 let mut out: Vec<TextLineCluster> = Vec::with_capacity(clusters.len());
752 for c in clusters {
753 let text_range = c.text_range();
754 out.push(TextLineCluster::new(
755 (base_offset + text_range.start)..(base_offset + text_range.end),
756 Px(c.x0().max(0.0)),
757 Px(c.x1().max(0.0)),
758 c.is_rtl(),
759 ));
760 }
761 Arc::from(out)
762 }
763
764 fn caret_x_for_index_from_single_line(
765 lines: &[crate::line_layout::TextLineLayout],
766 index: usize,
767 ) -> Px {
768 assert_eq!(lines.len(), 1, "expected a single-line layout");
769 caret_x_from_stops(lines[0].caret_stops(), index)
770 }
771
772 fn assert_caret_rects_are_non_degenerate_at_grapheme_boundaries(text: &str, style: &TextStyle) {
773 use unicode_segmentation::UnicodeSegmentation as _;
774
775 let constraints = TextConstraints {
776 max_width: None,
777 wrap: TextWrap::None,
778 overflow: TextOverflow::Clip,
779 align: fret_core::TextAlign::Start,
780 scale_factor: 1.0,
781 };
782
783 let mut shaper = shaper_with_bundled_fonts();
784 let lines = prepare_lines(&mut shaper, text, style, constraints);
785 assert_eq!(lines.len(), 1, "expected a single-line layout");
786
787 let mut boundaries: Vec<usize> = Vec::new();
788 boundaries.push(0);
789 let mut cursor = 0usize;
790 for g in text.graphemes(true) {
791 cursor = cursor.saturating_add(g.len());
792 boundaries.push(cursor.min(text.len()));
793 }
794 boundaries.sort_unstable();
795 boundaries.dedup();
796
797 let mut last_x = 0.0_f32;
798 for idx in boundaries {
799 assert!(
800 text.is_char_boundary(idx),
801 "expected grapheme boundary to be a char boundary: idx={idx}"
802 );
803
804 let x = caret_x_from_stops(lines[0].caret_stops(), idx);
805 assert!(
806 x.0.is_finite(),
807 "expected caret_x to be finite for idx={idx}, got {x:?}"
808 );
809 assert!(
810 x.0 + 0.01 >= last_x,
811 "expected caret_x to be monotonic for LTR text; idx={idx} last_x={last_x} x={x:?}"
812 );
813 last_x = x.0;
814
815 let rect =
816 caret_rect_from_lines(&lines, idx, CaretAffinity::Downstream).expect("caret rect");
817 assert!(
818 rect.origin.x.0.is_finite()
819 && rect.origin.y.0.is_finite()
820 && rect.size.width.0.is_finite()
821 && rect.size.height.0.is_finite(),
822 "expected caret rect to be finite; idx={idx} rect={rect:?}"
823 );
824 assert!(
825 rect.size.height.0 > 0.1 && rect.size.width.0 > 0.0,
826 "expected non-degenerate caret rect; idx={idx} rect={rect:?}"
827 );
828 }
829 }
830
831 #[test]
832 fn fixed_line_box_baseline_is_stable_across_fallback_glyphs() {
833 let style = TextStyle {
834 font: FontId::family("Inter"),
835 size: Px(14.0),
836 line_height: Some(Px(18.0)),
837 line_height_policy: TextLineHeightPolicy::FixedFromStyle,
838 ..Default::default()
839 };
840 let constraints = TextConstraints {
841 max_width: None,
842 wrap: TextWrap::None,
843 overflow: TextOverflow::Clip,
844 align: fret_core::TextAlign::Start,
845 scale_factor: 1.0,
846 };
847
848 let mut shaper = shaper_with_bundled_fonts();
849
850 let baseline_for = |shaper: &mut ParleyShaper, text: &str| -> (Px, Px) {
851 let prepared = prepare_layout_for_test(shaper, text, &style, constraints);
852 assert_eq!(
853 prepared.metrics().size.height,
854 Px(18.0),
855 "expected fixed line box height to remain stable for text={text:?}"
856 );
857 assert!(
858 !prepared.lines().is_empty(),
859 "expected at least one line for text={text:?}"
860 );
861 assert_eq!(
862 prepared.lines()[0].layout().height(),
863 Px(18.0),
864 "expected first line height to match fixed line box for text={text:?}"
865 );
866 (
867 prepared.metrics().baseline,
868 prepared.lines()[0].layout().y_baseline(),
869 )
870 };
871
872 let (baseline_ascii, line_baseline_ascii) = baseline_for(&mut shaper, "Settings");
873 let (baseline_emoji, line_baseline_emoji) = baseline_for(&mut shaper, "Settings 😄");
874 let (baseline_cjk, line_baseline_cjk) = baseline_for(&mut shaper, "Settings 漢字");
875 let (baseline_mixed, line_baseline_mixed) = baseline_for(&mut shaper, "😄 漢字");
876
877 assert_eq!(baseline_ascii, baseline_emoji);
878 assert_eq!(baseline_ascii, baseline_cjk);
879 assert_eq!(baseline_ascii, baseline_mixed);
880
881 assert_eq!(line_baseline_ascii, line_baseline_emoji);
882 assert_eq!(line_baseline_ascii, line_baseline_cjk);
883 assert_eq!(line_baseline_ascii, line_baseline_mixed);
884 }
885
886 #[test]
887 fn caret_stops_for_slice_interpolates_within_cluster_ltr() {
888 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
889 0..4,
890 0.0,
891 40.0,
892 false,
893 )];
894
895 let stops = super::caret_stops_for_slice("abcd", 0, &clusters, 40.0, 1.0, 4);
896 let x_at = |i: usize| stops.iter().find(|(idx, _)| *idx == i).unwrap().1.0;
897
898 assert_eq!(x_at(0), 0.0);
899 assert_eq!(x_at(1), 10.0);
900 assert_eq!(x_at(2), 20.0);
901 assert_eq!(x_at(3), 30.0);
902 assert_eq!(x_at(4), 40.0);
903 }
904
905 #[test]
906 fn caret_stops_for_slice_interpolates_within_cluster_rtl() {
907 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
908 0..4,
909 0.0,
910 40.0,
911 true,
912 )];
913
914 let stops = super::caret_stops_for_slice("abcd", 0, &clusters, 40.0, 1.0, 4);
915 let x_at = |i: usize| stops.iter().find(|(idx, _)| *idx == i).unwrap().1.0;
916
917 assert_eq!(x_at(0), 40.0);
918 assert_eq!(x_at(1), 30.0);
919 assert_eq!(x_at(2), 20.0);
920 assert_eq!(x_at(3), 10.0);
921 assert_eq!(x_at(4), 0.0);
922 }
923
924 #[test]
925 fn selection_rects_for_rtl_line_has_positive_width() {
926 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
927 0..4,
928 0.0,
929 40.0,
930 true,
931 )];
932 let stops = super::caret_stops_for_slice("abcd", 0, &clusters, 40.0, 1.0, 4);
933 let line = crate::line_layout::TextLineLayout::new(
934 0,
935 4,
936 Px(40.0),
937 Px(0.0),
938 Px(0.0),
939 Px(10.0),
940 Px(0.0),
941 Px(0.0),
942 Px(0.0),
943 Px(0.0),
944 stops,
945 line_clusters_from_shaped(0, &clusters),
946 );
947
948 let mut rects = Vec::new();
949 selection_rects_from_lines(&[line], (0, 4), &mut rects);
950 assert_eq!(rects.len(), 1);
951 assert!((rects[0].origin.x.0 - 0.0).abs() < 0.001);
952 assert!((rects[0].size.width.0 - 40.0).abs() < 0.001);
953 }
954
955 #[test]
956 fn hit_test_point_for_rtl_line_maps_left_edge_to_logical_end() {
957 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
958 0..4,
959 0.0,
960 40.0,
961 true,
962 )];
963 let stops = super::caret_stops_for_slice("abcd", 0, &clusters, 40.0, 1.0, 4);
964 let line = crate::line_layout::TextLineLayout::new(
965 0,
966 4,
967 Px(40.0),
968 Px(0.0),
969 Px(0.0),
970 Px(10.0),
971 Px(0.0),
972 Px(0.0),
973 Px(0.0),
974 Px(0.0),
975 stops,
976 line_clusters_from_shaped(0, &clusters),
977 );
978
979 let left =
980 hit_test_point_from_lines(std::slice::from_ref(&line), Point::new(Px(0.0), Px(5.0)))
981 .expect("hit test");
982 assert_eq!(left.index, 4);
983
984 let right =
985 hit_test_point_from_lines(std::slice::from_ref(&line), Point::new(Px(40.0), Px(5.0)))
986 .expect("hit test");
987 assert_eq!(right.index, 0);
988 }
989
990 #[test]
991 fn mixed_direction_selection_rects_are_nonempty() {
992 let text = "abc אבג (123)";
994 let clusters = synthetic_clusters_for_text(text, 10.0);
995 let stops = super::caret_stops_for_slice(
996 text,
997 0,
998 &clusters,
999 10.0 * clusters.len() as f32,
1000 1.0,
1001 text.len(),
1002 );
1003 let line = crate::line_layout::TextLineLayout::new(
1004 0,
1005 text.len(),
1006 Px(10.0 * clusters.len() as f32),
1007 Px(0.0),
1008 Px(0.0),
1009 Px(10.0),
1010 Px(0.0),
1011 Px(0.0),
1012 Px(0.0),
1013 Px(0.0),
1014 stops,
1015 line_clusters_from_shaped(0, &clusters),
1016 );
1017
1018 let rtl_start = text.find('א').expect("hebrew start");
1019 let rtl_end = text.find('ג').expect("hebrew end") + 'ג'.len_utf8();
1020
1021 let mut rects = Vec::new();
1022 selection_rects_from_lines(&[line], (rtl_start, rtl_end), &mut rects);
1023 assert_eq!(rects.len(), 1);
1024 assert!(
1025 rects[0].size.width.0 > 0.1,
1026 "expected a non-empty selection rect"
1027 );
1028 }
1029
1030 #[test]
1031 fn mixed_direction_selection_rects_split_across_visual_runs() {
1032 let text = "aaa אבג def";
1035 let clusters = vec![
1036 crate::parley_shaper::ShapedCluster::new(0..4, 0.0, 40.0, false), crate::parley_shaper::ShapedCluster::new(4..11, 70.0, 110.0, true),
1038 crate::parley_shaper::ShapedCluster::new(11..14, 40.0, 70.0, false), ];
1040
1041 let stops = super::caret_stops_for_slice(text, 0, &clusters, 110.0, 1.0, text.len());
1042 let line = crate::line_layout::TextLineLayout::new(
1043 0,
1044 text.len(),
1045 Px(110.0),
1046 Px(0.0),
1047 Px(0.0),
1048 Px(10.0),
1049 Px(0.0),
1050 Px(0.0),
1051 Px(0.0),
1052 Px(0.0),
1053 stops,
1054 line_clusters_from_shaped(0, &clusters),
1055 );
1056
1057 let mut rects = Vec::new();
1058 selection_rects_from_lines(&[line], (0, 11), &mut rects);
1059
1060 assert_eq!(
1061 rects.len(),
1062 2,
1063 "expected two disjoint visual spans, got {rects:?}"
1064 );
1065 rects.sort_by(|a, b| a.origin.x.0.total_cmp(&b.origin.x.0));
1066
1067 assert!((rects[0].origin.x.0 - 0.0).abs() < 0.001);
1068 assert!((rects[0].size.width.0 - 40.0).abs() < 0.001);
1069
1070 assert!((rects[1].origin.x.0 - 70.0).abs() < 0.001);
1071 assert!((rects[1].size.width.0 - 40.0).abs() < 0.001);
1072 }
1073
1074 #[test]
1075 fn caret_stops_for_slice_use_grapheme_boundaries_for_combining_marks_and_emoji_sequences() {
1076 use unicode_segmentation::UnicodeSegmentation as _;
1077
1078 let cases = [
1079 ("e\u{0301}x", "combining mark (e + acute)"),
1080 ("1\u{FE0F}\u{20E3}", "keycap sequence"),
1081 ("\u{1F1FA}\u{1F1F8}", "flag sequence"),
1082 ("\u{1F469}\u{200D}\u{1F4BB}", "zwj emoji sequence"),
1083 ];
1084
1085 for (text, label) in cases {
1086 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
1087 0..text.len(),
1088 0.0,
1089 40.0,
1090 false,
1091 )];
1092
1093 let stops = super::caret_stops_for_slice(text, 0, &clusters, 40.0, 1.0, text.len());
1094 let indices: Vec<usize> = stops.iter().map(|(idx, _)| *idx).collect();
1095
1096 let mut expected: Vec<usize> = text.grapheme_indices(true).map(|(i, _)| i).collect();
1097 expected.push(text.len());
1098 expected.sort_unstable();
1099 expected.dedup();
1100
1101 assert_eq!(
1102 indices, expected,
1103 "expected caret stops to land on grapheme boundaries ({label}): text={text:?} stops={indices:?} expected={expected:?}"
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn empty_string_produces_nonzero_line_metrics_and_caret_rect() {
1110 let mut shaper = shaper_with_bundled_fonts();
1111
1112 let style = TextStyle {
1113 font: FontId::family("Inter"),
1114 size: Px(16.0),
1115 ..Default::default()
1116 };
1117 let constraints = TextConstraints {
1118 max_width: None,
1119 wrap: TextWrap::None,
1120 overflow: TextOverflow::Clip,
1121 align: fret_core::TextAlign::Start,
1122 scale_factor: 1.0,
1123 };
1124
1125 let prepared = prepare_layout_for_test(&mut shaper, "", &style, constraints);
1126 assert!(
1127 prepared.metrics().size.height.0 > 0.1,
1128 "expected empty string to have non-zero metrics height, got {:?}",
1129 prepared.metrics()
1130 );
1131 assert!(
1132 prepared.metrics().baseline.0 >= 0.0
1133 && prepared.metrics().baseline.0 <= prepared.metrics().size.height.0 + 0.01,
1134 "expected empty string baseline to be within the metrics box, got {:?}",
1135 prepared.metrics()
1136 );
1137
1138 let lines: Vec<_> = prepared
1139 .lines()
1140 .iter()
1141 .map(|line| line.layout().clone())
1142 .collect();
1143 assert!(!lines.is_empty(), "expected at least one line layout");
1144 assert!(
1145 lines[0].height().0 > 0.1,
1146 "expected a non-zero line height for empty string, got {:?}",
1147 lines[0]
1148 );
1149
1150 let caret = caret_rect_from_lines(&lines, 0, CaretAffinity::Downstream)
1151 .expect("expected caret rect for empty string");
1152 assert!(
1153 caret.size.height.0 > 0.1,
1154 "expected a non-zero caret rect height for empty string, got {caret:?}"
1155 );
1156 }
1157
1158 #[test]
1159 fn selection_and_caret_rects_are_nonzero_even_with_zero_line_height_override() {
1160 let mut shaper = shaper_with_bundled_fonts();
1161
1162 let style = TextStyle {
1163 font: FontId::family("Inter"),
1164 size: Px(16.0),
1165 line_height: Some(Px(0.0)),
1166 line_height_policy: TextLineHeightPolicy::ExpandToFit,
1167 ..Default::default()
1168 };
1169 let constraints = TextConstraints {
1170 max_width: None,
1171 wrap: TextWrap::None,
1172 overflow: TextOverflow::Clip,
1173 align: fret_core::TextAlign::Start,
1174 scale_factor: 1.0,
1175 };
1176
1177 let lines = prepare_lines(&mut shaper, "a", &style, constraints);
1178
1179 let caret =
1180 caret_rect_from_lines(&lines, 0, CaretAffinity::Downstream).expect("caret rect");
1181 assert!(
1182 caret.size.height.0 > 0.1,
1183 "expected a non-zero caret rect height even with a zero line-height override, got {caret:?}"
1184 );
1185
1186 let mut rects: Vec<Rect> = Vec::new();
1187 selection_rects_from_lines(&lines, (0, 1), &mut rects);
1188 assert!(
1189 rects.iter().any(|r| r.size.height.0 > 0.1),
1190 "expected selection rects to have non-zero height even with a zero line-height override, got {rects:?}"
1191 );
1192 }
1193
1194 #[test]
1195 fn selection_rects_clipped_do_not_return_zero_height_rects() {
1196 let mut shaper = shaper_with_bundled_fonts();
1197
1198 let content = "hello world hello world hello world";
1199 let style = TextStyle {
1200 font: FontId::family("Inter"),
1201 size: Px(16.0),
1202 ..Default::default()
1203 };
1204 let constraints = TextConstraints {
1205 max_width: Some(Px(60.0)),
1206 wrap: TextWrap::Word,
1207 overflow: TextOverflow::Clip,
1208 align: fret_core::TextAlign::Start,
1209 scale_factor: 1.0,
1210 };
1211
1212 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1213
1214 let mut rects: Vec<Rect> = Vec::new();
1215 selection_rects_from_lines_clipped(
1216 &lines,
1217 (0, content.len()),
1218 Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(200.0), Px(20.0))),
1219 &mut rects,
1220 );
1221 assert!(
1222 !rects.is_empty(),
1223 "expected selection_rects_from_lines_clipped to produce at least one rect"
1224 );
1225 for r in &rects {
1226 assert!(
1227 r.size.height.0 > 0.1,
1228 "expected clipped selection rect height to be non-zero, got {r:?}"
1229 );
1230 }
1231 }
1232
1233 #[test]
1234 fn selection_rects_clipped_handles_mixed_bidi_word_wrap_and_clips_to_rect() {
1235 let mut shaper = shaper_with_bundled_fonts();
1236
1237 let content = "hello אבג def ghi jkl אבג mno pqr stu אבג vwx yz";
1239 let style = TextStyle {
1240 font: FontId::family("Inter"),
1241 size: Px(16.0),
1242 ..Default::default()
1243 };
1244 let max_width = Px(80.0);
1245 let constraints = TextConstraints {
1246 max_width: Some(max_width),
1247 wrap: TextWrap::Word,
1248 overflow: TextOverflow::Clip,
1249 align: fret_core::TextAlign::Start,
1250 scale_factor: 1.0,
1251 };
1252
1253 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1254 assert!(lines.len() >= 2, "expected mixed bidi content to wrap");
1255 let line0 = &lines[0];
1256 let line1 = &lines[1];
1257
1258 let clip_y0 = line0.y_top().0 + (line0.height().0 * 0.5);
1260 let clip_y1 = line1.y_top().0 + (line1.height().0 * 0.5);
1261 let clip_width = Px((max_width.0 * 0.5).max(1.0));
1262 let clip_height = Px((clip_y1 - clip_y0).max(1.0));
1263 let clip = Rect::new(
1264 Point::new(Px(0.0), Px(clip_y0)),
1265 Size::new(clip_width, clip_height),
1266 );
1267
1268 let clip_x0 = clip.origin.x.0;
1269 let clip_y0 = clip.origin.y.0;
1270 let clip_x1 = clip_x0 + clip.size.width.0;
1271 let clip_y1 = clip_y0 + clip.size.height.0;
1272
1273 let mut full_rects: Vec<Rect> = Vec::new();
1275 selection_rects_from_lines(&lines, (0, content.len()), &mut full_rects);
1276 assert!(
1277 full_rects
1278 .iter()
1279 .any(|r| r.origin.x.0 + r.size.width.0 > clip_x1 + 0.5),
1280 "expected unclipped selection to extend beyond clip_x1"
1281 );
1282
1283 let mut rects: Vec<Rect> = Vec::new();
1284 selection_rects_from_lines_clipped(&lines, (0, content.len()), clip, &mut rects);
1285
1286 assert!(!rects.is_empty(), "expected clipped selection rects");
1287 assert!(
1288 rects.iter().any(|r| (r.origin.y.0 - clip_y0).abs() < 0.02),
1289 "expected at least one rect to be trimmed at clip_y0"
1290 );
1291 assert!(
1292 rects
1293 .iter()
1294 .any(|r| ((r.origin.y.0 + r.size.height.0) - clip_y1).abs() < 0.02),
1295 "expected at least one rect to be trimmed at clip_y1"
1296 );
1297
1298 let eps = 0.02;
1299 for r in &rects {
1300 assert!(
1301 r.size.width.0 > 0.1 && r.size.height.0 > 0.1,
1302 "expected non-degenerate clipped rect, got {r:?}"
1303 );
1304 assert!(
1305 r.origin.x.0 + eps >= clip_x0
1306 && r.origin.y.0 + eps >= clip_y0
1307 && (r.origin.x.0 + r.size.width.0) <= clip_x1 + eps
1308 && (r.origin.y.0 + r.size.height.0) <= clip_y1 + eps,
1309 "expected rect to be inside clip; rect={r:?} clip={clip:?}"
1310 );
1311 }
1312
1313 rects.sort_by(|a, b| {
1315 a.origin
1316 .y
1317 .0
1318 .total_cmp(&b.origin.y.0)
1319 .then_with(|| a.size.height.0.total_cmp(&b.size.height.0))
1320 .then_with(|| a.origin.x.0.total_cmp(&b.origin.x.0))
1321 });
1322 for w in rects.windows(2) {
1323 let a = &w[0];
1324 let b = &w[1];
1325 if a.origin.y == b.origin.y && a.size.height == b.size.height {
1326 assert!(
1327 b.origin.x.0 + 0.001 >= a.origin.x.0 + a.size.width.0,
1328 "expected rects on the same line slice to not overlap; a={a:?} b={b:?}"
1329 );
1330 }
1331 }
1332 }
1333
1334 #[test]
1335 fn mixed_direction_word_wrap_selection_rects_for_rtl_range_are_nonempty() {
1336 let mut shaper = shaper_with_bundled_fonts();
1337
1338 let content = "abc אבג (123) def ghi jkl mno pqr";
1339 let style = TextStyle {
1340 font: FontId::family("Inter"),
1341 size: Px(16.0),
1342 ..Default::default()
1343 };
1344 let constraints = TextConstraints {
1345 max_width: Some(Px(70.0)),
1346 wrap: TextWrap::Word,
1347 overflow: TextOverflow::Clip,
1348 align: fret_core::TextAlign::Start,
1349 scale_factor: 1.0,
1350 };
1351 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1352
1353 let rtl_start = content.find('א').expect("hebrew start");
1354 let rtl_end = content.find('ג').expect("hebrew end") + 'ג'.len_utf8();
1355
1356 let mut rects = Vec::new();
1357 selection_rects_from_lines(&lines, (rtl_start, rtl_end), &mut rects);
1358 assert!(
1359 rects.iter().any(|r| r.size.width.0 > 0.1),
1360 "expected a non-empty selection rect for wrapped RTL range: rects={rects:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn mixed_direction_word_wrap_caret_affinity_at_soft_wrap_boundary_selects_previous_or_next_line()
1366 {
1367 let mut shaper = shaper_with_bundled_fonts();
1368
1369 let content = "abc אבג def ghi jkl";
1370 let style = TextStyle {
1371 font: FontId::family("Inter"),
1372 size: Px(16.0),
1373 ..Default::default()
1374 };
1375
1376 let single_line_constraints = TextConstraints {
1378 max_width: None,
1379 wrap: TextWrap::None,
1380 overflow: TextOverflow::Clip,
1381 align: fret_core::TextAlign::Start,
1382 scale_factor: 1.0,
1383 };
1384 let single_lines = prepare_lines(&mut shaper, content, &style, single_line_constraints);
1385 let def_end = content.find("def").expect("def") + "def".len();
1386 let ghi_end = content.find("ghi").expect("ghi") + "ghi".len();
1387 let x_def_end = caret_x_for_index_from_single_line(&single_lines, def_end);
1388 let x_ghi_end = caret_x_for_index_from_single_line(&single_lines, ghi_end);
1389 assert!(
1390 x_ghi_end.0 > x_def_end.0 + 0.1,
1391 "expected ghi to advance beyond def in single-line layout"
1392 );
1393
1394 let constraints = TextConstraints {
1395 max_width: Some(Px((x_def_end.0 + x_ghi_end.0) * 0.5)),
1396 wrap: TextWrap::Word,
1397 overflow: TextOverflow::Clip,
1398 align: fret_core::TextAlign::Start,
1399 scale_factor: 1.0,
1400 };
1401 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1402 assert!(lines.len() >= 2, "expected the text to wrap");
1403 let line0 = &lines[0];
1404 let line1 = &lines[1];
1405 assert_eq!(
1406 line0.end(),
1407 line1.start(),
1408 "expected a shared soft-wrap boundary index"
1409 );
1410 let break_index = line1.start();
1411
1412 let upstream = caret_rect_from_lines(&lines, break_index, CaretAffinity::Upstream)
1413 .expect("caret rect upstream");
1414 let downstream = caret_rect_from_lines(&lines, break_index, CaretAffinity::Downstream)
1415 .expect("caret rect downstream");
1416
1417 assert!(
1418 (upstream.origin.y.0 - line0.y_top().0).abs() < 0.01,
1419 "expected upstream caret to be on the previous line at wrap boundary"
1420 );
1421 assert!(
1422 (downstream.origin.y.0 - line1.y_top().0).abs() < 0.01,
1423 "expected downstream caret to be on the next line at wrap boundary"
1424 );
1425 }
1426
1427 #[test]
1428 fn mixed_direction_word_wrap_hit_test_reports_expected_affinity_at_soft_wrap_boundary() {
1429 let mut shaper = shaper_with_bundled_fonts();
1430
1431 let content = "abc אבג def ghi jkl";
1432 let style = TextStyle {
1433 font: FontId::family("Inter"),
1434 size: Px(16.0),
1435 ..Default::default()
1436 };
1437 let constraints = TextConstraints {
1438 max_width: Some(Px(70.0)),
1439 wrap: TextWrap::Word,
1440 overflow: TextOverflow::Clip,
1441 align: fret_core::TextAlign::Start,
1442 scale_factor: 1.0,
1443 };
1444 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1445 assert!(lines.len() >= 2, "expected wrapped lines");
1446 let line0 = &lines[0];
1447 let line1 = &lines[1];
1448 assert_eq!(line0.end(), line1.start(), "expected shared boundary");
1449 let break_index = line1.start();
1450
1451 let x0 = caret_x_from_stops(line0.caret_stops(), break_index);
1452 let x1 = caret_x_from_stops(line1.caret_stops(), break_index);
1453
1454 let y0 = Px(line0.y_top().0 + line0.height().0 * 0.5);
1455 let y1 = Px(line1.y_top().0 + line1.height().0 * 0.5);
1456
1457 let ht0 = hit_test_point_from_lines(&lines, Point::new(x0, y0)).expect("hit test (line0)");
1458 assert_eq!(ht0.index, break_index);
1459 assert_eq!(
1460 ht0.affinity,
1461 CaretAffinity::Upstream,
1462 "expected wrap-boundary hit on line0 to report upstream affinity"
1463 );
1464
1465 let ht1 = hit_test_point_from_lines(&lines, Point::new(x1, y1)).expect("hit test (line1)");
1466 assert_eq!(ht1.index, break_index);
1467 assert_eq!(
1468 ht1.affinity,
1469 CaretAffinity::Downstream,
1470 "expected wrap-boundary hit on line1 to report downstream affinity"
1471 );
1472 }
1473
1474 #[test]
1475 fn mixed_direction_word_wrap_selection_rects_are_coalesced_per_visual_line() {
1476 let mut shaper = shaper_with_bundled_fonts();
1477
1478 let content = "abc אבג def ghi jkl mno pqr";
1479 let style = TextStyle {
1480 font: FontId::family("Inter"),
1481 size: Px(16.0),
1482 ..Default::default()
1483 };
1484 let constraints = TextConstraints {
1485 max_width: Some(Px(70.0)),
1486 wrap: TextWrap::Word,
1487 overflow: TextOverflow::Clip,
1488 align: fret_core::TextAlign::Start,
1489 scale_factor: 1.0,
1490 };
1491 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1492
1493 let mut rects = Vec::new();
1494 selection_rects_from_lines(&lines, (0, content.len()), &mut rects);
1495 assert!(
1496 !rects.is_empty(),
1497 "expected selection rects for full-range selection"
1498 );
1499
1500 rects.sort_by(|a, b| {
1501 a.origin
1502 .y
1503 .0
1504 .total_cmp(&b.origin.y.0)
1505 .then_with(|| a.size.height.0.total_cmp(&b.size.height.0))
1506 .then_with(|| a.origin.x.0.total_cmp(&b.origin.x.0))
1507 });
1508
1509 let mut prev: Option<Rect> = None;
1510 for r in rects.iter() {
1511 assert!(
1512 r.size.width.0 > 0.0 && r.size.height.0 > 0.0,
1513 "expected non-degenerate selection rects, got {r:?}"
1514 );
1515 if let Some(p) = prev
1516 && (p.origin.y.0 - r.origin.y.0).abs() < 1e-3
1517 && (p.size.height.0 - r.size.height.0).abs() < 1e-3
1518 {
1519 let p_end = p.origin.x.0 + p.size.width.0;
1520 assert!(
1521 r.origin.x.0 + 1e-3 >= p_end,
1522 "expected coalesced selection rects to be non-overlapping on the same line: prev={p:?} next={r:?}"
1523 );
1524 }
1525 prev = Some(*r);
1526 }
1527 }
1528
1529 #[test]
1530 fn caret_rects_are_non_degenerate_at_grapheme_boundaries_for_zwj_emoji() {
1531 let content = "👩👩👧👦 hello";
1532 let style = TextStyle {
1533 font: FontId::family("Inter"),
1534 size: Px(16.0),
1535 ..Default::default()
1536 };
1537 assert_caret_rects_are_non_degenerate_at_grapheme_boundaries(content, &style);
1538 }
1539
1540 #[test]
1541 fn caret_rects_are_non_degenerate_at_grapheme_boundaries_for_keycap_emoji() {
1542 let content = "1️⃣ hello";
1543 let style = TextStyle {
1544 font: FontId::family("Inter"),
1545 size: Px(16.0),
1546 ..Default::default()
1547 };
1548 assert_caret_rects_are_non_degenerate_at_grapheme_boundaries(content, &style);
1549 }
1550
1551 #[test]
1552 fn caret_rects_are_non_degenerate_at_grapheme_boundaries_for_regional_indicator_flag() {
1553 let content = "🇺🇸 hello";
1554 let style = TextStyle {
1555 font: FontId::family("Inter"),
1556 size: Px(16.0),
1557 ..Default::default()
1558 };
1559 assert_caret_rects_are_non_degenerate_at_grapheme_boundaries(content, &style);
1560 }
1561
1562 #[test]
1563 fn caret_rects_are_non_degenerate_at_grapheme_boundaries_for_vs16_emoji() {
1564 let content = "✈️ hello";
1565 let style = TextStyle {
1566 font: FontId::family("Inter"),
1567 size: Px(16.0),
1568 ..Default::default()
1569 };
1570 assert_caret_rects_are_non_degenerate_at_grapheme_boundaries(content, &style);
1571 }
1572
1573 #[test]
1574 fn caret_affinity_at_soft_wrap_boundary_selects_previous_or_next_line() {
1575 let mut shaper = shaper_with_bundled_fonts();
1576
1577 let content = "hello world";
1578 let style = TextStyle {
1579 font: FontId::family("Fira Mono"),
1580 size: Px(16.0),
1581 ..Default::default()
1582 };
1583
1584 let single_line_constraints = TextConstraints {
1585 max_width: None,
1586 wrap: TextWrap::None,
1587 overflow: TextOverflow::Clip,
1588 align: fret_core::TextAlign::Start,
1589 scale_factor: 1.0,
1590 };
1591
1592 let single_lines = prepare_lines(&mut shaper, content, &style, single_line_constraints);
1593 let x_space_end = caret_x_for_index_from_single_line(&single_lines, 6);
1594 let x_w_start = caret_x_for_index_from_single_line(&single_lines, 6 + "w".len());
1595
1596 let max_width = Px((x_space_end.0 + x_w_start.0) * 0.5);
1598 let wrapped_constraints = TextConstraints {
1599 max_width: Some(max_width),
1600 wrap: TextWrap::Word,
1601 overflow: TextOverflow::Clip,
1602 align: fret_core::TextAlign::Start,
1603 scale_factor: 1.0,
1604 };
1605
1606 let lines = prepare_lines(&mut shaper, content, &style, wrapped_constraints);
1607 assert!(lines.len() >= 2, "expected the text to wrap");
1608 let line0 = &lines[0];
1609 let line1 = &lines[1];
1610
1611 let wrap_index = line1.start();
1612 assert_eq!(
1613 line0.end(),
1614 wrap_index,
1615 "expected wrapped lines to share the boundary index"
1616 );
1617
1618 let upstream =
1619 caret_rect_from_lines(&lines, wrap_index, CaretAffinity::Upstream).expect("caret rect");
1620 let downstream = caret_rect_from_lines(&lines, wrap_index, CaretAffinity::Downstream)
1621 .expect("caret rect");
1622
1623 assert!(
1624 (upstream.origin.y.0 - line0.y_top().0).abs() < 0.01,
1625 "expected upstream caret to be on the previous line; upstream={upstream:?} line0={line0:?}"
1626 );
1627 assert!(
1628 (downstream.origin.y.0 - line1.y_top().0).abs() < 0.01,
1629 "expected downstream caret to be on the next line; downstream={downstream:?} line1={line1:?}"
1630 );
1631 }
1632
1633 #[test]
1634 fn hit_test_point_reports_upstream_affinity_at_visual_line_end() {
1635 let mut shaper = shaper_with_bundled_fonts();
1636
1637 let content = "hello world";
1638 let style = TextStyle {
1639 font: FontId::family("Fira Mono"),
1640 size: Px(16.0),
1641 ..Default::default()
1642 };
1643
1644 let constraints = TextConstraints {
1645 max_width: Some(Px(60.0)),
1646 wrap: TextWrap::Word,
1647 overflow: TextOverflow::Clip,
1648 align: fret_core::TextAlign::Start,
1649 scale_factor: 1.0,
1650 };
1651
1652 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1653 assert!(lines.len() >= 2, "expected wrapped lines");
1654 let line0 = &lines[0];
1655 let line1 = &lines[1];
1656 assert_eq!(line0.end(), line1.start(), "expected a shared break index");
1657
1658 let p0 =
1659 fret_core::Point::new(Px(10_000.0), Px(line0.y_top().0 + (line0.height().0 * 0.5)));
1660 let ht0 = hit_test_point_from_lines(&lines, p0).expect("hit test point");
1661 assert_eq!(ht0.index, line0.end());
1662 assert_eq!(ht0.affinity, CaretAffinity::Upstream);
1663
1664 let p1 = fret_core::Point::new(Px(0.0), Px(line1.y_top().0 + (line1.height().0 * 0.5)));
1665 let ht1 = hit_test_point_from_lines(&lines, p1).expect("hit test point");
1666 assert_eq!(ht1.index, line1.start());
1667 assert_eq!(ht1.affinity, CaretAffinity::Downstream);
1668 }
1669
1670 #[test]
1671 fn explicit_newline_boundary_is_not_ambiguous_for_caret_affinity() {
1672 let mut shaper = shaper_with_bundled_fonts();
1673
1674 let content = "hello\nworld";
1675 let style = TextStyle {
1676 font: FontId::family("Fira Mono"),
1677 size: Px(16.0),
1678 ..Default::default()
1679 };
1680
1681 let constraints = TextConstraints {
1682 max_width: None,
1683 wrap: TextWrap::None,
1684 overflow: TextOverflow::Clip,
1685 align: fret_core::TextAlign::Start,
1686 scale_factor: 1.0,
1687 };
1688
1689 let lines = prepare_lines(&mut shaper, content, &style, constraints);
1690 assert!(lines.len() >= 2, "expected multiple lines");
1691 let line0 = &lines[0];
1692 let line1 = &lines[1];
1693
1694 let newline_index = content.find('\n').expect("expected newline");
1695 let after_newline = newline_index + "\n".len();
1696
1697 assert_eq!(
1698 line0.end(),
1699 newline_index,
1700 "expected line0 to end at newline index"
1701 );
1702 assert_eq!(
1703 line1.start(),
1704 after_newline,
1705 "expected line1 to start after newline index"
1706 );
1707
1708 let end_line0_upstream =
1709 caret_rect_from_lines(&lines, newline_index, CaretAffinity::Upstream).expect("caret");
1710 let end_line0_downstream =
1711 caret_rect_from_lines(&lines, newline_index, CaretAffinity::Downstream).expect("caret");
1712 assert!(
1713 (end_line0_upstream.origin.y.0 - line0.y_top().0).abs() < 0.01,
1714 "expected newline_index caret to be on line0 regardless of affinity"
1715 );
1716 assert!(
1717 (end_line0_downstream.origin.y.0 - line0.y_top().0).abs() < 0.01,
1718 "expected newline_index caret to be on line0 regardless of affinity"
1719 );
1720
1721 let start_line1_upstream =
1722 caret_rect_from_lines(&lines, after_newline, CaretAffinity::Upstream).expect("caret");
1723 let start_line1_downstream =
1724 caret_rect_from_lines(&lines, after_newline, CaretAffinity::Downstream).expect("caret");
1725 assert!(
1726 (start_line1_upstream.origin.y.0 - line1.y_top().0).abs() < 0.01,
1727 "expected after_newline caret to be on line1 regardless of affinity"
1728 );
1729 assert!(
1730 (start_line1_downstream.origin.y.0 - line1.y_top().0).abs() < 0.01,
1731 "expected after_newline caret to be on line1 regardless of affinity"
1732 );
1733 }
1734
1735 #[test]
1736 fn selection_rects_clipped_culls_offscreen_lines() {
1737 let mut lines = Vec::new();
1738 for i in 0..1000usize {
1739 let start = i * 4;
1740 let end = start + 4;
1741 lines.push(crate::line_layout::TextLineLayout::new(
1742 start,
1743 end,
1744 Px(100.0),
1745 Px((i as f32) * 10.0),
1746 Px(0.0),
1747 Px(10.0),
1748 Px(0.0),
1749 Px(0.0),
1750 Px(0.0),
1751 Px(0.0),
1752 vec![(start, Px(0.0)), (end, Px(100.0))],
1753 Arc::from([]),
1754 ));
1755 }
1756
1757 let clip = Rect::new(
1758 Point::new(Px(0.0), Px(1000.0)),
1759 Size::new(Px(100.0), Px(100.0)),
1760 );
1761 let mut rects = Vec::new();
1762 selection_rects_from_lines_clipped(&lines, (0, 4000), clip, &mut rects);
1763
1764 assert_eq!(rects.len(), 10);
1765 for r in &rects {
1766 assert!(r.origin.y.0 >= 1000.0 && r.origin.y.0 < 1100.0);
1767 assert!(r.size.height.0 > 0.0);
1768 }
1769 }
1770
1771 #[test]
1772 fn selection_rects_clipped_trims_partially_visible_line() {
1773 let line = crate::line_layout::TextLineLayout::new(
1774 0,
1775 4,
1776 Px(100.0),
1777 Px(0.0),
1778 Px(0.0),
1779 Px(10.0),
1780 Px(0.0),
1781 Px(0.0),
1782 Px(0.0),
1783 Px(0.0),
1784 vec![(0, Px(0.0)), (4, Px(100.0))],
1785 Arc::from([]),
1786 );
1787 let clip = Rect::new(Point::new(Px(0.0), Px(5.0)), Size::new(Px(100.0), Px(10.0)));
1788 let mut rects = Vec::new();
1789 selection_rects_from_lines_clipped(&[line], (0, 4), clip, &mut rects);
1790
1791 assert_eq!(rects.len(), 1);
1792 assert!((rects[0].origin.y.0 - 5.0).abs() < 0.001);
1793 assert!((rects[0].size.height.0 - 5.0).abs() < 0.001);
1794 }
1795
1796 #[test]
1797 fn trailing_space_at_soft_wrap_is_selectable() {
1798 let mut shaper = shaper_with_bundled_fonts();
1799
1800 let content = "hello world";
1801 let style = TextStyle {
1802 font: FontId::family("Fira Mono"),
1803 size: Px(16.0),
1804 ..Default::default()
1805 };
1806
1807 let single_line_constraints = TextConstraints {
1808 max_width: None,
1809 wrap: TextWrap::None,
1810 overflow: TextOverflow::Clip,
1811 align: fret_core::TextAlign::Start,
1812 scale_factor: 1.0,
1813 };
1814 let single_lines = prepare_lines(&mut shaper, content, &style, single_line_constraints);
1815 let x_space_end = caret_x_for_index_from_single_line(&single_lines, 6);
1816 let x_w_end = caret_x_for_index_from_single_line(&single_lines, 7);
1817 assert!(
1818 x_w_end.0 > x_space_end.0 + 0.1,
1819 "expected the 'w' to advance beyond the trailing space"
1820 );
1821
1822 let max_width = Px((x_space_end.0 + x_w_end.0) * 0.5);
1823 let wrapped_constraints = TextConstraints {
1824 max_width: Some(max_width),
1825 wrap: TextWrap::Word,
1826 overflow: TextOverflow::Clip,
1827 align: fret_core::TextAlign::Start,
1828 scale_factor: 1.0,
1829 };
1830 let lines = prepare_lines(&mut shaper, content, &style, wrapped_constraints);
1831 assert!(lines.len() >= 2, "expected the text to wrap");
1832
1833 let first = &lines[0];
1834 assert!(
1835 first.end() >= 6,
1836 "expected the first visual line to include the trailing space (end={})",
1837 first.end()
1838 );
1839
1840 let caret_after_o =
1841 caret_rect_from_lines(&lines, 5, CaretAffinity::Downstream).expect("caret rect");
1842 let caret_after_space =
1843 caret_rect_from_lines(&lines, 6, CaretAffinity::Upstream).expect("caret rect");
1844 assert!(
1845 caret_after_space.origin.x.0 > caret_after_o.origin.x.0 + 0.1,
1846 "expected the trailing space to have positive width in caret geometry"
1847 );
1848
1849 let mut rects = Vec::new();
1850 selection_rects_from_lines(&lines, (5, 6), &mut rects);
1851 assert_eq!(rects.len(), 1);
1852 assert!(
1853 rects[0].size.width.0 > 0.1,
1854 "expected a non-empty selection rect for the trailing space"
1855 );
1856 }
1857
1858 #[test]
1859 fn trailing_whitespace_run_at_soft_wrap_is_selectable() {
1860 let mut shaper = shaper_with_bundled_fonts();
1861
1862 let content = "foo bar";
1863 let style = TextStyle {
1864 font: FontId::family("Fira Mono"),
1865 size: Px(16.0),
1866 ..Default::default()
1867 };
1868
1869 let single_line_constraints = TextConstraints {
1870 max_width: None,
1871 wrap: TextWrap::None,
1872 overflow: TextOverflow::Clip,
1873 align: fret_core::TextAlign::Start,
1874 scale_factor: 1.0,
1875 };
1876 let single_lines = prepare_lines(&mut shaper, content, &style, single_line_constraints);
1877
1878 let space_run_end = 6;
1879 let b_end = 7;
1880
1881 let x_space_end = caret_x_for_index_from_single_line(&single_lines, space_run_end);
1882 let x_b_end = caret_x_for_index_from_single_line(&single_lines, b_end);
1883 assert!(
1884 x_b_end.0 > x_space_end.0 + 0.1,
1885 "expected 'b' to advance beyond the trailing whitespace"
1886 );
1887
1888 let max_width = Px((x_space_end.0 + x_b_end.0) * 0.5);
1889 let wrapped_constraints = TextConstraints {
1890 max_width: Some(max_width),
1891 wrap: TextWrap::Word,
1892 overflow: TextOverflow::Clip,
1893 align: fret_core::TextAlign::Start,
1894 scale_factor: 1.0,
1895 };
1896 let lines = prepare_lines(&mut shaper, content, &style, wrapped_constraints);
1897 assert!(lines.len() >= 2, "expected the text to wrap");
1898
1899 let first = &lines[0];
1900 assert!(
1901 first.end() >= space_run_end,
1902 "expected the first visual line to include the trailing whitespace run (end={})",
1903 first.end()
1904 );
1905
1906 let caret_after_second_space =
1907 caret_rect_from_lines(&lines, 5, CaretAffinity::Downstream).expect("caret rect");
1908 let caret_after_space_run =
1909 caret_rect_from_lines(&lines, space_run_end, CaretAffinity::Upstream)
1910 .expect("caret rect");
1911 assert!(
1912 caret_after_space_run.origin.x.0 > caret_after_second_space.origin.x.0 + 0.1,
1913 "expected the trailing whitespace run to have positive width in caret geometry"
1914 );
1915
1916 let mut rects = Vec::new();
1917 selection_rects_from_lines(&lines, (5, 6), &mut rects);
1918 assert_eq!(rects.len(), 1);
1919 assert!(
1920 rects[0].size.width.0 > 0.1,
1921 "expected a non-empty selection rect for the trailing whitespace"
1922 );
1923 }
1924
1925 #[test]
1926 fn trailing_whitespace_run_at_soft_wrap_is_selectable_for_attributed_text() {
1927 let mut shaper = shaper_with_bundled_fonts();
1928
1929 let content = "foo bar";
1930 let style = TextStyle {
1931 font: FontId::family("Fira Mono"),
1932 size: Px(16.0),
1933 ..Default::default()
1934 };
1935
1936 let spans = vec![
1937 TextSpan {
1938 len: 4,
1939 shaping: TextShapingStyle::default(),
1940 paint: Default::default(),
1941 },
1942 TextSpan {
1943 len: content.len() - 4,
1944 shaping: TextShapingStyle::default(),
1945 paint: Default::default(),
1946 },
1947 ];
1948
1949 let single_line_constraints = TextConstraints {
1950 max_width: None,
1951 wrap: TextWrap::None,
1952 overflow: TextOverflow::Clip,
1953 align: fret_core::TextAlign::Start,
1954 scale_factor: 1.0,
1955 };
1956 let single_lines = prepare_lines_attributed(
1957 &mut shaper,
1958 content,
1959 &style,
1960 &spans,
1961 single_line_constraints,
1962 );
1963
1964 let space_run_end = 6;
1965 let b_end = 7;
1966
1967 let x_space_end = caret_x_for_index_from_single_line(&single_lines, space_run_end);
1968 let x_b_end = caret_x_for_index_from_single_line(&single_lines, b_end);
1969 assert!(
1970 x_b_end.0 > x_space_end.0 + 0.1,
1971 "expected 'b' to advance beyond the trailing whitespace"
1972 );
1973
1974 let max_width = Px((x_space_end.0 + x_b_end.0) * 0.5);
1975 let wrapped_constraints = TextConstraints {
1976 max_width: Some(max_width),
1977 wrap: TextWrap::Word,
1978 overflow: TextOverflow::Clip,
1979 align: fret_core::TextAlign::Start,
1980 scale_factor: 1.0,
1981 };
1982 let lines =
1983 prepare_lines_attributed(&mut shaper, content, &style, &spans, wrapped_constraints);
1984 assert!(lines.len() >= 2, "expected the text to wrap");
1985
1986 let first = &lines[0];
1987 assert!(
1988 first.end() >= space_run_end,
1989 "expected the first visual line to include the trailing whitespace run (end={})",
1990 first.end()
1991 );
1992
1993 let caret_after_second_space =
1994 caret_rect_from_lines(&lines, 5, CaretAffinity::Downstream).expect("caret rect");
1995 let caret_after_space_run =
1996 caret_rect_from_lines(&lines, space_run_end, CaretAffinity::Upstream)
1997 .expect("caret rect");
1998 assert!(
1999 caret_after_space_run.origin.x.0 > caret_after_second_space.origin.x.0 + 0.1,
2000 "expected the trailing whitespace run to have positive width in caret geometry"
2001 );
2002
2003 let mut rects = Vec::new();
2004 selection_rects_from_lines(&lines, (5, 6), &mut rects);
2005 assert_eq!(rects.len(), 1);
2006 assert!(
2007 rects[0].size.width.0 > 0.1,
2008 "expected a non-empty selection rect for the trailing whitespace"
2009 );
2010 }
2011
2012 #[test]
2013 fn rtl_multiline_hit_test_maps_line_edges_to_logical_ends() {
2014 let clusters = vec![crate::parley_shaper::ShapedCluster::new(
2015 0..4,
2016 0.0,
2017 40.0,
2018 true,
2019 )];
2020
2021 let stops0 = super::caret_stops_for_slice("abcd", 0, &clusters, 40.0, 1.0, 4);
2022 let line0 = crate::line_layout::TextLineLayout::new(
2023 0,
2024 4,
2025 Px(40.0),
2026 Px(0.0),
2027 Px(0.0),
2028 Px(10.0),
2029 Px(0.0),
2030 Px(0.0),
2031 Px(0.0),
2032 Px(0.0),
2033 stops0,
2034 line_clusters_from_shaped(0, &clusters),
2035 );
2036
2037 let stops1 = super::caret_stops_for_slice("efgh", 4, &clusters, 40.0, 1.0, 8);
2038 let line1 = crate::line_layout::TextLineLayout::new(
2039 4,
2040 8,
2041 Px(40.0),
2042 Px(10.0),
2043 Px(0.0),
2044 Px(10.0),
2045 Px(0.0),
2046 Px(0.0),
2047 Px(0.0),
2048 Px(0.0),
2049 stops1,
2050 line_clusters_from_shaped(4, &clusters),
2051 );
2052
2053 let lines = [line0, line1];
2054
2055 let left0 = hit_test_point_from_lines(&lines, Point::new(Px(0.0), Px(5.0)))
2056 .expect("hit test line0");
2057 let right0 = hit_test_point_from_lines(&lines, Point::new(Px(40.0), Px(5.0)))
2058 .expect("hit test line0");
2059 assert_eq!(left0.index, 4);
2060 assert_eq!(right0.index, 0);
2061
2062 let left1 = hit_test_point_from_lines(&lines, Point::new(Px(0.0), Px(15.0)))
2063 .expect("hit test line1");
2064 let right1 = hit_test_point_from_lines(&lines, Point::new(Px(40.0), Px(15.0)))
2065 .expect("hit test line1");
2066 assert_eq!(left1.index, 8);
2067 assert_eq!(right1.index, 4);
2068 }
2069
2070 #[test]
2071 fn ellipsis_truncation_hit_test_maps_ellipsis_region_to_kept_end() {
2072 let text = "This is a long line that should truncate";
2073 let constraints = TextConstraints {
2074 max_width: Some(Px(80.0)),
2075 wrap: TextWrap::None,
2076 overflow: TextOverflow::Ellipsis,
2077 align: fret_core::TextAlign::Start,
2078 scale_factor: 1.0,
2079 };
2080
2081 let mut shaper = shaper_with_bundled_fonts();
2082 let base = TextStyle {
2083 font: FontId::family("Inter"),
2084 size: Px(16.0),
2085 ..Default::default()
2086 };
2087 let wrapped = wrapper::wrap_with_constraints(
2088 &mut shaper,
2089 TextInputRef::plain(text, &base),
2090 constraints,
2091 );
2092
2093 assert_eq!(wrapped.lines().len(), 1);
2094 let kept_end = wrapped.kept_end();
2095 assert!(kept_end < text.len());
2096
2097 let line_layout = &wrapped.lines()[0];
2098 assert!(
2099 line_layout
2100 .clusters()
2101 .iter()
2102 .any(|c| c.text_range() == (kept_end..kept_end)),
2103 "expected a synthetic zero-length cluster at kept_end for ellipsis mapping"
2104 );
2105
2106 let slice = &text[..kept_end];
2107 let caret_stops = super::caret_stops_for_slice(
2108 slice,
2109 0,
2110 line_layout.clusters(),
2111 line_layout.width(),
2112 1.0,
2113 kept_end,
2114 );
2115 let line = crate::line_layout::TextLineLayout::new(
2116 0,
2117 kept_end,
2118 Px(line_layout.width()),
2119 Px(0.0),
2120 Px(0.0),
2121 Px(10.0),
2122 Px(0.0),
2123 Px(0.0),
2124 Px(0.0),
2125 Px(0.0),
2126 caret_stops,
2127 line_clusters_from_shaped(0, line_layout.clusters()),
2128 );
2129
2130 let x = Px((line_layout.width() - 1.0).max(0.0));
2131 let hit = hit_test_point_from_lines(&[line], Point::new(x, Px(5.0))).expect("hit test");
2132 assert_eq!(hit.index, kept_end);
2133 }
2134}