1mod 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
37type 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#[derive(Debug, Clone)]
50pub struct CapturedFrame {
51 pub width: u32,
52 pub height: u32,
53 pub pixels: Vec<u8>,
54}
55
56#[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, 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
96pub(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: 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 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 if !text_changed && !font_changed && !line_height_changed && !style_changed {
138 return; }
140
141 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 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 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; }
221
222 pub(crate) fn size(&mut self) -> Size {
224 if let Some(size) = self.cached_size {
225 return size;
226 }
227
228 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; 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
254pub(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
533pub(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 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
555const MAX_CACHE_ITEMS: usize = 256;
557
558pub struct WgpuRenderer {
566 scene: Scene,
567 gpu_renderer: Option<GpuRenderer>,
568 font_system: Arc<Mutex<FontSystem>>,
569 font_family_resolver: SharedFontFamilyResolver,
570 text_cache: SharedTextCache,
572 root_scale: f32,
574}
575
576impl WgpuRenderer {
577 pub fn new(fonts: &[&[u8]]) -> Self {
584 let mut font_system = FontSystem::new();
585
586 #[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 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 pub fn set_root_scale(&mut self, scale: f32) {
636 self.root_scale = scale;
637 }
638
639 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 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 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 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 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 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 let padding = 8.0;
769 let font_size = 14.0;
770
771 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 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 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), 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#[derive(Clone)]
1018struct WgpuTextMeasurer {
1019 font_system: Arc<Mutex<FontSystem>>,
1020 font_family_resolver: SharedFontFamilyResolver,
1021 size_cache: TextSizeCache,
1022 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 size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
1037 text_cache,
1038 }
1039 }
1040}
1041
1042pub 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
1057impl 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 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 {
1080 let mut cache = self.size_cache.lock().unwrap();
1081 if let Some((cached_text, size)) = cache.get(&cache_key) {
1082 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 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 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 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 buffer.size()
1131 };
1132
1133 trim_text_cache(&mut text_cache);
1135
1136 drop(font_system);
1137 drop(text_cache);
1138
1139 let mut size_cache = self.size_cache.lock().unwrap();
1141 size_cache.put(cache_key, (text_str.to_string(), size));
1143
1144 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 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 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 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 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 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 let mut font_system = empty_font_system();
1589 let mut resolver = WgpuFontFamilyResolver::default();
1590 let span_style = cranpose_ui::text::SpanStyle::default();
1591 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}