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}