Skip to main content

ftui_text/
shaped_render.rs

1#![forbid(unsafe_code)]
2
3//! Shaped-run render path with spacing/kerning deltas.
4//!
5//! This module transforms a [`ShapedRun`] into a sequence of cell-ready
6//! placements that a renderer can consume to produce output with correct
7//! spacing, kerning, and ligature handling.
8//!
9//! # Design
10//!
11//! The render path operates in sub-cell units (1/256 cell column) for
12//! precision, then quantizes to integer cell positions for the terminal
13//! grid. This preserves the kerning and spacing fidelity from the shaping
14//! engine while producing deterministic cell-grid output.
15//!
16//! # Pipeline
17//!
18//! ```text
19//! ShapedRun + text
20//!     → ClusterMap (byte↔cell mapping)
21//!     → ShapedLineLayout (cell placements with sub-cell spacing)
22//!     → apply justification/tracking deltas
23//!     → quantized cell positions for buffer rendering
24//! ```
25//!
26//! # Example
27//!
28//! ```
29//! use ftui_text::shaped_render::{ShapedLineLayout, RenderHint};
30//! use ftui_text::shaping::{NoopShaper, TextShaper, FontFeatures};
31//! use ftui_text::script_segmentation::{Script, RunDirection};
32//!
33//! let text = "Hello!";
34//! let shaper = NoopShaper;
35//! let features = FontFeatures::default();
36//! let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &features);
37//!
38//! let layout = ShapedLineLayout::from_run(text, &run);
39//! assert_eq!(layout.total_cells(), 6);
40//! assert_eq!(layout.placements().len(), 6);
41//! assert_eq!(layout.placements()[0].render_hint, RenderHint::DirectChar('H'));
42//! ```
43
44use crate::cluster_map::ClusterMap;
45use crate::justification::{GlueSpec, SUBCELL_SCALE};
46use crate::shaping::ShapedRun;
47
48// ---------------------------------------------------------------------------
49// SpacingDelta — sub-cell adjustment
50// ---------------------------------------------------------------------------
51
52/// A sub-cell spacing adjustment applied between or within clusters.
53///
54/// Positive values add space (kerning expansion, justification stretch);
55/// negative values remove space (kerning tightening, shrink).
56///
57/// Units: 1/256 of a cell column (same as [`SUBCELL_SCALE`]).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct SpacingDelta {
60    /// Horizontal offset from nominal position in sub-cell units.
61    /// Positive = shift right, negative = shift left.
62    pub x_subcell: i32,
63    /// Vertical offset from nominal position in sub-cell units.
64    /// Used for superscript/subscript adjustments.
65    pub y_subcell: i32,
66}
67
68impl SpacingDelta {
69    /// Zero delta (no adjustment).
70    pub const ZERO: Self = Self {
71        x_subcell: 0,
72        y_subcell: 0,
73    };
74
75    /// Whether this delta has any effect.
76    #[inline]
77    pub const fn is_zero(&self) -> bool {
78        self.x_subcell == 0 && self.y_subcell == 0
79    }
80
81    /// Convert x offset to whole cells (rounded towards zero).
82    #[inline]
83    pub const fn x_cells(&self) -> i32 {
84        self.x_subcell / SUBCELL_SCALE as i32
85    }
86}
87
88// ---------------------------------------------------------------------------
89// RenderHint — how to render cell content
90// ---------------------------------------------------------------------------
91
92/// Hint for how to render a cell's content.
93///
94/// This allows the renderer to choose the most efficient path: direct char
95/// encoding for simple characters, or grapheme pool interning for complex
96/// clusters (combining marks, emoji sequences, ligatures).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum RenderHint {
99    /// A single Unicode character that can be stored directly in a cell.
100    /// This is the fast path for ASCII and most BMP characters.
101    DirectChar(char),
102    /// A multi-codepoint grapheme cluster that requires pool interning.
103    /// Contains the full cluster string and its display width.
104    Grapheme {
105        /// The grapheme cluster text.
106        text: String,
107        /// Display width in cells.
108        width: u8,
109    },
110    /// A continuation cell for a wide character (no content to render).
111    Continuation,
112}
113
114// ---------------------------------------------------------------------------
115// CellPlacement — a positioned cell in the output
116// ---------------------------------------------------------------------------
117
118/// A single cell placement in the shaped output line.
119///
120/// Each placement represents one terminal cell position with its content,
121/// spacing adjustment, and source metadata for interaction overlays
122/// (cursor, selection, search highlighting).
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct CellPlacement {
125    /// Cell column index (0-based from line start).
126    pub cell_x: u32,
127    /// What to render in this cell.
128    pub render_hint: RenderHint,
129    /// Sub-cell spacing delta from nominal position.
130    /// The renderer may use this for sub-pixel positioning (web/GPU)
131    /// or accumulate into whole-cell shifts (terminal).
132    pub spacing: SpacingDelta,
133    /// Source byte range in the original text.
134    pub byte_start: u32,
135    pub byte_end: u32,
136    /// Grapheme index in the original text.
137    pub grapheme_index: u32,
138}
139
140// ---------------------------------------------------------------------------
141// ShapedLineLayout
142// ---------------------------------------------------------------------------
143
144/// A line of shaped text ready for rendering.
145///
146/// Contains cell placements with spacing deltas, plus metadata for
147/// cursor/selection overlay computation. Deterministic: the same input
148/// always produces the same layout.
149#[derive(Debug, Clone)]
150pub struct ShapedLineLayout {
151    /// Ordered cell placements (one per cell column).
152    placements: Vec<CellPlacement>,
153    /// Total width in cells.
154    total_cells: u32,
155    /// Accumulated sub-cell remainder from spacing deltas.
156    /// Renderers that support sub-pixel positioning can use this
157    /// for precise placement; terminal renderers can ignore it.
158    subcell_remainder: i32,
159    /// The cluster map for this line (retained for interaction queries).
160    cluster_map: ClusterMap,
161}
162
163impl ShapedLineLayout {
164    /// Build a layout from a shaped run and its source text.
165    ///
166    /// Uses the `ClusterMap` to map glyph clusters to cell positions,
167    /// and extracts spacing deltas from glyph advance differences.
168    pub fn from_run(text: &str, run: &ShapedRun) -> Self {
169        if text.is_empty() || run.is_empty() {
170            return Self {
171                placements: Vec::new(),
172                total_cells: 0,
173                subcell_remainder: 0,
174                cluster_map: ClusterMap::from_text(""),
175            };
176        }
177
178        let cluster_map = ClusterMap::from_shaped_run(text, run);
179        let cluster_metrics = cluster_glyph_metrics(run);
180        let mut placements = Vec::with_capacity(cluster_map.total_cells());
181        let mut subcell_accumulator: i32 = 0;
182
183        // Build placement for each cluster in the map.
184        for (entry, metrics) in cluster_map.entries().iter().zip(cluster_metrics.iter()) {
185            debug_assert_eq!(entry.byte_start, metrics.byte_start);
186            let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
187            let nominal_width = entry.cell_width as i32;
188
189            // Compute spacing delta from shaped glyph advances.
190            let shaped_advance = metrics.x_advance_subcell;
191            let delta_subcell = shaped_advance - (nominal_width * SUBCELL_SCALE as i32);
192            subcell_accumulator += delta_subcell;
193            let y_offset = metrics.first_y_offset_subcell;
194
195            let spacing = if delta_subcell != 0 {
196                SpacingDelta {
197                    x_subcell: delta_subcell,
198                    y_subcell: y_offset,
199                }
200            } else {
201                if y_offset != 0 {
202                    SpacingDelta {
203                        x_subcell: 0,
204                        y_subcell: y_offset,
205                    }
206                } else {
207                    SpacingDelta::ZERO
208                }
209            };
210
211            // Determine render hint.
212            let hint = render_hint_for_cluster(cluster_text, entry.cell_width);
213
214            // Emit primary cell.
215            placements.push(CellPlacement {
216                cell_x: entry.cell_start,
217                render_hint: hint,
218                spacing,
219                byte_start: entry.byte_start,
220                byte_end: entry.byte_end,
221                grapheme_index: entry.grapheme_index,
222            });
223
224            // Emit continuation cells for wide characters.
225            for cont in 1..entry.cell_width {
226                placements.push(CellPlacement {
227                    cell_x: entry.cell_start + cont as u32,
228                    render_hint: RenderHint::Continuation,
229                    spacing: SpacingDelta::ZERO,
230                    byte_start: entry.byte_start,
231                    byte_end: entry.byte_end,
232                    grapheme_index: entry.grapheme_index,
233                });
234            }
235        }
236
237        Self {
238            placements,
239            total_cells: cluster_map.total_cells() as u32,
240            subcell_remainder: subcell_accumulator,
241            cluster_map,
242        }
243    }
244
245    /// Build a layout from plain text (no shaping, terminal mode).
246    ///
247    /// Equivalent to shaping with `NoopShaper` — each grapheme maps to
248    /// cells based on display width, with no spacing deltas.
249    pub fn from_text(text: &str) -> Self {
250        if text.is_empty() {
251            return Self {
252                placements: Vec::new(),
253                total_cells: 0,
254                subcell_remainder: 0,
255                cluster_map: ClusterMap::from_text(""),
256            };
257        }
258
259        let cluster_map = ClusterMap::from_text(text);
260        let mut placements = Vec::with_capacity(cluster_map.total_cells());
261
262        for entry in cluster_map.entries() {
263            let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
264            let hint = render_hint_for_cluster(cluster_text, entry.cell_width);
265
266            placements.push(CellPlacement {
267                cell_x: entry.cell_start,
268                render_hint: hint,
269                spacing: SpacingDelta::ZERO,
270                byte_start: entry.byte_start,
271                byte_end: entry.byte_end,
272                grapheme_index: entry.grapheme_index,
273            });
274
275            for cont in 1..entry.cell_width {
276                placements.push(CellPlacement {
277                    cell_x: entry.cell_start + cont as u32,
278                    render_hint: RenderHint::Continuation,
279                    spacing: SpacingDelta::ZERO,
280                    byte_start: entry.byte_start,
281                    byte_end: entry.byte_end,
282                    grapheme_index: entry.grapheme_index,
283                });
284            }
285        }
286
287        Self {
288            placements,
289            total_cells: cluster_map.total_cells() as u32,
290            subcell_remainder: 0,
291            cluster_map,
292        }
293    }
294
295    /// Apply justification spacing to inter-word gaps.
296    ///
297    /// `ratio_fixed` is in 1/256 sub-cell units (positive = stretch,
298    /// negative = shrink). Space characters get their glue adjusted
299    /// according to the ratio.
300    pub fn apply_justification(&mut self, _text: &str, ratio_fixed: i32, glue: &GlueSpec) {
301        if ratio_fixed == 0 || self.placements.is_empty() {
302            return;
303        }
304
305        let adjusted_width_subcell = glue.adjusted_width(ratio_fixed);
306        let natural_subcell = glue.natural_subcell;
307        let delta_per_space = adjusted_width_subcell as i32 - natural_subcell as i32;
308
309        if delta_per_space == 0 {
310            return;
311        }
312
313        for placement in &mut self.placements {
314            if matches!(
315                placement.render_hint,
316                RenderHint::DirectChar(' ' | '\u{00A0}')
317            ) {
318                placement.spacing.x_subcell += delta_per_space;
319                self.subcell_remainder += delta_per_space;
320            }
321        }
322    }
323
324    /// Apply uniform letter-spacing (tracking) to all inter-cluster gaps.
325    ///
326    /// `tracking_subcell` is in 1/256 cell units. Positive = expand,
327    /// negative = tighten. The last cluster does not get trailing space.
328    pub fn apply_tracking(&mut self, tracking_subcell: i32) {
329        if tracking_subcell == 0 || self.placements.is_empty() {
330            return;
331        }
332
333        // Apply tracking to all primary cells except the last.
334        let mut last_grapheme = u32::MAX;
335        let primary_count = self
336            .placements
337            .iter()
338            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
339            .count();
340
341        if primary_count <= 1 {
342            return;
343        }
344
345        let mut seen = 0;
346        for placement in &mut self.placements {
347            if matches!(placement.render_hint, RenderHint::Continuation) {
348                continue;
349            }
350            seen += 1;
351            if seen < primary_count && placement.grapheme_index != last_grapheme {
352                placement.spacing.x_subcell += tracking_subcell;
353                self.subcell_remainder += tracking_subcell;
354                last_grapheme = placement.grapheme_index;
355            }
356        }
357    }
358
359    // -----------------------------------------------------------------------
360    // Accessors
361    // -----------------------------------------------------------------------
362
363    /// The cell placements in order.
364    #[inline]
365    pub fn placements(&self) -> &[CellPlacement] {
366        &self.placements
367    }
368
369    /// Total width in cells.
370    #[inline]
371    pub fn total_cells(&self) -> usize {
372        self.total_cells as usize
373    }
374
375    /// Accumulated sub-cell remainder from all spacing deltas.
376    ///
377    /// Terminal renderers can ignore this. Web/GPU renderers can use it
378    /// for sub-pixel positioning of subsequent content.
379    #[inline]
380    pub fn subcell_remainder(&self) -> i32 {
381        self.subcell_remainder
382    }
383
384    /// The underlying cluster map (for interaction queries).
385    #[inline]
386    pub fn cluster_map(&self) -> &ClusterMap {
387        &self.cluster_map
388    }
389
390    /// Whether the layout is empty.
391    #[inline]
392    pub fn is_empty(&self) -> bool {
393        self.placements.is_empty()
394    }
395
396    /// Get the placement for a cell column.
397    pub fn placement_at_cell(&self, cell_x: usize) -> Option<&CellPlacement> {
398        let idx = self
399            .placements
400            .partition_point(|p| (p.cell_x as usize) < cell_x);
401        self.placements
402            .get(idx)
403            .filter(|p| p.cell_x as usize == cell_x)
404    }
405
406    /// Get all placements for a grapheme index.
407    pub fn placements_for_grapheme(&self, grapheme_index: usize) -> Vec<&CellPlacement> {
408        self.placements
409            .iter()
410            .filter(|p| p.grapheme_index as usize == grapheme_index)
411            .collect()
412    }
413
414    /// Extract the source text for a cell range (delegates to ClusterMap).
415    pub fn extract_text<'a>(&self, source: &'a str, cell_start: usize, cell_end: usize) -> &'a str {
416        self.cluster_map
417            .extract_text_for_cells(source, cell_start, cell_end)
418    }
419
420    /// Check if any placement has non-zero spacing deltas.
421    pub fn has_spacing_deltas(&self) -> bool {
422        self.placements.iter().any(|p| !p.spacing.is_zero())
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Helper functions
428// ---------------------------------------------------------------------------
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431struct ClusterGlyphMetrics {
432    byte_start: u32,
433    x_advance_subcell: i32,
434    first_y_offset_subcell: i32,
435}
436
437/// Group shaped glyph metrics once in the same order as `ClusterMap::from_shaped_run`.
438fn cluster_glyph_metrics(run: &ShapedRun) -> Vec<ClusterGlyphMetrics> {
439    let mut metrics = Vec::with_capacity(run.glyphs.len());
440    let mut i = 0;
441
442    while i < run.glyphs.len() {
443        let cluster = run.glyphs[i].cluster;
444        let first_y_offset_subcell = run.glyphs[i].y_offset * SUBCELL_SCALE as i32;
445        let mut x_advance_subcell = 0i32;
446
447        while i < run.glyphs.len() && run.glyphs[i].cluster == cluster {
448            x_advance_subcell += run.glyphs[i].x_advance * SUBCELL_SCALE as i32;
449            i += 1;
450        }
451
452        metrics.push(ClusterGlyphMetrics {
453            byte_start: cluster,
454            x_advance_subcell,
455            first_y_offset_subcell,
456        });
457    }
458
459    metrics
460}
461
462/// Determine the render hint for a grapheme cluster.
463fn render_hint_for_cluster(cluster_text: &str, cell_width: u8) -> RenderHint {
464    let mut chars = cluster_text.chars();
465    let first = match chars.next() {
466        Some(c) => c,
467        None => return RenderHint::DirectChar(' '),
468    };
469
470    if chars.next().is_none() {
471        // Single-codepoint cluster: use direct char encoding.
472        RenderHint::DirectChar(first)
473    } else {
474        // Multi-codepoint cluster: needs grapheme pool interning.
475        RenderHint::Grapheme {
476            text: cluster_text.to_string(),
477            width: cell_width,
478        }
479    }
480}
481
482// ===========================================================================
483// Tests
484// ===========================================================================
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::script_segmentation::{RunDirection, Script};
490    use crate::shaping::{FontFeatures, NoopShaper, ShapedGlyph, TextShaper};
491
492    // -----------------------------------------------------------------------
493    // Construction tests
494    // -----------------------------------------------------------------------
495
496    #[test]
497    fn empty_layout() {
498        let layout = ShapedLineLayout::from_text("");
499        assert!(layout.is_empty());
500        assert_eq!(layout.total_cells(), 0);
501        assert_eq!(layout.subcell_remainder(), 0);
502    }
503
504    #[test]
505    fn ascii_layout() {
506        let layout = ShapedLineLayout::from_text("Hello");
507        assert_eq!(layout.total_cells(), 5);
508        assert_eq!(layout.placements().len(), 5);
509        assert!(!layout.has_spacing_deltas());
510
511        for (i, p) in layout.placements().iter().enumerate() {
512            assert_eq!(p.cell_x, i as u32);
513            assert_eq!(p.spacing, SpacingDelta::ZERO);
514            match &p.render_hint {
515                RenderHint::DirectChar(c) => {
516                    assert_eq!(*c, "Hello".chars().nth(i).unwrap());
517                }
518                _ => panic!("Expected DirectChar for ASCII"),
519            }
520        }
521    }
522
523    #[test]
524    fn wide_char_layout() {
525        let layout = ShapedLineLayout::from_text("A\u{4E16}B");
526        // A(1) + 世(2) + B(1) = 4 cells
527        assert_eq!(layout.total_cells(), 4);
528        // 3 graphemes → 3 primary + 1 continuation = 4 placements
529        assert_eq!(layout.placements().len(), 4);
530
531        // A at cell 0
532        assert_eq!(layout.placements()[0].cell_x, 0);
533        assert!(matches!(
534            layout.placements()[0].render_hint,
535            RenderHint::DirectChar('A')
536        ));
537
538        // 世 at cell 1
539        assert_eq!(layout.placements()[1].cell_x, 1);
540        assert!(matches!(
541            layout.placements()[1].render_hint,
542            RenderHint::DirectChar('\u{4E16}')
543        ));
544
545        // Continuation at cell 2
546        assert_eq!(layout.placements()[2].cell_x, 2);
547        assert!(matches!(
548            layout.placements()[2].render_hint,
549            RenderHint::Continuation
550        ));
551
552        // B at cell 3
553        assert_eq!(layout.placements()[3].cell_x, 3);
554        assert!(matches!(
555            layout.placements()[3].render_hint,
556            RenderHint::DirectChar('B')
557        ));
558    }
559
560    #[test]
561    fn combining_mark_uses_grapheme() {
562        let layout = ShapedLineLayout::from_text("e\u{0301}");
563        assert_eq!(layout.total_cells(), 1);
564        assert_eq!(layout.placements().len(), 1);
565
566        match &layout.placements()[0].render_hint {
567            RenderHint::Grapheme { text, width } => {
568                assert_eq!(text, "e\u{0301}");
569                assert_eq!(*width, 1);
570            }
571            _ => panic!("Expected Grapheme for combining mark"),
572        }
573    }
574
575    // -----------------------------------------------------------------------
576    // Shaped run construction
577    // -----------------------------------------------------------------------
578
579    #[test]
580    fn from_shaped_run_noop() {
581        let text = "Hello!";
582        let shaper = NoopShaper;
583        let ff = FontFeatures::default();
584        let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
585
586        let layout = ShapedLineLayout::from_run(text, &run);
587        assert_eq!(layout.total_cells(), 6);
588        assert_eq!(layout.placements().len(), 6);
589
590        // NoopShaper should produce no spacing deltas.
591        assert!(!layout.has_spacing_deltas());
592    }
593
594    #[test]
595    fn from_shaped_run_wide() {
596        let text = "Hi\u{4E16}!";
597        let shaper = NoopShaper;
598        let ff = FontFeatures::default();
599        let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
600
601        let layout = ShapedLineLayout::from_run(text, &run);
602        // H(1) + i(1) + 世(2) + !(1) = 5 cells
603        assert_eq!(layout.total_cells(), 5);
604    }
605
606    #[test]
607    fn from_run_empty() {
608        let layout = ShapedLineLayout::from_run(
609            "",
610            &ShapedRun {
611                glyphs: vec![],
612                total_advance: 0,
613            },
614        );
615        assert!(layout.is_empty());
616    }
617
618    #[test]
619    fn from_run_groups_multi_glyph_cluster_once() {
620        let text = "office";
621        let run = ShapedRun {
622            glyphs: vec![
623                ShapedGlyph {
624                    glyph_id: 'o' as u32,
625                    cluster: 0,
626                    x_advance: 1,
627                    y_advance: 0,
628                    x_offset: 0,
629                    y_offset: 0,
630                },
631                ShapedGlyph {
632                    glyph_id: 42,
633                    cluster: 1,
634                    x_advance: 2,
635                    y_advance: 0,
636                    x_offset: 0,
637                    y_offset: 3,
638                },
639                ShapedGlyph {
640                    glyph_id: 'c' as u32,
641                    cluster: 4,
642                    x_advance: 1,
643                    y_advance: 0,
644                    x_offset: 0,
645                    y_offset: 0,
646                },
647                ShapedGlyph {
648                    glyph_id: 'e' as u32,
649                    cluster: 5,
650                    x_advance: 1,
651                    y_advance: 0,
652                    x_offset: 0,
653                    y_offset: 0,
654                },
655            ],
656            total_advance: 5,
657        };
658
659        let layout = ShapedLineLayout::from_run(text, &run);
660        let ligature = layout
661            .placements()
662            .iter()
663            .find(|p| p.byte_start == 1)
664            .unwrap();
665
666        assert_eq!(layout.total_cells(), 5);
667        assert_eq!(ligature.byte_end, 4);
668        assert_eq!(ligature.spacing.x_subcell, 0);
669        assert_eq!(ligature.spacing.y_subcell, 3 * SUBCELL_SCALE as i32);
670    }
671
672    // -----------------------------------------------------------------------
673    // Interaction helpers
674    // -----------------------------------------------------------------------
675
676    #[test]
677    fn placement_at_cell() {
678        let layout = ShapedLineLayout::from_text("ABC");
679        let p = layout.placement_at_cell(1).unwrap();
680        assert_eq!(p.cell_x, 1);
681        assert!(matches!(p.render_hint, RenderHint::DirectChar('B')));
682
683        assert!(layout.placement_at_cell(5).is_none());
684    }
685
686    #[test]
687    fn placement_at_cell_returns_first_duplicate_cell() {
688        let layout = ShapedLineLayout {
689            placements: vec![
690                CellPlacement {
691                    cell_x: 0,
692                    render_hint: RenderHint::DirectChar('a'),
693                    spacing: SpacingDelta::ZERO,
694                    byte_start: 0,
695                    byte_end: 1,
696                    grapheme_index: 0,
697                },
698                CellPlacement {
699                    cell_x: 0,
700                    render_hint: RenderHint::DirectChar('b'),
701                    spacing: SpacingDelta::ZERO,
702                    byte_start: 1,
703                    byte_end: 2,
704                    grapheme_index: 1,
705                },
706            ],
707            total_cells: 0,
708            subcell_remainder: 0,
709            cluster_map: ClusterMap::from_text(""),
710        };
711
712        let placement = layout.placement_at_cell(0).unwrap();
713        assert_eq!(placement.byte_start, 0);
714    }
715
716    #[test]
717    fn placements_for_grapheme_wide() {
718        let layout = ShapedLineLayout::from_text("\u{4E16}");
719        let ps = layout.placements_for_grapheme(0);
720        assert_eq!(ps.len(), 2); // primary + continuation
721    }
722
723    #[test]
724    fn extract_text_range() {
725        let text = "Hello World";
726        let layout = ShapedLineLayout::from_text(text);
727        assert_eq!(layout.extract_text(text, 0, 5), "Hello");
728        assert_eq!(layout.extract_text(text, 6, 11), "World");
729    }
730
731    // -----------------------------------------------------------------------
732    // Justification
733    // -----------------------------------------------------------------------
734
735    #[test]
736    fn apply_justification_stretch() {
737        let text = "hello world";
738        let mut layout = ShapedLineLayout::from_text(text);
739
740        // Stretch the space to 1.5 cells.
741        let ratio = SUBCELL_SCALE as i32; // ratio = 1.0 (full stretch)
742        layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
743
744        // The space at index 5 should have a positive delta.
745        assert!(layout.has_spacing_deltas());
746
747        let space_placement = layout
748            .placements()
749            .iter()
750            .find(|p| p.byte_start == 5 && !matches!(p.render_hint, RenderHint::Continuation));
751        assert!(space_placement.is_some());
752        let sp = space_placement.unwrap();
753        assert!(sp.spacing.x_subcell > 0);
754    }
755
756    #[test]
757    fn apply_justification_stretches_nbsp() {
758        let text = "hello\u{00A0}world";
759        let mut layout = ShapedLineLayout::from_text(text);
760
761        let ratio = SUBCELL_SCALE as i32;
762        layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
763
764        let nbsp_placement = layout
765            .placements()
766            .iter()
767            .find(|p| matches!(p.render_hint, RenderHint::DirectChar('\u{00A0}')));
768        assert!(nbsp_placement.is_some());
769        assert!(nbsp_placement.unwrap().spacing.x_subcell > 0);
770    }
771
772    #[test]
773    fn apply_justification_no_ratio() {
774        let text = "hello world";
775        let mut layout = ShapedLineLayout::from_text(text);
776        layout.apply_justification(text, 0, &GlueSpec::WORD_SPACE);
777        assert!(!layout.has_spacing_deltas());
778    }
779
780    // -----------------------------------------------------------------------
781    // Tracking
782    // -----------------------------------------------------------------------
783
784    #[test]
785    fn apply_tracking_basic() {
786        let text = "ABC";
787        let mut layout = ShapedLineLayout::from_text(text);
788        layout.apply_tracking(32); // 1/8 cell per gap
789
790        // First two graphemes should have tracking, last should not.
791        let primary: Vec<_> = layout
792            .placements()
793            .iter()
794            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
795            .collect();
796
797        assert_eq!(primary.len(), 3);
798        assert_eq!(primary[0].spacing.x_subcell, 32);
799        assert_eq!(primary[1].spacing.x_subcell, 32);
800        assert_eq!(primary[2].spacing.x_subcell, 0); // last: no trailing
801    }
802
803    #[test]
804    fn apply_tracking_single_char() {
805        let text = "A";
806        let mut layout = ShapedLineLayout::from_text(text);
807        layout.apply_tracking(32);
808        // Single char: no tracking applied.
809        assert!(!layout.has_spacing_deltas());
810    }
811
812    // -----------------------------------------------------------------------
813    // Source metadata
814    // -----------------------------------------------------------------------
815
816    #[test]
817    fn placement_byte_ranges() {
818        let text = "A\u{4E16}B"; // A(1 byte) + 世(3 bytes) + B(1 byte)
819        let layout = ShapedLineLayout::from_text(text);
820
821        let primary: Vec<_> = layout
822            .placements()
823            .iter()
824            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
825            .collect();
826
827        assert_eq!(primary[0].byte_start, 0);
828        assert_eq!(primary[0].byte_end, 1);
829        assert_eq!(primary[1].byte_start, 1);
830        assert_eq!(primary[1].byte_end, 4);
831        assert_eq!(primary[2].byte_start, 4);
832        assert_eq!(primary[2].byte_end, 5);
833    }
834
835    #[test]
836    fn grapheme_indices_sequential() {
837        let text = "Hello";
838        let layout = ShapedLineLayout::from_text(text);
839
840        for (i, p) in layout.placements().iter().enumerate() {
841            assert_eq!(p.grapheme_index, i as u32);
842        }
843    }
844
845    // -----------------------------------------------------------------------
846    // Determinism
847    // -----------------------------------------------------------------------
848
849    #[test]
850    fn deterministic_output() {
851        let text = "Hello \u{4E16}\u{754C}!";
852
853        let layout1 = ShapedLineLayout::from_text(text);
854        let layout2 = ShapedLineLayout::from_text(text);
855
856        assert_eq!(layout1.total_cells(), layout2.total_cells());
857        assert_eq!(layout1.placements().len(), layout2.placements().len());
858
859        for (a, b) in layout1.placements().iter().zip(layout2.placements()) {
860            assert_eq!(a.cell_x, b.cell_x);
861            assert_eq!(a.render_hint, b.render_hint);
862            assert_eq!(a.spacing, b.spacing);
863            assert_eq!(a.byte_start, b.byte_start);
864            assert_eq!(a.byte_end, b.byte_end);
865        }
866    }
867
868    // -----------------------------------------------------------------------
869    // Spacing delta invariants
870    // -----------------------------------------------------------------------
871
872    #[test]
873    fn noop_shaper_no_deltas() {
874        let texts = ["Hello", "世界", "e\u{0301}f", "ABC 123"];
875        let shaper = NoopShaper;
876        let ff = FontFeatures::default();
877
878        for text in texts {
879            let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
880            let layout = ShapedLineLayout::from_run(text, &run);
881            assert!(
882                !layout.has_spacing_deltas(),
883                "NoopShaper should produce no deltas for {text:?}"
884            );
885        }
886    }
887
888    #[test]
889    fn cell_x_monotonic() {
890        let text = "Hello \u{4E16}\u{754C}!";
891        let layout = ShapedLineLayout::from_text(text);
892
893        for window in layout.placements().windows(2) {
894            assert!(
895                window[0].cell_x <= window[1].cell_x,
896                "Cell positions must be monotonically non-decreasing"
897            );
898        }
899    }
900
901    #[test]
902    fn all_cells_covered() {
903        let text = "Hi\u{4E16}!";
904        let layout = ShapedLineLayout::from_text(text);
905
906        // Every cell column from 0 to total_cells-1 should have a placement.
907        for col in 0..layout.total_cells() {
908            assert!(
909                layout.placement_at_cell(col).is_some(),
910                "Cell column {col} has no placement"
911            );
912        }
913    }
914}