Skip to main content

cranpose_render_wgpu/
lib.rs

1//! WGPU renderer backend for GPU-accelerated 2D rendering.
2//!
3//! This renderer uses WGPU for cross-platform GPU support across
4//! desktop (Windows/Mac/Linux), web (WebGPU), and mobile (Android/iOS).
5
6mod effect_renderer;
7pub(crate) mod gpu_stats;
8mod offscreen;
9mod pipeline;
10mod render;
11mod scene;
12mod shader_cache;
13mod shaders;
14
15pub use scene::{BackdropLayer, ClickAction, DrawShape, HitRegion, ImageDraw, Scene, TextDraw};
16
17use cranpose_core::{MemoryApplier, NodeId};
18use cranpose_render_common::{
19    text_hyphenation::choose_auto_hyphen_break as choose_shared_auto_hyphen_break, RenderScene,
20    Renderer,
21};
22use cranpose_ui::{set_text_measurer, LayoutTree, TextMeasurer};
23use cranpose_ui_graphics::Size;
24use glyphon::{
25    Attrs, AttrsOwned, Buffer, FamilyOwned, FontSystem, Metrics, Shaping, Style as GlyphonStyle,
26    Weight as GlyphonWeight,
27};
28use lru::LruCache;
29use render::GpuRenderer;
30use rustc_hash::FxHasher;
31use std::cell::RefCell;
32use std::collections::{HashMap, HashSet};
33use std::hash::{Hash, Hasher};
34use std::num::NonZeroUsize;
35use std::rc::Rc;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, OnceLock};
38
39/// Size-only cache for ultra-fast text measurement lookups.
40/// Key: (text_hash, font_size_fixed_point, style_hash)
41/// Value: (text_content, size) - text stored to handle hash collisions
42type TextSizeCache = Arc<Mutex<LruCache<(u64, i32, u64), (String, Size)>>>;
43type PreparedTextLayoutCache = Rc<
44    RefCell<LruCache<PreparedTextLayoutCacheKey, (String, cranpose_ui::text::PreparedTextLayout)>>,
45>;
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48struct PreparedTextLayoutCacheKey {
49    text_hash: u64,
50    size_int: i32,
51    style_hash: u64,
52    options: cranpose_ui::text::TextLayoutOptions,
53    max_width_bits: Option<u32>,
54}
55
56static TEXT_MEASURE_TELEMETRY_ENABLED: OnceLock<bool> = OnceLock::new();
57static TEXT_MEASURE_TELEMETRY: OnceLock<TextMeasureTelemetry> = OnceLock::new();
58
59#[derive(Default)]
60struct TextMeasureTelemetry {
61    measure_calls: AtomicU64,
62    layout_calls: AtomicU64,
63    offset_calls: AtomicU64,
64    measure_with_options_calls: AtomicU64,
65    prepare_with_options_calls: AtomicU64,
66    measure_fast_path_hits: AtomicU64,
67    measure_fast_path_misses: AtomicU64,
68    prepare_fast_path_hits: AtomicU64,
69    prepare_fast_path_misses: AtomicU64,
70    prepared_layout_cache_hits: AtomicU64,
71    prepared_layout_cache_misses: AtomicU64,
72    size_cache_hits: AtomicU64,
73    size_cache_misses: AtomicU64,
74    text_cache_hits: AtomicU64,
75    text_cache_misses: AtomicU64,
76    text_cache_evictions: AtomicU64,
77    text_cache_occupancy: AtomicU64,
78    ensure_reshapes: AtomicU64,
79    ensure_reuses: AtomicU64,
80}
81
82fn text_measure_telemetry_enabled() -> bool {
83    *TEXT_MEASURE_TELEMETRY_ENABLED
84        .get_or_init(|| std::env::var_os("CRANPOSE_TEXT_MEASURE_TELEMETRY").is_some())
85}
86
87fn text_measure_telemetry() -> &'static TextMeasureTelemetry {
88    TEXT_MEASURE_TELEMETRY.get_or_init(TextMeasureTelemetry::default)
89}
90
91fn maybe_report_text_measure_telemetry(sequence: u64) {
92    if !text_measure_telemetry_enabled() || !sequence.is_multiple_of(200) {
93        return;
94    }
95    let telemetry = text_measure_telemetry();
96    let measure_calls = telemetry.measure_calls.load(Ordering::Relaxed);
97    let layout_calls = telemetry.layout_calls.load(Ordering::Relaxed);
98    let offset_calls = telemetry.offset_calls.load(Ordering::Relaxed);
99    let measure_with_options_calls = telemetry.measure_with_options_calls.load(Ordering::Relaxed);
100    let prepare_with_options_calls = telemetry.prepare_with_options_calls.load(Ordering::Relaxed);
101    let measure_fast_path_hits = telemetry.measure_fast_path_hits.load(Ordering::Relaxed);
102    let measure_fast_path_misses = telemetry.measure_fast_path_misses.load(Ordering::Relaxed);
103    let prepare_fast_path_hits = telemetry.prepare_fast_path_hits.load(Ordering::Relaxed);
104    let prepare_fast_path_misses = telemetry.prepare_fast_path_misses.load(Ordering::Relaxed);
105    let prepared_layout_cache_hits = telemetry.prepared_layout_cache_hits.load(Ordering::Relaxed);
106    let prepared_layout_cache_misses = telemetry
107        .prepared_layout_cache_misses
108        .load(Ordering::Relaxed);
109    let size_hits = telemetry.size_cache_hits.load(Ordering::Relaxed);
110    let size_misses = telemetry.size_cache_misses.load(Ordering::Relaxed);
111    let text_hits = telemetry.text_cache_hits.load(Ordering::Relaxed);
112    let text_misses = telemetry.text_cache_misses.load(Ordering::Relaxed);
113    let text_cache_evictions = telemetry.text_cache_evictions.load(Ordering::Relaxed);
114    let text_cache_occupancy = telemetry.text_cache_occupancy.load(Ordering::Relaxed);
115    let reshapes = telemetry.ensure_reshapes.load(Ordering::Relaxed);
116    let reuses = telemetry.ensure_reuses.load(Ordering::Relaxed);
117
118    let size_total = size_hits + size_misses;
119    let text_total = text_hits + text_misses;
120    let ensure_total = reshapes + reuses;
121    let size_hit_rate = if size_total > 0 {
122        (size_hits as f64 / size_total as f64) * 100.0
123    } else {
124        0.0
125    };
126    let text_hit_rate = if text_total > 0 {
127        (text_hits as f64 / text_total as f64) * 100.0
128    } else {
129        0.0
130    };
131    let measure_fast_path_total = measure_fast_path_hits + measure_fast_path_misses;
132    let measure_fast_path_rate = if measure_fast_path_total > 0 {
133        (measure_fast_path_hits as f64 / measure_fast_path_total as f64) * 100.0
134    } else {
135        0.0
136    };
137    let prepare_fast_path_total = prepare_fast_path_hits + prepare_fast_path_misses;
138    let prepare_fast_path_rate = if prepare_fast_path_total > 0 {
139        (prepare_fast_path_hits as f64 / prepare_fast_path_total as f64) * 100.0
140    } else {
141        0.0
142    };
143    let prepared_layout_cache_total = prepared_layout_cache_hits + prepared_layout_cache_misses;
144    let prepared_layout_cache_hit_rate = if prepared_layout_cache_total > 0 {
145        (prepared_layout_cache_hits as f64 / prepared_layout_cache_total as f64) * 100.0
146    } else {
147        0.0
148    };
149    let reshape_rate = if ensure_total > 0 {
150        (reshapes as f64 / ensure_total as f64) * 100.0
151    } else {
152        0.0
153    };
154
155    log::warn!(
156        "[text-measure-telemetry] measure_calls={} layout_calls={} offset_calls={} measure_with_options_calls={} prepare_with_options_calls={} measure_fast_path_rate={:.1}% prepare_fast_path_rate={:.1}% prepared_layout_cache_hit_rate={:.1}% size_hit_rate={:.1}% text_cache_hit_rate={:.1}% text_cache_occupancy={} text_cache_evictions={} reshape_rate={:.1}% reshapes={} reuses={}",
157        measure_calls,
158        layout_calls,
159        offset_calls,
160        measure_with_options_calls,
161        prepare_with_options_calls,
162        measure_fast_path_rate,
163        prepare_fast_path_rate,
164        prepared_layout_cache_hit_rate,
165        size_hit_rate,
166        text_hit_rate,
167        text_cache_occupancy,
168        text_cache_evictions,
169        reshape_rate,
170        reshapes,
171        reuses
172    );
173}
174
175#[derive(Debug)]
176pub enum WgpuRendererError {
177    Layout(String),
178    Wgpu(String),
179}
180
181/// CPU-readable RGBA frame captured from the renderer output.
182#[derive(Debug, Clone)]
183pub struct CapturedFrame {
184    pub width: u32,
185    pub height: u32,
186    pub pixels: Vec<u8>,
187}
188
189/// Unified hash key for text caching - shared between measurement and rendering.
190#[derive(Clone, PartialEq, Eq, Hash)]
191pub(crate) enum TextKey {
192    Content(String),
193    Node(NodeId),
194}
195
196#[derive(Clone, PartialEq, Eq)]
197pub(crate) struct TextCacheKey {
198    key: TextKey,
199    scale_bits: u32, // f32 as bits for hashing
200    style_hash: u64,
201}
202
203impl TextCacheKey {
204    fn new(text: &str, font_size: f32, style_hash: u64) -> Self {
205        Self {
206            key: TextKey::Content(text.to_string()),
207            scale_bits: font_size.to_bits(),
208            style_hash,
209        }
210    }
211
212    fn for_node(node_id: NodeId, font_size: f32, style_hash: u64) -> Self {
213        Self {
214            key: TextKey::Node(node_id),
215            scale_bits: font_size.to_bits(),
216            style_hash,
217        }
218    }
219}
220
221impl Hash for TextCacheKey {
222    fn hash<H: Hasher>(&self, state: &mut H) {
223        self.key.hash(state);
224        self.scale_bits.hash(state);
225        self.style_hash.hash(state);
226    }
227}
228
229/// Cached text buffer shared between measurement and rendering
230pub(crate) struct SharedTextBuffer {
231    pub(crate) buffer: Buffer,
232    text: String,
233    font_size: f32,
234    line_height: f32,
235    style_hash: u64,
236    /// Cached size to avoid recalculating on every access
237    cached_size: Option<Size>,
238}
239
240pub(crate) struct EnsureTextBufferParams<'a> {
241    pub(crate) annotated_text: &'a cranpose_ui::text::AnnotatedString,
242    pub(crate) font_size_px: f32,
243    pub(crate) line_height_px: f32,
244    pub(crate) style_hash: u64,
245    pub(crate) style: &'a cranpose_ui::text::TextStyle,
246    pub(crate) scale: f32,
247}
248
249fn requires_advanced_shaping(text: &str) -> bool {
250    text.chars().any(requires_advanced_shaping_char)
251}
252
253fn requires_advanced_shaping_char(ch: char) -> bool {
254    let code = ch as u32;
255    if ch.is_ascii() || ch.is_whitespace() {
256        return false;
257    }
258
259    matches!(
260        code,
261        0x0300..=0x036F
262            | 0x0590..=0x08FF
263            | 0x0900..=0x109F
264            | 0x135D..=0x135F
265            | 0x1712..=0x1715
266            | 0x1732..=0x1735
267            | 0x1752..=0x1753
268            | 0x1772..=0x1773
269            | 0x17B4..=0x17D3
270            | 0x1885..=0x18A9
271            | 0x1A17..=0x1A1B
272            | 0x1AB0..=0x1AFF
273            | 0x1B00..=0x1CFF
274            | 0x1CD0..=0x1DFF
275            | 0x200C..=0x200F
276            | 0x202A..=0x202E
277            | 0x2066..=0x2069
278            | 0x20D0..=0x20FF
279            | 0x2DE0..=0x2DFF
280            | 0x2E80..=0xA7FF
281            | 0xA980..=0xABFF
282            | 0xD800..=0xF8FF
283            | 0xFB1D..=0xFEFF
284            | 0x1F000..=u32::MAX
285    )
286}
287
288fn select_text_shaping(
289    annotated_text: &cranpose_ui::text::AnnotatedString,
290    style: &cranpose_ui::text::TextStyle,
291) -> Shaping {
292    let requested = style
293        .paragraph_style
294        .platform_style
295        .and_then(|platform| platform.shaping);
296
297    match requested {
298        Some(cranpose_ui::text::TextShaping::Basic)
299            if !requires_advanced_shaping(annotated_text.text.as_str()) =>
300        {
301            Shaping::Basic
302        }
303        _ => Shaping::Advanced,
304    }
305}
306
307impl SharedTextBuffer {
308    /// Ensure the buffer has the correct text and font_size, only reshaping if needed
309    pub(crate) fn ensure(
310        &mut self,
311        font_system: &mut FontSystem,
312        font_family_resolver: &mut WgpuFontFamilyResolver,
313        params: EnsureTextBufferParams<'_>,
314    ) -> bool {
315        let annotated_text = params.annotated_text;
316        let font_size_px = params.font_size_px;
317        let line_height_px = params.line_height_px;
318        let style_hash = params.style_hash;
319        let style = params.style;
320        let scale = params.scale;
321        let text_str = annotated_text.text.as_str();
322        let text_changed = self.text != text_str;
323        let font_changed = (self.font_size - font_size_px).abs() > 0.1;
324        let line_height_changed = (self.line_height - line_height_px).abs() > 0.1;
325        let style_changed = self.style_hash != style_hash;
326
327        // Only reshape if something actually changed
328        if !text_changed && !font_changed && !line_height_changed && !style_changed {
329            return false;
330        }
331
332        // Set metrics and size for unlimited layout
333        let metrics = Metrics::new(font_size_px, line_height_px);
334        self.buffer.set_metrics(font_system, metrics);
335        self.buffer
336            .set_size(font_system, Some(f32::MAX), Some(f32::MAX));
337
338        let unscaled_base_size = if scale > 0.0 {
339            font_size_px / scale
340        } else {
341            14.0
342        };
343        let shaping = select_text_shaping(annotated_text, style);
344
345        // Set text and shape
346        if annotated_text.span_styles.is_empty() {
347            let attrs = attrs_from_text_style(
348                style,
349                unscaled_base_size,
350                scale,
351                font_system,
352                font_family_resolver,
353            );
354            let attrs_ref = attrs.as_attrs();
355            self.buffer
356                .set_text(font_system, text_str, &attrs_ref, shaping, None);
357        } else {
358            let boundaries = annotated_text.span_boundaries();
359            let mut rich_spans: Vec<(usize, usize, AttrsOwned)> =
360                Vec::with_capacity(boundaries.len().saturating_sub(1));
361            let mut chunk_text_style = style.clone();
362            for window in boundaries.windows(2) {
363                let start = window[0];
364                let end = window[1];
365                if start == end {
366                    continue;
367                }
368                let mut merged_style = style.span_style.clone();
369                for span in &annotated_text.span_styles {
370                    if span.range.start <= start && span.range.end >= end {
371                        merged_style = merged_style.merge(&span.item);
372                    }
373                }
374                chunk_text_style.span_style = merged_style;
375                let attrs = attrs_from_text_style(
376                    &chunk_text_style,
377                    unscaled_base_size,
378                    scale,
379                    font_system,
380                    font_family_resolver,
381                );
382                if let Some((_, previous_end, previous_attrs)) = rich_spans.last_mut() {
383                    if *previous_end == start && *previous_attrs == attrs {
384                        *previous_end = end;
385                        continue;
386                    }
387                }
388                rich_spans.push((start, end, attrs));
389            }
390            let default_attrs = attrs_from_text_style(
391                style,
392                unscaled_base_size,
393                scale,
394                font_system,
395                font_family_resolver,
396            );
397            let default_attrs_ref = default_attrs.as_attrs();
398            self.buffer.set_rich_text(
399                font_system,
400                rich_spans.iter().map(|(start, end, attrs)| {
401                    (&annotated_text.text[*start..*end], attrs.as_attrs())
402                }),
403                &default_attrs_ref,
404                shaping,
405                None,
406            );
407        }
408        self.buffer.shape_until_scroll(font_system, false);
409
410        // Update cached values
411        self.text.clear();
412        self.text.push_str(text_str);
413        self.font_size = font_size_px;
414        self.line_height = line_height_px;
415        self.style_hash = style_hash;
416        self.cached_size = None; // Invalidate size cache
417        true
418    }
419
420    /// Get or calculate the size of the shaped text
421    pub(crate) fn size(&mut self) -> Size {
422        if let Some(size) = self.cached_size {
423            return size;
424        }
425
426        // Calculate size from buffer
427        let mut max_width = 0.0f32;
428        let mut total_height = 0.0f32;
429        for run in self.buffer.layout_runs() {
430            let mut run_height = run.line_height;
431            for glyph in run.glyphs {
432                let physical_height = glyph.font_size * 1.4; // 1.4 is our default line_height modifier
433                if physical_height > run_height {
434                    run_height = physical_height;
435                }
436            }
437
438            max_width = max_width.max(run.line_w);
439            total_height = total_height.max(run.line_top + run_height);
440        }
441
442        let size = Size {
443            width: max_width,
444            height: total_height,
445        };
446
447        self.cached_size = Some(size);
448        size
449    }
450}
451
452#[derive(Clone, Debug, PartialEq, Eq, Hash)]
453struct TypefaceRequest {
454    font_family: Option<cranpose_ui::text::FontFamily>,
455    font_weight: cranpose_ui::text::FontWeight,
456    font_style: cranpose_ui::text::FontStyle,
457    font_synthesis: cranpose_ui::text::FontSynthesis,
458}
459
460impl TypefaceRequest {
461    fn from_span_style(span_style: &cranpose_ui::text::SpanStyle) -> Self {
462        Self {
463            font_family: span_style.font_family.clone(),
464            font_weight: span_style.font_weight.unwrap_or_default(),
465            font_style: span_style.font_style.unwrap_or_default(),
466            font_synthesis: span_style.font_synthesis.unwrap_or_default(),
467        }
468    }
469}
470
471#[derive(Clone, Debug, PartialEq, Eq, Hash)]
472enum FamilyCacheKey {
473    Name(String),
474    Serif,
475    SansSerif,
476    Monospace,
477    Cursive,
478    Fantasy,
479}
480
481impl FamilyCacheKey {
482    fn from_family_owned(family: &FamilyOwned) -> Self {
483        match family {
484            FamilyOwned::Name(name) => Self::Name(name.to_string()),
485            FamilyOwned::Serif => Self::Serif,
486            FamilyOwned::SansSerif => Self::SansSerif,
487            FamilyOwned::Monospace => Self::Monospace,
488            FamilyOwned::Cursive => Self::Cursive,
489            FamilyOwned::Fantasy => Self::Fantasy,
490        }
491    }
492}
493
494#[derive(Clone, Debug, PartialEq, Eq, Hash)]
495struct StyleWeightRequest {
496    family: FamilyCacheKey,
497    requested_weight: cranpose_ui::text::FontWeight,
498    requested_style: cranpose_ui::text::FontStyle,
499}
500
501type ResolvedStyleWeight = Option<(GlyphonStyle, GlyphonWeight)>;
502
503#[derive(Default)]
504struct WgpuFontFamilyResolver {
505    request_cache: HashMap<TypefaceRequest, FamilyOwned>,
506    style_weight_cache: HashMap<StyleWeightRequest, ResolvedStyleWeight>,
507    loaded_typeface_paths: HashMap<String, String>,
508    unavailable_typeface_paths: HashSet<String>,
509    available_family_names: HashMap<String, String>,
510    indexed_face_count: usize,
511    generic_fallback_seeded: bool,
512}
513
514impl WgpuFontFamilyResolver {
515    fn prime(&mut self, font_system: &mut FontSystem) {
516        self.ensure_non_empty_font_db(font_system);
517        self.ensure_family_index(font_system);
518        self.ensure_generic_fallbacks(font_system);
519    }
520
521    fn clear_resolution_caches(&mut self) {
522        self.request_cache.clear();
523        self.style_weight_cache.clear();
524    }
525
526    fn resolve_family_owned(
527        &mut self,
528        font_system: &mut FontSystem,
529        span_style: &cranpose_ui::text::SpanStyle,
530    ) -> FamilyOwned {
531        self.ensure_non_empty_font_db(font_system);
532        self.ensure_family_index(font_system);
533        self.ensure_generic_fallbacks(font_system);
534
535        let request = TypefaceRequest::from_span_style(span_style);
536        if let Some(cached) = self.request_cache.get(&request) {
537            return cached.clone();
538        }
539
540        let resolved = self.resolve_family_owned_uncached(font_system, &request);
541        self.request_cache.insert(request, resolved.clone());
542        resolved
543    }
544
545    fn resolve_available_style_and_weight(
546        &mut self,
547        font_system: &FontSystem,
548        family: &FamilyOwned,
549        requested_weight: Option<cranpose_ui::text::FontWeight>,
550        requested_style: Option<cranpose_ui::text::FontStyle>,
551    ) -> Option<(GlyphonStyle, GlyphonWeight)> {
552        let request = StyleWeightRequest {
553            family: FamilyCacheKey::from_family_owned(family),
554            requested_weight: requested_weight.unwrap_or_default(),
555            requested_style: requested_style.unwrap_or_default(),
556        };
557
558        if let Some(cached) = self.style_weight_cache.get(&request) {
559            return *cached;
560        }
561
562        let resolved = resolve_available_style_and_weight_uncached(
563            font_system,
564            family,
565            requested_weight,
566            requested_style,
567        );
568        self.style_weight_cache.insert(request, resolved);
569        resolved
570    }
571
572    fn ensure_non_empty_font_db(&mut self, font_system: &mut FontSystem) {
573        if font_system.db().faces().next().is_none() {
574            log::warn!("Font database is empty; text will not render. Provide fonts via AppLauncher::with_fonts.");
575        }
576    }
577
578    fn resolve_family_owned_uncached(
579        &mut self,
580        font_system: &mut FontSystem,
581        request: &TypefaceRequest,
582    ) -> FamilyOwned {
583        use cranpose_ui::text::FontFamily;
584
585        match request.font_family.as_ref() {
586            None | Some(FontFamily::Default | FontFamily::SansSerif) => FamilyOwned::SansSerif,
587            Some(FontFamily::Serif) => FamilyOwned::Serif,
588            Some(FontFamily::Monospace) => FamilyOwned::Monospace,
589            Some(FontFamily::Cursive) => FamilyOwned::Cursive,
590            Some(FontFamily::Fantasy) => FamilyOwned::Fantasy,
591            Some(FontFamily::Named(name)) => self
592                .canonical_family_name(name)
593                .map(|resolved| FamilyOwned::Name(resolved.into()))
594                .unwrap_or(FamilyOwned::SansSerif),
595            Some(FontFamily::FileBacked(file_backed)) => self
596                .resolve_file_backed_family(font_system, file_backed, request)
597                .unwrap_or(FamilyOwned::SansSerif),
598            Some(FontFamily::LoadedTypeface(typeface_path)) => self
599                .resolve_loaded_typeface_family(font_system, typeface_path.path.as_str())
600                .unwrap_or(FamilyOwned::SansSerif),
601        }
602    }
603
604    fn resolve_file_backed_family(
605        &mut self,
606        font_system: &mut FontSystem,
607        file_backed: &cranpose_ui::text::FileBackedFontFamily,
608        request: &TypefaceRequest,
609    ) -> Option<FamilyOwned> {
610        let mut candidates: Vec<&cranpose_ui::text::FontFile> = file_backed.fonts.iter().collect();
611        candidates.sort_by_key(|candidate| {
612            let style_penalty = if candidate.style == request.font_style {
613                0u32
614            } else {
615                10_000u32
616            };
617            let weight_penalty =
618                (i32::from(candidate.weight.0) - i32::from(request.font_weight.0)).unsigned_abs();
619            style_penalty + weight_penalty
620        });
621
622        for candidate in candidates {
623            let Some(family_name) = self.load_typeface_path(font_system, candidate.path.as_str())
624            else {
625                continue;
626            };
627            if let Some(canonical) = self.canonical_family_name(family_name.as_str()) {
628                return Some(FamilyOwned::Name(canonical.into()));
629            }
630        }
631        None
632    }
633
634    fn resolve_loaded_typeface_family(
635        &mut self,
636        font_system: &mut FontSystem,
637        path: &str,
638    ) -> Option<FamilyOwned> {
639        self.load_typeface_path(font_system, path)
640            .map(|family_name| {
641                self.canonical_family_name(family_name.as_str())
642                    .map(|resolved| FamilyOwned::Name(resolved.into()))
643                    .unwrap_or(FamilyOwned::SansSerif)
644            })
645    }
646
647    fn ensure_family_index(&mut self, font_system: &FontSystem) {
648        let face_count = font_system.db().faces().count();
649        if face_count == self.indexed_face_count {
650            return;
651        }
652
653        self.available_family_names.clear();
654        for face in font_system.db().faces() {
655            for (family_name, _) in &face.families {
656                self.available_family_names
657                    .entry(family_name.to_lowercase())
658                    .or_insert_with(|| family_name.clone());
659            }
660        }
661        self.indexed_face_count = face_count;
662        self.clear_resolution_caches();
663        self.generic_fallback_seeded = false;
664    }
665
666    fn canonical_family_name(&self, family_name: &str) -> Option<String> {
667        self.available_family_names
668            .get(&family_name.to_lowercase())
669            .cloned()
670    }
671
672    fn ensure_generic_fallbacks(&mut self, font_system: &mut FontSystem) {
673        if self.generic_fallback_seeded {
674            return;
675        }
676
677        let Some(primary_family) = font_system
678            .db()
679            .faces()
680            .find_map(|face| face.families.first().map(|(name, _)| name.clone()))
681        else {
682            return;
683        };
684
685        let db = font_system.db_mut();
686        db.set_sans_serif_family(primary_family.clone());
687        db.set_serif_family(primary_family.clone());
688        db.set_monospace_family(primary_family.clone());
689        db.set_cursive_family(primary_family.clone());
690        db.set_fantasy_family(primary_family);
691
692        self.generic_fallback_seeded = true;
693        self.clear_resolution_caches();
694    }
695
696    fn load_typeface_path(&mut self, font_system: &mut FontSystem, path: &str) -> Option<String> {
697        if let Some(family_name) = self.loaded_typeface_paths.get(path) {
698            return Some(family_name.clone());
699        }
700
701        if self.unavailable_typeface_paths.contains(path) {
702            return None;
703        }
704
705        #[cfg(target_arch = "wasm32")]
706        let _ = font_system;
707
708        #[cfg(target_arch = "wasm32")]
709        {
710            log::warn!(
711                "Typeface path '{}' requested on wasm target; filesystem font loading is unavailable",
712                path
713            );
714            self.unavailable_typeface_paths.insert(path.to_string());
715            return None;
716        }
717
718        #[cfg(not(target_arch = "wasm32"))]
719        {
720            let font_bytes = match std::fs::read(path) {
721                Ok(bytes) => bytes,
722                Err(error) => {
723                    log::warn!("Failed to read typeface path '{}': {}", path, error);
724                    self.unavailable_typeface_paths.insert(path.to_string());
725                    return None;
726                }
727            };
728            let preferred_family = primary_family_name_from_bytes(font_bytes.as_slice());
729            let previous_face_count = font_system.db().faces().count();
730            font_system.db_mut().load_font_data(font_bytes);
731
732            self.ensure_family_index(font_system);
733
734            let mut resolved_family =
735                preferred_family.and_then(|name| self.canonical_family_name(name.as_str()));
736            if resolved_family.is_none() && self.indexed_face_count > previous_face_count {
737                resolved_family = font_system
738                    .db()
739                    .faces()
740                    .skip(previous_face_count)
741                    .find_map(|face| face.families.first().map(|(name, _)| name.clone()));
742            }
743
744            let Some(family_name) = resolved_family else {
745                log::warn!(
746                    "Typeface path '{}' loaded but no usable family name was resolved",
747                    path
748                );
749                self.unavailable_typeface_paths.insert(path.to_string());
750                return None;
751            };
752            let family_name = self
753                .canonical_family_name(family_name.as_str())
754                .unwrap_or(family_name);
755
756            self.loaded_typeface_paths
757                .insert(path.to_string(), family_name.clone());
758            self.unavailable_typeface_paths.remove(path);
759            Some(family_name)
760        }
761    }
762}
763
764fn load_fonts(font_system: &mut FontSystem, fonts: &[&[u8]]) {
765    for (i, font_data) in fonts.iter().enumerate() {
766        log::info!("Loading font #{}, size: {} bytes", i, font_data.len());
767        font_system.db_mut().load_font_data(font_data.to_vec());
768    }
769    log::info!(
770        "Total font faces loaded: {}",
771        font_system.db().faces().count()
772    );
773}
774
775#[cfg(not(target_arch = "wasm32"))]
776fn primary_family_name_from_bytes(bytes: &[u8]) -> Option<String> {
777    let face = ttf_parser::Face::parse(bytes, 0).ok()?;
778    let mut fallback_family = None;
779    for name in face.names() {
780        if name.name_id == ttf_parser::name_id::TYPOGRAPHIC_FAMILY {
781            let resolved = name.to_string().filter(|value| !value.is_empty());
782            if resolved.is_some() {
783                return resolved;
784            }
785        }
786        if fallback_family.is_none() && name.name_id == ttf_parser::name_id::FAMILY {
787            fallback_family = name.to_string().filter(|value| !value.is_empty());
788        }
789    }
790    fallback_family
791}
792
793const SHARED_TEXT_CACHE_CAPACITY: usize = 256;
794
795fn new_shared_text_buffer(
796    font_system: &mut FontSystem,
797    font_size: f32,
798    line_height: f32,
799) -> SharedTextBuffer {
800    let buffer = Buffer::new(font_system, Metrics::new(font_size, line_height));
801    SharedTextBuffer {
802        buffer,
803        text: String::new(),
804        font_size: 0.0,
805        line_height: 0.0,
806        style_hash: 0,
807        cached_size: None,
808    }
809}
810
811fn new_text_cache() -> LruCache<TextCacheKey, SharedTextBuffer> {
812    LruCache::new(NonZeroUsize::new(SHARED_TEXT_CACHE_CAPACITY).unwrap())
813}
814
815pub(crate) fn shared_text_buffer_mut<'a>(
816    cache: &'a mut LruCache<TextCacheKey, SharedTextBuffer>,
817    key: TextCacheKey,
818    font_system: &mut FontSystem,
819    font_size: f32,
820    line_height: f32,
821) -> (bool, bool, usize, &'a mut SharedTextBuffer) {
822    if cache.contains(&key) {
823        let len = cache.len();
824        let buffer = cache.get_mut(&key).expect("text cache hit must exist");
825        return (true, false, len, buffer);
826    }
827
828    let evicted = cache
829        .push(
830            key.clone(),
831            new_shared_text_buffer(font_system, font_size, line_height),
832        )
833        .is_some();
834    let len = cache.len();
835    let buffer = cache
836        .get_mut(&key)
837        .expect("inserted text cache entry must exist");
838    (false, evicted, len, buffer)
839}
840
841pub(crate) struct TextSystemState {
842    pub(crate) font_system: FontSystem,
843    pub(crate) font_family_resolver: WgpuFontFamilyResolver,
844    pub(crate) text_cache: LruCache<TextCacheKey, SharedTextBuffer>,
845}
846
847impl TextSystemState {
848    fn from_fonts(fonts: &[&[u8]]) -> Self {
849        let mut font_system = FontSystem::new();
850
851        // On Android never load system fonts: modern Android ships variable Roboto
852        // which can cause rasterization corruption or font-ID conflicts with glyphon.
853        #[cfg(target_os = "android")]
854        log::info!("Skipping Android system fonts – using application-provided fonts only");
855
856        load_fonts(&mut font_system, fonts);
857
858        let mut font_family_resolver = WgpuFontFamilyResolver::default();
859        font_family_resolver.prime(&mut font_system);
860        Self::from_parts(font_system, font_family_resolver)
861    }
862
863    fn from_parts(font_system: FontSystem, font_family_resolver: WgpuFontFamilyResolver) -> Self {
864        Self {
865            font_system,
866            font_family_resolver,
867            text_cache: new_text_cache(),
868        }
869    }
870
871    pub(crate) fn parts_mut(
872        &mut self,
873    ) -> (
874        &mut FontSystem,
875        &mut WgpuFontFamilyResolver,
876        &mut LruCache<TextCacheKey, SharedTextBuffer>,
877    ) {
878        (
879            &mut self.font_system,
880            &mut self.font_family_resolver,
881            &mut self.text_cache,
882        )
883    }
884}
885
886type SharedTextSystemState = Arc<Mutex<TextSystemState>>;
887
888/// WGPU-based renderer for GPU-accelerated 2D rendering.
889///
890/// This renderer supports:
891/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
892/// - Gradients (solid, linear, radial)
893/// - GPU text rendering via glyphon
894/// - Cross-platform support (Desktop, Web, Mobile)
895pub struct WgpuRenderer {
896    scene: Scene,
897    gpu_renderer: Option<GpuRenderer>,
898    render_text_state: TextSystemState,
899    /// Root scale factor for text rendering (use for density scaling)
900    root_scale: f32,
901}
902
903impl WgpuRenderer {
904    /// Create a new WGPU renderer.
905    ///
906    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
907    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
908    ///
909    /// Call [`init_gpu`][Self::init_gpu] before rendering.
910    pub fn new(fonts: &[&[u8]]) -> Self {
911        let render_text_state = TextSystemState::from_fonts(fonts);
912        let measure_text_state = Arc::new(Mutex::new(TextSystemState::from_fonts(fonts)));
913        let text_measurer = WgpuTextMeasurer::new(measure_text_state);
914        set_text_measurer(text_measurer.clone());
915
916        Self {
917            scene: Scene::new(),
918            gpu_renderer: None,
919            render_text_state,
920            root_scale: 1.0,
921        }
922    }
923
924    /// Initialize GPU resources with a WGPU device and queue.
925    pub fn init_gpu(
926        &mut self,
927        device: Arc<wgpu::Device>,
928        queue: Arc<wgpu::Queue>,
929        surface_format: wgpu::TextureFormat,
930    ) {
931        self.gpu_renderer = Some(GpuRenderer::new(device, queue, surface_format));
932    }
933
934    /// Set root scale factor for text rendering (e.g., density scaling on Android)
935    pub fn set_root_scale(&mut self, scale: f32) {
936        self.root_scale = scale;
937    }
938
939    /// Render the scene to a texture view.
940    pub fn render(
941        &mut self,
942        view: &wgpu::TextureView,
943        width: u32,
944        height: u32,
945    ) -> Result<(), WgpuRendererError> {
946        if let Some(gpu_renderer) = &mut self.gpu_renderer {
947            gpu_renderer
948                .render(
949                    &mut self.render_text_state,
950                    view,
951                    &self.scene.shapes,
952                    &self.scene.images,
953                    &self.scene.texts,
954                    &self.scene.shadow_draws,
955                    &self.scene.effect_layers,
956                    &self.scene.backdrop_layers,
957                    width,
958                    height,
959                    self.root_scale,
960                )
961                .map_err(WgpuRendererError::Wgpu)
962        } else {
963            Err(WgpuRendererError::Wgpu(
964                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
965            ))
966        }
967    }
968
969    /// Render the current scene into an RGBA pixel buffer for robot tests.
970    ///
971    /// Uses the renderer's configured root scale.
972    pub fn capture_frame(
973        &mut self,
974        width: u32,
975        height: u32,
976    ) -> Result<CapturedFrame, WgpuRendererError> {
977        self.capture_frame_with_scale(width, height, self.root_scale)
978    }
979
980    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
981    pub fn capture_frame_with_scale(
982        &mut self,
983        width: u32,
984        height: u32,
985        root_scale: f32,
986    ) -> Result<CapturedFrame, WgpuRendererError> {
987        if let Some(gpu_renderer) = &mut self.gpu_renderer {
988            let pixels = gpu_renderer
989                .render_to_rgba_pixels(
990                    &mut self.render_text_state,
991                    &self.scene.shapes,
992                    &self.scene.images,
993                    &self.scene.texts,
994                    &self.scene.shadow_draws,
995                    &self.scene.effect_layers,
996                    &self.scene.backdrop_layers,
997                    width,
998                    height,
999                    root_scale,
1000                )
1001                .map_err(WgpuRendererError::Wgpu)?;
1002            Ok(CapturedFrame {
1003                width,
1004                height,
1005                pixels,
1006            })
1007        } else {
1008            Err(WgpuRendererError::Wgpu(
1009                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
1010            ))
1011        }
1012    }
1013
1014    /// Get access to the WGPU device (for surface configuration).
1015    pub fn device(&self) -> &wgpu::Device {
1016        self.gpu_renderer
1017            .as_ref()
1018            .map(|r| &*r.device)
1019            .expect("GPU renderer not initialized")
1020    }
1021}
1022
1023impl Default for WgpuRenderer {
1024    fn default() -> Self {
1025        Self::new(&[])
1026    }
1027}
1028
1029impl Renderer for WgpuRenderer {
1030    type Scene = Scene;
1031    type Error = WgpuRendererError;
1032
1033    fn scene(&self) -> &Self::Scene {
1034        &self.scene
1035    }
1036
1037    fn scene_mut(&mut self) -> &mut Self::Scene {
1038        &mut self.scene
1039    }
1040
1041    fn rebuild_scene(
1042        &mut self,
1043        layout_tree: &LayoutTree,
1044        _viewport: Size,
1045    ) -> Result<(), Self::Error> {
1046        self.scene.clear();
1047        // Build scene in logical dp - scaling happens in GPU vertex upload
1048        pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
1049        Ok(())
1050    }
1051
1052    fn rebuild_scene_from_applier(
1053        &mut self,
1054        applier: &mut MemoryApplier,
1055        root: NodeId,
1056        _viewport: Size,
1057    ) -> Result<(), Self::Error> {
1058        self.scene.clear();
1059        // Build scene in logical dp - scaling happens in GPU vertex upload
1060        // Traverse layout nodes via applier instead of rebuilding LayoutTree
1061        pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
1062        Ok(())
1063    }
1064
1065    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1066        use cranpose_ui_graphics::{BlendMode, Brush, Color, Rect, RoundedCornerShape};
1067
1068        // Draw FPS text in top-right corner with semi-transparent background
1069        // Position: 8px from right edge, 8px from top
1070        let padding = 8.0;
1071        let font_size = 14.0;
1072
1073        // Measure text width (approximate: ~7px per character at 14px font)
1074        let char_width = 7.0;
1075        let text_width = text.len() as f32 * char_width;
1076        let text_height = font_size * 1.4;
1077
1078        let x = viewport.width - text_width - padding * 2.0;
1079        let y = padding;
1080
1081        // Add background rectangle (dark semi-transparent)
1082        let bg_rect = Rect {
1083            x,
1084            y,
1085            width: text_width + padding,
1086            height: text_height + padding / 2.0,
1087        };
1088        self.scene.push_shape(
1089            bg_rect,
1090            Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
1091            Some(RoundedCornerShape::uniform(4.0)),
1092            None,
1093            BlendMode::SrcOver,
1094        );
1095
1096        // Add text (green color for visibility)
1097        let text_rect = Rect {
1098            x: x + padding / 2.0,
1099            y: y + padding / 4.0,
1100            width: text_width,
1101            height: text_height,
1102        };
1103        self.scene.push_text(
1104            NodeId::MAX,
1105            text_rect,
1106            Rc::new(cranpose_ui::text::AnnotatedString::from(text)),
1107            Color(0.0, 1.0, 0.0, 1.0), // Green
1108            cranpose_ui::TextStyle::default(),
1109            font_size,
1110            1.0,
1111            cranpose_ui::TextLayoutOptions::default(),
1112            None,
1113        );
1114    }
1115}
1116
1117fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
1118    style.resolve_font_size(14.0)
1119}
1120
1121fn resolve_line_height(style: &cranpose_ui::text::TextStyle, font_size: f32) -> f32 {
1122    style.resolve_line_height(14.0, font_size * 1.4)
1123}
1124
1125fn resolve_max_span_font_size(
1126    style: &cranpose_ui::text::TextStyle,
1127    text: &cranpose_ui::text::AnnotatedString,
1128    base_font_size: f32,
1129) -> f32 {
1130    if text.span_styles.is_empty() {
1131        return base_font_size;
1132    }
1133
1134    let mut max_font_size = base_font_size;
1135    for window in text.span_boundaries().windows(2) {
1136        let start = window[0];
1137        let end = window[1];
1138        if start == end {
1139            continue;
1140        }
1141
1142        let mut merged_span = style.span_style.clone();
1143        for span in &text.span_styles {
1144            if span.range.start <= start && span.range.end >= end {
1145                merged_span = merged_span.merge(&span.item);
1146            }
1147        }
1148        let mut chunk_style = style.clone();
1149        chunk_style.span_style = merged_span;
1150        max_font_size = max_font_size.max(chunk_style.resolve_font_size(base_font_size));
1151    }
1152    max_font_size
1153}
1154
1155pub(crate) fn resolve_effective_line_height(
1156    style: &cranpose_ui::text::TextStyle,
1157    text: &cranpose_ui::text::AnnotatedString,
1158    base_font_size: f32,
1159) -> f32 {
1160    let max_font_size = resolve_max_span_font_size(style, text, base_font_size);
1161    resolve_line_height(style, max_font_size)
1162}
1163
1164fn family_owned_to_fontdb_family(family: &FamilyOwned) -> glyphon::fontdb::Family<'_> {
1165    match family {
1166        FamilyOwned::Name(name) => glyphon::fontdb::Family::Name(name.as_str()),
1167        FamilyOwned::Serif => glyphon::fontdb::Family::Serif,
1168        FamilyOwned::SansSerif => glyphon::fontdb::Family::SansSerif,
1169        FamilyOwned::Monospace => glyphon::fontdb::Family::Monospace,
1170        FamilyOwned::Cursive => glyphon::fontdb::Family::Cursive,
1171        FamilyOwned::Fantasy => glyphon::fontdb::Family::Fantasy,
1172    }
1173}
1174
1175fn requested_fontdb_style(style: Option<cranpose_ui::text::FontStyle>) -> glyphon::fontdb::Style {
1176    match style.unwrap_or_default() {
1177        cranpose_ui::text::FontStyle::Normal => glyphon::fontdb::Style::Normal,
1178        cranpose_ui::text::FontStyle::Italic => glyphon::fontdb::Style::Italic,
1179    }
1180}
1181
1182fn glyphon_style_from_fontdb(style: glyphon::fontdb::Style) -> GlyphonStyle {
1183    match style {
1184        glyphon::fontdb::Style::Italic | glyphon::fontdb::Style::Oblique => GlyphonStyle::Italic,
1185        glyphon::fontdb::Style::Normal => GlyphonStyle::Normal,
1186    }
1187}
1188
1189fn resolve_available_style_and_weight_uncached(
1190    font_system: &FontSystem,
1191    family: &FamilyOwned,
1192    requested_weight: Option<cranpose_ui::text::FontWeight>,
1193    requested_style: Option<cranpose_ui::text::FontStyle>,
1194) -> Option<(GlyphonStyle, GlyphonWeight)> {
1195    let requested_fontdb_weight = requested_weight.unwrap_or_default().0;
1196    let requested_style = requested_fontdb_style(requested_style);
1197    let requested_family = family_owned_to_fontdb_family(family);
1198    let requested_family_name = font_system
1199        .db()
1200        .family_name(&requested_family)
1201        .to_ascii_lowercase();
1202
1203    let style_penalty = |face_style: glyphon::fontdb::Style| -> u32 {
1204        if face_style == requested_style {
1205            0
1206        } else if requested_style != glyphon::fontdb::Style::Normal
1207            && face_style == glyphon::fontdb::Style::Normal
1208        {
1209            1_000
1210        } else {
1211            10_000
1212        }
1213    };
1214
1215    let weight_penalty = |face_weight: u16| -> u32 {
1216        (i32::from(face_weight) - i32::from(requested_fontdb_weight)).unsigned_abs()
1217    };
1218
1219    let mut best_in_family: Option<(u32, glyphon::fontdb::Style, u16)> = None;
1220    let mut best_global: Option<(u32, glyphon::fontdb::Style, u16)> = None;
1221
1222    for face in font_system.db().faces() {
1223        let score = style_penalty(face.style) + weight_penalty(face.weight.0);
1224        let in_family = face
1225            .families
1226            .iter()
1227            .any(|(name, _)| name.eq_ignore_ascii_case(requested_family_name.as_str()));
1228
1229        if in_family
1230            && best_in_family
1231                .as_ref()
1232                .map(|(best_score, _, _)| score < *best_score)
1233                .unwrap_or(true)
1234        {
1235            best_in_family = Some((score, face.style, face.weight.0));
1236        }
1237
1238        if best_global
1239            .as_ref()
1240            .map(|(best_score, _, _)| score < *best_score)
1241            .unwrap_or(true)
1242        {
1243            best_global = Some((score, face.style, face.weight.0));
1244        }
1245    }
1246
1247    let (_, resolved_style, resolved_weight) = best_in_family.or(best_global)?;
1248    Some((
1249        glyphon_style_from_fontdb(resolved_style),
1250        GlyphonWeight(resolved_weight),
1251    ))
1252}
1253
1254fn attrs_from_text_style(
1255    style: &cranpose_ui::text::TextStyle,
1256    unscaled_base_font_size: f32,
1257    scale: f32,
1258    font_system: &mut FontSystem,
1259    font_family_resolver: &mut WgpuFontFamilyResolver,
1260) -> AttrsOwned {
1261    let mut attrs = Attrs::new();
1262    let span_style = &style.span_style;
1263    let font_weight = span_style.font_weight;
1264    let font_style = span_style.font_style;
1265    let letter_spacing = span_style.letter_spacing;
1266
1267    let unscaled_font_size = style.resolve_font_size(unscaled_base_font_size);
1268    let unscaled_line_height =
1269        style.resolve_line_height(unscaled_base_font_size, unscaled_font_size * 1.4);
1270
1271    let font_size_px = unscaled_font_size * scale;
1272    let line_height_px = unscaled_line_height * scale;
1273    attrs = attrs.metrics(glyphon::Metrics::new(font_size_px, line_height_px));
1274
1275    if let Some(color) = &span_style.color {
1276        let r = (color.0 * 255.0).clamp(0.0, 255.0) as u8;
1277        let g = (color.1 * 255.0).clamp(0.0, 255.0) as u8;
1278        let b = (color.2 * 255.0).clamp(0.0, 255.0) as u8;
1279        let a = (color.3 * 255.0).clamp(0.0, 255.0) as u8;
1280        attrs = attrs.color(glyphon::Color::rgba(r, g, b, a));
1281    }
1282
1283    let family_owned = font_family_resolver.resolve_family_owned(font_system, span_style);
1284    attrs = attrs.family(family_owned.as_family());
1285
1286    if let Some((resolved_style, resolved_weight)) = font_family_resolver
1287        .resolve_available_style_and_weight(font_system, &family_owned, font_weight, font_style)
1288    {
1289        attrs = attrs.style(resolved_style).weight(resolved_weight);
1290    } else {
1291        if let Some(font_weight) = font_weight {
1292            attrs = attrs.weight(GlyphonWeight(font_weight.0));
1293        }
1294
1295        if let Some(font_style) = font_style {
1296            attrs = attrs.style(match font_style {
1297                cranpose_ui::text::FontStyle::Normal => GlyphonStyle::Normal,
1298                cranpose_ui::text::FontStyle::Italic => GlyphonStyle::Italic,
1299            });
1300        }
1301    }
1302
1303    attrs = match letter_spacing {
1304        cranpose_ui::text::TextUnit::Em(value) => attrs.letter_spacing(value),
1305        cranpose_ui::text::TextUnit::Sp(value) if font_size_px > 0.0 => {
1306            attrs.letter_spacing((value * scale) / font_size_px)
1307        }
1308        _ => attrs,
1309    };
1310
1311    AttrsOwned::new(&attrs)
1312}
1313
1314// Text measurer implementation for WGPU
1315
1316// Text measurer implementation for WGPU
1317
1318#[derive(Clone)]
1319struct WgpuTextMeasurer {
1320    text_state: SharedTextSystemState,
1321    size_cache: TextSizeCache,
1322    prepared_layout_cache: PreparedTextLayoutCache,
1323}
1324
1325impl WgpuTextMeasurer {
1326    fn new(text_state: SharedTextSystemState) -> Self {
1327        Self {
1328            text_state,
1329            // Larger cache size (1024) reduces misses, FxHasher for faster lookups
1330            size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
1331            prepared_layout_cache: Rc::new(RefCell::new(LruCache::new(
1332                NonZeroUsize::new(256).unwrap(),
1333            ))),
1334        }
1335    }
1336
1337    fn text_buffer_key(
1338        node_id: Option<NodeId>,
1339        text: &str,
1340        font_size: f32,
1341        style_hash: u64,
1342    ) -> TextCacheKey {
1343        match node_id {
1344            Some(node_id) => TextCacheKey::for_node(node_id, font_size, style_hash),
1345            None => TextCacheKey::new(text, font_size, style_hash),
1346        }
1347    }
1348
1349    fn try_measure_with_options_fast_path(
1350        &self,
1351        node_id: Option<NodeId>,
1352        text: &cranpose_ui::text::AnnotatedString,
1353        style: &cranpose_ui::text::TextStyle,
1354        options: cranpose_ui::text::TextLayoutOptions,
1355        max_width: Option<f32>,
1356    ) -> Option<cranpose_ui::TextMetrics> {
1357        let options = options.normalized();
1358        let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0)?;
1359        if !Self::supports_fast_wrap_options(style, options) {
1360            return None;
1361        }
1362
1363        let text_str = text.text.as_str();
1364        let font_size = resolve_font_size(style);
1365        let line_height = resolve_effective_line_height(style, text, font_size);
1366        let style_hash = style.measurement_hash()
1367            ^ text.span_styles_hash()
1368            ^ (max_width.to_bits() as u64).rotate_left(17)
1369            ^ 0x9f4c_3314_2d5b_79e1;
1370        let size_int = (font_size * 100.0) as i32;
1371
1372        let mut hasher = FxHasher::default();
1373        text_str.hash(&mut hasher);
1374        let text_hash = hasher.finish();
1375        let cache_key = (text_hash, size_int, style_hash);
1376
1377        {
1378            let mut cache = self.size_cache.lock().unwrap();
1379            if let Some((cached_text, size)) = cache.get(&cache_key) {
1380                if cached_text == text_str {
1381                    let width = size.width.min(max_width);
1382                    let min_height = options.min_lines as f32 * line_height;
1383                    let height = size.height.max(min_height);
1384                    let line_count =
1385                        ((height / line_height).ceil() as usize).max(options.min_lines);
1386                    return Some(cranpose_ui::TextMetrics {
1387                        width,
1388                        height,
1389                        line_height,
1390                        line_count,
1391                    });
1392                }
1393            }
1394        }
1395
1396        let text_buffer_key = Self::text_buffer_key(node_id, text_str, font_size, style_hash);
1397        let mut text_state = self.text_state.lock().unwrap();
1398        let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1399
1400        let (size, wrapped_line_count) = {
1401            let (_, _, _, buffer) = shared_text_buffer_mut(
1402                text_cache,
1403                text_buffer_key,
1404                font_system,
1405                font_size,
1406                line_height,
1407            );
1408
1409            let _ = buffer.ensure(
1410                font_system,
1411                font_family_resolver,
1412                EnsureTextBufferParams {
1413                    annotated_text: text,
1414                    font_size_px: font_size,
1415                    line_height_px: line_height,
1416                    style_hash,
1417                    style,
1418                    scale: 1.0,
1419                },
1420            );
1421
1422            buffer
1423                .buffer
1424                .set_size(font_system, Some(max_width), Some(f32::MAX));
1425            buffer.buffer.shape_until_scroll(font_system, false);
1426            buffer.cached_size = None;
1427            let size = buffer.size();
1428            let line_count = buffer.buffer.layout_runs().count();
1429            (size, line_count)
1430        };
1431        drop(text_state);
1432
1433        let mut size_cache = self.size_cache.lock().unwrap();
1434        size_cache.put(cache_key, (text_str.to_string(), size));
1435
1436        let width = size.width.min(max_width);
1437        let min_height = options.min_lines as f32 * line_height;
1438        let height = size.height.max(min_height);
1439        let line_count = wrapped_line_count.max(options.min_lines).max(1);
1440
1441        Some(cranpose_ui::TextMetrics {
1442            width,
1443            height,
1444            line_height,
1445            line_count,
1446        })
1447    }
1448
1449    fn try_prepare_with_options_fast_path(
1450        &self,
1451        node_id: Option<NodeId>,
1452        text: &cranpose_ui::text::AnnotatedString,
1453        style: &cranpose_ui::text::TextStyle,
1454        options: cranpose_ui::text::TextLayoutOptions,
1455        max_width: Option<f32>,
1456    ) -> Option<cranpose_ui::text::PreparedTextLayout> {
1457        let options = options.normalized();
1458        let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0)?;
1459        if !Self::supports_fast_wrap_options(style, options) {
1460            return None;
1461        }
1462
1463        let text_str = text.text.as_str();
1464        let font_size = resolve_font_size(style);
1465        let line_height = resolve_effective_line_height(style, text, font_size);
1466        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1467
1468        let text_buffer_key = Self::text_buffer_key(node_id, text_str, font_size, style_hash);
1469        let mut text_state = self.text_state.lock().unwrap();
1470        let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1471
1472        let (size, wrapped_ranges) = {
1473            let (_, _, _, buffer) = shared_text_buffer_mut(
1474                text_cache,
1475                text_buffer_key,
1476                font_system,
1477                font_size,
1478                line_height,
1479            );
1480
1481            let _ = buffer.ensure(
1482                font_system,
1483                font_family_resolver,
1484                EnsureTextBufferParams {
1485                    annotated_text: text,
1486                    font_size_px: font_size,
1487                    line_height_px: line_height,
1488                    style_hash,
1489                    style,
1490                    scale: 1.0,
1491                },
1492            );
1493
1494            buffer
1495                .buffer
1496                .set_size(font_system, Some(max_width), Some(f32::MAX));
1497            buffer.buffer.shape_until_scroll(font_system, false);
1498            buffer.cached_size = None;
1499            let size = buffer.size();
1500            let wrapped_ranges = collect_wrapped_ranges(text_str, &buffer.buffer)?;
1501            (size, wrapped_ranges)
1502        };
1503
1504        let mut builder = cranpose_ui::text::AnnotatedString::builder();
1505        for (idx, (start, end)) in wrapped_ranges.iter().enumerate() {
1506            builder = builder.append_annotated_subsequence(text, *start..*end);
1507            if idx + 1 < wrapped_ranges.len() {
1508                builder = builder.append("\n");
1509            }
1510        }
1511        let wrapped_annotated = builder.to_annotated_string();
1512
1513        let line_count = wrapped_ranges.len().max(options.min_lines).max(1);
1514        let min_height = options.min_lines as f32 * line_height;
1515        let height = (line_count as f32 * line_height).max(min_height);
1516
1517        Some(cranpose_ui::text::PreparedTextLayout {
1518            text: wrapped_annotated,
1519            metrics: cranpose_ui::TextMetrics {
1520                width: size.width.min(max_width),
1521                height,
1522                line_height,
1523                line_count,
1524            },
1525            did_overflow: false,
1526        })
1527    }
1528
1529    fn supports_fast_wrap_options(
1530        style: &cranpose_ui::text::TextStyle,
1531        options: cranpose_ui::text::TextLayoutOptions,
1532    ) -> bool {
1533        if options.overflow != cranpose_ui::text::TextOverflow::Clip || !options.soft_wrap {
1534            return false;
1535        }
1536        if options.max_lines != usize::MAX {
1537            return false;
1538        }
1539
1540        let line_break = style
1541            .paragraph_style
1542            .line_break
1543            .take_or_else(|| cranpose_ui::text::LineBreak::Simple);
1544        let hyphens = style
1545            .paragraph_style
1546            .hyphens
1547            .take_or_else(|| cranpose_ui::text::Hyphens::None);
1548        line_break == cranpose_ui::text::LineBreak::Simple
1549            && hyphens == cranpose_ui::text::Hyphens::None
1550    }
1551}
1552
1553fn collect_wrapped_ranges(text: &str, buffer: &Buffer) -> Option<Vec<(usize, usize)>> {
1554    if text.is_empty() {
1555        return Some(vec![(0, 0)]);
1556    }
1557
1558    let text_lines: Vec<&str> = text.split('\n').collect();
1559    let line_offsets: Vec<(usize, usize)> = text_lines
1560        .iter()
1561        .scan(0usize, |line_start, line| {
1562            let start = *line_start;
1563            let end = start + line.len();
1564            *line_start = end.saturating_add(1);
1565            Some((start, end))
1566        })
1567        .collect();
1568
1569    let mut wrapped_ranges = Vec::new();
1570    for run in buffer.layout_runs() {
1571        let (line_start, line_end) = line_offsets
1572            .get(run.line_i)
1573            .copied()
1574            .unwrap_or((0usize, text.len()));
1575        let line_len = line_end.saturating_sub(line_start);
1576
1577        if run.glyphs.is_empty() {
1578            wrapped_ranges.push((line_start, line_start));
1579            continue;
1580        }
1581
1582        let mut local_start = line_len;
1583        let mut local_end = 0usize;
1584        for glyph in run.glyphs.iter() {
1585            local_start = local_start.min(glyph.start.min(line_len));
1586            local_end = local_end.max(glyph.end.min(line_len));
1587        }
1588
1589        let range_start = line_start.saturating_add(local_start.min(line_len));
1590        let range_end = line_start.saturating_add(local_end.min(line_len));
1591        if range_start > range_end
1592            || range_end > text.len()
1593            || !text.is_char_boundary(range_start)
1594            || !text.is_char_boundary(range_end)
1595        {
1596            return None;
1597        }
1598        wrapped_ranges.push((range_start, range_end));
1599    }
1600
1601    if wrapped_ranges.is_empty() {
1602        Some(vec![(0, text.len())])
1603    } else {
1604        Some(wrapped_ranges)
1605    }
1606}
1607
1608/// Convenience function for tests to initialize an accurate wgpu text measurer without launching a window.
1609pub fn setup_headless_text_measurer() {
1610    let mut font_system = FontSystem::new();
1611    let mut font_family_resolver_impl = WgpuFontFamilyResolver::default();
1612    font_family_resolver_impl.prime(&mut font_system);
1613    let text_state = Arc::new(Mutex::new(TextSystemState::from_parts(
1614        font_system,
1615        font_family_resolver_impl,
1616    )));
1617    cranpose_ui::text::set_text_measurer(WgpuTextMeasurer::new(text_state));
1618}
1619
1620// Base font size in logical units (dp) - shared between measurement and rendering
1621
1622impl TextMeasurer for WgpuTextMeasurer {
1623    fn measure(
1624        &self,
1625        text: &cranpose_ui::text::AnnotatedString,
1626        style: &cranpose_ui::text::TextStyle,
1627    ) -> cranpose_ui::TextMetrics {
1628        self.measure_for_node(None, text, style)
1629    }
1630
1631    fn measure_for_node(
1632        &self,
1633        node_id: Option<NodeId>,
1634        text: &cranpose_ui::text::AnnotatedString,
1635        style: &cranpose_ui::text::TextStyle,
1636    ) -> cranpose_ui::TextMetrics {
1637        let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1638        let telemetry_sequence = telemetry
1639            .map(|t| t.measure_calls.fetch_add(1, Ordering::Relaxed) + 1)
1640            .unwrap_or(0);
1641        let text_str = text.text.as_str();
1642        let font_size = resolve_font_size(style);
1643        let line_height = resolve_effective_line_height(style, text, font_size);
1644        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1645        let size_int = (font_size * 100.0) as i32;
1646
1647        // Calculate hash to avoid allocating String for lookup
1648        // FxHasher is ~3x faster than DefaultHasher for short strings
1649        let mut hasher = FxHasher::default();
1650        text_str.hash(&mut hasher);
1651        let text_hash = hasher.finish();
1652        let cache_key = (text_hash, size_int, style_hash);
1653
1654        // Check size cache first (fastest path)
1655        {
1656            let mut cache = self.size_cache.lock().unwrap();
1657            if let Some((cached_text, size)) = cache.get(&cache_key) {
1658                // Verify partial collision
1659                if cached_text == text_str {
1660                    if let Some(t) = telemetry {
1661                        t.size_cache_hits.fetch_add(1, Ordering::Relaxed);
1662                        maybe_report_text_measure_telemetry(telemetry_sequence);
1663                    }
1664                    let line_count = text_str.split('\n').count().max(1);
1665                    return cranpose_ui::TextMetrics {
1666                        width: size.width,
1667                        height: size.height,
1668                        line_height,
1669                        line_count,
1670                    };
1671                }
1672            }
1673        }
1674        if let Some(t) = telemetry {
1675            t.size_cache_misses.fetch_add(1, Ordering::Relaxed);
1676        }
1677
1678        // Get or create text buffer
1679        let text_buffer_key = Self::text_buffer_key(node_id, text_str, font_size, style_hash);
1680        let mut text_state = self.text_state.lock().unwrap();
1681        let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1682
1683        // Get or create buffer and calculate size
1684        let size = {
1685            let (text_cache_hit, evicted, cache_len, buffer) = shared_text_buffer_mut(
1686                text_cache,
1687                text_buffer_key,
1688                font_system,
1689                font_size,
1690                line_height,
1691            );
1692            if let Some(t) = telemetry {
1693                if text_cache_hit {
1694                    t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
1695                } else {
1696                    t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
1697                }
1698                if evicted {
1699                    t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
1700                }
1701                t.text_cache_occupancy
1702                    .store(cache_len as u64, Ordering::Relaxed);
1703            }
1704
1705            // Ensure buffer has the correct text
1706            let reshaped = buffer.ensure(
1707                font_system,
1708                font_family_resolver,
1709                EnsureTextBufferParams {
1710                    annotated_text: text,
1711                    font_size_px: font_size,
1712                    line_height_px: line_height,
1713                    style_hash,
1714                    style,
1715                    scale: 1.0,
1716                },
1717            );
1718            if let Some(t) = telemetry {
1719                if reshaped {
1720                    t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
1721                } else {
1722                    t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
1723                }
1724            }
1725
1726            // Calculate size if not cached
1727            buffer.size()
1728        };
1729
1730        drop(text_state);
1731
1732        // Cache the size result
1733        let mut size_cache = self.size_cache.lock().unwrap();
1734        // Only allocate string on cache miss
1735        size_cache.put(cache_key, (text_str.to_string(), size));
1736
1737        // Calculate line info for multiline support
1738        let line_count = text_str.split('\n').count().max(1);
1739        if telemetry.is_some() {
1740            maybe_report_text_measure_telemetry(telemetry_sequence);
1741        }
1742
1743        cranpose_ui::TextMetrics {
1744            width: size.width,
1745            height: size.height,
1746            line_height,
1747            line_count,
1748        }
1749    }
1750
1751    fn measure_with_options(
1752        &self,
1753        text: &cranpose_ui::text::AnnotatedString,
1754        style: &cranpose_ui::text::TextStyle,
1755        options: cranpose_ui::text::TextLayoutOptions,
1756        max_width: Option<f32>,
1757    ) -> cranpose_ui::TextMetrics {
1758        self.measure_with_options_for_node(None, text, style, options, max_width)
1759    }
1760
1761    fn measure_with_options_for_node(
1762        &self,
1763        node_id: Option<NodeId>,
1764        text: &cranpose_ui::text::AnnotatedString,
1765        style: &cranpose_ui::text::TextStyle,
1766        options: cranpose_ui::text::TextLayoutOptions,
1767        max_width: Option<f32>,
1768    ) -> cranpose_ui::TextMetrics {
1769        let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1770        let telemetry_sequence = telemetry
1771            .map(|t| t.measure_with_options_calls.fetch_add(1, Ordering::Relaxed) + 1)
1772            .unwrap_or(0);
1773        if let Some(metrics) =
1774            self.try_measure_with_options_fast_path(node_id, text, style, options, max_width)
1775        {
1776            if let Some(t) = telemetry {
1777                t.measure_fast_path_hits.fetch_add(1, Ordering::Relaxed);
1778                maybe_report_text_measure_telemetry(telemetry_sequence);
1779            }
1780            return metrics;
1781        }
1782        if let Some(t) = telemetry {
1783            t.measure_fast_path_misses.fetch_add(1, Ordering::Relaxed);
1784            maybe_report_text_measure_telemetry(telemetry_sequence);
1785        }
1786        self.prepare_with_options_for_node(node_id, text, style, options, max_width)
1787            .metrics
1788    }
1789
1790    fn prepare_with_options(
1791        &self,
1792        text: &cranpose_ui::text::AnnotatedString,
1793        style: &cranpose_ui::text::TextStyle,
1794        options: cranpose_ui::text::TextLayoutOptions,
1795        max_width: Option<f32>,
1796    ) -> cranpose_ui::text::PreparedTextLayout {
1797        self.prepare_with_options_for_node(None, text, style, options, max_width)
1798    }
1799
1800    fn prepare_with_options_for_node(
1801        &self,
1802        node_id: Option<NodeId>,
1803        text: &cranpose_ui::text::AnnotatedString,
1804        style: &cranpose_ui::text::TextStyle,
1805        options: cranpose_ui::text::TextLayoutOptions,
1806        max_width: Option<f32>,
1807    ) -> cranpose_ui::text::PreparedTextLayout {
1808        let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1809        let telemetry_sequence = telemetry
1810            .map(|t| t.prepare_with_options_calls.fetch_add(1, Ordering::Relaxed) + 1)
1811            .unwrap_or(0);
1812        let normalized_options = options.normalized();
1813        let normalized_max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
1814        let text_str = text.text.as_str();
1815        let font_size = resolve_font_size(style);
1816        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1817        let size_int = (font_size * 100.0) as i32;
1818
1819        let mut hasher = FxHasher::default();
1820        text_str.hash(&mut hasher);
1821        let text_hash = hasher.finish();
1822        let cache_key = PreparedTextLayoutCacheKey {
1823            text_hash,
1824            size_int,
1825            style_hash,
1826            options: normalized_options,
1827            max_width_bits: normalized_max_width.map(f32::to_bits),
1828        };
1829
1830        {
1831            let mut cache = self.prepared_layout_cache.borrow_mut();
1832            if let Some((cached_text, prepared)) = cache.get(&cache_key) {
1833                if cached_text == text_str {
1834                    if let Some(t) = telemetry {
1835                        t.prepared_layout_cache_hits.fetch_add(1, Ordering::Relaxed);
1836                        maybe_report_text_measure_telemetry(telemetry_sequence);
1837                    }
1838                    return prepared.clone();
1839                }
1840            }
1841        }
1842        if let Some(t) = telemetry {
1843            t.prepared_layout_cache_misses
1844                .fetch_add(1, Ordering::Relaxed);
1845        }
1846
1847        let prepared = if let Some(prepared) = self.try_prepare_with_options_fast_path(
1848            node_id,
1849            text,
1850            style,
1851            normalized_options,
1852            normalized_max_width,
1853        ) {
1854            if let Some(t) = telemetry {
1855                t.prepare_fast_path_hits.fetch_add(1, Ordering::Relaxed);
1856            }
1857            prepared
1858        } else {
1859            if let Some(t) = telemetry {
1860                t.prepare_fast_path_misses.fetch_add(1, Ordering::Relaxed);
1861            }
1862            cranpose_ui::text::measure::prepare_text_layout_with_measurer_for_node(
1863                self,
1864                node_id,
1865                text,
1866                style,
1867                normalized_options,
1868                normalized_max_width,
1869            )
1870        };
1871
1872        let mut cache = self.prepared_layout_cache.borrow_mut();
1873        cache.put(cache_key, (text_str.to_string(), prepared.clone()));
1874        if telemetry.is_some() {
1875            maybe_report_text_measure_telemetry(telemetry_sequence);
1876        }
1877
1878        prepared
1879    }
1880
1881    fn get_offset_for_position(
1882        &self,
1883        text: &cranpose_ui::text::AnnotatedString,
1884        style: &cranpose_ui::text::TextStyle,
1885        x: f32,
1886        y: f32,
1887    ) -> usize {
1888        let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1889        let telemetry_sequence = telemetry
1890            .map(|t| t.offset_calls.fetch_add(1, Ordering::Relaxed) + 1)
1891            .unwrap_or(0);
1892        let text_str = text.text.as_str();
1893        let font_size = resolve_font_size(style);
1894        let line_height = resolve_effective_line_height(style, text, font_size);
1895        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1896        if text_str.is_empty() {
1897            return 0;
1898        }
1899
1900        let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
1901
1902        let mut text_state = self.text_state.lock().unwrap();
1903        let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1904
1905        let (text_cache_hit, evicted, cache_len, buffer) =
1906            shared_text_buffer_mut(text_cache, cache_key, font_system, font_size, line_height);
1907        if let Some(t) = telemetry {
1908            if text_cache_hit {
1909                t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
1910            } else {
1911                t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
1912            }
1913            if evicted {
1914                t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
1915            }
1916            t.text_cache_occupancy
1917                .store(cache_len as u64, Ordering::Relaxed);
1918        }
1919
1920        let reshaped = buffer.ensure(
1921            font_system,
1922            font_family_resolver,
1923            EnsureTextBufferParams {
1924                annotated_text: text,
1925                font_size_px: font_size,
1926                line_height_px: line_height,
1927                style_hash,
1928                style,
1929                scale: 1.0,
1930            },
1931        );
1932        if let Some(t) = telemetry {
1933            if reshaped {
1934                t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
1935            } else {
1936                t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
1937            }
1938            maybe_report_text_measure_telemetry(telemetry_sequence);
1939        }
1940
1941        let line_offsets: Vec<(usize, usize)> = text_str
1942            .split('\n')
1943            .scan(0usize, |line_start, line| {
1944                let start = *line_start;
1945                let end = start + line.len();
1946                *line_start = end.saturating_add(1);
1947                Some((start, end))
1948            })
1949            .collect();
1950
1951        let mut target_line = None;
1952        let mut best_vertical_distance = f32::INFINITY;
1953
1954        for run in buffer.buffer.layout_runs() {
1955            let mut run_height = run.line_height;
1956            for glyph in run.glyphs.iter() {
1957                run_height = run_height.max(glyph.font_size * 1.4);
1958            }
1959
1960            let top = run.line_top;
1961            let bottom = top + run_height.max(1.0);
1962            let vertical_distance = if y < top {
1963                top - y
1964            } else if y > bottom {
1965                y - bottom
1966            } else {
1967                0.0
1968            };
1969
1970            if vertical_distance < best_vertical_distance {
1971                best_vertical_distance = vertical_distance;
1972                target_line = Some(run.line_i);
1973                if vertical_distance == 0.0 {
1974                    break;
1975                }
1976            }
1977        }
1978
1979        let fallback_line = (y / line_height).floor().max(0.0) as usize;
1980        let target_line = target_line
1981            .unwrap_or(fallback_line)
1982            .min(line_offsets.len().saturating_sub(1));
1983        let (line_start, line_end) = line_offsets
1984            .get(target_line)
1985            .copied()
1986            .unwrap_or((0, text_str.len()));
1987        let line_len = line_end.saturating_sub(line_start);
1988
1989        let mut best_offset = line_offsets
1990            .get(target_line)
1991            .map(|(_, end)| *end)
1992            .unwrap_or(text_str.len());
1993        let mut best_distance = f32::INFINITY;
1994        let mut found_glyph = false;
1995
1996        for run in buffer.buffer.layout_runs() {
1997            if run.line_i != target_line {
1998                continue;
1999            }
2000            for glyph in run.glyphs.iter() {
2001                found_glyph = true;
2002                let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
2003                let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
2004                let left_dist = (x - glyph.x).abs();
2005                if left_dist < best_distance {
2006                    best_distance = left_dist;
2007                    best_offset = glyph_start;
2008                }
2009
2010                let right_x = glyph.x + glyph.w;
2011                let right_dist = (x - right_x).abs();
2012                if right_dist < best_distance {
2013                    best_distance = right_dist;
2014                    best_offset = glyph_end;
2015                }
2016            }
2017        }
2018
2019        if !found_glyph {
2020            if let Some((start, end)) = line_offsets.get(target_line) {
2021                best_offset = if x <= 0.0 { *start } else { *end };
2022            }
2023        }
2024
2025        best_offset.min(text_str.len())
2026    }
2027
2028    fn get_cursor_x_for_offset(
2029        &self,
2030        text: &cranpose_ui::text::AnnotatedString,
2031        style: &cranpose_ui::text::TextStyle,
2032        offset: usize,
2033    ) -> f32 {
2034        let text = text.text.as_str();
2035        let clamped_offset = offset.min(text.len());
2036        if clamped_offset == 0 {
2037            return 0.0;
2038        }
2039
2040        // Measure text up to offset
2041        let prefix = &text[..clamped_offset];
2042        self.measure(&cranpose_ui::text::AnnotatedString::from(prefix), style)
2043            .width
2044    }
2045
2046    fn choose_auto_hyphen_break(
2047        &self,
2048        line: &str,
2049        style: &cranpose_ui::text::TextStyle,
2050        segment_start_char: usize,
2051        measured_break_char: usize,
2052    ) -> Option<usize> {
2053        choose_shared_auto_hyphen_break(line, style, segment_start_char, measured_break_char)
2054    }
2055
2056    fn layout(
2057        &self,
2058        text: &cranpose_ui::text::AnnotatedString,
2059        style: &cranpose_ui::text::TextStyle,
2060    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
2061        let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
2062        let telemetry_sequence = telemetry
2063            .map(|t| t.layout_calls.fetch_add(1, Ordering::Relaxed) + 1)
2064            .unwrap_or(0);
2065        let text_str = text.text.as_str();
2066        use cranpose_ui::text_layout_result::{
2067            GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult,
2068        };
2069
2070        let font_size = resolve_font_size(style);
2071        let line_height = resolve_effective_line_height(style, text, font_size);
2072        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
2073
2074        let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
2075        let mut text_state = self.text_state.lock().unwrap();
2076        let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
2077
2078        let (text_cache_hit, evicted, cache_len, buffer) = shared_text_buffer_mut(
2079            text_cache,
2080            cache_key.clone(),
2081            font_system,
2082            font_size,
2083            line_height,
2084        );
2085        if let Some(t) = telemetry {
2086            if text_cache_hit {
2087                t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
2088            } else {
2089                t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
2090            }
2091            if evicted {
2092                t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
2093            }
2094            t.text_cache_occupancy
2095                .store(cache_len as u64, Ordering::Relaxed);
2096        }
2097        let reshaped = buffer.ensure(
2098            font_system,
2099            font_family_resolver,
2100            EnsureTextBufferParams {
2101                annotated_text: text,
2102                font_size_px: font_size,
2103                line_height_px: line_height,
2104                style_hash,
2105                style,
2106                scale: 1.0,
2107            },
2108        );
2109        if let Some(t) = telemetry {
2110            if reshaped {
2111                t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
2112            } else {
2113                t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
2114            }
2115            maybe_report_text_measure_telemetry(telemetry_sequence);
2116        }
2117        let measured_size = buffer.size();
2118
2119        // Extract glyph positions from layout runs
2120        let mut glyph_x_positions = Vec::new();
2121        let mut char_to_byte = Vec::new();
2122        let mut glyph_layouts = Vec::new();
2123        let mut lines = Vec::new();
2124        let text_lines: Vec<&str> = text_str.split('\n').collect();
2125        let line_offsets: Vec<(usize, usize)> = text_lines
2126            .iter()
2127            .scan(0usize, |line_start, line| {
2128                let start = *line_start;
2129                let end = start + line.len();
2130                *line_start = end.saturating_add(1);
2131                Some((start, end))
2132            })
2133            .collect();
2134
2135        for run in buffer.buffer.layout_runs() {
2136            let line_idx = run.line_i;
2137            let run_height = run
2138                .glyphs
2139                .iter()
2140                .fold(run.line_height, |acc, glyph| acc.max(glyph.font_size * 1.4))
2141                .max(1.0);
2142
2143            for glyph in run.glyphs.iter() {
2144                let (line_start, line_end) = line_offsets
2145                    .get(line_idx)
2146                    .copied()
2147                    .unwrap_or((0, text_str.len()));
2148                let line_len = line_end.saturating_sub(line_start);
2149                let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
2150                let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
2151
2152                glyph_x_positions.push(glyph.x);
2153                char_to_byte.push(glyph_start);
2154                if glyph_end > glyph_start {
2155                    glyph_layouts.push(GlyphLayout {
2156                        line_index: line_idx,
2157                        start_offset: glyph_start,
2158                        end_offset: glyph_end,
2159                        x: glyph.x,
2160                        y: run.line_top,
2161                        width: glyph.w.max(0.0),
2162                        height: run_height,
2163                    });
2164                }
2165            }
2166        }
2167
2168        // Add end position
2169        glyph_x_positions.push(measured_size.width);
2170        char_to_byte.push(text_str.len());
2171
2172        // Build lines from text newlines
2173        let mut y = 0.0f32;
2174        let mut line_start = 0usize;
2175        for (i, line_text) in text_lines.iter().enumerate() {
2176            let line_end = if i == text_lines.len() - 1 {
2177                text_str.len()
2178            } else {
2179                line_start + line_text.len()
2180            };
2181
2182            lines.push(LineLayout {
2183                start_offset: line_start,
2184                end_offset: line_end,
2185                y,
2186                height: line_height,
2187            });
2188
2189            line_start = line_end + 1;
2190            y += line_height;
2191        }
2192
2193        if lines.is_empty() {
2194            lines.push(LineLayout {
2195                start_offset: 0,
2196                end_offset: 0,
2197                y: 0.0,
2198                height: line_height,
2199            });
2200        }
2201
2202        let metrics = cranpose_ui::TextMetrics {
2203            width: measured_size.width,
2204            height: measured_size.height,
2205            line_height,
2206            line_count: text_lines.len().max(1),
2207        };
2208        TextLayoutResult::new(
2209            text_str,
2210            TextLayoutData {
2211                width: metrics.width,
2212                height: metrics.height,
2213                line_height,
2214                glyph_x_positions,
2215                char_to_byte,
2216                lines,
2217                glyph_layouts,
2218            },
2219        )
2220    }
2221}
2222
2223#[cfg(test)]
2224mod tests {
2225    use super::*;
2226
2227    fn seeded_font_system_and_resolver() -> (FontSystem, WgpuFontFamilyResolver) {
2228        let mut db = glyphon::fontdb::Database::new();
2229        db.load_font_data(TEST_FONT.to_vec());
2230        let mut font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
2231        let mut resolver = WgpuFontFamilyResolver::default();
2232        resolver.prime(&mut font_system);
2233        (font_system, resolver)
2234    }
2235
2236    fn seeded_text_state() -> SharedTextSystemState {
2237        let (font_system, resolver) = seeded_font_system_and_resolver();
2238        Arc::new(Mutex::new(TextSystemState::from_parts(
2239            font_system,
2240            resolver,
2241        )))
2242    }
2243
2244    #[test]
2245    fn attrs_resolution_falls_back_for_missing_named_family() {
2246        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2247        let style = cranpose_ui::text::TextStyle {
2248            span_style: cranpose_ui::text::SpanStyle {
2249                font_family: Some(cranpose_ui::text::FontFamily::named("Missing Family Name")),
2250                ..Default::default()
2251            },
2252            ..Default::default()
2253        };
2254
2255        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2256        assert_eq!(attrs.family_owned, FamilyOwned::SansSerif);
2257    }
2258
2259    #[test]
2260    fn attrs_resolution_seeds_generic_families_from_loaded_fonts() {
2261        let (font_system, resolver) = seeded_font_system_and_resolver();
2262        assert!(
2263            resolver.generic_fallback_seeded,
2264            "expected generic fallback seeding after resolver prime"
2265        );
2266        let query = glyphon::fontdb::Query {
2267            families: &[glyphon::fontdb::Family::Monospace],
2268            weight: glyphon::fontdb::Weight::NORMAL,
2269            stretch: glyphon::fontdb::Stretch::Normal,
2270            style: glyphon::fontdb::Style::Normal,
2271        };
2272        assert!(
2273            font_system.db().query(&query).is_some(),
2274            "generic monospace query should resolve after fallback seeding"
2275        );
2276    }
2277
2278    #[test]
2279    fn attrs_resolution_named_family_lookup_is_case_insensitive() {
2280        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2281        let style = cranpose_ui::text::TextStyle {
2282            span_style: cranpose_ui::text::SpanStyle {
2283                font_family: Some(cranpose_ui::text::FontFamily::named("noto sans")),
2284                ..Default::default()
2285            },
2286            ..Default::default()
2287        };
2288
2289        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2290        assert!(
2291            matches!(attrs.family_owned, FamilyOwned::Name(_)),
2292            "case-insensitive family lookup should resolve to a concrete family name"
2293        );
2294    }
2295
2296    #[test]
2297    fn attrs_resolution_downgrades_missing_italic_to_available_style() {
2298        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2299        let style = cranpose_ui::text::TextStyle {
2300            span_style: cranpose_ui::text::SpanStyle {
2301                font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
2302                font_style: Some(cranpose_ui::text::FontStyle::Italic),
2303                ..Default::default()
2304            },
2305            ..Default::default()
2306        };
2307
2308        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2309        assert_eq!(
2310            attrs.style,
2311            GlyphonStyle::Normal,
2312            "missing italic face should downgrade to available style instead of panicking during shaping"
2313        );
2314    }
2315
2316    #[test]
2317    fn attrs_resolution_downgrades_missing_weight_to_available_weight() {
2318        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2319        let style = cranpose_ui::text::TextStyle {
2320            span_style: cranpose_ui::text::SpanStyle {
2321                font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
2322                font_weight: Some(cranpose_ui::text::FontWeight::BOLD),
2323                ..Default::default()
2324            },
2325            ..Default::default()
2326        };
2327
2328        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2329        assert_eq!(
2330            attrs.weight,
2331            GlyphonWeight(cranpose_ui::text::FontWeight::NORMAL.0),
2332            "missing bold face should downgrade to available weight instead of panicking during shaping"
2333        );
2334    }
2335
2336    #[test]
2337    fn select_text_shaping_uses_basic_for_simple_text_when_requested() {
2338        let style =
2339            cranpose_ui::text::TextStyle::from_paragraph_style(cranpose_ui::text::ParagraphStyle {
2340                platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
2341                    include_font_padding: None,
2342                    shaping: Some(cranpose_ui::text::TextShaping::Basic),
2343                }),
2344                ..Default::default()
2345            });
2346        let text = cranpose_ui::text::AnnotatedString::from("• Item 0042: basic markdown text");
2347
2348        assert_eq!(select_text_shaping(&text, &style), Shaping::Basic);
2349    }
2350
2351    #[test]
2352    fn select_text_shaping_falls_back_to_advanced_for_complex_text() {
2353        let style =
2354            cranpose_ui::text::TextStyle::from_paragraph_style(cranpose_ui::text::ParagraphStyle {
2355                platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
2356                    include_font_padding: None,
2357                    shaping: Some(cranpose_ui::text::TextShaping::Basic),
2358                }),
2359                ..Default::default()
2360            });
2361        let text = cranpose_ui::text::AnnotatedString::from("emoji 😀 requires fallback");
2362
2363        assert_eq!(select_text_shaping(&text, &style), Shaping::Advanced);
2364    }
2365
2366    #[test]
2367    fn layout_matches_measure_without_reentrant_mutex_lock() {
2368        use std::sync::mpsc;
2369        use std::time::Duration;
2370
2371        let (tx, rx) = mpsc::channel();
2372        std::thread::spawn(move || {
2373            let measurer = WgpuTextMeasurer::new(seeded_text_state());
2374            let text = cranpose_ui::text::AnnotatedString::from("hello\nworld");
2375            let style = cranpose_ui::text::TextStyle::default();
2376
2377            let layout = measurer.layout(&text, &style);
2378            let metrics = measurer.measure(&text, &style);
2379            tx.send((
2380                layout.width,
2381                layout.height,
2382                layout.lines.len(),
2383                metrics.width,
2384                metrics.height,
2385                metrics.line_count,
2386            ))
2387            .expect("send layout metrics");
2388        });
2389
2390        let (
2391            layout_width,
2392            layout_height,
2393            layout_lines,
2394            measured_width,
2395            measured_height,
2396            measured_lines,
2397        ) = rx
2398            .recv_timeout(Duration::from_secs(2))
2399            .expect("layout timed out; possible recursive mutex acquisition");
2400
2401        assert!((layout_width - measured_width).abs() < 0.5);
2402        assert!((layout_height - measured_height).abs() < 0.5);
2403        assert_eq!(layout_lines, measured_lines.max(1));
2404    }
2405
2406    #[test]
2407    fn measure_with_options_fast_path_wraps_to_width() {
2408        use std::sync::mpsc;
2409        use std::time::Duration;
2410
2411        let (tx, rx) = mpsc::channel();
2412        std::thread::spawn(move || {
2413            let measurer = WgpuTextMeasurer::new(seeded_text_state());
2414            let text = cranpose_ui::text::AnnotatedString::from("wrap me ".repeat(120));
2415            let style = cranpose_ui::text::TextStyle::default();
2416            let options = cranpose_ui::text::TextLayoutOptions {
2417                overflow: cranpose_ui::text::TextOverflow::Clip,
2418                soft_wrap: true,
2419                max_lines: usize::MAX,
2420                min_lines: 1,
2421            };
2422            let metrics =
2423                TextMeasurer::measure_with_options(&measurer, &text, &style, options, Some(120.0));
2424            tx.send((metrics.width, metrics.line_count))
2425                .expect("send wrapped metrics");
2426        });
2427
2428        let (width, line_count) = rx
2429            .recv_timeout(Duration::from_secs(2))
2430            .expect("measure_with_options timed out");
2431        assert!(width <= 120.5, "wrapped width should honor max width");
2432        assert!(line_count > 1, "wrapped text should produce multiple lines");
2433    }
2434
2435    #[test]
2436    fn prepare_with_options_reuses_cached_layout() {
2437        use std::sync::mpsc;
2438        use std::time::Duration;
2439
2440        let (tx, rx) = mpsc::channel();
2441        std::thread::spawn(move || {
2442            let measurer = WgpuTextMeasurer::new(seeded_text_state());
2443            let text = cranpose_ui::text::AnnotatedString::from(
2444                "This paragraph demonstrates wrapping with a cached prepared layout.",
2445            );
2446            let style = cranpose_ui::text::TextStyle::default();
2447            let options = cranpose_ui::text::TextLayoutOptions {
2448                overflow: cranpose_ui::text::TextOverflow::Clip,
2449                soft_wrap: true,
2450                max_lines: usize::MAX,
2451                min_lines: 1,
2452            };
2453
2454            let first =
2455                TextMeasurer::prepare_with_options(&measurer, &text, &style, options, Some(96.0));
2456            let first_cache_len = measurer.prepared_layout_cache.borrow().len();
2457
2458            let second =
2459                TextMeasurer::prepare_with_options(&measurer, &text, &style, options, Some(96.0));
2460            let second_cache_len = measurer.prepared_layout_cache.borrow().len();
2461
2462            tx.send((
2463                first == second,
2464                first.text.text.contains('\n'),
2465                first_cache_len,
2466                second_cache_len,
2467            ))
2468            .expect("send prepared layout cache result");
2469        });
2470
2471        let (same_layout, wrapped_text, first_cache_len, second_cache_len) = rx
2472            .recv_timeout(Duration::from_secs(2))
2473            .expect("prepare_with_options timed out");
2474        assert!(same_layout, "cached prepared layout should be identical");
2475        assert!(
2476            wrapped_text,
2477            "prepared layout should preserve wrapped text output"
2478        );
2479        assert_eq!(first_cache_len, 1);
2480        assert_eq!(second_cache_len, 1);
2481    }
2482
2483    #[test]
2484    fn measure_for_node_uses_node_cache_identity() {
2485        use std::sync::mpsc;
2486        use std::time::Duration;
2487
2488        let (tx, rx) = mpsc::channel();
2489        std::thread::spawn(move || {
2490            let measurer = WgpuTextMeasurer::new(seeded_text_state());
2491            let text = cranpose_ui::text::AnnotatedString::from("shared node identity");
2492            let style = cranpose_ui::text::TextStyle::default();
2493            let node_id = 4242;
2494
2495            let _ = TextMeasurer::measure_for_node(&measurer, Some(node_id), &text, &style);
2496
2497            let font_size = resolve_font_size(&style);
2498            let style_hash = style.measurement_hash() ^ text.span_styles_hash();
2499            let expected_key = TextCacheKey::for_node(node_id, font_size, style_hash);
2500            let text_state = measurer.text_state.lock().expect("text state lock");
2501            let cache = &text_state.text_cache;
2502
2503            tx.send((
2504                cache.len(),
2505                cache.contains(&expected_key),
2506                cache
2507                    .iter()
2508                    .any(|(key, _)| matches!(key.key, TextKey::Content(_))),
2509            ))
2510            .expect("send node cache result");
2511        });
2512
2513        let (cache_len, has_node_key, has_content_key) = rx
2514            .recv_timeout(Duration::from_secs(2))
2515            .expect("measure_for_node timed out");
2516        assert_eq!(cache_len, 1);
2517        assert!(
2518            has_node_key,
2519            "node-aware measurement should populate node cache key"
2520        );
2521        assert!(
2522            !has_content_key,
2523            "node-aware measurement should not populate content cache keys"
2524        );
2525    }
2526
2527    #[test]
2528    fn renderer_measurement_keeps_render_text_cache_empty() {
2529        use std::sync::mpsc;
2530        use std::time::Duration;
2531
2532        let (tx, rx) = mpsc::channel();
2533        std::thread::spawn(move || {
2534            let renderer = WgpuRenderer::new(&[TEST_FONT]);
2535            let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
2536            let style = cranpose_ui::text::TextStyle::default();
2537
2538            let _ = cranpose_ui::text::measure_text(&text, &style);
2539
2540            tx.send(renderer.render_text_state.text_cache.len())
2541                .expect("send render text cache size");
2542        });
2543
2544        let render_text_cache_len = rx
2545            .recv_timeout(Duration::from_secs(2))
2546            .expect("renderer measurement isolation timed out");
2547        assert_eq!(
2548            render_text_cache_len, 0,
2549            "measurement should not populate render-owned text cache"
2550        );
2551    }
2552
2553    #[test]
2554    fn shared_text_cache_uses_bounded_lru_eviction() {
2555        let mut font_system = FontSystem::new();
2556        let mut cache = LruCache::new(NonZeroUsize::new(SHARED_TEXT_CACHE_CAPACITY).unwrap());
2557
2558        for index in 0..=SHARED_TEXT_CACHE_CAPACITY {
2559            let text = format!("cache-entry-{index}");
2560            let key = TextCacheKey::new(text.as_str(), 14.0, 7);
2561            let _ = shared_text_buffer_mut(&mut cache, key, &mut font_system, 14.0, 16.0);
2562        }
2563
2564        let oldest = TextCacheKey::new("cache-entry-0", 14.0, 7);
2565        let newest = TextCacheKey::new(
2566            format!("cache-entry-{}", SHARED_TEXT_CACHE_CAPACITY).as_str(),
2567            14.0,
2568            7,
2569        );
2570
2571        assert_eq!(cache.len(), SHARED_TEXT_CACHE_CAPACITY);
2572        assert!(!cache.contains(&oldest));
2573        assert!(cache.contains(&newest));
2574    }
2575
2576    // Font bytes used by tests — the same file the demo app ships.
2577    static TEST_FONT: &[u8] =
2578        include_bytes!("../../../../apps/desktop-demo/assets/NotoSansMerged.ttf");
2579
2580    fn empty_font_system() -> FontSystem {
2581        let db = glyphon::fontdb::Database::new();
2582        FontSystem::new_with_locale_and_db("en-US".to_string(), db)
2583    }
2584
2585    #[test]
2586    fn load_fonts_populates_face_db() {
2587        let mut fs = empty_font_system();
2588        load_fonts(&mut fs, &[TEST_FONT]);
2589        assert!(
2590            fs.db().faces().count() > 0,
2591            "load_fonts must load at least one face"
2592        );
2593    }
2594
2595    #[test]
2596    fn load_fonts_empty_slice_leaves_db_empty() {
2597        let mut fs = empty_font_system();
2598        load_fonts(&mut fs, &[]);
2599        assert_eq!(
2600            fs.db().faces().count(),
2601            0,
2602            "empty slice must not load any faces"
2603        );
2604    }
2605
2606    #[test]
2607    fn resolver_logs_warning_if_font_db_is_empty() {
2608        // With no fonts loaded the resolver should not panic; it just warns.
2609        let mut font_system = empty_font_system();
2610        let mut resolver = WgpuFontFamilyResolver::default();
2611        let span_style = cranpose_ui::text::SpanStyle::default();
2612        // Must not panic even with an empty DB.
2613        let _ = resolver.resolve_family_owned(&mut font_system, &span_style);
2614    }
2615
2616    #[test]
2617    #[cfg(not(target_arch = "wasm32"))]
2618    fn attrs_resolution_loads_file_backed_family_from_path() {
2619        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2620        let nonce = std::time::SystemTime::now()
2621            .duration_since(std::time::UNIX_EPOCH)
2622            .map(|duration| duration.as_nanos())
2623            .unwrap_or_default();
2624        let unique_path = format!(
2625            "{}/cranpose-font-resolver-{}-{}.ttf",
2626            std::env::temp_dir().display(),
2627            std::process::id(),
2628            nonce
2629        );
2630        std::fs::write(&unique_path, TEST_FONT).expect("write font fixture");
2631
2632        let style = cranpose_ui::text::TextStyle {
2633            span_style: cranpose_ui::text::SpanStyle {
2634                font_family: Some(cranpose_ui::text::FontFamily::file_backed(vec![
2635                    cranpose_ui::text::FontFile::new(unique_path.clone()),
2636                ])),
2637                ..Default::default()
2638            },
2639            ..Default::default()
2640        };
2641
2642        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2643        assert!(
2644            matches!(attrs.family_owned, FamilyOwned::Name(_)),
2645            "file-backed font family should resolve to an installed family name"
2646        );
2647
2648        let _ = std::fs::remove_file(&unique_path);
2649    }
2650}