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