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//! Both paths produce a [`ShapedLineLayout`] with identical interface,
26//! ensuring downstream code (cursor navigation, selection, copy) works
27//! without branching on which path was taken.
28//!
29//! # Example
30//!
31//! ```
32//! use ftui_text::shaping_fallback::{ShapingFallback, FallbackEvent};
33//! use ftui_text::shaping::NoopShaper;
34//! use ftui_text::script_segmentation::{Script, RunDirection};
35//!
36//! // Create a fallback that always uses NoopShaper (terminal mode).
37//! let fallback = ShapingFallback::terminal();
38//! let (layout, event) = fallback.shape_line("Hello!", Script::Latin, RunDirection::Ltr);
39//!
40//! assert_eq!(layout.total_cells(), 6);
41//! assert_eq!(event, FallbackEvent::NoopUsed);
42//! ```
43
44use crate::layout_policy::{LayoutTier, RuntimeCapability};
45use crate::script_segmentation::{RunDirection, Script};
46use crate::shaped_render::ShapedLineLayout;
47use crate::shaping::{FontFeatures, NoopShaper, ShapedRun, TextShaper};
48
49// ---------------------------------------------------------------------------
50// FallbackEvent — what happened during shaping
51// ---------------------------------------------------------------------------
52
53/// Diagnostic event describing which path was taken.
54///
55/// Useful for telemetry, logging, and adaptive quality controllers that
56/// may want to track fallback frequency.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum FallbackEvent {
59    /// Full shaping was used successfully.
60    ShapedSuccessfully,
61    /// The shaper was invoked but the result was rejected (e.g., empty
62    /// output for non-empty input). Fell back to NoopShaper.
63    ShapingRejected,
64    /// No shaper was available; used NoopShaper directly.
65    NoopUsed,
66    /// Shaping was skipped because the runtime tier doesn't require it.
67    SkippedByPolicy,
68}
69
70impl FallbackEvent {
71    /// Whether shaping was actually performed.
72    #[inline]
73    pub const fn was_shaped(&self) -> bool {
74        matches!(self, Self::ShapedSuccessfully)
75    }
76
77    /// Whether a fallback was triggered.
78    #[inline]
79    pub const fn is_fallback(&self) -> bool {
80        !self.was_shaped()
81    }
82}
83
84// ---------------------------------------------------------------------------
85// FallbackStats — counters for monitoring
86// ---------------------------------------------------------------------------
87
88/// Accumulated fallback statistics for monitoring quality degradation.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub struct FallbackStats {
91    /// Total lines processed.
92    pub total_lines: u64,
93    /// Lines that used full shaping.
94    pub shaped_lines: u64,
95    /// Lines that fell back to NoopShaper.
96    pub fallback_lines: u64,
97    /// Lines where shaping was rejected after attempt.
98    pub rejected_lines: u64,
99    /// Lines skipped by policy.
100    pub skipped_lines: u64,
101}
102
103impl FallbackStats {
104    /// Record a fallback event.
105    pub fn record(&mut self, event: FallbackEvent) {
106        self.total_lines += 1;
107        match event {
108            FallbackEvent::ShapedSuccessfully => self.shaped_lines += 1,
109            FallbackEvent::ShapingRejected => {
110                self.fallback_lines += 1;
111                self.rejected_lines += 1;
112            }
113            FallbackEvent::NoopUsed => self.fallback_lines += 1,
114            FallbackEvent::SkippedByPolicy => self.skipped_lines += 1,
115        }
116    }
117
118    /// Fraction of lines that used full shaping (0.0-1.0).
119    pub fn shaping_rate(&self) -> f64 {
120        if self.total_lines == 0 {
121            return 0.0;
122        }
123        self.shaped_lines as f64 / self.total_lines as f64
124    }
125
126    /// Fraction of lines that fell back (0.0-1.0).
127    pub fn fallback_rate(&self) -> f64 {
128        if self.total_lines == 0 {
129            return 0.0;
130        }
131        self.fallback_lines as f64 / self.total_lines as f64
132    }
133}
134
135// ---------------------------------------------------------------------------
136// ShapingFallback
137// ---------------------------------------------------------------------------
138
139/// Transparent shaping with guaranteed fallback.
140///
141/// Wraps an optional primary shaper and a `NoopShaper` fallback. Always
142/// produces a valid [`ShapedLineLayout`] regardless of whether the primary
143/// shaper is available or succeeds.
144///
145/// The output layout has identical API surface for both paths, so
146/// downstream code (cursor, selection, copy, rendering) does not need
147/// to branch on which shaping path was used.
148pub struct ShapingFallback<S: TextShaper = NoopShaper> {
149    /// Primary shaper (may be NoopShaper for terminal mode).
150    primary: Option<S>,
151    /// Font features to apply during shaping.
152    features: FontFeatures,
153    /// Minimum tier required for shaping to be attempted.
154    /// Below this tier, NoopShaper is used directly.
155    shaping_tier: LayoutTier,
156    /// Current runtime capabilities.
157    capabilities: RuntimeCapability,
158    /// Whether to validate shaped output and reject suspicious results.
159    validate_output: bool,
160}
161
162impl ShapingFallback<NoopShaper> {
163    /// Create a terminal-mode fallback (always uses NoopShaper).
164    #[must_use]
165    pub fn terminal() -> Self {
166        Self {
167            primary: None,
168            features: FontFeatures::default(),
169            shaping_tier: LayoutTier::Quality,
170            capabilities: RuntimeCapability::TERMINAL,
171            validate_output: false,
172        }
173    }
174}
175
176impl<S: TextShaper> ShapingFallback<S> {
177    /// Create a fallback with a primary shaper.
178    #[must_use]
179    pub fn with_shaper(shaper: S, capabilities: RuntimeCapability) -> Self {
180        Self {
181            primary: Some(shaper),
182            features: FontFeatures::default(),
183            shaping_tier: LayoutTier::Balanced,
184            capabilities,
185            validate_output: true,
186        }
187    }
188
189    /// Set the font features used for shaping.
190    pub fn set_features(&mut self, features: FontFeatures) {
191        self.features = features;
192    }
193
194    /// Set the minimum tier for shaping.
195    pub fn set_shaping_tier(&mut self, tier: LayoutTier) {
196        self.shaping_tier = tier;
197    }
198
199    /// Update runtime capabilities (e.g., after font load/unload).
200    pub fn set_capabilities(&mut self, caps: RuntimeCapability) {
201        self.capabilities = caps;
202    }
203
204    /// Enable or disable output validation.
205    pub fn set_validate_output(&mut self, validate: bool) {
206        self.validate_output = validate;
207    }
208
209    /// Shape a line of text with automatic fallback.
210    ///
211    /// Returns the layout and a diagnostic event describing which path
212    /// was taken. The layout is guaranteed to be valid and non-empty
213    /// for non-empty input.
214    pub fn shape_line(
215        &self,
216        text: &str,
217        script: Script,
218        direction: RunDirection,
219    ) -> (ShapedLineLayout, FallbackEvent) {
220        if text.is_empty() {
221            return (ShapedLineLayout::from_text(""), FallbackEvent::NoopUsed);
222        }
223
224        // No primary shaper available — use NoopShaper directly.
225        let Some(shaper) = &self.primary else {
226            return (ShapedLineLayout::from_text(text), FallbackEvent::NoopUsed);
227        };
228
229        // Check if the current tier requires shaping.
230        let effective_tier = self.capabilities.best_tier();
231        if effective_tier < self.shaping_tier {
232            return (
233                ShapedLineLayout::from_text(text),
234                FallbackEvent::SkippedByPolicy,
235            );
236        }
237
238        // Try shaping with the primary shaper.
239        {
240            let run = shaper.shape(text, script, direction, &self.features);
241
242            if self.validate_output
243                && let Some(rejection) = validate_shaped_run(text, &run)
244            {
245                tracing::debug!(
246                    text_len = text.len(),
247                    glyph_count = run.glyphs.len(),
248                    reason = %rejection,
249                    "Shaped output rejected, falling back to NoopShaper"
250                );
251                return (
252                    ShapedLineLayout::from_text(text),
253                    FallbackEvent::ShapingRejected,
254                );
255            }
256
257            (
258                ShapedLineLayout::from_run(text, &run),
259                FallbackEvent::ShapedSuccessfully,
260            )
261        }
262    }
263
264    /// Shape multiple lines with fallback, collecting stats.
265    ///
266    /// Returns layouts and accumulated statistics.
267    pub fn shape_lines(
268        &self,
269        lines: &[&str],
270        script: Script,
271        direction: RunDirection,
272    ) -> (Vec<ShapedLineLayout>, FallbackStats) {
273        let mut layouts = Vec::with_capacity(lines.len());
274        let mut stats = FallbackStats::default();
275
276        for text in lines {
277            let (layout, event) = self.shape_line(text, script, direction);
278            stats.record(event);
279            layouts.push(layout);
280        }
281
282        (layouts, stats)
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Validation
288// ---------------------------------------------------------------------------
289
290/// Validate a shaped run and return a rejection reason if invalid.
291///
292/// Checks for common shaping failures:
293/// - Empty output for non-empty input
294/// - Glyph count dramatically exceeding text length (runaway shaping)
295/// - All zero advances (broken font/shaper)
296fn validate_shaped_run(text: &str, run: &ShapedRun) -> Option<&'static str> {
297    if text.is_empty() {
298        return None; // Empty input is always valid
299    }
300
301    // Rejection: no glyphs produced for non-empty text.
302    if run.glyphs.is_empty() {
303        return Some("no glyphs produced for non-empty input");
304    }
305
306    // Rejection: glyph count > 4x text byte length (runaway).
307    // Legitimate cases (complex scripts, ligature decomposition) rarely
308    // exceed 2x. 4x gives ample headroom.
309    if run.glyphs.len() > text.len() * 4 {
310        return Some("glyph count exceeds 4x text byte length");
311    }
312
313    // Rejection: all advances are zero (broken font).
314    if run.glyphs.iter().all(|g| g.x_advance == 0) {
315        return Some("all glyph advances are zero");
316    }
317
318    None
319}
320
321// ===========================================================================
322// Tests
323// ===========================================================================
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    // -----------------------------------------------------------------------
330    // Terminal mode
331    // -----------------------------------------------------------------------
332
333    #[test]
334    fn terminal_fallback() {
335        let fb = ShapingFallback::terminal();
336        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
337
338        assert_eq!(layout.total_cells(), 5);
339        assert_eq!(event, FallbackEvent::NoopUsed);
340    }
341
342    #[test]
343    fn terminal_empty_input() {
344        let fb = ShapingFallback::terminal();
345        let (layout, event) = fb.shape_line("", Script::Latin, RunDirection::Ltr);
346
347        assert!(layout.is_empty());
348        assert_eq!(event, FallbackEvent::NoopUsed);
349    }
350
351    #[test]
352    fn terminal_wide_chars() {
353        let fb = ShapingFallback::terminal();
354        let (layout, _) = fb.shape_line("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr);
355
356        assert_eq!(layout.total_cells(), 4); // 2 CJK chars × 2 cells each
357    }
358
359    // -----------------------------------------------------------------------
360    // With shaper
361    // -----------------------------------------------------------------------
362
363    #[test]
364    fn noop_shaper_primary() {
365        let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::TERMINAL);
366        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
367
368        assert_eq!(layout.total_cells(), 5);
369        // TERMINAL best_tier is Balanced, shaping_tier is Balanced → tier check passes,
370        // NoopShaper shapes successfully.
371        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
372    }
373
374    #[test]
375    fn noop_shaper_with_full_caps() {
376        let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
377        let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
378
379        assert_eq!(layout.total_cells(), 5);
380        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
381    }
382
383    // -----------------------------------------------------------------------
384    // Validation
385    // -----------------------------------------------------------------------
386
387    #[test]
388    fn validate_empty_run() {
389        let run = ShapedRun {
390            glyphs: vec![],
391            total_advance: 0,
392        };
393        assert!(validate_shaped_run("Hello", &run).is_some());
394    }
395
396    #[test]
397    fn validate_empty_input() {
398        let run = ShapedRun {
399            glyphs: vec![],
400            total_advance: 0,
401        };
402        assert!(validate_shaped_run("", &run).is_none());
403    }
404
405    #[test]
406    fn validate_zero_advances() {
407        use crate::shaping::ShapedGlyph;
408
409        let run = ShapedRun {
410            glyphs: vec![
411                ShapedGlyph {
412                    glyph_id: 1,
413                    cluster: 0,
414                    x_advance: 0,
415                    y_advance: 0,
416                    x_offset: 0,
417                    y_offset: 0,
418                },
419                ShapedGlyph {
420                    glyph_id: 2,
421                    cluster: 1,
422                    x_advance: 0,
423                    y_advance: 0,
424                    x_offset: 0,
425                    y_offset: 0,
426                },
427            ],
428            total_advance: 0,
429        };
430        assert!(validate_shaped_run("AB", &run).is_some());
431    }
432
433    #[test]
434    fn validate_valid_run() {
435        use crate::shaping::ShapedGlyph;
436
437        let run = ShapedRun {
438            glyphs: vec![
439                ShapedGlyph {
440                    glyph_id: 1,
441                    cluster: 0,
442                    x_advance: 1,
443                    y_advance: 0,
444                    x_offset: 0,
445                    y_offset: 0,
446                },
447                ShapedGlyph {
448                    glyph_id: 2,
449                    cluster: 1,
450                    x_advance: 1,
451                    y_advance: 0,
452                    x_offset: 0,
453                    y_offset: 0,
454                },
455            ],
456            total_advance: 2,
457        };
458        assert!(validate_shaped_run("AB", &run).is_none());
459    }
460
461    // -----------------------------------------------------------------------
462    // Fallback stats
463    // -----------------------------------------------------------------------
464
465    #[test]
466    fn stats_tracking() {
467        let mut stats = FallbackStats::default();
468
469        stats.record(FallbackEvent::ShapedSuccessfully);
470        stats.record(FallbackEvent::ShapedSuccessfully);
471        stats.record(FallbackEvent::NoopUsed);
472        stats.record(FallbackEvent::ShapingRejected);
473
474        assert_eq!(stats.total_lines, 4);
475        assert_eq!(stats.shaped_lines, 2);
476        assert_eq!(stats.fallback_lines, 2);
477        assert_eq!(stats.rejected_lines, 1);
478        assert_eq!(stats.shaping_rate(), 0.5);
479        assert_eq!(stats.fallback_rate(), 0.5);
480    }
481
482    #[test]
483    fn stats_empty() {
484        let stats = FallbackStats::default();
485        assert_eq!(stats.shaping_rate(), 0.0);
486        assert_eq!(stats.fallback_rate(), 0.0);
487    }
488
489    // -----------------------------------------------------------------------
490    // Batch shaping
491    // -----------------------------------------------------------------------
492
493    #[test]
494    fn shape_lines_batch() {
495        let fb = ShapingFallback::terminal();
496        let lines = vec!["Hello", "World", "\u{4E16}\u{754C}"];
497
498        let (layouts, stats) = fb.shape_lines(&lines, Script::Latin, RunDirection::Ltr);
499
500        assert_eq!(layouts.len(), 3);
501        assert_eq!(stats.total_lines, 3);
502        assert_eq!(stats.fallback_lines, 3);
503    }
504
505    // -----------------------------------------------------------------------
506    // FallbackEvent predicates
507    // -----------------------------------------------------------------------
508
509    #[test]
510    fn event_predicates() {
511        assert!(FallbackEvent::ShapedSuccessfully.was_shaped());
512        assert!(!FallbackEvent::ShapedSuccessfully.is_fallback());
513
514        assert!(!FallbackEvent::NoopUsed.was_shaped());
515        assert!(FallbackEvent::NoopUsed.is_fallback());
516
517        assert!(!FallbackEvent::ShapingRejected.was_shaped());
518        assert!(FallbackEvent::ShapingRejected.is_fallback());
519
520        assert!(!FallbackEvent::SkippedByPolicy.was_shaped());
521        assert!(FallbackEvent::SkippedByPolicy.is_fallback());
522    }
523
524    // -----------------------------------------------------------------------
525    // Determinism: both paths produce consistent layouts
526    // -----------------------------------------------------------------------
527
528    #[test]
529    fn shaped_and_unshaped_same_total_cells() {
530        let text = "Hello World!";
531
532        // Shaped path (via NoopShaper → shaped successfully with FULL caps).
533        let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
534        let (layout_shaped, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
535
536        // Unshaped path (terminal fallback).
537        let fb_unshaped = ShapingFallback::terminal();
538        let (layout_unshaped, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
539
540        // NoopShaper should produce identical total cell counts.
541        assert_eq!(layout_shaped.total_cells(), layout_unshaped.total_cells());
542    }
543
544    #[test]
545    fn shaped_and_unshaped_identical_interaction() {
546        let text = "A\u{4E16}B";
547
548        let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
549        let (layout_s, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
550
551        let fb_unshaped = ShapingFallback::terminal();
552        let (layout_u, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
553
554        // Cluster maps should agree on byte↔cell mappings.
555        let cm_s = layout_s.cluster_map();
556        let cm_u = layout_u.cluster_map();
557
558        for byte in [0, 1, 4] {
559            assert_eq!(
560                cm_s.byte_to_cell(byte),
561                cm_u.byte_to_cell(byte),
562                "byte_to_cell mismatch at byte {byte}"
563            );
564        }
565
566        for cell in 0..layout_s.total_cells() {
567            assert_eq!(
568                cm_s.cell_to_byte(cell),
569                cm_u.cell_to_byte(cell),
570                "cell_to_byte mismatch at cell {cell}"
571            );
572        }
573    }
574
575    // -----------------------------------------------------------------------
576    // Configuration
577    // -----------------------------------------------------------------------
578
579    #[test]
580    fn set_features() {
581        let mut fb = ShapingFallback::terminal();
582        fb.set_features(FontFeatures::default());
583        // Just verifies no panic.
584        let (layout, _) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
585        assert_eq!(layout.total_cells(), 4);
586    }
587
588    #[test]
589    fn set_shaping_tier() {
590        let mut fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
591        fb.set_shaping_tier(LayoutTier::Quality);
592
593        // FULL caps support Quality tier, so shaping should still work.
594        let (_, event) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
595        assert_eq!(event, FallbackEvent::ShapedSuccessfully);
596    }
597}