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