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