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