Skip to main content

cranpose_render_wgpu/
lib.rs

1//! WGPU renderer backend for GPU-accelerated 2D rendering.
2//!
3//! This renderer uses WGPU for cross-platform GPU support across
4//! desktop (Windows/Mac/Linux), web (WebGPU), and mobile (Android/iOS).
5
6mod effect_renderer;
7pub(crate) mod gpu_stats;
8mod offscreen;
9mod pipeline;
10mod render;
11mod scene;
12mod shader_cache;
13mod shaders;
14
15pub use scene::{BackdropLayer, ClickAction, DrawShape, HitRegion, ImageDraw, Scene, TextDraw};
16
17use cranpose_core::{MemoryApplier, NodeId};
18use cranpose_render_common::{
19    text_hyphenation::choose_auto_hyphen_break as choose_shared_auto_hyphen_break, RenderScene,
20    Renderer,
21};
22use cranpose_ui::{set_text_measurer, LayoutTree, TextMeasurer};
23use cranpose_ui_graphics::Size;
24use glyphon::{
25    Attrs, AttrsOwned, Buffer, FamilyOwned, FontSystem, Metrics, Shaping, Style as GlyphonStyle,
26    Weight as GlyphonWeight,
27};
28use lru::LruCache;
29use render::GpuRenderer;
30use rustc_hash::FxHasher;
31use std::collections::{HashMap, HashSet};
32use std::hash::{Hash, Hasher};
33use std::num::NonZeroUsize;
34use std::rc::Rc;
35use std::sync::{Arc, Mutex};
36
37/// Size-only cache for ultra-fast text measurement lookups.
38/// Key: (text_hash, font_size_fixed_point, style_hash)
39/// Value: (text_content, size) - text stored to handle hash collisions
40type TextSizeCache = Arc<Mutex<LruCache<(u64, i32, u64), (String, Size)>>>;
41
42#[derive(Debug)]
43pub enum WgpuRendererError {
44    Layout(String),
45    Wgpu(String),
46}
47
48/// CPU-readable RGBA frame captured from the renderer output.
49#[derive(Debug, Clone)]
50pub struct CapturedFrame {
51    pub width: u32,
52    pub height: u32,
53    pub pixels: Vec<u8>,
54}
55
56/// Unified hash key for text caching - shared between measurement and rendering.
57#[derive(Clone, PartialEq, Eq, Hash)]
58pub(crate) enum TextKey {
59    Content(String),
60    Node(NodeId),
61}
62
63#[derive(Clone, PartialEq, Eq)]
64pub(crate) struct TextCacheKey {
65    key: TextKey,
66    scale_bits: u32, // f32 as bits for hashing
67    style_hash: u64,
68}
69
70impl TextCacheKey {
71    fn new(text: &str, font_size: f32, style_hash: u64) -> Self {
72        Self {
73            key: TextKey::Content(text.to_string()),
74            scale_bits: font_size.to_bits(),
75            style_hash,
76        }
77    }
78
79    fn for_node(node_id: NodeId, font_size: f32, style_hash: u64) -> Self {
80        Self {
81            key: TextKey::Node(node_id),
82            scale_bits: font_size.to_bits(),
83            style_hash,
84        }
85    }
86}
87
88impl Hash for TextCacheKey {
89    fn hash<H: Hasher>(&self, state: &mut H) {
90        self.key.hash(state);
91        self.scale_bits.hash(state);
92        self.style_hash.hash(state);
93    }
94}
95
96/// Cached text buffer shared between measurement and rendering
97pub(crate) struct SharedTextBuffer {
98    pub(crate) buffer: Buffer,
99    text: String,
100    font_size: f32,
101    line_height: f32,
102    style_hash: u64,
103    /// Cached size to avoid recalculating on every access
104    cached_size: Option<Size>,
105}
106
107pub(crate) struct EnsureTextBufferParams<'a> {
108    pub(crate) annotated_text: &'a cranpose_ui::text::AnnotatedString,
109    pub(crate) font_size_px: f32,
110    pub(crate) line_height_px: f32,
111    pub(crate) style_hash: u64,
112    pub(crate) style: &'a cranpose_ui::text::TextStyle,
113    pub(crate) scale: f32,
114}
115
116impl SharedTextBuffer {
117    /// Ensure the buffer has the correct text and font_size, only reshaping if needed
118    pub(crate) fn ensure(
119        &mut self,
120        font_system: &mut FontSystem,
121        font_family_resolver: &mut WgpuFontFamilyResolver,
122        params: EnsureTextBufferParams<'_>,
123    ) {
124        let annotated_text = params.annotated_text;
125        let font_size_px = params.font_size_px;
126        let line_height_px = params.line_height_px;
127        let style_hash = params.style_hash;
128        let style = params.style;
129        let scale = params.scale;
130        let text_str = annotated_text.text.as_str();
131        let text_changed = self.text != text_str;
132        let font_changed = (self.font_size - font_size_px).abs() > 0.1;
133        let line_height_changed = (self.line_height - line_height_px).abs() > 0.1;
134        let style_changed = self.style_hash != style_hash;
135
136        // Only reshape if something actually changed
137        if !text_changed && !font_changed && !line_height_changed && !style_changed {
138            return; // Nothing changed, skip reshape
139        }
140
141        // Set metrics and size for unlimited layout
142        let metrics = Metrics::new(font_size_px, line_height_px);
143        self.buffer.set_metrics(font_system, metrics);
144        self.buffer
145            .set_size(font_system, Some(f32::MAX), Some(f32::MAX));
146
147        let unscaled_base_size = if scale > 0.0 {
148            font_size_px / scale
149        } else {
150            14.0
151        };
152
153        // Set text and shape
154        if annotated_text.span_styles.is_empty() {
155            let attrs = attrs_from_text_style(
156                style,
157                unscaled_base_size,
158                scale,
159                font_system,
160                font_family_resolver,
161            );
162            let attrs_ref = attrs.as_attrs();
163            self.buffer
164                .set_text(font_system, text_str, &attrs_ref, Shaping::Advanced);
165        } else {
166            let boundaries = annotated_text.span_boundaries();
167            let mut rich_spans: Vec<(&str, AttrsOwned)> =
168                Vec::with_capacity(boundaries.len().saturating_sub(1));
169            for window in boundaries.windows(2) {
170                let start = window[0];
171                let end = window[1];
172                if start == end {
173                    continue;
174                }
175                let slice = &annotated_text.text[start..end];
176                let mut merged_style = style.span_style.clone();
177                for span in &annotated_text.span_styles {
178                    if span.range.start <= start && span.range.end >= end {
179                        merged_style = merged_style.merge(&span.item);
180                    }
181                }
182                let mut chunk_text_style = style.clone();
183                chunk_text_style.span_style = merged_style;
184                let attrs = attrs_from_text_style(
185                    &chunk_text_style,
186                    unscaled_base_size,
187                    scale,
188                    font_system,
189                    font_family_resolver,
190                );
191                rich_spans.push((slice, attrs));
192            }
193            let default_attrs = attrs_from_text_style(
194                style,
195                unscaled_base_size,
196                scale,
197                font_system,
198                font_family_resolver,
199            );
200            let default_attrs_ref = default_attrs.as_attrs();
201            self.buffer.set_rich_text(
202                font_system,
203                rich_spans
204                    .iter()
205                    .map(|(slice, attrs)| (*slice, attrs.as_attrs())),
206                &default_attrs_ref,
207                Shaping::Advanced,
208                None,
209            );
210        }
211        self.buffer.shape_until_scroll(font_system, false);
212
213        // Update cached values
214        self.text.clear();
215        self.text.push_str(text_str);
216        self.font_size = font_size_px;
217        self.line_height = line_height_px;
218        self.style_hash = style_hash;
219        self.cached_size = None; // Invalidate size cache
220    }
221
222    /// Get or calculate the size of the shaped text
223    pub(crate) fn size(&mut self) -> Size {
224        if let Some(size) = self.cached_size {
225            return size;
226        }
227
228        // Calculate size from buffer
229        let mut max_width = 0.0f32;
230        let mut total_height = 0.0f32;
231        for run in self.buffer.layout_runs() {
232            let mut run_height = run.line_height;
233            for glyph in run.glyphs {
234                let physical_height = glyph.font_size * 1.4; // 1.4 is our default line_height modifier
235                if physical_height > run_height {
236                    run_height = physical_height;
237                }
238            }
239
240            max_width = max_width.max(run.line_w);
241            total_height = total_height.max(run.line_top + run_height);
242        }
243
244        let size = Size {
245            width: max_width,
246            height: total_height,
247        };
248
249        self.cached_size = Some(size);
250        size
251    }
252}
253
254/// Shared cache for text buffers used by both measurement and rendering
255pub(crate) type SharedTextCache = Arc<Mutex<HashMap<TextCacheKey, SharedTextBuffer>>>;
256
257#[derive(Clone, Debug, PartialEq, Eq, Hash)]
258struct TypefaceRequest {
259    font_family: Option<cranpose_ui::text::FontFamily>,
260    font_weight: cranpose_ui::text::FontWeight,
261    font_style: cranpose_ui::text::FontStyle,
262    font_synthesis: cranpose_ui::text::FontSynthesis,
263}
264
265impl TypefaceRequest {
266    fn from_span_style(span_style: &cranpose_ui::text::SpanStyle) -> Self {
267        Self {
268            font_family: span_style.font_family.clone(),
269            font_weight: span_style.font_weight.unwrap_or_default(),
270            font_style: span_style.font_style.unwrap_or_default(),
271            font_synthesis: span_style.font_synthesis.unwrap_or_default(),
272        }
273    }
274}
275
276#[derive(Default)]
277struct WgpuFontFamilyResolver {
278    request_cache: HashMap<TypefaceRequest, FamilyOwned>,
279    loaded_typeface_paths: HashMap<String, String>,
280    unavailable_typeface_paths: HashSet<String>,
281    available_family_names: HashMap<String, String>,
282    indexed_face_count: usize,
283    generic_fallback_seeded: bool,
284}
285
286impl WgpuFontFamilyResolver {
287    fn prime(&mut self, font_system: &mut FontSystem) {
288        self.ensure_non_empty_font_db(font_system);
289        self.ensure_family_index(font_system);
290        self.ensure_generic_fallbacks(font_system);
291    }
292
293    fn resolve_family_owned(
294        &mut self,
295        font_system: &mut FontSystem,
296        span_style: &cranpose_ui::text::SpanStyle,
297    ) -> FamilyOwned {
298        self.ensure_non_empty_font_db(font_system);
299        self.ensure_family_index(font_system);
300        self.ensure_generic_fallbacks(font_system);
301
302        let request = TypefaceRequest::from_span_style(span_style);
303        if let Some(cached) = self.request_cache.get(&request) {
304            return cached.clone();
305        }
306
307        let resolved = self.resolve_family_owned_uncached(font_system, &request);
308        self.request_cache.insert(request, resolved.clone());
309        resolved
310    }
311
312    fn ensure_non_empty_font_db(&mut self, font_system: &mut FontSystem) {
313        if font_system.db().faces().next().is_none() {
314            log::warn!("Font database is empty; text will not render. Provide fonts via AppLauncher::with_fonts.");
315        }
316    }
317
318    fn resolve_family_owned_uncached(
319        &mut self,
320        font_system: &mut FontSystem,
321        request: &TypefaceRequest,
322    ) -> FamilyOwned {
323        use cranpose_ui::text::FontFamily;
324
325        match request.font_family.as_ref() {
326            None | Some(FontFamily::Default | FontFamily::SansSerif) => FamilyOwned::SansSerif,
327            Some(FontFamily::Serif) => FamilyOwned::Serif,
328            Some(FontFamily::Monospace) => FamilyOwned::Monospace,
329            Some(FontFamily::Cursive) => FamilyOwned::Cursive,
330            Some(FontFamily::Fantasy) => FamilyOwned::Fantasy,
331            Some(FontFamily::Named(name)) => self
332                .canonical_family_name(name)
333                .map(|resolved| FamilyOwned::Name(resolved.into()))
334                .unwrap_or(FamilyOwned::SansSerif),
335            Some(FontFamily::FileBacked(file_backed)) => self
336                .resolve_file_backed_family(font_system, file_backed, request)
337                .unwrap_or(FamilyOwned::SansSerif),
338            Some(FontFamily::LoadedTypeface(typeface_path)) => self
339                .resolve_loaded_typeface_family(font_system, typeface_path.path.as_str())
340                .unwrap_or(FamilyOwned::SansSerif),
341        }
342    }
343
344    fn resolve_file_backed_family(
345        &mut self,
346        font_system: &mut FontSystem,
347        file_backed: &cranpose_ui::text::FileBackedFontFamily,
348        request: &TypefaceRequest,
349    ) -> Option<FamilyOwned> {
350        let mut candidates: Vec<&cranpose_ui::text::FontFile> = file_backed.fonts.iter().collect();
351        candidates.sort_by_key(|candidate| {
352            let style_penalty = if candidate.style == request.font_style {
353                0u32
354            } else {
355                10_000u32
356            };
357            let weight_penalty =
358                (i32::from(candidate.weight.0) - i32::from(request.font_weight.0)).unsigned_abs();
359            style_penalty + weight_penalty
360        });
361
362        for candidate in candidates {
363            let Some(family_name) = self.load_typeface_path(font_system, candidate.path.as_str())
364            else {
365                continue;
366            };
367            if let Some(canonical) = self.canonical_family_name(family_name.as_str()) {
368                return Some(FamilyOwned::Name(canonical.into()));
369            }
370        }
371        None
372    }
373
374    fn resolve_loaded_typeface_family(
375        &mut self,
376        font_system: &mut FontSystem,
377        path: &str,
378    ) -> Option<FamilyOwned> {
379        self.load_typeface_path(font_system, path)
380            .map(|family_name| {
381                self.canonical_family_name(family_name.as_str())
382                    .map(|resolved| FamilyOwned::Name(resolved.into()))
383                    .unwrap_or(FamilyOwned::SansSerif)
384            })
385    }
386
387    fn ensure_family_index(&mut self, font_system: &FontSystem) {
388        let face_count = font_system.db().faces().count();
389        if face_count == self.indexed_face_count {
390            return;
391        }
392
393        self.available_family_names.clear();
394        for face in font_system.db().faces() {
395            for (family_name, _) in &face.families {
396                self.available_family_names
397                    .entry(family_name.to_lowercase())
398                    .or_insert_with(|| family_name.clone());
399            }
400        }
401        self.indexed_face_count = face_count;
402        self.request_cache.clear();
403        self.generic_fallback_seeded = false;
404    }
405
406    fn canonical_family_name(&self, family_name: &str) -> Option<String> {
407        self.available_family_names
408            .get(&family_name.to_lowercase())
409            .cloned()
410    }
411
412    fn ensure_generic_fallbacks(&mut self, font_system: &mut FontSystem) {
413        if self.generic_fallback_seeded {
414            return;
415        }
416
417        let Some(primary_family) = font_system
418            .db()
419            .faces()
420            .find_map(|face| face.families.first().map(|(name, _)| name.clone()))
421        else {
422            return;
423        };
424
425        let db = font_system.db_mut();
426        db.set_sans_serif_family(primary_family.clone());
427        db.set_serif_family(primary_family.clone());
428        db.set_monospace_family(primary_family.clone());
429        db.set_cursive_family(primary_family.clone());
430        db.set_fantasy_family(primary_family);
431
432        self.generic_fallback_seeded = true;
433        self.request_cache.clear();
434    }
435
436    fn load_typeface_path(&mut self, font_system: &mut FontSystem, path: &str) -> Option<String> {
437        if let Some(family_name) = self.loaded_typeface_paths.get(path) {
438            return Some(family_name.clone());
439        }
440
441        if self.unavailable_typeface_paths.contains(path) {
442            return None;
443        }
444
445        #[cfg(target_arch = "wasm32")]
446        let _ = font_system;
447
448        #[cfg(target_arch = "wasm32")]
449        {
450            log::warn!(
451                "Typeface path '{}' requested on wasm target; filesystem font loading is unavailable",
452                path
453            );
454            self.unavailable_typeface_paths.insert(path.to_string());
455            return None;
456        }
457
458        #[cfg(not(target_arch = "wasm32"))]
459        {
460            let font_bytes = match std::fs::read(path) {
461                Ok(bytes) => bytes,
462                Err(error) => {
463                    log::warn!("Failed to read typeface path '{}': {}", path, error);
464                    self.unavailable_typeface_paths.insert(path.to_string());
465                    return None;
466                }
467            };
468            let preferred_family = primary_family_name_from_bytes(font_bytes.as_slice());
469            let previous_face_count = font_system.db().faces().count();
470            font_system.db_mut().load_font_data(font_bytes);
471
472            self.ensure_family_index(font_system);
473
474            let mut resolved_family =
475                preferred_family.and_then(|name| self.canonical_family_name(name.as_str()));
476            if resolved_family.is_none() && self.indexed_face_count > previous_face_count {
477                resolved_family = font_system
478                    .db()
479                    .faces()
480                    .skip(previous_face_count)
481                    .find_map(|face| face.families.first().map(|(name, _)| name.clone()));
482            }
483
484            let Some(family_name) = resolved_family else {
485                log::warn!(
486                    "Typeface path '{}' loaded but no usable family name was resolved",
487                    path
488                );
489                self.unavailable_typeface_paths.insert(path.to_string());
490                return None;
491            };
492            let family_name = self
493                .canonical_family_name(family_name.as_str())
494                .unwrap_or(family_name);
495
496            self.loaded_typeface_paths
497                .insert(path.to_string(), family_name.clone());
498            self.unavailable_typeface_paths.remove(path);
499            Some(family_name)
500        }
501    }
502}
503
504fn load_fonts(font_system: &mut FontSystem, fonts: &[&[u8]]) {
505    for (i, font_data) in fonts.iter().enumerate() {
506        log::info!("Loading font #{}, size: {} bytes", i, font_data.len());
507        font_system.db_mut().load_font_data(font_data.to_vec());
508    }
509    log::info!("Total font faces loaded: {}", font_system.db().faces().count());
510}
511
512
513#[cfg(not(target_arch = "wasm32"))]
514fn primary_family_name_from_bytes(bytes: &[u8]) -> Option<String> {
515    let face = ttf_parser::Face::parse(bytes, 0).ok()?;
516    let mut fallback_family = None;
517    for name in face.names() {
518        if name.name_id == ttf_parser::name_id::TYPOGRAPHIC_FAMILY {
519            let resolved = name.to_string().filter(|value| !value.is_empty());
520            if resolved.is_some() {
521                return resolved;
522            }
523        }
524        if fallback_family.is_none() && name.name_id == ttf_parser::name_id::FAMILY {
525            fallback_family = name.to_string().filter(|value| !value.is_empty());
526        }
527    }
528    fallback_family
529}
530
531type SharedFontFamilyResolver = Arc<Mutex<WgpuFontFamilyResolver>>;
532
533/// Trim text cache if it exceeds MAX_CACHE_ITEMS.
534/// Removes the oldest half of entries when limit is reached.
535pub(crate) fn trim_text_cache(cache: &mut HashMap<TextCacheKey, SharedTextBuffer>) {
536    if cache.len() > MAX_CACHE_ITEMS {
537        let target_size = MAX_CACHE_ITEMS / 2;
538        let to_remove = cache.len() - target_size;
539
540        // Remove oldest entries (arbitrary keys from the front)
541        let keys_to_remove: Vec<TextCacheKey> = cache.keys().take(to_remove).cloned().collect();
542
543        for key in keys_to_remove {
544            cache.remove(&key);
545        }
546
547        log::debug!(
548            "Trimmed text cache from {} to {} entries",
549            cache.len() + to_remove,
550            cache.len()
551        );
552    }
553}
554
555/// Maximum number of cached text buffers before trimming occurs
556const MAX_CACHE_ITEMS: usize = 256;
557
558/// WGPU-based renderer for GPU-accelerated 2D rendering.
559///
560/// This renderer supports:
561/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
562/// - Gradients (solid, linear, radial)
563/// - GPU text rendering via glyphon
564/// - Cross-platform support (Desktop, Web, Mobile)
565pub struct WgpuRenderer {
566    scene: Scene,
567    gpu_renderer: Option<GpuRenderer>,
568    font_system: Arc<Mutex<FontSystem>>,
569    font_family_resolver: SharedFontFamilyResolver,
570    /// Shared text buffer cache used by both measurement and rendering
571    text_cache: SharedTextCache,
572    /// Root scale factor for text rendering (use for density scaling)
573    root_scale: f32,
574}
575
576impl WgpuRenderer {
577    /// Create a new WGPU renderer.
578    ///
579    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
580    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
581    ///
582    /// Call [`init_gpu`][Self::init_gpu] before rendering.
583    pub fn new(fonts: &[&[u8]]) -> Self {
584        let mut font_system = FontSystem::new();
585
586        // On Android never load system fonts: modern Android ships variable Roboto
587        // which can cause rasterization corruption or font-ID conflicts with glyphon.
588        #[cfg(target_os = "android")]
589        log::info!("Skipping Android system fonts – using application-provided fonts only");
590
591        load_fonts(&mut font_system, fonts);
592
593        let mut font_family_resolver_impl = WgpuFontFamilyResolver::default();
594        font_family_resolver_impl.prime(&mut font_system);
595
596        let font_system = Arc::new(Mutex::new(font_system));
597        let font_family_resolver = Arc::new(Mutex::new(font_family_resolver_impl));
598        let text_cache = Arc::new(Mutex::new(HashMap::new()));
599
600        let text_measurer = WgpuTextMeasurer::new(
601            font_system.clone(),
602            text_cache.clone(),
603            font_family_resolver.clone(),
604        );
605        set_text_measurer(text_measurer.clone());
606
607        Self {
608            scene: Scene::new(),
609            gpu_renderer: None,
610            font_system,
611            font_family_resolver,
612            text_cache,
613            root_scale: 1.0,
614        }
615    }
616
617    /// Initialize GPU resources with a WGPU device and queue.
618    pub fn init_gpu(
619        &mut self,
620        device: Arc<wgpu::Device>,
621        queue: Arc<wgpu::Queue>,
622        surface_format: wgpu::TextureFormat,
623    ) {
624        self.gpu_renderer = Some(GpuRenderer::new(
625            device,
626            queue,
627            surface_format,
628            self.font_system.clone(),
629            self.font_family_resolver.clone(),
630            self.text_cache.clone(),
631        ));
632    }
633
634    /// Set root scale factor for text rendering (e.g., density scaling on Android)
635    pub fn set_root_scale(&mut self, scale: f32) {
636        self.root_scale = scale;
637    }
638
639    /// Render the scene to a texture view.
640    pub fn render(
641        &mut self,
642        view: &wgpu::TextureView,
643        width: u32,
644        height: u32,
645    ) -> Result<(), WgpuRendererError> {
646        if let Some(gpu_renderer) = &mut self.gpu_renderer {
647            gpu_renderer
648                .render(
649                    view,
650                    &self.scene.shapes,
651                    &self.scene.images,
652                    &self.scene.texts,
653                    &self.scene.shadow_draws,
654                    &self.scene.effect_layers,
655                    &self.scene.backdrop_layers,
656                    width,
657                    height,
658                    self.root_scale,
659                )
660                .map_err(WgpuRendererError::Wgpu)
661        } else {
662            Err(WgpuRendererError::Wgpu(
663                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
664            ))
665        }
666    }
667
668    /// Render the current scene into an RGBA pixel buffer for robot tests.
669    ///
670    /// Uses the renderer's configured root scale.
671    pub fn capture_frame(
672        &mut self,
673        width: u32,
674        height: u32,
675    ) -> Result<CapturedFrame, WgpuRendererError> {
676        self.capture_frame_with_scale(width, height, self.root_scale)
677    }
678
679    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
680    pub fn capture_frame_with_scale(
681        &mut self,
682        width: u32,
683        height: u32,
684        root_scale: f32,
685    ) -> Result<CapturedFrame, WgpuRendererError> {
686        if let Some(gpu_renderer) = &mut self.gpu_renderer {
687            let pixels = gpu_renderer
688                .render_to_rgba_pixels(
689                    &self.scene.shapes,
690                    &self.scene.images,
691                    &self.scene.texts,
692                    &self.scene.shadow_draws,
693                    &self.scene.effect_layers,
694                    &self.scene.backdrop_layers,
695                    width,
696                    height,
697                    root_scale,
698                )
699                .map_err(WgpuRendererError::Wgpu)?;
700            Ok(CapturedFrame {
701                width,
702                height,
703                pixels,
704            })
705        } else {
706            Err(WgpuRendererError::Wgpu(
707                "GPU renderer not initialized. Call init_gpu() first.".to_string(),
708            ))
709        }
710    }
711
712    /// Get access to the WGPU device (for surface configuration).
713    pub fn device(&self) -> &wgpu::Device {
714        self.gpu_renderer
715            .as_ref()
716            .map(|r| &*r.device)
717            .expect("GPU renderer not initialized")
718    }
719}
720
721impl Default for WgpuRenderer {
722    fn default() -> Self {
723        Self::new(&[])
724    }
725}
726
727impl Renderer for WgpuRenderer {
728    type Scene = Scene;
729    type Error = WgpuRendererError;
730
731    fn scene(&self) -> &Self::Scene {
732        &self.scene
733    }
734
735    fn scene_mut(&mut self) -> &mut Self::Scene {
736        &mut self.scene
737    }
738
739    fn rebuild_scene(
740        &mut self,
741        layout_tree: &LayoutTree,
742        _viewport: Size,
743    ) -> Result<(), Self::Error> {
744        self.scene.clear();
745        // Build scene in logical dp - scaling happens in GPU vertex upload
746        pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
747        Ok(())
748    }
749
750    fn rebuild_scene_from_applier(
751        &mut self,
752        applier: &mut MemoryApplier,
753        root: NodeId,
754        _viewport: Size,
755    ) -> Result<(), Self::Error> {
756        self.scene.clear();
757        // Build scene in logical dp - scaling happens in GPU vertex upload
758        // Traverse layout nodes via applier instead of rebuilding LayoutTree
759        pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
760        Ok(())
761    }
762
763    fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
764        use cranpose_ui_graphics::{BlendMode, Brush, Color, Rect, RoundedCornerShape};
765
766        // Draw FPS text in top-right corner with semi-transparent background
767        // Position: 8px from right edge, 8px from top
768        let padding = 8.0;
769        let font_size = 14.0;
770
771        // Measure text width (approximate: ~7px per character at 14px font)
772        let char_width = 7.0;
773        let text_width = text.len() as f32 * char_width;
774        let text_height = font_size * 1.4;
775
776        let x = viewport.width - text_width - padding * 2.0;
777        let y = padding;
778
779        // Add background rectangle (dark semi-transparent)
780        let bg_rect = Rect {
781            x,
782            y,
783            width: text_width + padding,
784            height: text_height + padding / 2.0,
785        };
786        self.scene.push_shape(
787            bg_rect,
788            Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
789            Some(RoundedCornerShape::uniform(4.0)),
790            None,
791            BlendMode::SrcOver,
792        );
793
794        // Add text (green color for visibility)
795        let text_rect = Rect {
796            x: x + padding / 2.0,
797            y: y + padding / 4.0,
798            width: text_width,
799            height: text_height,
800        };
801        self.scene.push_text(
802            NodeId::MAX,
803            text_rect,
804            Rc::new(cranpose_ui::text::AnnotatedString::from(text)),
805            Color(0.0, 1.0, 0.0, 1.0), // Green
806            cranpose_ui::TextStyle::default(),
807            font_size,
808            1.0,
809            cranpose_ui::TextLayoutOptions::default(),
810            None,
811        );
812    }
813}
814
815fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
816    style.resolve_font_size(14.0)
817}
818
819fn resolve_line_height(style: &cranpose_ui::text::TextStyle, font_size: f32) -> f32 {
820    style.resolve_line_height(14.0, font_size * 1.4)
821}
822
823fn resolve_max_span_font_size(
824    style: &cranpose_ui::text::TextStyle,
825    text: &cranpose_ui::text::AnnotatedString,
826    base_font_size: f32,
827) -> f32 {
828    if text.span_styles.is_empty() {
829        return base_font_size;
830    }
831
832    let mut max_font_size = base_font_size;
833    for window in text.span_boundaries().windows(2) {
834        let start = window[0];
835        let end = window[1];
836        if start == end {
837            continue;
838        }
839
840        let mut merged_span = style.span_style.clone();
841        for span in &text.span_styles {
842            if span.range.start <= start && span.range.end >= end {
843                merged_span = merged_span.merge(&span.item);
844            }
845        }
846        let mut chunk_style = style.clone();
847        chunk_style.span_style = merged_span;
848        max_font_size = max_font_size.max(chunk_style.resolve_font_size(base_font_size));
849    }
850    max_font_size
851}
852
853pub(crate) fn resolve_effective_line_height(
854    style: &cranpose_ui::text::TextStyle,
855    text: &cranpose_ui::text::AnnotatedString,
856    base_font_size: f32,
857) -> f32 {
858    let max_font_size = resolve_max_span_font_size(style, text, base_font_size);
859    resolve_line_height(style, max_font_size)
860}
861
862fn family_owned_to_fontdb_family(family: &FamilyOwned) -> glyphon::fontdb::Family<'_> {
863    match family {
864        FamilyOwned::Name(name) => glyphon::fontdb::Family::Name(name.as_str()),
865        FamilyOwned::Serif => glyphon::fontdb::Family::Serif,
866        FamilyOwned::SansSerif => glyphon::fontdb::Family::SansSerif,
867        FamilyOwned::Monospace => glyphon::fontdb::Family::Monospace,
868        FamilyOwned::Cursive => glyphon::fontdb::Family::Cursive,
869        FamilyOwned::Fantasy => glyphon::fontdb::Family::Fantasy,
870    }
871}
872
873fn requested_fontdb_style(style: Option<cranpose_ui::text::FontStyle>) -> glyphon::fontdb::Style {
874    match style.unwrap_or_default() {
875        cranpose_ui::text::FontStyle::Normal => glyphon::fontdb::Style::Normal,
876        cranpose_ui::text::FontStyle::Italic => glyphon::fontdb::Style::Italic,
877    }
878}
879
880fn glyphon_style_from_fontdb(style: glyphon::fontdb::Style) -> GlyphonStyle {
881    match style {
882        glyphon::fontdb::Style::Italic | glyphon::fontdb::Style::Oblique => GlyphonStyle::Italic,
883        glyphon::fontdb::Style::Normal => GlyphonStyle::Normal,
884    }
885}
886
887fn resolve_available_style_and_weight(
888    font_system: &FontSystem,
889    family: &FamilyOwned,
890    requested_weight: Option<cranpose_ui::text::FontWeight>,
891    requested_style: Option<cranpose_ui::text::FontStyle>,
892) -> Option<(GlyphonStyle, GlyphonWeight)> {
893    let requested_fontdb_weight = requested_weight.unwrap_or_default().0;
894    let requested_style = requested_fontdb_style(requested_style);
895    let requested_family = family_owned_to_fontdb_family(family);
896    let requested_family_name = font_system
897        .db()
898        .family_name(&requested_family)
899        .to_ascii_lowercase();
900
901    let style_penalty = |face_style: glyphon::fontdb::Style| -> u32 {
902        if face_style == requested_style {
903            0
904        } else if requested_style != glyphon::fontdb::Style::Normal
905            && face_style == glyphon::fontdb::Style::Normal
906        {
907            1_000
908        } else {
909            10_000
910        }
911    };
912
913    let weight_penalty = |face_weight: u16| -> u32 {
914        (i32::from(face_weight) - i32::from(requested_fontdb_weight)).unsigned_abs()
915    };
916
917    let mut best_in_family: Option<(u32, glyphon::fontdb::Style, u16)> = None;
918    let mut best_global: Option<(u32, glyphon::fontdb::Style, u16)> = None;
919
920    for face in font_system.db().faces() {
921        let score = style_penalty(face.style) + weight_penalty(face.weight.0);
922        let in_family = face
923            .families
924            .iter()
925            .any(|(name, _)| name.eq_ignore_ascii_case(requested_family_name.as_str()));
926
927        if in_family
928            && best_in_family
929                .as_ref()
930                .map(|(best_score, _, _)| score < *best_score)
931                .unwrap_or(true)
932        {
933            best_in_family = Some((score, face.style, face.weight.0));
934        }
935
936        if best_global
937            .as_ref()
938            .map(|(best_score, _, _)| score < *best_score)
939            .unwrap_or(true)
940        {
941            best_global = Some((score, face.style, face.weight.0));
942        }
943    }
944
945    let (_, resolved_style, resolved_weight) = best_in_family.or(best_global)?;
946    Some((
947        glyphon_style_from_fontdb(resolved_style),
948        GlyphonWeight(resolved_weight),
949    ))
950}
951
952fn attrs_from_text_style(
953    style: &cranpose_ui::text::TextStyle,
954    unscaled_base_font_size: f32,
955    scale: f32,
956    font_system: &mut FontSystem,
957    font_family_resolver: &mut WgpuFontFamilyResolver,
958) -> AttrsOwned {
959    let mut attrs = Attrs::new();
960    let span_style = &style.span_style;
961    let font_weight = span_style.font_weight;
962    let font_style = span_style.font_style;
963    let letter_spacing = span_style.letter_spacing;
964
965    let unscaled_font_size = style.resolve_font_size(unscaled_base_font_size);
966    let unscaled_line_height =
967        style.resolve_line_height(unscaled_base_font_size, unscaled_font_size * 1.4);
968
969    let font_size_px = unscaled_font_size * scale;
970    let line_height_px = unscaled_line_height * scale;
971
972    attrs = attrs.metrics(glyphon::Metrics::new(font_size_px, line_height_px));
973
974    if let Some(color) = &span_style.color {
975        let r = (color.0 * 255.0).clamp(0.0, 255.0) as u8;
976        let g = (color.1 * 255.0).clamp(0.0, 255.0) as u8;
977        let b = (color.2 * 255.0).clamp(0.0, 255.0) as u8;
978        let a = (color.3 * 255.0).clamp(0.0, 255.0) as u8;
979        attrs = attrs.color(glyphon::Color::rgba(r, g, b, a));
980    }
981
982    let family_owned = font_family_resolver.resolve_family_owned(font_system, span_style);
983    attrs = attrs.family(family_owned.as_family());
984
985    if let Some((resolved_style, resolved_weight)) =
986        resolve_available_style_and_weight(font_system, &family_owned, font_weight, font_style)
987    {
988        attrs = attrs.style(resolved_style).weight(resolved_weight);
989    } else {
990        if let Some(font_weight) = font_weight {
991            attrs = attrs.weight(GlyphonWeight(font_weight.0));
992        }
993
994        if let Some(font_style) = font_style {
995            attrs = attrs.style(match font_style {
996                cranpose_ui::text::FontStyle::Normal => GlyphonStyle::Normal,
997                cranpose_ui::text::FontStyle::Italic => GlyphonStyle::Italic,
998            });
999        }
1000    }
1001
1002    attrs = match letter_spacing {
1003        cranpose_ui::text::TextUnit::Em(value) => attrs.letter_spacing(value),
1004        cranpose_ui::text::TextUnit::Sp(value) if font_size_px > 0.0 => {
1005            attrs.letter_spacing((value * scale) / font_size_px)
1006        }
1007        _ => attrs,
1008    };
1009
1010    AttrsOwned::new(&attrs)
1011}
1012
1013// Text measurer implementation for WGPU
1014
1015// Text measurer implementation for WGPU
1016
1017#[derive(Clone)]
1018struct WgpuTextMeasurer {
1019    font_system: Arc<Mutex<FontSystem>>,
1020    font_family_resolver: SharedFontFamilyResolver,
1021    size_cache: TextSizeCache,
1022    /// Shared buffer cache used by both measurement and rendering
1023    text_cache: SharedTextCache,
1024}
1025
1026impl WgpuTextMeasurer {
1027    fn new(
1028        font_system: Arc<Mutex<FontSystem>>,
1029        text_cache: SharedTextCache,
1030        font_family_resolver: SharedFontFamilyResolver,
1031    ) -> Self {
1032        Self {
1033            font_system,
1034            font_family_resolver,
1035            // Larger cache size (1024) reduces misses, FxHasher for faster lookups
1036            size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
1037            text_cache,
1038        }
1039    }
1040}
1041
1042/// Convenience function for tests to initialize an accurate wgpu text measurer without launching a window.
1043pub fn setup_headless_text_measurer() {
1044    let mut font_system = FontSystem::new();
1045    let mut font_family_resolver_impl = WgpuFontFamilyResolver::default();
1046    font_family_resolver_impl.prime(&mut font_system);
1047    let font_system = Arc::new(Mutex::new(font_system));
1048    let font_family_resolver = Arc::new(Mutex::new(font_family_resolver_impl));
1049    let text_cache = Arc::new(Mutex::new(HashMap::new()));
1050    cranpose_ui::text::set_text_measurer(WgpuTextMeasurer::new(
1051        font_system,
1052        text_cache,
1053        font_family_resolver,
1054    ));
1055}
1056
1057// Base font size in logical units (dp) - shared between measurement and rendering
1058
1059impl TextMeasurer for WgpuTextMeasurer {
1060    fn measure(
1061        &self,
1062        text: &cranpose_ui::text::AnnotatedString,
1063        style: &cranpose_ui::text::TextStyle,
1064    ) -> cranpose_ui::TextMetrics {
1065        let text_str = text.text.as_str();
1066        let font_size = resolve_font_size(style);
1067        let line_height = resolve_effective_line_height(style, text, font_size);
1068        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1069        let size_int = (font_size * 100.0) as i32;
1070
1071        // Calculate hash to avoid allocating String for lookup
1072        // FxHasher is ~3x faster than DefaultHasher for short strings
1073        let mut hasher = FxHasher::default();
1074        text_str.hash(&mut hasher);
1075        let text_hash = hasher.finish();
1076        let cache_key = (text_hash, size_int, style_hash);
1077
1078        // Check size cache first (fastest path)
1079        {
1080            let mut cache = self.size_cache.lock().unwrap();
1081            if let Some((cached_text, size)) = cache.get(&cache_key) {
1082                // Verify partial collision
1083                if cached_text == text_str {
1084                    let line_count = text_str.split('\n').count().max(1);
1085                    return cranpose_ui::TextMetrics {
1086                        width: size.width,
1087                        height: size.height,
1088                        line_height,
1089                        line_count,
1090                    };
1091                }
1092            }
1093        }
1094
1095        // Get or create text buffer
1096        let text_buffer_key = TextCacheKey::new(text_str, font_size, style_hash);
1097        let mut font_system = self.font_system.lock().unwrap();
1098        let mut text_cache = self.text_cache.lock().unwrap();
1099        let mut font_family_resolver = self.font_family_resolver.lock().unwrap();
1100
1101        // Get or create buffer and calculate size
1102        let size = {
1103            let buffer = text_cache.entry(text_buffer_key).or_insert_with(|| {
1104                let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
1105                SharedTextBuffer {
1106                    buffer,
1107                    text: String::new(),
1108                    font_size: 0.0,
1109                    line_height: 0.0,
1110                    style_hash: 0,
1111                    cached_size: None,
1112                }
1113            });
1114
1115            // Ensure buffer has the correct text
1116            buffer.ensure(
1117                &mut font_system,
1118                &mut font_family_resolver,
1119                EnsureTextBufferParams {
1120                    annotated_text: text,
1121                    font_size_px: font_size,
1122                    line_height_px: line_height,
1123                    style_hash,
1124                    style,
1125                    scale: 1.0,
1126                },
1127            );
1128
1129            // Calculate size if not cached
1130            buffer.size()
1131        };
1132
1133        // Trim cache if needed (after we're done with buffer reference)
1134        trim_text_cache(&mut text_cache);
1135
1136        drop(font_system);
1137        drop(text_cache);
1138
1139        // Cache the size result
1140        let mut size_cache = self.size_cache.lock().unwrap();
1141        // Only allocate string on cache miss
1142        size_cache.put(cache_key, (text_str.to_string(), size));
1143
1144        // Calculate line info for multiline support
1145        let line_count = text_str.split('\n').count().max(1);
1146
1147        cranpose_ui::TextMetrics {
1148            width: size.width,
1149            height: size.height,
1150            line_height,
1151            line_count,
1152        }
1153    }
1154
1155    fn get_offset_for_position(
1156        &self,
1157        text: &cranpose_ui::text::AnnotatedString,
1158        style: &cranpose_ui::text::TextStyle,
1159        x: f32,
1160        y: f32,
1161    ) -> usize {
1162        let text_str = text.text.as_str();
1163        let font_size = resolve_font_size(style);
1164        let line_height = resolve_effective_line_height(style, text, font_size);
1165        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1166        if text_str.is_empty() {
1167            return 0;
1168        }
1169
1170        let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
1171
1172        let mut font_system = self.font_system.lock().unwrap();
1173        let mut text_cache = self.text_cache.lock().unwrap();
1174        let mut font_family_resolver = self.font_family_resolver.lock().unwrap();
1175
1176        let buffer = text_cache.entry(cache_key).or_insert_with(|| {
1177            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
1178            SharedTextBuffer {
1179                buffer,
1180                text: String::new(),
1181                font_size: 0.0,
1182                line_height: 0.0,
1183                style_hash: 0,
1184                cached_size: None,
1185            }
1186        });
1187
1188        buffer.ensure(
1189            &mut font_system,
1190            &mut font_family_resolver,
1191            EnsureTextBufferParams {
1192                annotated_text: text,
1193                font_size_px: font_size,
1194                line_height_px: line_height,
1195                style_hash,
1196                style,
1197                scale: 1.0,
1198            },
1199        );
1200
1201        let line_offsets: Vec<(usize, usize)> = text_str
1202            .split('\n')
1203            .scan(0usize, |line_start, line| {
1204                let start = *line_start;
1205                let end = start + line.len();
1206                *line_start = end.saturating_add(1);
1207                Some((start, end))
1208            })
1209            .collect();
1210
1211        let mut target_line = None;
1212        let mut best_vertical_distance = f32::INFINITY;
1213
1214        for run in buffer.buffer.layout_runs() {
1215            let mut run_height = run.line_height;
1216            for glyph in run.glyphs.iter() {
1217                run_height = run_height.max(glyph.font_size * 1.4);
1218            }
1219
1220            let top = run.line_top;
1221            let bottom = top + run_height.max(1.0);
1222            let vertical_distance = if y < top {
1223                top - y
1224            } else if y > bottom {
1225                y - bottom
1226            } else {
1227                0.0
1228            };
1229
1230            if vertical_distance < best_vertical_distance {
1231                best_vertical_distance = vertical_distance;
1232                target_line = Some(run.line_i);
1233                if vertical_distance == 0.0 {
1234                    break;
1235                }
1236            }
1237        }
1238
1239        let fallback_line = (y / line_height).floor().max(0.0) as usize;
1240        let target_line = target_line
1241            .unwrap_or(fallback_line)
1242            .min(line_offsets.len().saturating_sub(1));
1243        let (line_start, line_end) = line_offsets
1244            .get(target_line)
1245            .copied()
1246            .unwrap_or((0, text_str.len()));
1247        let line_len = line_end.saturating_sub(line_start);
1248
1249        let mut best_offset = line_offsets
1250            .get(target_line)
1251            .map(|(_, end)| *end)
1252            .unwrap_or(text_str.len());
1253        let mut best_distance = f32::INFINITY;
1254        let mut found_glyph = false;
1255
1256        for run in buffer.buffer.layout_runs() {
1257            if run.line_i != target_line {
1258                continue;
1259            }
1260            for glyph in run.glyphs.iter() {
1261                found_glyph = true;
1262                let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
1263                let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
1264                let left_dist = (x - glyph.x).abs();
1265                if left_dist < best_distance {
1266                    best_distance = left_dist;
1267                    best_offset = glyph_start;
1268                }
1269
1270                let right_x = glyph.x + glyph.w;
1271                let right_dist = (x - right_x).abs();
1272                if right_dist < best_distance {
1273                    best_distance = right_dist;
1274                    best_offset = glyph_end;
1275                }
1276            }
1277        }
1278
1279        if !found_glyph {
1280            if let Some((start, end)) = line_offsets.get(target_line) {
1281                best_offset = if x <= 0.0 { *start } else { *end };
1282            }
1283        }
1284
1285        best_offset.min(text_str.len())
1286    }
1287
1288    fn get_cursor_x_for_offset(
1289        &self,
1290        text: &cranpose_ui::text::AnnotatedString,
1291        style: &cranpose_ui::text::TextStyle,
1292        offset: usize,
1293    ) -> f32 {
1294        let text = text.text.as_str();
1295        let clamped_offset = offset.min(text.len());
1296        if clamped_offset == 0 {
1297            return 0.0;
1298        }
1299
1300        // Measure text up to offset
1301        let prefix = &text[..clamped_offset];
1302        self.measure(&cranpose_ui::text::AnnotatedString::from(prefix), style)
1303            .width
1304    }
1305
1306    fn choose_auto_hyphen_break(
1307        &self,
1308        line: &str,
1309        style: &cranpose_ui::text::TextStyle,
1310        segment_start_char: usize,
1311        measured_break_char: usize,
1312    ) -> Option<usize> {
1313        choose_shared_auto_hyphen_break(line, style, segment_start_char, measured_break_char)
1314    }
1315
1316    fn layout(
1317        &self,
1318        text: &cranpose_ui::text::AnnotatedString,
1319        style: &cranpose_ui::text::TextStyle,
1320    ) -> cranpose_ui::text_layout_result::TextLayoutResult {
1321        let text_str = text.text.as_str();
1322        use cranpose_ui::text_layout_result::{
1323            GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult,
1324        };
1325
1326        let font_size = resolve_font_size(style);
1327        let line_height = resolve_effective_line_height(style, text, font_size);
1328        let style_hash = style.measurement_hash() ^ text.span_styles_hash();
1329
1330        let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
1331        let mut font_system = self.font_system.lock().unwrap();
1332        let mut text_cache = self.text_cache.lock().unwrap();
1333        let mut font_family_resolver = self.font_family_resolver.lock().unwrap();
1334
1335        let buffer = text_cache.entry(cache_key.clone()).or_insert_with(|| {
1336            let buffer = Buffer::new(&mut font_system, Metrics::new(font_size, line_height));
1337            SharedTextBuffer {
1338                buffer,
1339                text: String::new(),
1340                font_size: 0.0,
1341                line_height: 0.0,
1342                style_hash: 0,
1343                cached_size: None,
1344            }
1345        });
1346        buffer.ensure(
1347            &mut font_system,
1348            &mut font_family_resolver,
1349            EnsureTextBufferParams {
1350                annotated_text: text,
1351                font_size_px: font_size,
1352                line_height_px: line_height,
1353                style_hash,
1354                style,
1355                scale: 1.0,
1356            },
1357        );
1358
1359        // Extract glyph positions from layout runs
1360        let mut glyph_x_positions = Vec::new();
1361        let mut char_to_byte = Vec::new();
1362        let mut glyph_layouts = Vec::new();
1363        let mut lines = Vec::new();
1364        let line_offsets: Vec<(usize, usize)> = text_str
1365            .split('\n')
1366            .scan(0usize, |line_start, line| {
1367                let start = *line_start;
1368                let end = start + line.len();
1369                *line_start = end.saturating_add(1);
1370                Some((start, end))
1371            })
1372            .collect();
1373
1374        for run in buffer.buffer.layout_runs() {
1375            let line_idx = run.line_i;
1376            let run_height = run
1377                .glyphs
1378                .iter()
1379                .fold(run.line_height, |acc, glyph| acc.max(glyph.font_size * 1.4))
1380                .max(1.0);
1381
1382            for glyph in run.glyphs.iter() {
1383                let (line_start, line_end) = line_offsets
1384                    .get(line_idx)
1385                    .copied()
1386                    .unwrap_or((0, text_str.len()));
1387                let line_len = line_end.saturating_sub(line_start);
1388                let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
1389                let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
1390
1391                glyph_x_positions.push(glyph.x);
1392                char_to_byte.push(glyph_start);
1393                if glyph_end > glyph_start {
1394                    glyph_layouts.push(GlyphLayout {
1395                        line_index: line_idx,
1396                        start_offset: glyph_start,
1397                        end_offset: glyph_end,
1398                        x: glyph.x,
1399                        y: run.line_top,
1400                        width: glyph.w.max(0.0),
1401                        height: run_height,
1402                    });
1403                }
1404            }
1405        }
1406
1407        // Add end position
1408        let total_width = self.measure(text, style).width;
1409        glyph_x_positions.push(total_width);
1410        char_to_byte.push(text_str.len());
1411
1412        // Build lines from text newlines
1413        let mut y = 0.0f32;
1414        let mut line_start = 0usize;
1415        for (i, line_text) in text_str.split('\n').enumerate() {
1416            let line_end = if i == text_str.split('\n').count() - 1 {
1417                text_str.len()
1418            } else {
1419                line_start + line_text.len()
1420            };
1421
1422            lines.push(LineLayout {
1423                start_offset: line_start,
1424                end_offset: line_end,
1425                y,
1426                height: line_height,
1427            });
1428
1429            line_start = line_end + 1;
1430            y += line_height;
1431        }
1432
1433        if lines.is_empty() {
1434            lines.push(LineLayout {
1435                start_offset: 0,
1436                end_offset: 0,
1437                y: 0.0,
1438                height: line_height,
1439            });
1440        }
1441
1442        let metrics = self.measure(text, style);
1443        TextLayoutResult::new(
1444            text_str,
1445            TextLayoutData {
1446                width: metrics.width,
1447                height: metrics.height,
1448                line_height,
1449                glyph_x_positions,
1450                char_to_byte,
1451                lines,
1452                glyph_layouts,
1453            },
1454        )
1455    }
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use super::*;
1461
1462    fn seeded_font_system_and_resolver() -> (FontSystem, WgpuFontFamilyResolver) {
1463        let mut db = glyphon::fontdb::Database::new();
1464        db.load_font_data(TEST_FONT.to_vec());
1465        let mut font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
1466        let mut resolver = WgpuFontFamilyResolver::default();
1467        resolver.prime(&mut font_system);
1468        (font_system, resolver)
1469    }
1470
1471    #[test]
1472    fn attrs_resolution_falls_back_for_missing_named_family() {
1473        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
1474        let style = cranpose_ui::text::TextStyle {
1475            span_style: cranpose_ui::text::SpanStyle {
1476                font_family: Some(cranpose_ui::text::FontFamily::named("Missing Family Name")),
1477                ..Default::default()
1478            },
1479            ..Default::default()
1480        };
1481
1482        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
1483        assert_eq!(attrs.family_owned, FamilyOwned::SansSerif);
1484    }
1485
1486    #[test]
1487    fn attrs_resolution_seeds_generic_families_from_loaded_fonts() {
1488        let (font_system, resolver) = seeded_font_system_and_resolver();
1489        assert!(
1490            resolver.generic_fallback_seeded,
1491            "expected generic fallback seeding after resolver prime"
1492        );
1493        let query = glyphon::fontdb::Query {
1494            families: &[glyphon::fontdb::Family::Monospace],
1495            weight: glyphon::fontdb::Weight::NORMAL,
1496            stretch: glyphon::fontdb::Stretch::Normal,
1497            style: glyphon::fontdb::Style::Normal,
1498        };
1499        assert!(
1500            font_system.db().query(&query).is_some(),
1501            "generic monospace query should resolve after fallback seeding"
1502        );
1503    }
1504
1505    #[test]
1506    fn attrs_resolution_named_family_lookup_is_case_insensitive() {
1507        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
1508        let style = cranpose_ui::text::TextStyle {
1509            span_style: cranpose_ui::text::SpanStyle {
1510                font_family: Some(cranpose_ui::text::FontFamily::named("noto sans")),
1511                ..Default::default()
1512            },
1513            ..Default::default()
1514        };
1515
1516        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
1517        assert!(
1518            matches!(attrs.family_owned, FamilyOwned::Name(_)),
1519            "case-insensitive family lookup should resolve to a concrete family name"
1520        );
1521    }
1522
1523    #[test]
1524    fn attrs_resolution_downgrades_missing_italic_to_available_style() {
1525        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
1526        let style = cranpose_ui::text::TextStyle {
1527            span_style: cranpose_ui::text::SpanStyle {
1528                font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
1529                font_style: Some(cranpose_ui::text::FontStyle::Italic),
1530                ..Default::default()
1531            },
1532            ..Default::default()
1533        };
1534
1535        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
1536        assert_eq!(
1537            attrs.style,
1538            GlyphonStyle::Normal,
1539            "missing italic face should downgrade to available style instead of panicking during shaping"
1540        );
1541    }
1542
1543    #[test]
1544    fn attrs_resolution_downgrades_missing_weight_to_available_weight() {
1545        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
1546        let style = cranpose_ui::text::TextStyle {
1547            span_style: cranpose_ui::text::SpanStyle {
1548                font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
1549                font_weight: Some(cranpose_ui::text::FontWeight::BOLD),
1550                ..Default::default()
1551            },
1552            ..Default::default()
1553        };
1554
1555        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
1556        assert_eq!(
1557            attrs.weight,
1558            GlyphonWeight(cranpose_ui::text::FontWeight::NORMAL.0),
1559            "missing bold face should downgrade to available weight instead of panicking during shaping"
1560        );
1561    }
1562
1563    // Font bytes used by tests — the same file the demo app ships.
1564    static TEST_FONT: &[u8] = include_bytes!("../../../../apps/desktop-demo/assets/NotoSansMerged.ttf");
1565
1566    fn empty_font_system() -> FontSystem {
1567        let db = glyphon::fontdb::Database::new();
1568        FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1569    }
1570
1571    #[test]
1572    fn load_fonts_populates_face_db() {
1573        let mut fs = empty_font_system();
1574        load_fonts(&mut fs, &[TEST_FONT]);
1575        assert!(fs.db().faces().count() > 0, "load_fonts must load at least one face");
1576    }
1577
1578    #[test]
1579    fn load_fonts_empty_slice_leaves_db_empty() {
1580        let mut fs = empty_font_system();
1581        load_fonts(&mut fs, &[]);
1582        assert_eq!(fs.db().faces().count(), 0, "empty slice must not load any faces");
1583    }
1584
1585    #[test]
1586    fn resolver_logs_warning_if_font_db_is_empty() {
1587        // With no fonts loaded the resolver should not panic; it just warns.
1588        let mut font_system = empty_font_system();
1589        let mut resolver = WgpuFontFamilyResolver::default();
1590        let span_style = cranpose_ui::text::SpanStyle::default();
1591        // Must not panic even with an empty DB.
1592        let _ = resolver.resolve_family_owned(&mut font_system, &span_style);
1593    }
1594
1595    #[test]
1596    #[cfg(not(target_arch = "wasm32"))]
1597    fn attrs_resolution_loads_file_backed_family_from_path() {
1598        let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
1599        let nonce = std::time::SystemTime::now()
1600            .duration_since(std::time::UNIX_EPOCH)
1601            .map(|duration| duration.as_nanos())
1602            .unwrap_or_default();
1603        let unique_path = format!(
1604            "{}/cranpose-font-resolver-{}-{}.ttf",
1605            std::env::temp_dir().display(),
1606            std::process::id(),
1607            nonce
1608        );
1609        std::fs::write(&unique_path, TEST_FONT).expect("write font fixture");
1610
1611        let style = cranpose_ui::text::TextStyle {
1612            span_style: cranpose_ui::text::SpanStyle {
1613                font_family: Some(cranpose_ui::text::FontFamily::file_backed(vec![
1614                    cranpose_ui::text::FontFile::new(unique_path.clone()),
1615                ])),
1616                ..Default::default()
1617            },
1618            ..Default::default()
1619        };
1620
1621        let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
1622        assert!(
1623            matches!(attrs.family_owned, FamilyOwned::Name(_)),
1624            "file-backed font family should resolve to an installed family name"
1625        );
1626
1627        let _ = std::fs::remove_file(&unique_path);
1628    }
1629}