Skip to main content

fret_render_text/
geometry.rs

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        // A minimal heuristic for test inputs; the production shaper determines RTL runs via
718        // Unicode properties.
719        matches!(
720            ch,
721            '\u{0590}'..='\u{05FF}' // Hebrew
722                | '\u{0600}'..='\u{06FF}' // Arabic
723                | '\u{0750}'..='\u{077F}' // Arabic Supplement
724                | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A
725        )
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        // Mixed LTR + RTL + numbers + punctuation.
993        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        // Simulate bidi reordering by assigning cluster x positions that do not correspond to the
1033        // logical order of the text ranges.
1034        let text = "aaa אבג def";
1035        let clusters = vec![
1036            crate::parley_shaper::ShapedCluster::new(0..4, 0.0, 40.0, false), // "aaa "
1037            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), // "def"
1039        ];
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        // Ensure mixed directionality plus enough content to reliably wrap.
1238        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        // Clip both X and Y so we exercise trimming of partially visible runs/lines.
1259        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        // Sanity: unclipped selection should extend beyond our clip width on at least one line.
1274        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 should already be coalesced; ensure no overlap within the same (clipped) line slice.
1314        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        // Pick a width between "…def" and "…ghi" so the first line includes mixed-direction text.
1377        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        // Force a soft wrap between the space and the next word.
1597        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}