Skip to main content

lumen_text/
geometry.rs

1//! D4: byte<->pixel geometry derived from a shaped run.
2//!
3//! Relocated from the private `render-wgpu` `ShapedRunSegmentIndex` (which
4//! drew the caret + selection) and extended with the hit-test / visual-line
5//! queries the main-world editing logic needs. Deterministic: an identical
6//! [`ShapedRun`] yields an identical [`TextGeometry`], so the value computed
7//! in the main world matches the glyphs the render world paints.
8//!
9//! Coordinates are RUN-LOCAL logical pixels: x grows from the run's leading
10//! edge, y (`baseline_y`) is the byte's visual-line baseline offset (`0` on
11//! line 1, `line_idx * line_height` further down). Callers add the draw
12//! origin. All byte<->x math is BiDi-correct: it splits at segment
13//! boundaries and follows visual (not logical) glyph order.
14
15use crate::ShapedRun;
16use std::cmp::Ordering;
17
18/// Caret placement for a logical byte, in run-local coords.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct CaretGeometry {
21    /// Visual x from the run origin (BiDi-correct).
22    pub x: f32,
23    /// Y of the caret rect top, from the run origin baseline of line 1.
24    /// Requires size metrics ([`TextGeometry::with_size`]); `0` otherwise.
25    pub top: f32,
26    /// Caret rect height (line height). Requires size metrics.
27    pub height: f32,
28    /// 0-based visual line index the byte sits on.
29    pub line: usize,
30}
31
32/// One selection highlight rectangle, in run-local coords.
33///
34/// Selection spanning several lines yields one band per line rather than
35/// one merged span, so the painter can place each on its own baseline.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct SelectionBand {
38    /// Baseline of the line this band sits on, measured from the first
39    /// line's baseline. Zero for the first line.
40    pub baseline_y: f32,
41    /// Visual left edge from the run origin.
42    pub x0: f32,
43    /// Visual right edge from the run origin.
44    pub x1: f32,
45}
46
47/// Per-cluster `(byte range, visual leading/trailing x, line baseline)`
48/// record in cosmic-text's visual order.
49#[derive(Debug, Clone, Copy)]
50struct ClusterEntry {
51    byte_start: u32,
52    byte_end: u32,
53    /// Local x of the cluster's leading visual edge.
54    leading_x: f32,
55    /// Local x of the cluster's trailing visual edge (`leading_x + advance`).
56    trailing_x: f32,
57    /// Baseline y offset of the cluster's line relative to the run origin.
58    baseline_y: f32,
59}
60
61/// One maximal `(font, BiDi level)` slice of the run, with the per-cluster
62/// byte->x advances used for caret + selection math.
63#[derive(Debug, Clone)]
64struct SegmentIndex {
65    /// Visual origin of this segment relative to the run origin.
66    x_offset: f32,
67    /// Total visual advance of the segment.
68    width: f32,
69    /// BiDi level. Even = LTR, odd = RTL.
70    level: u8,
71    clusters: Vec<ClusterEntry>,
72    /// Lowest source byte covered by this segment.
73    byte_lo: u32,
74    /// Highest source byte (exclusive) covered by this segment.
75    byte_hi: u32,
76}
77
78/// One visual (post-wrap) line: its byte span, baseline, and the visual x
79/// edges of every cluster on it (for hit-testing and goal-x landing).
80#[derive(Debug, Clone)]
81struct LineEntry {
82    baseline_y: f32,
83    byte_lo: u32,
84    byte_hi: u32,
85    /// `(visual_x, logical_byte)` for every cluster edge on this line,
86    /// sorted ascending by `visual_x`. LTR clusters contribute
87    /// `(leading, byte_start)` + `(trailing, byte_end)`; RTL clusters
88    /// contribute `(leading, byte_end)` + `(trailing, byte_start)` so the
89    /// nearest-edge search lands on the correct logical byte in both
90    /// directions.
91    edges: Vec<(f32, u32)>,
92}
93
94/// Font-size-derived line metrics. Zero until [`TextGeometry::with_size`]
95/// bakes them (the render draw path supplies size itself, so it never
96/// needs them; the main-world producer bakes them for caret rects).
97#[derive(Debug, Clone, Copy, Default)]
98struct Metrics {
99    line_height: f32,
100    ascent: f32,
101    descent: f32,
102}
103
104/// Byte<->pixel geometry derived from one [`ShapedRun`].
105#[derive(Debug, Clone)]
106pub struct TextGeometry {
107    segments: Vec<SegmentIndex>,
108    lines: Vec<LineEntry>,
109    /// Total run width; trailing-edge fallback for a past-end caret.
110    run_width: f32,
111    /// Baseline y of the last emitted cluster.
112    end_baseline_y: f32,
113    metrics: Metrics,
114}
115
116impl From<&ShapedRun> for TextGeometry {
117    fn from(run: &ShapedRun) -> Self {
118        let mut segments = Vec::with_capacity(run.segments.len());
119        let mut lines: Vec<LineEntry> = Vec::new();
120        let mut end_baseline_y = 0.0_f32;
121        for seg in &run.segments {
122            // Visual origin = smallest x across the segment's glyphs.
123            let x_offset = seg.glyphs.iter().map(|g| g.x).fold(f32::INFINITY, f32::min);
124            let x_offset = if x_offset.is_finite() { x_offset } else { 0.0 };
125            let ltr = seg.level % 2 == 0;
126            let mut clusters: Vec<ClusterEntry> = Vec::with_capacity(seg.glyphs.len());
127            let mut byte_lo: u32 = u32::MAX;
128            let mut byte_hi: u32 = 0;
129            for g in &seg.glyphs {
130                // Skip the synthesised ellipsis glyph (byte range 0..0 with
131                // non-zero advance is the truncation sentinel).
132                if g.byte_start == 0 && g.byte_end == 0 && g.advance > 0.0 {
133                    continue;
134                }
135                if g.byte_start < byte_lo {
136                    byte_lo = g.byte_start;
137                }
138                if g.byte_end > byte_hi {
139                    byte_hi = g.byte_end;
140                }
141                let leading_x = g.x - x_offset;
142                let trailing_x = leading_x + g.advance;
143                end_baseline_y = g.y;
144                clusters.push(ClusterEntry {
145                    byte_start: g.byte_start,
146                    byte_end: g.byte_end,
147                    leading_x,
148                    trailing_x,
149                    baseline_y: g.y,
150                });
151                // Accumulate the byte's visual line.
152                let li = match lines.iter().position(|l| l.baseline_y == g.y) {
153                    Some(i) => i,
154                    None => {
155                        lines.push(LineEntry {
156                            baseline_y: g.y,
157                            byte_lo: u32::MAX,
158                            byte_hi: 0,
159                            edges: Vec::new(),
160                        });
161                        lines.len() - 1
162                    }
163                };
164                let line = &mut lines[li];
165                if g.byte_start < line.byte_lo {
166                    line.byte_lo = g.byte_start;
167                }
168                if g.byte_end > line.byte_hi {
169                    line.byte_hi = g.byte_end;
170                }
171                let vis_lead = x_offset + leading_x;
172                let vis_trail = x_offset + trailing_x;
173                if ltr {
174                    line.edges.push((vis_lead, g.byte_start));
175                    line.edges.push((vis_trail, g.byte_end));
176                } else {
177                    line.edges.push((vis_lead, g.byte_end));
178                    line.edges.push((vis_trail, g.byte_start));
179                }
180            }
181            if byte_lo == u32::MAX {
182                byte_lo = 0;
183                byte_hi = 0;
184            }
185            segments.push(SegmentIndex {
186                x_offset,
187                width: seg.width,
188                level: seg.level,
189                clusters,
190                byte_lo,
191                byte_hi,
192            });
193        }
194        // Order lines top-to-bottom and each line's edges left-to-right.
195        lines.sort_by(|a, b| f32_cmp(a.baseline_y, b.baseline_y));
196        for l in &mut lines {
197            l.edges.sort_by(|a, b| f32_cmp(a.0, b.0));
198        }
199        // Infer line height from the smallest positive baseline step (used
200        // to pick the visual line by y when size metrics were not baked).
201        let mut line_height = 0.0_f32;
202        for w in lines.windows(2) {
203            let d = w[1].baseline_y - w[0].baseline_y;
204            if d > 0.0 && (line_height == 0.0 || d < line_height) {
205                line_height = d;
206            }
207        }
208        Self {
209            segments,
210            lines,
211            run_width: run.width,
212            end_baseline_y,
213            metrics: Metrics {
214                line_height,
215                ascent: 0.0,
216                descent: 0.0,
217            },
218        }
219    }
220}
221
222impl TextGeometry {
223    /// Bake font-size line metrics so [`Self::byte_to_caret`] returns a real
224    /// caret rect `top` / `height`. Matches the render caret math: ascent =
225    /// `size_px * 0.9` above the baseline, descent = `size_px * 0.15` below
226    /// (fixed font-metric ratios, independent of `line-height` - only the
227    /// spacing between lines changes, not the glyph box a caret hugs).
228    /// `line_height_px` is the resolved CSS `line-height` (or the
229    /// `size_px * DEFAULT_LINE_HEIGHT_MULTIPLIER` fallback), supplied by
230    /// the caller so this pure-math module never has to know the fallback
231    /// constant itself.
232    pub fn with_size(mut self, size_px: f32, line_height_px: f32) -> Self {
233        self.metrics = Metrics {
234            line_height: line_height_px,
235            ascent: size_px * 0.9,
236            descent: size_px * 0.15,
237        };
238        self
239    }
240
241    /// Visual `(x, baseline_y)` of the caret for a logical byte offset, in
242    /// run-local coords. This is the byte->pixel math the render caret /
243    /// selection draw path consumes (relocated verbatim from render-wgpu).
244    pub fn caret_xy(&self, byte: usize) -> (f32, f32) {
245        if byte == 0 {
246            return (
247                self.segments.first().map(|s| s.x_offset).unwrap_or(0.0),
248                self.segments
249                    .first()
250                    .and_then(|s| s.clusters.first())
251                    .map(|c| c.baseline_y)
252                    .unwrap_or(0.0),
253            );
254        }
255        let byte = byte as u32;
256        for seg in &self.segments {
257            if byte < seg.byte_lo || byte > seg.byte_hi {
258                continue;
259            }
260            for c in &seg.clusters {
261                if byte >= c.byte_start && byte <= c.byte_end {
262                    let local = if seg.level % 2 == 1 {
263                        // RTL: byte == byte_end -> visual-left (leading);
264                        // byte == byte_start -> visual-right (trailing).
265                        if byte == c.byte_end {
266                            c.leading_x
267                        } else {
268                            c.trailing_x
269                        }
270                    } else {
271                        // LTR: byte == byte_start -> leading; end -> trailing.
272                        if byte == c.byte_start {
273                            c.leading_x
274                        } else {
275                            c.trailing_x
276                        }
277                    };
278                    return (seg.x_offset + local, c.baseline_y);
279                }
280            }
281            let y = seg
282                .clusters
283                .last()
284                .map(|c| c.baseline_y)
285                .unwrap_or(self.end_baseline_y);
286            return (seg.x_offset + seg.width, y);
287        }
288        (self.run_width, self.end_baseline_y)
289    }
290
291    /// Logical byte -> caret rect (run-local). `top` / `height` are only
292    /// meaningful once [`Self::with_size`] has baked the metrics.
293    pub fn byte_to_caret(&self, byte: usize) -> CaretGeometry {
294        let (x, baseline) = self.caret_xy(byte);
295        let line = if self.metrics.line_height > 0.0 {
296            (baseline / self.metrics.line_height).round().max(0.0) as usize
297        } else {
298            0
299        };
300        CaretGeometry {
301            x,
302            top: baseline - self.metrics.ascent,
303            height: self.metrics.ascent + self.metrics.descent,
304            line,
305        }
306    }
307
308    /// Number of visual (post-wrap) lines.
309    pub fn line_count(&self) -> usize {
310        self.lines.len()
311    }
312
313    /// Visual line index containing `byte`. Past-end / between-line bytes
314    /// snap to the nearest preceding line.
315    pub fn visual_line_of_byte(&self, byte: usize) -> usize {
316        let b = byte as u32;
317        for (i, l) in self.lines.iter().enumerate() {
318            if b >= l.byte_lo && b <= l.byte_hi {
319                return i;
320            }
321        }
322        let mut best = 0usize;
323        for (i, l) in self.lines.iter().enumerate() {
324            if l.byte_lo <= b {
325                best = i;
326            }
327        }
328        best
329    }
330
331    /// Logical byte range `[start, end)` of visual line `line` (end excludes
332    /// a soft-wrap break and a trailing '\n', since neither emits a glyph
333    /// cluster). Empty tuple for an out-of-range line.
334    pub fn visual_line_bounds(&self, line: usize) -> (usize, usize) {
335        match self.lines.get(line) {
336            Some(l) => (l.byte_lo as usize, l.byte_hi as usize),
337            None => (0, 0),
338        }
339    }
340
341    /// Byte on visual line `line` whose caret x is nearest `goal_x`
342    /// (goal-column landing). Clamps to the line's byte range.
343    pub fn byte_at_line_x(&self, line: usize, goal_x: f32) -> usize {
344        match self.lines.get(line) {
345            Some(l) => nearest_edge_byte(&l.edges, goal_x).unwrap_or(l.byte_lo as usize),
346            None => 0,
347        }
348    }
349
350    /// Visual hit-test: run-local pointer `(x, y)` -> logical byte. Picks the
351    /// visual line by y, then the nearest cluster edge by x within that line
352    /// (BiDi-correct). Past-end snaps to the line's trailing byte.
353    ///
354    /// A line that produced no glyphs - a blank line between two
355    /// paragraphs - has no entry in `lines`, so the entry cannot be
356    /// reached by `y / line_height`: that counts every visual line while
357    /// `lines` counts only the ones that drew something, and the two
358    /// diverge by the number of blank lines above the pointer. The line
359    /// is matched on its baseline instead. A `y` over a blank line
360    /// resolves to the end of the last line above it.
361    pub fn x_to_byte(&self, x: f32, y: f32) -> usize {
362        if self.lines.is_empty() {
363            return 0;
364        }
365        let l = if self.metrics.line_height > 0.0 {
366            // Baseline of the visual line the pointer is over, counting
367            // blank lines - which is what the glyph y offsets encode.
368            let want = (y / self.metrics.line_height).floor().max(0.0) * self.metrics.line_height;
369            self.lines
370                .iter()
371                .rev()
372                .find(|l| l.baseline_y <= want + 0.5)
373                .unwrap_or(&self.lines[0])
374        } else {
375            &self.lines[0]
376        };
377        nearest_edge_byte(&l.edges, x).unwrap_or(l.byte_lo as usize)
378    }
379
380    /// Emit one selection band per line-portion of each maximal
381    /// contiguous BiDi-level run the logical range `[lo, hi)` intersects.
382    ///
383    /// A segment is a maximal `(font, BiDi level)` run and keeps going
384    /// across a line break, so a single min/max over a whole segment would
385    /// merge two lines into one wide rectangle. Bucketing by baseline
386    /// keeps each line's highlight on its own line; `baseline_y` is the
387    /// offset from the first line's baseline, which is what the caret path
388    /// already uses.
389    pub fn selection_bands(&self, lo: usize, hi: usize) -> Vec<SelectionBand> {
390        if hi <= lo {
391            return Vec::new();
392        }
393        let lo = lo as u32;
394        let hi = hi as u32;
395        let mut out: Vec<SelectionBand> = Vec::new();
396        for seg in &self.segments {
397            let seg_lo = lo.max(seg.byte_lo);
398            let seg_hi = hi.min(seg.byte_hi);
399            if seg_lo >= seg_hi {
400                continue;
401            }
402            // (baseline_y, min_x, max_x) per line this segment touches.
403            let mut per_line: Vec<(f32, f32, f32)> = Vec::new();
404            for c in &seg.clusters {
405                if c.byte_end <= seg_lo || c.byte_start >= seg_hi {
406                    continue;
407                }
408                match per_line.iter_mut().find(|(y, _, _)| *y == c.baseline_y) {
409                    Some((_, min_x, max_x)) => {
410                        if c.leading_x < *min_x {
411                            *min_x = c.leading_x;
412                        }
413                        if c.trailing_x > *max_x {
414                            *max_x = c.trailing_x;
415                        }
416                    }
417                    None => per_line.push((c.baseline_y, c.leading_x, c.trailing_x)),
418                }
419            }
420            for (baseline_y, min_x, max_x) in per_line {
421                if max_x > min_x {
422                    out.push(SelectionBand {
423                        baseline_y,
424                        x0: seg.x_offset + min_x,
425                        x1: seg.x_offset + max_x,
426                    });
427                }
428            }
429        }
430        out.sort_by(|a, b| f32_cmp(a.baseline_y, b.baseline_y).then(f32_cmp(a.x0, b.x0)));
431        out
432    }
433}
434
435/// Total ordering for the non-NaN f32s the shaper emits.
436fn f32_cmp(a: f32, b: f32) -> Ordering {
437    a.partial_cmp(&b).unwrap_or(Ordering::Equal)
438}
439
440/// Byte whose edge visual-x is nearest `target`.
441fn nearest_edge_byte(edges: &[(f32, u32)], target: f32) -> Option<usize> {
442    edges
443        .iter()
444        .min_by(|a, b| f32_cmp((a.0 - target).abs(), (b.0 - target).abs()))
445        .map(|e| e.1 as usize)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{GlyphPosition, ShapedRun, ShapedSegment};
452    use std::sync::Arc;
453
454    fn glyph(byte_start: u32, byte_end: u32, x: f32, advance: f32) -> GlyphPosition {
455        GlyphPosition {
456            id: 0,
457            x,
458            y: 0.0,
459            advance,
460            byte_start,
461            byte_end,
462        }
463    }
464
465    fn seg_at_level(level: u8, glyphs: Vec<GlyphPosition>) -> ShapedSegment {
466        let width: f32 = glyphs.iter().map(|g| g.advance).sum();
467        ShapedSegment {
468            font_id: 1,
469            font_data: Arc::new(Vec::new()),
470            font_index: 0,
471            normalized_coords: Vec::new(),
472            level,
473            glyphs,
474            width,
475        }
476    }
477
478    /// Pure-LTR paragraph: byte->x grows monotonically and matches each
479    /// glyph's leading edge (W3.6 fast-path regression check).
480    #[test]
481    fn ltr_caret_byte_to_x_is_monotone() {
482        let seg = seg_at_level(
483            0,
484            vec![
485                glyph(0, 1, 0.0, 10.0),
486                glyph(1, 2, 10.0, 10.0),
487                glyph(2, 3, 20.0, 10.0),
488            ],
489        );
490        let run = ShapedRun {
491            font_data: seg.font_data.clone(),
492            font_index: 0,
493            glyphs: seg.glyphs.clone(),
494            segments: vec![seg],
495            width: 30.0,
496        };
497        let g = TextGeometry::from(&run);
498        assert_eq!(g.caret_xy(0).0, 0.0);
499        assert_eq!(g.caret_xy(1).0, 10.0);
500        assert_eq!(g.caret_xy(2).0, 20.0);
501        assert_eq!(g.caret_xy(3).0, 30.0);
502    }
503
504    /// D4: x_to_byte round-trips with byte_to_caret for LTR; a click past
505    /// the run end snaps to the trailing byte.
506    #[test]
507    fn ltr_x_to_byte_roundtrips_and_snaps_past_end() {
508        let seg = seg_at_level(
509            0,
510            vec![
511                glyph(0, 1, 0.0, 10.0),
512                glyph(1, 2, 10.0, 10.0),
513                glyph(2, 3, 20.0, 10.0),
514            ],
515        );
516        let run = ShapedRun {
517            font_data: seg.font_data.clone(),
518            font_index: 0,
519            glyphs: seg.glyphs.clone(),
520            segments: vec![seg],
521            width: 30.0,
522        };
523        let g = TextGeometry::from(&run);
524        // Nearest-edge hit-testing: left-half of a glyph -> its start byte,
525        // right-half -> its end byte.
526        assert_eq!(g.x_to_byte(1.0, 0.0), 0);
527        assert_eq!(g.x_to_byte(9.0, 0.0), 1);
528        assert_eq!(g.x_to_byte(11.0, 0.0), 1);
529        assert_eq!(g.x_to_byte(1000.0, 0.0), 3);
530        // Round-trip: caret x for each byte hit-tests back to that byte.
531        for b in [0usize, 1, 2, 3] {
532            let x = g.caret_xy(b).0;
533            assert_eq!(g.x_to_byte(x, 0.0), b);
534        }
535    }
536
537    /// D4: a proportional run (wide 'W', narrow 'i') hit-tests by real glyph
538    /// widths, not a uniform advance. Clicking inside the wide glyph lands on
539    /// the boundary nearest the pointer, which the 0.55-avg estimate missed.
540    #[test]
541    fn proportional_hit_test_uses_real_widths() {
542        // 'W' 24px wide, 'i' 4px wide, 'l' 4px wide.
543        let seg = seg_at_level(
544            0,
545            vec![
546                glyph(0, 1, 0.0, 24.0),
547                glyph(1, 2, 24.0, 4.0),
548                glyph(2, 3, 28.0, 4.0),
549            ],
550        );
551        let run = ShapedRun {
552            font_data: seg.font_data.clone(),
553            font_index: 0,
554            glyphs: seg.glyphs.clone(),
555            segments: vec![seg],
556            width: 32.0,
557        };
558        let g = TextGeometry::from(&run);
559        // A click at x=20 is inside the wide 'W' (0..24), past its midpoint
560        // (12), so it lands on byte 1 (after 'W'). A uniform 0.55*size
561        // estimate would misplace this.
562        assert_eq!(g.x_to_byte(20.0, 0.0), 1);
563        // A click at x=10 is in the left half of 'W' -> byte 0.
564        assert_eq!(g.x_to_byte(10.0, 0.0), 0);
565    }
566
567    /// A multiline shape carries per-line baseline offsets on its glyphs; the
568    /// caret must land on the byte's own line, and x_to_byte must pick the
569    /// visual line by y.
570    #[test]
571    fn multiline_caret_and_hit_test_use_line() {
572        let mk = |bs: u32, be: u32, x: f32, y: f32| GlyphPosition {
573            id: 0,
574            x,
575            y,
576            advance: 10.0,
577            byte_start: bs,
578            byte_end: be,
579        };
580        let seg = seg_at_level(
581            0,
582            vec![
583                mk(0, 1, 0.0, 0.0),
584                mk(1, 2, 10.0, 0.0),
585                mk(3, 4, 0.0, 19.2),
586                mk(4, 5, 10.0, 19.2),
587            ],
588        );
589        let run = ShapedRun {
590            font_data: seg.font_data.clone(),
591            font_index: 0,
592            glyphs: seg.glyphs.clone(),
593            segments: vec![seg],
594            width: 20.0,
595        };
596        let g = TextGeometry::from(&run);
597        assert_eq!(g.caret_xy(0), (0.0, 0.0));
598        assert_eq!(g.caret_xy(3), (0.0, 19.2));
599        assert_eq!(g.caret_xy(4), (10.0, 19.2));
600        assert_eq!(g.caret_xy(5), (20.0, 19.2));
601        assert_eq!(g.line_count(), 2);
602        // Inferred line height is the 19.2 baseline step; y in the second
603        // band hit-tests on line 2's bytes.
604        assert_eq!(g.x_to_byte(1.0, 25.0), 3);
605        assert_eq!(g.x_to_byte(9.0, 25.0), 4);
606        assert_eq!(g.visual_line_of_byte(4), 1);
607        assert_eq!(g.visual_line_bounds(1), (3, 5));
608    }
609
610    /// D5/D6 support: byte_at_line_x lands on the byte nearest a goal x on a
611    /// target line (goal-column preservation).
612    #[test]
613    fn byte_at_line_x_lands_nearest_goal() {
614        let mk = |bs: u32, be: u32, x: f32, y: f32| GlyphPosition {
615            id: 0,
616            x,
617            y,
618            advance: 10.0,
619            byte_start: bs,
620            byte_end: be,
621        };
622        // Line 1 "abcd" (bytes 0..4), line 2 "ef" (bytes 5..7).
623        let seg = seg_at_level(
624            0,
625            vec![
626                mk(0, 1, 0.0, 0.0),
627                mk(1, 2, 10.0, 0.0),
628                mk(2, 3, 20.0, 0.0),
629                mk(3, 4, 30.0, 0.0),
630                mk(5, 6, 0.0, 19.2),
631                mk(6, 7, 10.0, 19.2),
632            ],
633        );
634        let run = ShapedRun {
635            font_data: seg.font_data.clone(),
636            font_index: 0,
637            glyphs: seg.glyphs.clone(),
638            segments: vec![seg],
639            width: 40.0,
640        };
641        let g = TextGeometry::from(&run);
642        // Goal x=30 on the short line 2 clamps to its trailing byte (7).
643        assert_eq!(g.byte_at_line_x(1, 30.0), 7);
644        // Goal x=10 on line 1 lands on byte 1.
645        assert_eq!(g.byte_at_line_x(0, 10.0), 1);
646    }
647
648    /// BiDi caret math: an LTR->RTL->LTR paragraph reorders caret positions
649    /// visually, not logically.
650    #[test]
651    fn bidi_caret_byte_to_x_is_visual_not_logical() {
652        let ltr_head = seg_at_level(
653            0,
654            vec![
655                glyph(0, 1, 0.0, 10.0),
656                glyph(1, 2, 10.0, 10.0),
657                glyph(2, 3, 20.0, 10.0),
658            ],
659        );
660        let rtl_mid = seg_at_level(
661            1,
662            vec![
663                glyph(5, 6, 30.0, 10.0),
664                glyph(4, 5, 40.0, 10.0),
665                glyph(3, 4, 50.0, 10.0),
666            ],
667        );
668        let ltr_tail = seg_at_level(
669            0,
670            vec![
671                glyph(6, 7, 60.0, 10.0),
672                glyph(7, 8, 70.0, 10.0),
673                glyph(8, 9, 80.0, 10.0),
674            ],
675        );
676        let mut all_glyphs = Vec::new();
677        all_glyphs.extend(ltr_head.glyphs.iter().copied());
678        all_glyphs.extend(rtl_mid.glyphs.iter().copied());
679        all_glyphs.extend(ltr_tail.glyphs.iter().copied());
680        let run = ShapedRun {
681            font_data: ltr_head.font_data.clone(),
682            font_index: 0,
683            glyphs: all_glyphs,
684            segments: vec![ltr_head, rtl_mid, ltr_tail],
685            width: 90.0,
686        };
687        let g = TextGeometry::from(&run);
688        assert_eq!(g.caret_xy(0).0, 0.0);
689        assert_eq!(g.caret_xy(3).0, 30.0);
690        let x_byte3 = g.caret_xy(3).0;
691        let x_byte6 = g.caret_xy(6).0;
692        assert!(
693            x_byte3 == 30.0 || x_byte6 == 60.0,
694            "RTL run must reorder caret positions visually; got \
695             byte3.x={x_byte3} byte6.x={x_byte6}"
696        );
697        assert_eq!(g.caret_xy(9).0, 90.0);
698    }
699
700    /// Selection across an RTL run emits >= 2 rectangles (one per maximal
701    /// contiguous-level slice); pure-LTR collapses to one.
702    #[test]
703    fn bidi_selection_emits_rect_per_level_run() {
704        let ltr_head = seg_at_level(
705            0,
706            vec![
707                glyph(0, 1, 0.0, 10.0),
708                glyph(1, 2, 10.0, 10.0),
709                glyph(2, 3, 20.0, 10.0),
710            ],
711        );
712        let rtl_mid = seg_at_level(
713            1,
714            vec![
715                glyph(5, 6, 30.0, 10.0),
716                glyph(4, 5, 40.0, 10.0),
717                glyph(3, 4, 50.0, 10.0),
718            ],
719        );
720        let ltr_tail = seg_at_level(
721            0,
722            vec![
723                glyph(6, 7, 60.0, 10.0),
724                glyph(7, 8, 70.0, 10.0),
725                glyph(8, 9, 80.0, 10.0),
726            ],
727        );
728        let run = ShapedRun {
729            font_data: ltr_head.font_data.clone(),
730            font_index: 0,
731            glyphs: Vec::new(),
732            segments: vec![ltr_head, rtl_mid, ltr_tail],
733            width: 90.0,
734        };
735        let g = TextGeometry::from(&run);
736        let one = g.selection_bands(0, 3);
737        assert_eq!(one.len(), 1);
738        assert_eq!((one[0].x0, one[0].x1), (0.0, 30.0));
739        let three = g.selection_bands(1, 8);
740        assert_eq!(three.len(), 3, "got {three:?}");
741    }
742
743    /// with_size bakes caret rect top/height matching the render caret math.
744    #[test]
745    fn with_size_bakes_caret_rect() {
746        let seg = seg_at_level(0, vec![glyph(0, 1, 0.0, 10.0), glyph(1, 2, 10.0, 10.0)]);
747        let run = ShapedRun {
748            font_data: seg.font_data.clone(),
749            font_index: 0,
750            glyphs: seg.glyphs.clone(),
751            segments: vec![seg],
752            width: 20.0,
753        };
754        let g = TextGeometry::from(&run).with_size(16.0, 16.0 * 1.2);
755        let c = g.byte_to_caret(1);
756        assert_eq!(c.x, 10.0);
757        assert_eq!(c.top, -16.0 * 0.9);
758        assert!((c.height - 16.0 * 1.05).abs() < 1e-4);
759        assert_eq!(c.line, 0);
760    }
761
762    /// `with_size`'s `line_height_px` param is caller-supplied (a CSS
763    /// `line-height` override or the default multiplier) and drives
764    /// `byte_to_caret`'s visual-line selection independently of
765    /// `size_px`; ascent/descent (hence caret `top` / `height`) stay
766    /// fixed font-metric ratios of `size_px` regardless.
767    #[test]
768    fn with_size_honours_explicit_line_height_for_line_selection() {
769        let mk = |bs: u32, be: u32, x: f32, y: f32| GlyphPosition {
770            id: 0,
771            x,
772            y,
773            advance: 10.0,
774            byte_start: bs,
775            byte_end: be,
776        };
777        let seg = seg_at_level(0, vec![mk(0, 1, 0.0, 0.0), mk(1, 2, 0.0, 30.0)]);
778        let run = ShapedRun {
779            font_data: seg.font_data.clone(),
780            font_index: 0,
781            glyphs: seg.glyphs.clone(),
782            segments: vec![seg],
783            width: 10.0,
784        };
785        // A CSS `line-height: 30px` override should place baseline 30.0 on
786        // visual line 1 (30.0 / 30.0 == 1), not line 2 (round(30.0 / 19.2))
787        // that the default 16px*1.2 multiplier would infer.
788        //
789        // Query byte 2, not byte 1. Byte 1 sits on the boundary between the
790        // two clusters, so it resolves to the trailing edge of the first one
791        // and reports line 0; that caret-affinity choice is orthogonal to
792        // what this test covers.
793        let g = TextGeometry::from(&run).with_size(16.0, 30.0);
794        let c = g.byte_to_caret(2);
795        assert_eq!(c.line, 1);
796        // Ascent/descent (caret top/height) stay size_px-derived, unaffected
797        // by the line-height override.
798        assert_eq!(c.top, 30.0 - 16.0 * 0.9);
799        assert!((c.height - 16.0 * 1.05).abs() < 1e-4);
800    }
801}