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