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}