Skip to main content

cranpose_render_wgpu/
lib.rs

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