Skip to main content

fret_render_text/
font_trace.rs

1use crate::{fallback_policy, fallback_policy::TextFallbackPolicyV1, parley_shaper::ParleyShaper};
2use std::{
3    collections::{HashSet, VecDeque},
4    sync::OnceLock,
5};
6
7#[derive(Debug, Default, Clone)]
8pub struct FontTraceState {
9    active: bool,
10    entries: VecDeque<fret_core::RendererTextFontTraceEntry>,
11}
12
13#[derive(Debug, Clone)]
14pub struct FontTraceFamilyResolved {
15    family: String,
16    glyphs: u32,
17    missing_glyphs: u32,
18}
19
20impl FontTraceFamilyResolved {
21    pub fn new(family: String, glyphs: u32, missing_glyphs: u32) -> Self {
22        Self {
23            family,
24            glyphs,
25            missing_glyphs,
26        }
27    }
28
29    pub fn family(&self) -> &str {
30        &self.family
31    }
32
33    pub fn glyphs(&self) -> u32 {
34        self.glyphs
35    }
36
37    pub fn missing_glyphs(&self) -> u32 {
38        self.missing_glyphs
39    }
40
41    pub fn into_parts(self) -> (String, u32, u32) {
42        (self.family, self.glyphs, self.missing_glyphs)
43    }
44}
45
46impl FontTraceState {
47    pub fn begin_frame(&mut self) {
48        self.active = true;
49        self.entries.clear();
50    }
51
52    pub fn snapshot(
53        &self,
54        frame_id: fret_core::FrameId,
55    ) -> fret_core::RendererTextFontTraceSnapshot {
56        fret_core::RendererTextFontTraceSnapshot {
57            frame_id,
58            entries: self.entries.iter().cloned().collect(),
59        }
60    }
61
62    #[allow(clippy::too_many_arguments)]
63    pub fn maybe_record(
64        &mut self,
65        text: &str,
66        style: &fret_core::TextStyle,
67        constraints: fret_core::TextConstraints,
68        fallback_policy: &TextFallbackPolicyV1,
69        shaper: &ParleyShaper,
70        missing_glyphs: u32,
71        families: Vec<FontTraceFamilyResolved>,
72    ) {
73        if !self.active {
74            return;
75        }
76
77        let record_all = font_trace_record_all();
78        if !record_all && missing_glyphs == 0 {
79            return;
80        }
81
82        let max_entries = font_trace_entries_limit();
83        if max_entries == 0 {
84            return;
85        }
86
87        let max_text_bytes = font_trace_max_text_bytes();
88        let text_preview = truncate_text_preview(text, max_text_bytes);
89
90        let mut common_fallback_lower: HashSet<String> = HashSet::new();
91        if fallback_policy.uses_common_fallback_for_font(&style.font) {
92            for f in fallback_policy.common_fallback_candidates() {
93                common_fallback_lower.insert(f.trim().to_ascii_lowercase());
94            }
95        }
96        let requested_generic_lower =
97            requested_generic_lower_families(&style.font, fallback_policy, shaper);
98
99        let mut usages: Vec<fret_core::RendererTextFontTraceFamilyUsage> =
100            Vec::with_capacity(families.len().max(1));
101        for family in families {
102            let class = classify_trace_family(
103                &style.font,
104                family.family(),
105                &requested_generic_lower,
106                &common_fallback_lower,
107            );
108            let (family, glyphs, missing_glyphs) = family.into_parts();
109            usages.push(fret_core::RendererTextFontTraceFamilyUsage {
110                family,
111                glyphs,
112                missing_glyphs,
113                class,
114            });
115        }
116
117        let entry = fret_core::RendererTextFontTraceEntry {
118            text_preview,
119            text_len_bytes: text.len().min(u32::MAX as usize) as u32,
120            font: style.font.clone(),
121            font_size: style.size,
122            scale_factor: constraints.scale_factor,
123            wrap: constraints.wrap,
124            overflow: constraints.overflow,
125            max_width: constraints.max_width,
126            locale_bcp47: fallback_policy.locale_bcp47().map(str::to_string),
127            missing_glyphs,
128            families: usages,
129        };
130
131        self.entries.push_back(entry);
132        while self.entries.len() > max_entries {
133            self.entries.pop_front();
134        }
135    }
136}
137
138fn font_trace_record_all() -> bool {
139    static FLAG: OnceLock<bool> = OnceLock::new();
140    *FLAG.get_or_init(|| {
141        std::env::var("FRET_TEXT_FONT_TRACE_ALL")
142            .ok()
143            .is_some_and(|v| !v.trim().is_empty() && v.trim() != "0")
144    })
145}
146
147fn font_trace_entries_limit() -> usize {
148    static LIMIT: OnceLock<usize> = OnceLock::new();
149    *LIMIT.get_or_init(|| {
150        std::env::var("FRET_TEXT_FONT_TRACE_ENTRIES")
151            .ok()
152            .and_then(|v| v.parse::<usize>().ok())
153            .unwrap_or(64)
154            .min(4096)
155    })
156}
157
158fn font_trace_max_text_bytes() -> usize {
159    static LIMIT: OnceLock<usize> = OnceLock::new();
160    *LIMIT.get_or_init(|| {
161        std::env::var("FRET_TEXT_FONT_TRACE_MAX_TEXT_BYTES")
162            .ok()
163            .and_then(|v| v.parse::<usize>().ok())
164            .unwrap_or(256)
165            .clamp(16, 16 * 1024)
166    })
167}
168
169fn truncate_text_preview(text: &str, max_bytes: usize) -> String {
170    if max_bytes == 0 || text.len() <= max_bytes {
171        return text.to_string();
172    }
173
174    let mut end = max_bytes.min(text.len());
175    while end > 0 && !text.is_char_boundary(end) {
176        end = end.saturating_sub(1);
177    }
178    let mut out = text[..end].to_string();
179    out.push('…');
180    out
181}
182
183fn classify_trace_family(
184    requested: &fret_core::FontId,
185    family: &str,
186    requested_generic_lower: &HashSet<String>,
187    common_fallback_lower: &HashSet<String>,
188) -> fret_core::RendererTextFontTraceFamilyClass {
189    let family_lower = family.trim().to_ascii_lowercase();
190    if family_lower.is_empty() {
191        return fret_core::RendererTextFontTraceFamilyClass::Unknown;
192    }
193
194    let is_requested_generic = requested_generic_lower.contains(&family_lower);
195    let is_common = common_fallback_lower.contains(&family_lower);
196    match requested {
197        fret_core::FontId::Family(name) => {
198            if name.eq_ignore_ascii_case(family) {
199                fret_core::RendererTextFontTraceFamilyClass::Requested
200            } else if is_common {
201                fret_core::RendererTextFontTraceFamilyClass::CommonFallback
202            } else {
203                fret_core::RendererTextFontTraceFamilyClass::SystemFallback
204            }
205        }
206        _ => {
207            if is_requested_generic {
208                fret_core::RendererTextFontTraceFamilyClass::Requested
209            } else if is_common {
210                fret_core::RendererTextFontTraceFamilyClass::CommonFallback
211            } else {
212                fret_core::RendererTextFontTraceFamilyClass::SystemFallback
213            }
214        }
215    }
216}
217
218fn requested_generic_lower_families(
219    requested: &fret_core::FontId,
220    fallback_policy: &TextFallbackPolicyV1,
221    shaper: &ParleyShaper,
222) -> HashSet<String> {
223    let (configured, defaults): (&[String], &[&str]) = match requested {
224        fret_core::FontId::Ui => (
225            &fallback_policy.font_family_config().ui_sans,
226            fallback_policy::default_sans_candidates(shaper),
227        ),
228        fret_core::FontId::Serif => (
229            &fallback_policy.font_family_config().ui_serif,
230            fallback_policy::default_serif_candidates(shaper),
231        ),
232        fret_core::FontId::Monospace => (
233            &fallback_policy.font_family_config().ui_mono,
234            fallback_policy::default_monospace_candidates(shaper),
235        ),
236        fret_core::FontId::Family(_) => return HashSet::new(),
237    };
238
239    let mut families = HashSet::new();
240    for family in configured {
241        let family = family.trim().to_ascii_lowercase();
242        if !family.is_empty() {
243            families.insert(family);
244        }
245    }
246    for family in defaults {
247        let family = family.trim().to_ascii_lowercase();
248        if !family.is_empty() {
249            families.insert(family);
250        }
251    }
252    families
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn generic_requested_lane_includes_configured_and_default_candidates() {
261        let shaper = ParleyShaper::new_without_system_fonts();
262        let mut policy = TextFallbackPolicyV1::new(&shaper);
263        let mut config = policy.font_family_config().clone();
264        config.ui_sans = vec!["Custom UI".to_string()];
265        policy.set_font_family_config(config);
266
267        let families = requested_generic_lower_families(&fret_core::FontId::Ui, &policy, &shaper);
268        assert!(families.contains("custom ui"));
269        for family in fret_fonts::default_profile().ui_sans_families {
270            assert!(
271                families.contains(&family.to_ascii_lowercase()),
272                "expected requested generic lane to include bundled default sans family {family:?}"
273            );
274        }
275    }
276
277    #[test]
278    fn generic_requested_class_beats_common_fallback_overlap() {
279        let requested_generic_lower = HashSet::from([String::from("inter")]);
280        let common_fallback_lower = HashSet::from([String::from("inter")]);
281
282        let class = classify_trace_family(
283            &fret_core::FontId::Ui,
284            "Inter",
285            &requested_generic_lower,
286            &common_fallback_lower,
287        );
288
289        assert_eq!(
290            class,
291            fret_core::RendererTextFontTraceFamilyClass::Requested
292        );
293    }
294
295    #[test]
296    fn generic_nonrequested_noncommon_family_is_system_fallback() {
297        let requested_generic_lower = HashSet::from([String::from("inter")]);
298        let common_fallback_lower = HashSet::from([String::from("noto sans cjk sc")]);
299
300        let class = classify_trace_family(
301            &fret_core::FontId::Ui,
302            "Segoe UI Emoji",
303            &requested_generic_lower,
304            &common_fallback_lower,
305        );
306
307        assert_eq!(
308            class,
309            fret_core::RendererTextFontTraceFamilyClass::SystemFallback
310        );
311    }
312}