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