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::{ClusterEntry, 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 mut placements = Vec::with_capacity(cluster_map.total_cells());
180        let mut subcell_accumulator: i32 = 0;
181
182        // Build placement for each cluster in the map.
183        for entry in cluster_map.entries() {
184            let cluster_text = &text[entry.byte_start as usize..entry.byte_end as usize];
185            let nominal_width = entry.cell_width as i32;
186
187            // Compute spacing delta from shaped glyph advances.
188            let shaped_advance = sum_cluster_advance(run, entry);
189            let delta_subcell = shaped_advance - (nominal_width * SUBCELL_SCALE as i32);
190            subcell_accumulator += delta_subcell;
191
192            let spacing = if delta_subcell != 0 {
193                // Also check for y-offsets from the first glyph in this cluster.
194                let y_offset = first_cluster_y_offset(run, entry);
195                SpacingDelta {
196                    x_subcell: delta_subcell,
197                    y_subcell: y_offset,
198                }
199            } else {
200                let y_offset = first_cluster_y_offset(run, entry);
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!(placement.render_hint, RenderHint::Continuation) {
315                continue;
316            }
317
318            let byte_start = placement.byte_start as usize;
319            let byte_end = placement.byte_end as usize;
320            if byte_start < text.len() && byte_end <= text.len() {
321                let cluster = &text[byte_start..byte_end];
322                if cluster.chars().all(|c| c == ' ' || c == '\u{00A0}') {
323                    placement.spacing.x_subcell += delta_per_space;
324                    self.subcell_remainder += delta_per_space;
325                }
326            }
327        }
328    }
329
330    /// Apply uniform letter-spacing (tracking) to all inter-cluster gaps.
331    ///
332    /// `tracking_subcell` is in 1/256 cell units. Positive = expand,
333    /// negative = tighten. The last cluster does not get trailing space.
334    pub fn apply_tracking(&mut self, tracking_subcell: i32) {
335        if tracking_subcell == 0 || self.placements.is_empty() {
336            return;
337        }
338
339        // Apply tracking to all primary cells except the last.
340        let mut last_grapheme = u32::MAX;
341        let primary_count = self
342            .placements
343            .iter()
344            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
345            .count();
346
347        if primary_count <= 1 {
348            return;
349        }
350
351        let mut seen = 0;
352        for placement in &mut self.placements {
353            if matches!(placement.render_hint, RenderHint::Continuation) {
354                continue;
355            }
356            seen += 1;
357            if seen < primary_count && placement.grapheme_index != last_grapheme {
358                placement.spacing.x_subcell += tracking_subcell;
359                self.subcell_remainder += tracking_subcell;
360                last_grapheme = placement.grapheme_index;
361            }
362        }
363    }
364
365    // -----------------------------------------------------------------------
366    // Accessors
367    // -----------------------------------------------------------------------
368
369    /// The cell placements in order.
370    #[inline]
371    pub fn placements(&self) -> &[CellPlacement] {
372        &self.placements
373    }
374
375    /// Total width in cells.
376    #[inline]
377    pub fn total_cells(&self) -> usize {
378        self.total_cells as usize
379    }
380
381    /// Accumulated sub-cell remainder from all spacing deltas.
382    ///
383    /// Terminal renderers can ignore this. Web/GPU renderers can use it
384    /// for sub-pixel positioning of subsequent content.
385    #[inline]
386    pub fn subcell_remainder(&self) -> i32 {
387        self.subcell_remainder
388    }
389
390    /// The underlying cluster map (for interaction queries).
391    #[inline]
392    pub fn cluster_map(&self) -> &ClusterMap {
393        &self.cluster_map
394    }
395
396    /// Whether the layout is empty.
397    #[inline]
398    pub fn is_empty(&self) -> bool {
399        self.placements.is_empty()
400    }
401
402    /// Get the placement for a cell column.
403    pub fn placement_at_cell(&self, cell_x: usize) -> Option<&CellPlacement> {
404        self.placements.iter().find(|p| p.cell_x as usize == cell_x)
405    }
406
407    /// Get all placements for a grapheme index.
408    pub fn placements_for_grapheme(&self, grapheme_index: usize) -> Vec<&CellPlacement> {
409        self.placements
410            .iter()
411            .filter(|p| p.grapheme_index as usize == grapheme_index)
412            .collect()
413    }
414
415    /// Extract the source text for a cell range (delegates to ClusterMap).
416    pub fn extract_text<'a>(&self, source: &'a str, cell_start: usize, cell_end: usize) -> &'a str {
417        self.cluster_map
418            .extract_text_for_cells(source, cell_start, cell_end)
419    }
420
421    /// Check if any placement has non-zero spacing deltas.
422    pub fn has_spacing_deltas(&self) -> bool {
423        self.placements.iter().any(|p| !p.spacing.is_zero())
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Helper functions
429// ---------------------------------------------------------------------------
430
431/// Sum the x_advance values for all glyphs in a cluster, in sub-cell units.
432fn sum_cluster_advance(run: &ShapedRun, entry: &ClusterEntry) -> i32 {
433    let byte_start = entry.byte_start;
434    let mut total = 0i32;
435
436    for glyph in &run.glyphs {
437        if glyph.cluster == byte_start {
438            total += glyph.x_advance * SUBCELL_SCALE as i32;
439        }
440    }
441
442    total
443}
444
445/// Get the y_offset of the first glyph in a cluster, in sub-cell units.
446fn first_cluster_y_offset(run: &ShapedRun, entry: &ClusterEntry) -> i32 {
447    let byte_start = entry.byte_start;
448
449    for glyph in &run.glyphs {
450        if glyph.cluster == byte_start {
451            return glyph.y_offset * SUBCELL_SCALE as i32;
452        }
453    }
454
455    0
456}
457
458/// Determine the render hint for a grapheme cluster.
459fn render_hint_for_cluster(cluster_text: &str, cell_width: u8) -> RenderHint {
460    let mut chars = cluster_text.chars();
461    let first = match chars.next() {
462        Some(c) => c,
463        None => return RenderHint::DirectChar(' '),
464    };
465
466    if chars.next().is_none() {
467        // Single-codepoint cluster: use direct char encoding.
468        RenderHint::DirectChar(first)
469    } else {
470        // Multi-codepoint cluster: needs grapheme pool interning.
471        RenderHint::Grapheme {
472            text: cluster_text.to_string(),
473            width: cell_width,
474        }
475    }
476}
477
478// ===========================================================================
479// Tests
480// ===========================================================================
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::script_segmentation::{RunDirection, Script};
486    use crate::shaping::{FontFeatures, NoopShaper, TextShaper};
487
488    // -----------------------------------------------------------------------
489    // Construction tests
490    // -----------------------------------------------------------------------
491
492    #[test]
493    fn empty_layout() {
494        let layout = ShapedLineLayout::from_text("");
495        assert!(layout.is_empty());
496        assert_eq!(layout.total_cells(), 0);
497        assert_eq!(layout.subcell_remainder(), 0);
498    }
499
500    #[test]
501    fn ascii_layout() {
502        let layout = ShapedLineLayout::from_text("Hello");
503        assert_eq!(layout.total_cells(), 5);
504        assert_eq!(layout.placements().len(), 5);
505        assert!(!layout.has_spacing_deltas());
506
507        for (i, p) in layout.placements().iter().enumerate() {
508            assert_eq!(p.cell_x, i as u32);
509            assert_eq!(p.spacing, SpacingDelta::ZERO);
510            match &p.render_hint {
511                RenderHint::DirectChar(c) => {
512                    assert_eq!(*c, "Hello".chars().nth(i).unwrap());
513                }
514                _ => panic!("Expected DirectChar for ASCII"),
515            }
516        }
517    }
518
519    #[test]
520    fn wide_char_layout() {
521        let layout = ShapedLineLayout::from_text("A\u{4E16}B");
522        // A(1) + 世(2) + B(1) = 4 cells
523        assert_eq!(layout.total_cells(), 4);
524        // 3 graphemes → 3 primary + 1 continuation = 4 placements
525        assert_eq!(layout.placements().len(), 4);
526
527        // A at cell 0
528        assert_eq!(layout.placements()[0].cell_x, 0);
529        assert!(matches!(
530            layout.placements()[0].render_hint,
531            RenderHint::DirectChar('A')
532        ));
533
534        // 世 at cell 1
535        assert_eq!(layout.placements()[1].cell_x, 1);
536        assert!(matches!(
537            layout.placements()[1].render_hint,
538            RenderHint::DirectChar('\u{4E16}')
539        ));
540
541        // Continuation at cell 2
542        assert_eq!(layout.placements()[2].cell_x, 2);
543        assert!(matches!(
544            layout.placements()[2].render_hint,
545            RenderHint::Continuation
546        ));
547
548        // B at cell 3
549        assert_eq!(layout.placements()[3].cell_x, 3);
550        assert!(matches!(
551            layout.placements()[3].render_hint,
552            RenderHint::DirectChar('B')
553        ));
554    }
555
556    #[test]
557    fn combining_mark_uses_grapheme() {
558        let layout = ShapedLineLayout::from_text("e\u{0301}");
559        assert_eq!(layout.total_cells(), 1);
560        assert_eq!(layout.placements().len(), 1);
561
562        match &layout.placements()[0].render_hint {
563            RenderHint::Grapheme { text, width } => {
564                assert_eq!(text, "e\u{0301}");
565                assert_eq!(*width, 1);
566            }
567            _ => panic!("Expected Grapheme for combining mark"),
568        }
569    }
570
571    // -----------------------------------------------------------------------
572    // Shaped run construction
573    // -----------------------------------------------------------------------
574
575    #[test]
576    fn from_shaped_run_noop() {
577        let text = "Hello!";
578        let shaper = NoopShaper;
579        let ff = FontFeatures::default();
580        let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
581
582        let layout = ShapedLineLayout::from_run(text, &run);
583        assert_eq!(layout.total_cells(), 6);
584        assert_eq!(layout.placements().len(), 6);
585
586        // NoopShaper should produce no spacing deltas.
587        assert!(!layout.has_spacing_deltas());
588    }
589
590    #[test]
591    fn from_shaped_run_wide() {
592        let text = "Hi\u{4E16}!";
593        let shaper = NoopShaper;
594        let ff = FontFeatures::default();
595        let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
596
597        let layout = ShapedLineLayout::from_run(text, &run);
598        // H(1) + i(1) + 世(2) + !(1) = 5 cells
599        assert_eq!(layout.total_cells(), 5);
600    }
601
602    #[test]
603    fn from_run_empty() {
604        let layout = ShapedLineLayout::from_run(
605            "",
606            &ShapedRun {
607                glyphs: vec![],
608                total_advance: 0,
609            },
610        );
611        assert!(layout.is_empty());
612    }
613
614    // -----------------------------------------------------------------------
615    // Interaction helpers
616    // -----------------------------------------------------------------------
617
618    #[test]
619    fn placement_at_cell() {
620        let layout = ShapedLineLayout::from_text("ABC");
621        let p = layout.placement_at_cell(1).unwrap();
622        assert_eq!(p.cell_x, 1);
623        assert!(matches!(p.render_hint, RenderHint::DirectChar('B')));
624
625        assert!(layout.placement_at_cell(5).is_none());
626    }
627
628    #[test]
629    fn placements_for_grapheme_wide() {
630        let layout = ShapedLineLayout::from_text("\u{4E16}");
631        let ps = layout.placements_for_grapheme(0);
632        assert_eq!(ps.len(), 2); // primary + continuation
633    }
634
635    #[test]
636    fn extract_text_range() {
637        let text = "Hello World";
638        let layout = ShapedLineLayout::from_text(text);
639        assert_eq!(layout.extract_text(text, 0, 5), "Hello");
640        assert_eq!(layout.extract_text(text, 6, 11), "World");
641    }
642
643    // -----------------------------------------------------------------------
644    // Justification
645    // -----------------------------------------------------------------------
646
647    #[test]
648    fn apply_justification_stretch() {
649        let text = "hello world";
650        let mut layout = ShapedLineLayout::from_text(text);
651
652        // Stretch the space to 1.5 cells.
653        let ratio = SUBCELL_SCALE as i32; // ratio = 1.0 (full stretch)
654        layout.apply_justification(text, ratio, &GlueSpec::WORD_SPACE);
655
656        // The space at index 5 should have a positive delta.
657        assert!(layout.has_spacing_deltas());
658
659        let space_placement = layout
660            .placements()
661            .iter()
662            .find(|p| p.byte_start == 5 && !matches!(p.render_hint, RenderHint::Continuation));
663        assert!(space_placement.is_some());
664        let sp = space_placement.unwrap();
665        assert!(sp.spacing.x_subcell > 0);
666    }
667
668    #[test]
669    fn apply_justification_no_ratio() {
670        let text = "hello world";
671        let mut layout = ShapedLineLayout::from_text(text);
672        layout.apply_justification(text, 0, &GlueSpec::WORD_SPACE);
673        assert!(!layout.has_spacing_deltas());
674    }
675
676    // -----------------------------------------------------------------------
677    // Tracking
678    // -----------------------------------------------------------------------
679
680    #[test]
681    fn apply_tracking_basic() {
682        let text = "ABC";
683        let mut layout = ShapedLineLayout::from_text(text);
684        layout.apply_tracking(32); // 1/8 cell per gap
685
686        // First two graphemes should have tracking, last should not.
687        let primary: Vec<_> = layout
688            .placements()
689            .iter()
690            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
691            .collect();
692
693        assert_eq!(primary.len(), 3);
694        assert_eq!(primary[0].spacing.x_subcell, 32);
695        assert_eq!(primary[1].spacing.x_subcell, 32);
696        assert_eq!(primary[2].spacing.x_subcell, 0); // last: no trailing
697    }
698
699    #[test]
700    fn apply_tracking_single_char() {
701        let text = "A";
702        let mut layout = ShapedLineLayout::from_text(text);
703        layout.apply_tracking(32);
704        // Single char: no tracking applied.
705        assert!(!layout.has_spacing_deltas());
706    }
707
708    // -----------------------------------------------------------------------
709    // Source metadata
710    // -----------------------------------------------------------------------
711
712    #[test]
713    fn placement_byte_ranges() {
714        let text = "A\u{4E16}B"; // A(1 byte) + 世(3 bytes) + B(1 byte)
715        let layout = ShapedLineLayout::from_text(text);
716
717        let primary: Vec<_> = layout
718            .placements()
719            .iter()
720            .filter(|p| !matches!(p.render_hint, RenderHint::Continuation))
721            .collect();
722
723        assert_eq!(primary[0].byte_start, 0);
724        assert_eq!(primary[0].byte_end, 1);
725        assert_eq!(primary[1].byte_start, 1);
726        assert_eq!(primary[1].byte_end, 4);
727        assert_eq!(primary[2].byte_start, 4);
728        assert_eq!(primary[2].byte_end, 5);
729    }
730
731    #[test]
732    fn grapheme_indices_sequential() {
733        let text = "Hello";
734        let layout = ShapedLineLayout::from_text(text);
735
736        for (i, p) in layout.placements().iter().enumerate() {
737            assert_eq!(p.grapheme_index, i as u32);
738        }
739    }
740
741    // -----------------------------------------------------------------------
742    // Determinism
743    // -----------------------------------------------------------------------
744
745    #[test]
746    fn deterministic_output() {
747        let text = "Hello \u{4E16}\u{754C}!";
748
749        let layout1 = ShapedLineLayout::from_text(text);
750        let layout2 = ShapedLineLayout::from_text(text);
751
752        assert_eq!(layout1.total_cells(), layout2.total_cells());
753        assert_eq!(layout1.placements().len(), layout2.placements().len());
754
755        for (a, b) in layout1.placements().iter().zip(layout2.placements()) {
756            assert_eq!(a.cell_x, b.cell_x);
757            assert_eq!(a.render_hint, b.render_hint);
758            assert_eq!(a.spacing, b.spacing);
759            assert_eq!(a.byte_start, b.byte_start);
760            assert_eq!(a.byte_end, b.byte_end);
761        }
762    }
763
764    // -----------------------------------------------------------------------
765    // Spacing delta invariants
766    // -----------------------------------------------------------------------
767
768    #[test]
769    fn noop_shaper_no_deltas() {
770        let texts = ["Hello", "世界", "e\u{0301}f", "ABC 123"];
771        let shaper = NoopShaper;
772        let ff = FontFeatures::default();
773
774        for text in texts {
775            let run = shaper.shape(text, Script::Latin, RunDirection::Ltr, &ff);
776            let layout = ShapedLineLayout::from_run(text, &run);
777            assert!(
778                !layout.has_spacing_deltas(),
779                "NoopShaper should produce no deltas for {text:?}"
780            );
781        }
782    }
783
784    #[test]
785    fn cell_x_monotonic() {
786        let text = "Hello \u{4E16}\u{754C}!";
787        let layout = ShapedLineLayout::from_text(text);
788
789        for window in layout.placements().windows(2) {
790            assert!(
791                window[0].cell_x <= window[1].cell_x,
792                "Cell positions must be monotonically non-decreasing"
793            );
794        }
795    }
796
797    #[test]
798    fn all_cells_covered() {
799        let text = "Hi\u{4E16}!";
800        let layout = ShapedLineLayout::from_text(text);
801
802        // Every cell column from 0 to total_cells-1 should have a placement.
803        for col in 0..layout.total_cells() {
804            assert!(
805                layout.placement_at_cell(col).is_some(),
806                "Cell column {col} has no placement"
807            );
808        }
809    }
810}