Skip to main content

cranpose_render_wgpu/
lib.rs

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