Skip to main content

ftui_text/
shaping_fallback.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic fallback path for shaped text rendering.
4//!
5//! When the shaping engine is unavailable (no font data, feature disabled,
6//! or runtime budget exceeded), this module provides a guaranteed fallback
7//! that preserves:
8//!
9//! 1. **Semantic correctness**: all grapheme clusters are rendered.
10//! 2. **Interaction stability**: cursor, selection, and copy produce
11//!    identical results regardless of whether shaping was used.
12//! 3. **Determinism**: the same input always produces the same output.
13//!
14//! # Fallback strategy
15//!
16//! The [`ShapingFallback`] struct wraps an optional shaper and transparently
17//! degrades when shaping is unavailable or fails:
18//!
19//! ```text
20//!   RustybuzzShaper available → use shaped rendering
21//!       ↓ (failure or unavailable)
22//!   NoopShaper → terminal/monospace rendering (always succeeds)
23//! ```
24//!
25//! Ligature-sensitive flows are explicitly policy-controlled via
26//! [`LigatureMode`]:
27//! - `Auto`: preserve caller-provided feature set; if ligatures are unsupported,
28//!   force-disable standard ligatures for deterministic canonical output.
29//! - `Enabled`: force standard ligatures on when supported.
30//! - `Disabled`: force canonical grapheme boundaries.
31//!
32//! If ligatures are requested but unsupported by [`RuntimeCapability`],
33//! fallback returns deterministic canonical grapheme rendering.
34//!
35//! Both paths produce a [`ShapedLineLayout`] with identical interface,
36//! ensuring downstream code (cursor navigation, selection, copy) works
37//! without branching on which path was taken.
38//!
39//! # Example
40//!
41//! ```
42//! use ftui_text::shaping_fallback::{ShapingFallback, FallbackEvent};
43//! use ftui_text::shaping::NoopShaper;
44//! use ftui_text::script_segmentation::{Script, RunDirection};
45//!
46//! // Create a fallback that always uses NoopShaper (terminal mode).
47//! let fallback = ShapingFallback::terminal();
48//! let (layout, event) = fallback.shape_line("Hello!", Script::Latin, RunDirection::Ltr);
49//!
50//! assert_eq!(layout.total_cells(), 6);
51//! assert_eq!(event, FallbackEvent::NoopUsed);
52//! ```
53
54use crate::layout_policy::{LayoutTier, RuntimeCapability};
55use crate::script_segmentation::{RunDirection, Script};
56use crate::shaped_render::ShapedLineLayout;
57use crate::shaping::{FontFeatures, NoopShaper, ShapedRun, TextShaper};
58
59// ---------------------------------------------------------------------------
60// LigatureMode — explicit ligature policy
61// ---------------------------------------------------------------------------
62
63/// Explicit ligature-mode policy for shaping fallback.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
65pub enum LigatureMode {
66    /// Preserve the configured feature set.
67    ///
68    /// If runtime capability reports ligatures unsupported, standard ligatures
69    /// are force-disabled to avoid backend-default variability.
70    #[default]
71    Auto,
72    /// Force standard ligatures on (`liga`, `clig`), if supported.
73    Enabled,
74    /// Force standard ligatures off with canonical grapheme boundaries.
75    Disabled,
76}
77
78// ---------------------------------------------------------------------------
79// FallbackEvent — what happened during shaping
80// ---------------------------------------------------------------------------
81
82/// Diagnostic event describing which path was taken.
83///
84/// Useful for telemetry, logging, and adaptive quality controllers that
85/// may want to track fallback frequency.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum FallbackEvent {
88    /// Full shaping was used successfully.
89    ShapedSuccessfully,
90    /// The shaper was invoked but the result was rejected (e.g., empty
91    /// output for non-empty input). Fell back to NoopShaper.
92    ShapingRejected,
93    /// No shaper was available; used NoopShaper directly.
94    NoopUsed,
95    /// Shaping was skipped because the runtime tier doesn't require it.
96    SkippedByPolicy,
97}
98
99impl FallbackEvent {
100    /// Whether shaping was actually performed.
101    #[inline]
102    pub const fn was_shaped(&self) -> bool {
103        matches!(self, Self::ShapedSuccessfully)
104    }
105
106    /// Whether a fallback was triggered.
107    #[inline]
108    pub const fn is_fallback(&self) -> bool {
109        !self.was_shaped()
110    }
111}
112
113// ---------------------------------------------------------------------------
114// FallbackStats — counters for monitoring
115// ---------------------------------------------------------------------------
116
117/// Accumulated fallback statistics for monitoring quality degradation.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub struct FallbackStats {
120    /// Total lines processed.
121    pub total_lines: u64,
122    /// Lines that used full shaping.
123    pub shaped_lines: u64,
124    /// Lines that fell back to NoopShaper.
125    pub fallback_lines: u64,
126    /// Lines where shaping was rejected after attempt.
127    pub rejected_lines: u64,
128    /// Lines skipped by policy.
129    pub skipped_lines: u64,
130}
131
132impl FallbackStats {
133    /// Record a fallback event.
134    pub fn record(&mut self, event: FallbackEvent) {
135        self.total_lines += 1;
136        match event {
137            FallbackEvent::ShapedSuccessfully => self.shaped_lines += 1,
138            FallbackEvent::ShapingRejected => {
139                self.fallback_lines += 1;
140                self.rejected_lines += 1;
141            }
142            FallbackEvent::NoopUsed => self.fallback_lines += 1,
143            FallbackEvent::SkippedByPolicy => self.skipped_lines += 1,
144        }
145    }
146
147    /// Fraction of lines that used full shaping (0.0-1.0).
148    pub fn shaping_rate(&self) -> f64 {
149        if self.total_lines == 0 {
150            return 0.0;
151        }
152        self.shaped_lines as f64 / self.total_lines as f64
153    }
154
155    /// Fraction of lines that fell back (0.0-1.0).
156    pub fn fallback_rate(&self) -> f64 {
157        if self.total_lines == 0 {
158            return 0.0;
159        }
160        self.fallback_lines as f64 / self.total_lines as f64
161    }
162}
163
164// ---------------------------------------------------------------------------
165// ShapingFallback
166// ---------------------------------------------------------------------------
167
168/// Transparent shaping with guaranteed fallback.
169///
170/// Wraps an optional primary shaper and a `NoopShaper` fallback. Always
171/// produces a valid [`ShapedLineLayout`] regardless of whether the primary
172/// shaper is available or succeeds.
173///
174/// The output layout has identical API surface for both paths, so
175/// downstream code (cursor, selection, copy, rendering) does not need
176/// to branch on which shaping path was used.
177pub struct ShapingFallback<S: TextShaper = NoopShaper> {
178    /// Primary shaper (may be NoopShaper for terminal mode).
179    primary: Option<S>,
180    /// Font features to apply during shaping.
181    features: FontFeatures,
182    /// Minimum tier required for shaping to be attempted.
183    /// Below this tier, NoopShaper is used directly.
184    shaping_tier: LayoutTier,
185    /// Current runtime capabilities.
186    capabilities: RuntimeCapability,
187    /// Explicit ligature policy for shaping output.
188    ligature_mode: LigatureMode,
189    /// Whether to validate shaped output and reject suspicious results.
190    validate_output: bool,
191}
192
193impl ShapingFallback<NoopShaper> {
194    /// Create a terminal-mode fallback (always uses NoopShaper).
195    #[must_use]
196    pub fn terminal() -> Self {
197        Self {
198            primary: None,
199            features: FontFeatures::default(),
200            shaping_tier: LayoutTier::Quality,
201            capabilities: RuntimeCapability::TERMINAL,
202            ligature_mode: LigatureMode::Disabled,
203            validate_output: false,
204        }
205    }
206}
207
208impl<S: TextShaper> ShapingFallback<S> {
209    /// Create a fallback with a primary shaper.
210    #[must_use]
211    pub fn with_shaper(shaper: S, capabilities: RuntimeCapability) -> Self {
212        Self {
213            primary: Some(shaper),
214            features: FontFeatures::default(),
215            shaping_tier: LayoutTier::Balanced,
216            capabilities,
217            ligature_mode: LigatureMode::Auto,
218            validate_output: true,
219        }
220    }
221
222    /// Set the font features used for shaping.
223    pub fn set_features(&mut self, features: FontFeatures) {
224        self.features = features;
225    }
226
227    /// Set the minimum tier for shaping.
228    pub fn set_shaping_tier(&mut self, tier: LayoutTier) {
229        self.shaping_tier = tier;
230    }
231
232    /// Set explicit ligature policy.
233    pub fn set_ligature_mode(&mut self, mode: LigatureMode) {
234        self.ligature_mode = mode;
235    }
236
237    /// Update runtime capabilities (e.g., after font load/unload).
238    pub fn set_capabilities(&mut self, caps: RuntimeCapability) {
239        self.capabilities = caps;
240    }
241
242    /// Enable or disable output validation.
243    pub fn set_validate_output(&mut self, validate: bool) {
244        self.validate_output = validate;
245    }
246
247    /// Shape a line of text with automatic fallback.
248    ///
249    /// Returns the layout and a diagnostic event describing which path
250    /// was taken. The layout is guaranteed to be valid and non-empty
251    /// for non-empty input.
252    pub fn shape_line(
253        &self,
254        text: &str,
255        script: Script,
256        direction: RunDirection,
257    ) -> (ShapedLineLayout, FallbackEvent) {
258        if text.is_empty() {
259            return (ShapedLineLayout::from_text(""), FallbackEvent::NoopUsed);
260        }
261
262        // No primary shaper available — use NoopShaper directly.
263        let Some(shaper) = &self.primary else {
264            return (ShapedLineLayout::from_text(text), FallbackEvent::NoopUsed);
265        };
266
267        // Check if the current tier requires shaping.
268        let effective_tier = self.capabilities.best_tier();
269        if effective_tier < self.shaping_tier {
270            return (
271                ShapedLineLayout::from_text(text),
272                FallbackEvent::SkippedByPolicy,
273            );
274        }
275
276        let ligature_requested = match self.ligature_mode {
277            LigatureMode::Enabled => true,
278            LigatureMode::Disabled => false,
279            LigatureMode::Auto => self.features.standard_ligatures_enabled().unwrap_or(false),
280        };
281        if ligature_requested && !self.capabilities.ligature_support {
282            tracing::debug!(
283                text_len = text.len(),
284                mode = ?self.ligature_mode,
285                "Ligatures requested but unsupported, using canonical grapheme fallback"
286            );
287            return (
288                ShapedLineLayout::from_text(text),
289                FallbackEvent::SkippedByPolicy,
290            );
291        }
292
293        let mut effective_features = self.features.clone();
294        match self.ligature_mode {
295            LigatureMode::Enabled => effective_features.set_standard_ligatures(true),
296            LigatureMode::Disabled => effective_features.set_standard_ligatures(false),
297            LigatureMode::Auto => {
298                // Keep AUTO deterministic across runtimes: when ligatures are
299                // unsupported, explicitly disable standard ligatures so we do
300                // not depend on backend default-feature behavior.
301                if !self.capabilities.ligature_support {
302                    effective_features.set_standard_ligatures(false);
303                }
304            }
305        }
306
307        // Try shaping with the primary shaper.
308        {
309            let run = shaper.shape(text, script, direction, &effective_features);
310
311            if self.validate_output
312                && let Some(rejection) = validate_shaped_run(text, &run)
313            {
314                tracing::debug!(
315                    text_len = text.len(),
316                    glyph_count = run.glyphs.len(),
317                    reason = %rejection,
318                    "Shaped output rejected, falling back to NoopShaper"
319                );
320                return (
321                    ShapedLineLayout::from_text(text),
322                    FallbackEvent::ShapingRejected,
323                );
324            }
325
326            (
327                ShapedLineLayout::from_run(text, &run),
328                FallbackEvent::ShapedSuccessfully,
329            )
330        }
331    }
332
333    /// Shape multiple lines with fallback, collecting stats.
334    ///
335    /// Returns layouts and accumulated statistics.
336    pub fn shape_lines(
337        &self,
338        lines: &[&str],
339        script: Script,
340        direction: RunDirection,
341    ) -> (Vec<ShapedLineLayout>, FallbackStats) {
342        let mut layouts = Vec::with_capacity(lines.len());
343        let mut stats = FallbackStats::default();
344
345        for text in lines {
346            let (layout, event) = self.shape_line(text, script, direction);
347            stats.record(event);
348            layouts.push(layout);
349        }
350
351        (layouts, stats)
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Validation
357// ---------------------------------------------------------------------------
358
359/// Validate a shaped run and return a rejection reason if invalid.
360///
361/// Checks for common shaping failures:
362/// - Empty output for non-empty input
363/// - Glyph count dramatically exceeding text length (runaway shaping)
364/// - All zero advances (broken font/shaper)
365fn validate_shaped_run(text: &str, run: &ShapedRun) -> Option<&'static str> {
366    if text.is_empty() {
367        return None; // Empty input is always valid
368    }
369
370    // Rejection: no glyphs produced for non-empty text.
371    if run.glyphs.is_empty() {
372        return Some("no glyphs produced for non-empty input");
373    }
374
375    // Rejection: glyph count > 4x text byte length (runaway).
376    // Legitimate cases (complex scripts, ligature decomposition) rarely
377    // exceed 2x. 4x gives ample headroom.
378    if run.glyphs.len() > text.len() * 4 {
379        return Some("glyph count exceeds 4x text byte length");
380    }
381
382    // Rejection: all advances are zero (broken font).
383    if run.glyphs.iter().all(|g| g.x_advance == 0) {
384        return Some("all glyph advances are zero");
385    }
386
387    None
388}
389
390// ===========================================================================
391// Tests
392// ===========================================================================
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::shaping::ShapedGlyph;
398
399    #[derive(Debug, Clone, Copy)]
400    struct FeatureAwareLigatureShaper;
401
402    impl TextShaper for FeatureAwareLigatureShaper {
403        fn shape(
404            &self,
405            text: &str,
406            _script: Script,
407            _direction: RunDirection,
408            features: &FontFeatures,
409        ) -> ShapedRun {
410            // Simulate default-enabled standard ligatures unless explicitly
411            // disabled by caller features.
412            let ligatures_on = features.feature_value(*b"liga").unwrap_or(1) != 0;
413            if ligatures_on && text == "file" {
414                return ShapedRun {
415                    glyphs: vec![
416                        ShapedGlyph {
417                            glyph_id: 1,
418                            cluster: 0, // "fi" ligature
419                            x_advance: 2,
420                            y_advance: 0,
421                            x_offset: 0,
422                            y_offset: 0,
423                        },
424                        ShapedGlyph {
425                            glyph_id: 2,
426                            cluster: 2,
427                            x_advance: 1,
428                            y_advance: 0,
429                            x_offset: 0,
430                            y_offset: 0,
431                        },
432                        ShapedGlyph {
433                            glyph_id: 3,
434                            cluster: 3,
435                            x_advance: 1,
436                            y_advance: 0,
437                            x_offset: 0,
438                            y_offset: 0,
439                        },
440                    ],
441                    total_advance: 4,
442                };
443            }
444
445            let mut glyphs = Vec::new();
446            for (byte_offset, ch) in text.char_indices() {
447                glyphs.push(ShapedGlyph {
448                    glyph_id: ch as u32,
449                    cluster: byte_offset as u32,
450                    x_advance: 1,
451                    y_advance: 0,
452                    x_offset: 0,
453                    y_offset: 0,
454                });
455            }
456            let total_advance = i32::try_from(glyphs.len()).unwrap_or(i32::MAX);
457            ShapedRun {
458                glyphs,
459                total_advance,
460            }
461        }
462    }
463
464    // -----------------------------------------------------------------------
465    // Terminal mode
466    // -----------------------------------------------------------------------
467
468    #[test]
469    fn terminal_fallback() {
470        let fb = ShapingFallback::terminal();
471        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
472
473        assert_eq!(layout.total_cells(), 5);
474        assert_eq!(event, FallbackEvent::NoopUsed);
475    }
476
477    #[test]
478    fn terminal_empty_input() {
479        let fb = ShapingFallback::terminal();
480        let (layout, event) = fb.shape_line("", Script::Latin, RunDirection::Ltr);
481
482        assert!(layout.is_empty());
483        assert_eq!(event, FallbackEvent::NoopUsed);
484    }
485
486    #[test]
487    fn terminal_wide_chars() {
488        let fb = ShapingFallback::terminal();
489        let (layout, _) = fb.shape_line("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr);
490
491        assert_eq!(layout.total_cells(), 4); // 2 CJK chars × 2 cells each
492    }
493
494    // -----------------------------------------------------------------------
495    // With shaper
496    // -----------------------------------------------------------------------
497
498    #[test]
499    fn noop_shaper_primary() {
500        let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::TERMINAL);
501        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
502
503        assert_eq!(layout.total_cells(), 5);
504        // TERMINAL best_tier is Balanced, shaping_tier is Balanced → tier check passes,
505        // NoopShaper shapes successfully.
506        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
507    }
508
509    #[test]
510    fn noop_shaper_with_full_caps() {
511        let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
512        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
513
514        assert_eq!(layout.total_cells(), 5);
515        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
516    }
517
518    // -----------------------------------------------------------------------
519    // Validation
520    // -----------------------------------------------------------------------
521
522    #[test]
523    fn validate_empty_run() {
524        let run = ShapedRun {
525            glyphs: vec![],
526            total_advance: 0,
527        };
528        assert!(validate_shaped_run("Hello", &run).is_some());
529    }
530
531    #[test]
532    fn validate_empty_input() {
533        let run = ShapedRun {
534            glyphs: vec![],
535            total_advance: 0,
536        };
537        assert!(validate_shaped_run("", &run).is_none());
538    }
539
540    #[test]
541    fn validate_zero_advances() {
542        use crate::shaping::ShapedGlyph;
543
544        let run = ShapedRun {
545            glyphs: vec![
546                ShapedGlyph {
547                    glyph_id: 1,
548                    cluster: 0,
549                    x_advance: 0,
550                    y_advance: 0,
551                    x_offset: 0,
552                    y_offset: 0,
553                },
554                ShapedGlyph {
555                    glyph_id: 2,
556                    cluster: 1,
557                    x_advance: 0,
558                    y_advance: 0,
559                    x_offset: 0,
560                    y_offset: 0,
561                },
562            ],
563            total_advance: 0,
564        };
565        assert!(validate_shaped_run("AB", &run).is_some());
566    }
567
568    #[test]
569    fn validate_valid_run() {
570        use crate::shaping::ShapedGlyph;
571
572        let run = ShapedRun {
573            glyphs: vec![
574                ShapedGlyph {
575                    glyph_id: 1,
576                    cluster: 0,
577                    x_advance: 1,
578                    y_advance: 0,
579                    x_offset: 0,
580                    y_offset: 0,
581                },
582                ShapedGlyph {
583                    glyph_id: 2,
584                    cluster: 1,
585                    x_advance: 1,
586                    y_advance: 0,
587                    x_offset: 0,
588                    y_offset: 0,
589                },
590            ],
591            total_advance: 2,
592        };
593        assert!(validate_shaped_run("AB", &run).is_none());
594    }
595
596    // -----------------------------------------------------------------------
597    // Fallback stats
598    // -----------------------------------------------------------------------
599
600    #[test]
601    fn stats_tracking() {
602        let mut stats = FallbackStats::default();
603
604        stats.record(FallbackEvent::ShapedSuccessfully);
605        stats.record(FallbackEvent::ShapedSuccessfully);
606        stats.record(FallbackEvent::NoopUsed);
607        stats.record(FallbackEvent::ShapingRejected);
608
609        assert_eq!(stats.total_lines, 4);
610        assert_eq!(stats.shaped_lines, 2);
611        assert_eq!(stats.fallback_lines, 2);
612        assert_eq!(stats.rejected_lines, 1);
613        assert_eq!(stats.shaping_rate(), 0.5);
614        assert_eq!(stats.fallback_rate(), 0.5);
615    }
616
617    #[test]
618    fn stats_empty() {
619        let stats = FallbackStats::default();
620        assert_eq!(stats.shaping_rate(), 0.0);
621        assert_eq!(stats.fallback_rate(), 0.0);
622    }
623
624    // -----------------------------------------------------------------------
625    // Batch shaping
626    // -----------------------------------------------------------------------
627
628    #[test]
629    fn shape_lines_batch() {
630        let fb = ShapingFallback::terminal();
631        let lines = vec!["Hello", "World", "\u{4E16}\u{754C}"];
632
633        let (layouts, stats) = fb.shape_lines(&lines, Script::Latin, RunDirection::Ltr);
634
635        assert_eq!(layouts.len(), 3);
636        assert_eq!(stats.total_lines, 3);
637        assert_eq!(stats.fallback_lines, 3);
638    }
639
640    // -----------------------------------------------------------------------
641    // FallbackEvent predicates
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn event_predicates() {
646        assert!(FallbackEvent::ShapedSuccessfully.was_shaped());
647        assert!(!FallbackEvent::ShapedSuccessfully.is_fallback());
648
649        assert!(!FallbackEvent::NoopUsed.was_shaped());
650        assert!(FallbackEvent::NoopUsed.is_fallback());
651
652        assert!(!FallbackEvent::ShapingRejected.was_shaped());
653        assert!(FallbackEvent::ShapingRejected.is_fallback());
654
655        assert!(!FallbackEvent::SkippedByPolicy.was_shaped());
656        assert!(FallbackEvent::SkippedByPolicy.is_fallback());
657    }
658
659    // -----------------------------------------------------------------------
660    // Determinism: both paths produce consistent layouts
661    // -----------------------------------------------------------------------
662
663    #[test]
664    fn shaped_and_unshaped_same_total_cells() {
665        let text = "Hello World!";
666
667        // Shaped path (via NoopShaper → shaped successfully with FULL caps).
668        let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
669        let (layout_shaped, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
670
671        // Unshaped path (terminal fallback).
672        let fb_unshaped = ShapingFallback::terminal();
673        let (layout_unshaped, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
674
675        // NoopShaper should produce identical total cell counts.
676        assert_eq!(layout_shaped.total_cells(), layout_unshaped.total_cells());
677    }
678
679    #[test]
680    fn shaped_and_unshaped_identical_interaction() {
681        let text = "A\u{4E16}B";
682
683        let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
684        let (layout_s, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
685
686        let fb_unshaped = ShapingFallback::terminal();
687        let (layout_u, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
688
689        // Cluster maps should agree on byte↔cell mappings.
690        let cm_s = layout_s.cluster_map();
691        let cm_u = layout_u.cluster_map();
692
693        for byte in [0, 1, 4] {
694            assert_eq!(
695                cm_s.byte_to_cell(byte),
696                cm_u.byte_to_cell(byte),
697                "byte_to_cell mismatch at byte {byte}"
698            );
699        }
700
701        for cell in 0..layout_s.total_cells() {
702            assert_eq!(
703                cm_s.cell_to_byte(cell),
704                cm_u.cell_to_byte(cell),
705                "cell_to_byte mismatch at cell {cell}"
706            );
707        }
708    }
709
710    // -----------------------------------------------------------------------
711    // Configuration
712    // -----------------------------------------------------------------------
713
714    #[test]
715    fn set_features() {
716        let mut fb = ShapingFallback::terminal();
717        fb.set_features(FontFeatures::default());
718        // Just verifies no panic.
719        let (layout, _) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
720        assert_eq!(layout.total_cells(), 4);
721    }
722
723    #[test]
724    fn set_shaping_tier() {
725        let mut fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
726        fb.set_shaping_tier(LayoutTier::Quality);
727
728        // FULL caps support Quality tier, so shaping should still work.
729        let (_, event) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
730        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
731    }
732
733    #[test]
734    fn ligature_mode_enabled_without_capability_falls_back() {
735        let mut fb =
736            ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
737        fb.set_ligature_mode(LigatureMode::Enabled);
738
739        let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
740        assert_eq!(event, FallbackEvent::SkippedByPolicy);
741        assert_eq!(layout.total_cells(), 4);
742        assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
743    }
744
745    #[test]
746    fn ligature_mode_enabled_with_capability_shapes() {
747        let mut fb =
748            ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
749        fb.set_ligature_mode(LigatureMode::Enabled);
750
751        let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
752        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
753        assert_eq!(layout.total_cells(), 4);
754        assert_eq!(layout.cluster_map().byte_to_cell(1), 0); // "fi" snapped
755        assert_eq!(layout.extract_text("file", 0, 2), "fi");
756    }
757
758    #[test]
759    fn ligature_mode_disabled_forces_canonical_boundaries() {
760        let mut fb =
761            ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
762        fb.set_ligature_mode(LigatureMode::Disabled);
763
764        let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
765        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
766        assert_eq!(layout.total_cells(), 4);
767        assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
768    }
769
770    #[test]
771    fn auto_mode_honors_explicit_ligature_request_when_unsupported() {
772        let mut fb =
773            ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
774        let mut features = FontFeatures::default();
775        features.set_standard_ligatures(true);
776        fb.set_features(features);
777
778        let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
779        assert_eq!(event, FallbackEvent::SkippedByPolicy);
780        assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
781    }
782
783    #[test]
784    fn auto_mode_disables_implicit_ligatures_when_unsupported() {
785        let fb =
786            ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
787
788        // No explicit features set. The test shaper defaults `liga` to enabled,
789        // so AUTO must inject an explicit disable when ligatures are unsupported.
790        let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
791        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
792        assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
793    }
794}