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