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, Point, 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
963#[derive(Clone)]
964pub struct WgpuTextSystem {
965 render: SharedTextSystemState,
966 measure: SharedTextSystemState,
967}
968
969impl WgpuTextSystem {
970 pub fn from_fonts(fonts: &[&[u8]]) -> Self {
971 Self {
972 render: Arc::new(Mutex::new(TextSystemState::from_fonts(fonts))),
973 measure: Arc::new(Mutex::new(TextSystemState::from_fonts(fonts))),
974 }
975 }
976
977 fn install_measurer(&self) {
978 set_text_measurer(WgpuTextMeasurer::new(Arc::clone(&self.measure)));
979 }
980
981 fn render_state(&self) -> SharedTextSystemState {
982 Arc::clone(&self.render)
983 }
984}
985
986pub struct WgpuRenderer {
994 scene: Scene,
995 gpu_renderer: Option<GpuRenderer>,
996 render_text_state: SharedTextSystemState,
997 root_scale: f32,
999}
1000
1001impl WgpuRenderer {
1002 pub fn new(fonts: &[&[u8]]) -> Self {
1009 Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
1010 }
1011
1012 pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
1013 text_system.install_measurer();
1014
1015 Self {
1016 scene: Scene::new(),
1017 gpu_renderer: None,
1018 render_text_state: text_system.render_state(),
1019 root_scale: 1.0,
1020 }
1021 }
1022
1023 pub fn init_gpu(
1025 &mut self,
1026 device: Arc<wgpu::Device>,
1027 queue: Arc<wgpu::Queue>,
1028 surface_format: wgpu::TextureFormat,
1029 adapter_backend: wgpu::Backend,
1030 ) {
1031 self.gpu_renderer = Some(GpuRenderer::new(
1032 device,
1033 queue,
1034 surface_format,
1035 adapter_backend,
1036 ));
1037 }
1038
1039 pub fn set_root_scale(&mut self, scale: f32) {
1041 self.root_scale = scale;
1042 }
1043
1044 pub fn root_scale(&self) -> f32 {
1045 self.root_scale
1046 }
1047
1048 pub fn render(
1050 &mut self,
1051 view: &wgpu::TextureView,
1052 width: u32,
1053 height: u32,
1054 ) -> Result<(), WgpuRendererError> {
1055 if let Some(gpu_renderer) = &mut self.gpu_renderer {
1056 let graph = self
1057 .scene
1058 .graph
1059 .as_ref()
1060 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
1061 let mut text_state = self.render_text_state.lock().unwrap();
1062 let result =
1063 gpu_renderer.render(&mut text_state, view, graph, width, height, self.root_scale);
1064 result.map_err(WgpuRendererError::Wgpu)
1065 } else {
1066 Err(WgpuRendererError::Wgpu(
1067 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
1068 ))
1069 }
1070 }
1071
1072 pub fn capture_frame(
1076 &mut self,
1077 width: u32,
1078 height: u32,
1079 ) -> Result<CapturedFrame, WgpuRendererError> {
1080 self.capture_frame_with_scale(width, height, self.root_scale)
1081 }
1082
1083 pub fn capture_frame_with_scale(
1085 &mut self,
1086 width: u32,
1087 height: u32,
1088 root_scale: f32,
1089 ) -> Result<CapturedFrame, WgpuRendererError> {
1090 if let Some(gpu_renderer) = &mut self.gpu_renderer {
1091 let graph = self
1092 .scene
1093 .graph
1094 .as_ref()
1095 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
1096 let mut text_state = self.render_text_state.lock().unwrap();
1097 let pixels = gpu_renderer
1098 .render_to_rgba_pixels(&mut text_state, graph, width, height, root_scale)
1099 .map_err(WgpuRendererError::Wgpu)?;
1100 Ok(CapturedFrame {
1101 width,
1102 height,
1103 pixels,
1104 })
1105 } else {
1106 Err(WgpuRendererError::Wgpu(
1107 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
1108 ))
1109 }
1110 }
1111
1112 pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
1113 self.gpu_renderer
1114 .as_ref()
1115 .and_then(GpuRenderer::last_frame_stats)
1116 }
1117
1118 pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
1119 let mut stats = self
1120 .gpu_renderer
1121 .as_ref()
1122 .map(GpuRenderer::debug_cpu_allocation_stats)
1123 .unwrap_or_default();
1124 stats.scene_graph_node_count = self
1125 .scene
1126 .graph
1127 .as_ref()
1128 .map(RenderGraph::node_count)
1129 .unwrap_or(0);
1130 stats.scene_graph_heap_bytes = self
1131 .scene
1132 .graph
1133 .as_ref()
1134 .map(RenderGraph::heap_bytes)
1135 .unwrap_or(0);
1136 stats.scene_hits_len = self.scene.hits.len();
1137 stats.scene_hits_cap = self.scene.hits.capacity();
1138 stats.scene_node_index_len = self.scene.node_index.len();
1139 stats.scene_node_index_cap = self.scene.node_index.capacity();
1140 stats
1141 }
1142
1143 pub fn device(&self) -> &wgpu::Device {
1145 self.gpu_renderer
1146 .as_ref()
1147 .map(|r| &*r.device)
1148 .expect("GPU renderer not initialized")
1149 }
1150}
1151
1152impl Default for WgpuRenderer {
1153 fn default() -> Self {
1154 Self::new(&[])
1155 }
1156}
1157
1158impl Renderer for WgpuRenderer {
1159 type Scene = Scene;
1160 type Error = WgpuRendererError;
1161
1162 fn scene(&self) -> &Self::Scene {
1163 &self.scene
1164 }
1165
1166 fn scene_mut(&mut self) -> &mut Self::Scene {
1167 &mut self.scene
1168 }
1169
1170 fn rebuild_scene(
1171 &mut self,
1172 layout_tree: &LayoutTree,
1173 _viewport: Size,
1174 ) -> Result<(), Self::Error> {
1175 self.scene.clear();
1176 pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
1178 Ok(())
1179 }
1180
1181 fn rebuild_scene_from_applier(
1182 &mut self,
1183 applier: &mut MemoryApplier,
1184 root: NodeId,
1185 _viewport: Size,
1186 ) -> Result<(), Self::Error> {
1187 self.scene.clear();
1188 pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
1191 Ok(())
1192 }
1193
1194 fn update_scene_from_applier(
1195 &mut self,
1196 applier: &mut MemoryApplier,
1197 root: NodeId,
1198 viewport: Size,
1199 dirty_nodes: &[NodeId],
1200 ) -> Result<(), Self::Error> {
1201 if dirty_nodes.is_empty() {
1202 return self.rebuild_scene_from_applier(applier, root, viewport);
1203 }
1204 pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes);
1205 Ok(())
1206 }
1207
1208 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1209 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1210 let padding = 8.0;
1211 let font_size = 14.0;
1212 let char_width = 7.0;
1213 let text_width = text.len() as f32 * char_width;
1214 let text_height = font_size * 1.4;
1215 let x = (viewport.width - text_width - padding * 2.0).max(padding);
1216 let y = padding;
1217
1218 let mut overlay_layer = LayerNode {
1219 node_id: Some(DEV_OVERLAY_NODE_ID),
1220 local_bounds: Rect {
1221 x: 0.0,
1222 y: 0.0,
1223 width: text_width + padding,
1224 height: text_height + padding / 2.0,
1225 },
1226 transform_to_parent: ProjectiveTransform::translation(x, y),
1227 motion_context_animated: false,
1228 translated_content_context: false,
1229 translated_content_offset: Point::default(),
1230 graphics_layer: GraphicsLayer::default(),
1231 clip_to_bounds: false,
1232 shadow_clip: None,
1233 hit_test: None,
1234 has_hit_targets: false,
1235 isolation: IsolationReasons::default(),
1236 cache_policy: CachePolicy::None,
1237 cache_hashes: LayerRasterCacheHashes::default(),
1238 cache_hashes_valid: false,
1239 children: vec![
1240 RenderNode::Primitive(PrimitiveEntry {
1241 phase: PrimitivePhase::BeforeChildren,
1242 node: PrimitiveNode::Draw(DrawPrimitiveNode {
1243 primitive: DrawPrimitive::RoundRect {
1244 rect: Rect {
1245 x: 0.0,
1246 y: 0.0,
1247 width: text_width + padding,
1248 height: text_height + padding / 2.0,
1249 },
1250 brush: Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
1251 radii: CornerRadii::uniform(4.0),
1252 },
1253 clip: None,
1254 }),
1255 }),
1256 RenderNode::Primitive(PrimitiveEntry {
1257 phase: PrimitivePhase::AfterChildren,
1258 node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
1259 node_id: DEV_OVERLAY_NODE_ID,
1260 rect: Rect {
1261 x: padding / 2.0,
1262 y: padding / 4.0,
1263 width: text_width,
1264 height: text_height,
1265 },
1266 text: cranpose_ui::text::AnnotatedString::from(text),
1267 text_style: cranpose_ui::TextStyle::default(),
1268 font_size,
1269 layout_options: cranpose_ui::TextLayoutOptions::default(),
1270 clip: None,
1271 })),
1272 }),
1273 ],
1274 };
1275 overlay_layer.recompute_raster_cache_hashes();
1276
1277 let graph = self.scene.graph.get_or_insert_with(|| {
1278 RenderGraph::new(LayerNode {
1279 node_id: None,
1280 local_bounds: Rect::from_size(viewport),
1281 transform_to_parent: ProjectiveTransform::identity(),
1282 motion_context_animated: false,
1283 translated_content_context: false,
1284 translated_content_offset: Point::default(),
1285 graphics_layer: GraphicsLayer::default(),
1286 clip_to_bounds: false,
1287 shadow_clip: None,
1288 hit_test: None,
1289 has_hit_targets: false,
1290 isolation: IsolationReasons::default(),
1291 cache_policy: CachePolicy::None,
1292 cache_hashes: LayerRasterCacheHashes::default(),
1293 cache_hashes_valid: false,
1294 children: Vec::new(),
1295 })
1296 });
1297
1298 graph.root.children.retain(|child| {
1299 !matches!(
1300 child,
1301 RenderNode::Layer(layer) if layer.node_id == Some(DEV_OVERLAY_NODE_ID)
1302 )
1303 });
1304 graph
1305 .root
1306 .children
1307 .push(RenderNode::Layer(Box::new(overlay_layer)));
1308 graph.root.recompute_raster_cache_hashes();
1309 }
1310}
1311
1312fn resolve_font_size(style: &cranpose_ui::text::TextStyle) -> f32 {
1313 style.resolve_font_size(14.0)
1314}
1315
1316fn resolve_line_height(style: &cranpose_ui::text::TextStyle, font_size: f32) -> f32 {
1317 style.resolve_line_height(14.0, font_size * 1.4)
1318}
1319
1320fn resolve_max_span_font_size(
1321 style: &cranpose_ui::text::TextStyle,
1322 text: &cranpose_ui::text::AnnotatedString,
1323 base_font_size: f32,
1324) -> f32 {
1325 if text.span_styles.is_empty() {
1326 return base_font_size;
1327 }
1328
1329 let mut max_font_size = base_font_size;
1330 for window in text.span_boundaries().windows(2) {
1331 let start = window[0];
1332 let end = window[1];
1333 if start == end {
1334 continue;
1335 }
1336
1337 let mut merged_span = style.span_style.clone();
1338 for span in &text.span_styles {
1339 if span.range.start <= start && span.range.end >= end {
1340 merged_span = merged_span.merge(&span.item);
1341 }
1342 }
1343 let mut chunk_style = style.clone();
1344 chunk_style.span_style = merged_span;
1345 max_font_size = max_font_size.max(chunk_style.resolve_font_size(base_font_size));
1346 }
1347 max_font_size
1348}
1349
1350pub(crate) fn resolve_effective_line_height(
1351 style: &cranpose_ui::text::TextStyle,
1352 text: &cranpose_ui::text::AnnotatedString,
1353 base_font_size: f32,
1354) -> f32 {
1355 let max_font_size = resolve_max_span_font_size(style, text, base_font_size);
1356 resolve_line_height(style, max_font_size)
1357}
1358
1359fn family_has_italic_face(font_system: &FontSystem, family: &FamilyOwned) -> bool {
1362 let family_ref = family.as_family();
1363 let family_name = font_system.db().family_name(&family_ref);
1364 font_system.db().faces().any(|face| {
1365 (face.style == glyphon::fontdb::Style::Italic
1366 || face.style == glyphon::fontdb::Style::Oblique)
1367 && face
1368 .families
1369 .iter()
1370 .any(|(name, _)| name.eq_ignore_ascii_case(family_name))
1371 })
1372}
1373
1374fn attrs_from_text_style(
1375 style: &cranpose_ui::text::TextStyle,
1376 unscaled_base_font_size: f32,
1377 scale: f32,
1378 font_system: &mut FontSystem,
1379 font_family_resolver: &mut WgpuFontFamilyResolver,
1380) -> AttrsOwned {
1381 let mut attrs = Attrs::new();
1382 let span_style = &style.span_style;
1383 let font_weight = span_style.font_weight;
1384 let font_style = span_style.font_style;
1385 let letter_spacing = span_style.letter_spacing;
1386
1387 let unscaled_font_size = style.resolve_font_size(unscaled_base_font_size);
1388 let unscaled_line_height =
1389 style.resolve_line_height(unscaled_base_font_size, unscaled_font_size * 1.4);
1390
1391 let font_size_px = unscaled_font_size * scale;
1392 let line_height_px = unscaled_line_height * scale;
1393 attrs = attrs.metrics(glyphon::Metrics::new(font_size_px, line_height_px));
1394
1395 if let Some(color) = glyph_foreground_color(span_style) {
1396 let r = (color.0 * 255.0).clamp(0.0, 255.0) as u8;
1397 let g = (color.1 * 255.0).clamp(0.0, 255.0) as u8;
1398 let b = (color.2 * 255.0).clamp(0.0, 255.0) as u8;
1399 let a = (color.3 * 255.0).clamp(0.0, 255.0) as u8;
1400 attrs = attrs.color(glyphon::Color::rgba(r, g, b, a));
1401 }
1402
1403 let family_owned = font_family_resolver.resolve_family_owned(font_system, span_style);
1404 attrs = attrs.family(family_owned.as_family());
1405
1406 if let Some(font_weight) = font_weight {
1407 attrs = attrs.weight(GlyphonWeight(font_weight.0));
1408 }
1409
1410 let mut flags = glyphon::cosmic_text::CacheKeyFlags::DISABLE_HINTING;
1420 if let Some(font_style) = font_style {
1421 match font_style {
1422 cranpose_ui::text::FontStyle::Normal => {}
1423 cranpose_ui::text::FontStyle::Italic => {
1424 if family_has_italic_face(font_system, &family_owned) {
1425 attrs = attrs.style(GlyphonStyle::Italic);
1426 } else {
1427 flags |= glyphon::cosmic_text::CacheKeyFlags::FAKE_ITALIC;
1428 }
1429 }
1430 }
1431 }
1432
1433 attrs = match letter_spacing {
1434 cranpose_ui::text::TextUnit::Em(value) => attrs.letter_spacing(value),
1435 cranpose_ui::text::TextUnit::Sp(value) if font_size_px > 0.0 => {
1436 attrs.letter_spacing((value * scale) / font_size_px)
1437 }
1438 _ => attrs,
1439 };
1440 attrs = attrs.cache_key_flags(flags);
1441
1442 AttrsOwned::new(&attrs)
1443}
1444
1445#[derive(Clone)]
1448struct WgpuTextMeasurer {
1449 text_state: SharedTextSystemState,
1450 size_cache: TextSizeCache,
1451 prepared_layout_cache: PreparedTextLayoutCache,
1452}
1453
1454impl WgpuTextMeasurer {
1455 fn new(text_state: SharedTextSystemState) -> Self {
1456 Self {
1457 text_state,
1458 size_cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
1460 prepared_layout_cache: Rc::new(RefCell::new(LruCache::new(
1461 NonZeroUsize::new(256).unwrap(),
1462 ))),
1463 }
1464 }
1465
1466 fn text_buffer_key(
1467 node_id: Option<NodeId>,
1468 text: &str,
1469 font_size: f32,
1470 style_hash: u64,
1471 ) -> TextCacheKey {
1472 match node_id {
1473 Some(node_id) => TextCacheKey::for_node(node_id, font_size, style_hash),
1474 None => TextCacheKey::new(text, font_size, style_hash),
1475 }
1476 }
1477
1478 fn try_measure_with_options_fast_path(
1479 &self,
1480 node_id: Option<NodeId>,
1481 text: &cranpose_ui::text::AnnotatedString,
1482 style: &cranpose_ui::text::TextStyle,
1483 options: cranpose_ui::text::TextLayoutOptions,
1484 max_width: Option<f32>,
1485 ) -> Option<cranpose_ui::TextMetrics> {
1486 let options = options.normalized();
1487 let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0)?;
1488 if !Self::supports_fast_wrap_options(style, options) {
1489 return None;
1490 }
1491
1492 let text_str = text.text.as_str();
1493 let font_size = resolve_font_size(style);
1494 let line_height = resolve_effective_line_height(style, text, font_size);
1495 let size_style_hash = style.measurement_hash()
1496 ^ text.span_styles_hash()
1497 ^ (max_width.to_bits() as u64).rotate_left(17)
1498 ^ 0x9f4c_3314_2d5b_79e1;
1499 let buffer_style_hash = text_buffer_style_hash(style, text);
1500 let size_int = (font_size * 100.0) as i32;
1501
1502 let mut hasher = FxHasher::default();
1503 text_str.hash(&mut hasher);
1504 let text_hash = hasher.finish();
1505 let cache_key = (text_hash, size_int, size_style_hash);
1506
1507 {
1508 let mut cache = self.size_cache.lock().unwrap();
1509 if let Some((cached_text, size)) = cache.get(&cache_key) {
1510 if cached_text == text_str {
1511 let width = size.width.min(max_width);
1512 let min_height = options.min_lines as f32 * line_height;
1513 let height = size.height.max(min_height);
1514 let line_count =
1515 ((height / line_height).ceil() as usize).max(options.min_lines);
1516 return Some(cranpose_ui::TextMetrics {
1517 width,
1518 height,
1519 line_height,
1520 line_count,
1521 });
1522 }
1523 }
1524 }
1525
1526 let text_buffer_key =
1527 Self::text_buffer_key(node_id, text_str, font_size, buffer_style_hash);
1528 let mut text_state = self.text_state.lock().unwrap();
1529 let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1530
1531 let (size, wrapped_line_count) = {
1532 let (_, _, _, buffer) = shared_text_buffer_mut(
1533 text_cache,
1534 text_buffer_key,
1535 font_system,
1536 font_size,
1537 line_height,
1538 );
1539
1540 let _ = buffer.ensure(
1541 font_system,
1542 font_family_resolver,
1543 EnsureTextBufferParams {
1544 annotated_text: text,
1545 font_size_px: font_size,
1546 line_height_px: line_height,
1547 style_hash: buffer_style_hash,
1548 style,
1549 scale: 1.0,
1550 },
1551 );
1552
1553 buffer
1554 .buffer
1555 .set_size(font_system, Some(max_width), Some(f32::MAX));
1556 buffer.buffer.shape_until_scroll(font_system, false);
1557 buffer.cached_size = None;
1558 let size = buffer.size();
1559 let line_count = buffer.buffer.layout_runs().count();
1560 (size, line_count)
1561 };
1562 drop(text_state);
1563
1564 let mut size_cache = self.size_cache.lock().unwrap();
1565 size_cache.put(cache_key, (text_str.to_string(), size));
1566
1567 let width = size.width.min(max_width);
1568 let min_height = options.min_lines as f32 * line_height;
1569 let height = size.height.max(min_height);
1570 let line_count = wrapped_line_count.max(options.min_lines).max(1);
1571
1572 Some(cranpose_ui::TextMetrics {
1573 width,
1574 height,
1575 line_height,
1576 line_count,
1577 })
1578 }
1579
1580 fn try_prepare_with_options_fast_path(
1581 &self,
1582 node_id: Option<NodeId>,
1583 text: &cranpose_ui::text::AnnotatedString,
1584 style: &cranpose_ui::text::TextStyle,
1585 options: cranpose_ui::text::TextLayoutOptions,
1586 max_width: Option<f32>,
1587 ) -> Option<cranpose_ui::text::PreparedTextLayout> {
1588 let options = options.normalized();
1589 let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0)?;
1590 if !Self::supports_fast_wrap_options(style, options) {
1591 return None;
1592 }
1593
1594 let text_str = text.text.as_str();
1595 let font_size = resolve_font_size(style);
1596 let line_height = resolve_effective_line_height(style, text, font_size);
1597 let style_hash = text_buffer_style_hash(style, text);
1598
1599 let text_buffer_key = Self::text_buffer_key(node_id, text_str, font_size, style_hash);
1600 let mut text_state = self.text_state.lock().unwrap();
1601 let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1602
1603 let (size, wrapped_ranges) = {
1604 let (_, _, _, buffer) = shared_text_buffer_mut(
1605 text_cache,
1606 text_buffer_key,
1607 font_system,
1608 font_size,
1609 line_height,
1610 );
1611
1612 let _ = buffer.ensure(
1613 font_system,
1614 font_family_resolver,
1615 EnsureTextBufferParams {
1616 annotated_text: text,
1617 font_size_px: font_size,
1618 line_height_px: line_height,
1619 style_hash,
1620 style,
1621 scale: 1.0,
1622 },
1623 );
1624
1625 buffer
1626 .buffer
1627 .set_size(font_system, Some(max_width), Some(f32::MAX));
1628 buffer.buffer.shape_until_scroll(font_system, false);
1629 buffer.cached_size = None;
1630 let size = buffer.size();
1631 let wrapped_ranges = collect_wrapped_ranges(text_str, &buffer.buffer)?;
1632 (size, wrapped_ranges)
1633 };
1634
1635 let mut builder = cranpose_ui::text::AnnotatedString::builder();
1636 for (idx, (start, end)) in wrapped_ranges.iter().enumerate() {
1637 builder = builder.append_annotated_subsequence(text, *start..*end);
1638 if idx + 1 < wrapped_ranges.len() {
1639 builder = builder.append("\n");
1640 }
1641 }
1642 let wrapped_annotated = builder.to_annotated_string();
1643
1644 let line_count = wrapped_ranges.len().max(options.min_lines).max(1);
1645 let min_height = options.min_lines as f32 * line_height;
1646 let height = (line_count as f32 * line_height).max(min_height);
1647
1648 Some(cranpose_ui::text::PreparedTextLayout {
1649 text: wrapped_annotated,
1650 visual_style: style.clone(),
1651 metrics: cranpose_ui::TextMetrics {
1652 width: size.width.min(max_width),
1653 height,
1654 line_height,
1655 line_count,
1656 },
1657 did_overflow: false,
1658 })
1659 }
1660
1661 fn supports_fast_wrap_options(
1662 style: &cranpose_ui::text::TextStyle,
1663 options: cranpose_ui::text::TextLayoutOptions,
1664 ) -> bool {
1665 if options.overflow != cranpose_ui::text::TextOverflow::Clip || !options.soft_wrap {
1666 return false;
1667 }
1668 if options.max_lines != usize::MAX {
1669 return false;
1670 }
1671
1672 let line_break = style
1673 .paragraph_style
1674 .line_break
1675 .take_or_else(|| cranpose_ui::text::LineBreak::Simple);
1676 let hyphens = style
1677 .paragraph_style
1678 .hyphens
1679 .take_or_else(|| cranpose_ui::text::Hyphens::None);
1680 line_break == cranpose_ui::text::LineBreak::Simple
1681 && hyphens == cranpose_ui::text::Hyphens::None
1682 }
1683}
1684
1685fn collect_wrapped_ranges(text: &str, buffer: &Buffer) -> Option<Vec<(usize, usize)>> {
1686 if text.is_empty() {
1687 return Some(vec![(0, 0)]);
1688 }
1689
1690 let text_lines: Vec<&str> = text.split('\n').collect();
1691 let line_offsets: Vec<(usize, usize)> = text_lines
1692 .iter()
1693 .scan(0usize, |line_start, line| {
1694 let start = *line_start;
1695 let end = start + line.len();
1696 *line_start = end.saturating_add(1);
1697 Some((start, end))
1698 })
1699 .collect();
1700
1701 let mut wrapped_ranges = Vec::new();
1702 for run in buffer.layout_runs() {
1703 let (line_start, line_end) = line_offsets
1704 .get(run.line_i)
1705 .copied()
1706 .unwrap_or((0usize, text.len()));
1707 let line_len = line_end.saturating_sub(line_start);
1708
1709 if run.glyphs.is_empty() {
1710 wrapped_ranges.push((line_start, line_start));
1711 continue;
1712 }
1713
1714 let mut local_start = line_len;
1715 let mut local_end = 0usize;
1716 for glyph in run.glyphs.iter() {
1717 local_start = local_start.min(glyph.start.min(line_len));
1718 local_end = local_end.max(glyph.end.min(line_len));
1719 }
1720
1721 let range_start = line_start.saturating_add(local_start.min(line_len));
1722 let range_end = line_start.saturating_add(local_end.min(line_len));
1723 if range_start > range_end
1724 || range_end > text.len()
1725 || !text.is_char_boundary(range_start)
1726 || !text.is_char_boundary(range_end)
1727 {
1728 return None;
1729 }
1730 wrapped_ranges.push((range_start, range_end));
1731 }
1732
1733 if wrapped_ranges.is_empty() {
1734 Some(vec![(0, text.len())])
1735 } else {
1736 Some(wrapped_ranges)
1737 }
1738}
1739
1740pub fn setup_headless_text_measurer() {
1742 let mut font_system = FontSystem::new();
1743 let mut font_family_resolver_impl = WgpuFontFamilyResolver::default();
1744 font_family_resolver_impl.prime(&mut font_system);
1745 let text_state = Arc::new(Mutex::new(TextSystemState::from_parts(
1746 font_system,
1747 font_family_resolver_impl,
1748 )));
1749 cranpose_ui::text::set_text_measurer(WgpuTextMeasurer::new(text_state));
1750}
1751
1752impl TextMeasurer for WgpuTextMeasurer {
1755 fn measure(
1756 &self,
1757 text: &cranpose_ui::text::AnnotatedString,
1758 style: &cranpose_ui::text::TextStyle,
1759 ) -> cranpose_ui::TextMetrics {
1760 self.measure_for_node(None, text, style)
1761 }
1762
1763 fn measure_for_node(
1764 &self,
1765 node_id: Option<NodeId>,
1766 text: &cranpose_ui::text::AnnotatedString,
1767 style: &cranpose_ui::text::TextStyle,
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_calls.fetch_add(1, Ordering::Relaxed) + 1)
1772 .unwrap_or(0);
1773 let text_str = text.text.as_str();
1774 let font_size = resolve_font_size(style);
1775 let line_height = resolve_effective_line_height(style, text, font_size);
1776 let size_style_hash = style.measurement_hash() ^ text.span_styles_hash();
1777 let buffer_style_hash = text_buffer_style_hash(style, text);
1778 let size_int = (font_size * 100.0) as i32;
1779
1780 let mut hasher = FxHasher::default();
1783 text_str.hash(&mut hasher);
1784 let text_hash = hasher.finish();
1785 let cache_key = (text_hash, size_int, size_style_hash);
1786
1787 {
1789 let mut cache = self.size_cache.lock().unwrap();
1790 if let Some((cached_text, size)) = cache.get(&cache_key) {
1791 if cached_text == text_str {
1793 if let Some(t) = telemetry {
1794 t.size_cache_hits.fetch_add(1, Ordering::Relaxed);
1795 maybe_report_text_measure_telemetry(telemetry_sequence);
1796 }
1797 let line_count = text_str.split('\n').count().max(1);
1798 return cranpose_ui::TextMetrics {
1799 width: size.width,
1800 height: size.height,
1801 line_height,
1802 line_count,
1803 };
1804 }
1805 }
1806 }
1807 if let Some(t) = telemetry {
1808 t.size_cache_misses.fetch_add(1, Ordering::Relaxed);
1809 }
1810
1811 let text_buffer_key =
1813 Self::text_buffer_key(node_id, text_str, font_size, buffer_style_hash);
1814 let mut text_state = self.text_state.lock().unwrap();
1815 let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
1816
1817 let size = {
1819 let (text_cache_hit, evicted, cache_len, buffer) = shared_text_buffer_mut(
1820 text_cache,
1821 text_buffer_key,
1822 font_system,
1823 font_size,
1824 line_height,
1825 );
1826 if let Some(t) = telemetry {
1827 if text_cache_hit {
1828 t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
1829 } else {
1830 t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
1831 }
1832 if evicted {
1833 t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
1834 }
1835 t.text_cache_occupancy
1836 .store(cache_len as u64, Ordering::Relaxed);
1837 }
1838
1839 let reshaped = buffer.ensure(
1841 font_system,
1842 font_family_resolver,
1843 EnsureTextBufferParams {
1844 annotated_text: text,
1845 font_size_px: font_size,
1846 line_height_px: line_height,
1847 style_hash: buffer_style_hash,
1848 style,
1849 scale: 1.0,
1850 },
1851 );
1852 if let Some(t) = telemetry {
1853 if reshaped {
1854 t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
1855 } else {
1856 t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
1857 }
1858 }
1859
1860 buffer.size()
1862 };
1863
1864 drop(text_state);
1865
1866 let mut size_cache = self.size_cache.lock().unwrap();
1868 size_cache.put(cache_key, (text_str.to_string(), size));
1870
1871 let line_count = text_str.split('\n').count().max(1);
1873 if telemetry.is_some() {
1874 maybe_report_text_measure_telemetry(telemetry_sequence);
1875 }
1876
1877 cranpose_ui::TextMetrics {
1878 width: size.width,
1879 height: size.height,
1880 line_height,
1881 line_count,
1882 }
1883 }
1884
1885 fn measure_with_options(
1886 &self,
1887 text: &cranpose_ui::text::AnnotatedString,
1888 style: &cranpose_ui::text::TextStyle,
1889 options: cranpose_ui::text::TextLayoutOptions,
1890 max_width: Option<f32>,
1891 ) -> cranpose_ui::TextMetrics {
1892 self.measure_with_options_for_node(None, text, style, options, max_width)
1893 }
1894
1895 fn measure_with_options_for_node(
1896 &self,
1897 node_id: Option<NodeId>,
1898 text: &cranpose_ui::text::AnnotatedString,
1899 style: &cranpose_ui::text::TextStyle,
1900 options: cranpose_ui::text::TextLayoutOptions,
1901 max_width: Option<f32>,
1902 ) -> cranpose_ui::TextMetrics {
1903 let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1904 let telemetry_sequence = telemetry
1905 .map(|t| t.measure_with_options_calls.fetch_add(1, Ordering::Relaxed) + 1)
1906 .unwrap_or(0);
1907 if let Some(metrics) =
1908 self.try_measure_with_options_fast_path(node_id, text, style, options, max_width)
1909 {
1910 if let Some(t) = telemetry {
1911 t.measure_fast_path_hits.fetch_add(1, Ordering::Relaxed);
1912 maybe_report_text_measure_telemetry(telemetry_sequence);
1913 }
1914 return metrics;
1915 }
1916 if let Some(t) = telemetry {
1917 t.measure_fast_path_misses.fetch_add(1, Ordering::Relaxed);
1918 maybe_report_text_measure_telemetry(telemetry_sequence);
1919 }
1920 self.prepare_with_options_for_node(node_id, text, style, options, max_width)
1921 .metrics
1922 }
1923
1924 fn prepare_with_options(
1925 &self,
1926 text: &cranpose_ui::text::AnnotatedString,
1927 style: &cranpose_ui::text::TextStyle,
1928 options: cranpose_ui::text::TextLayoutOptions,
1929 max_width: Option<f32>,
1930 ) -> cranpose_ui::text::PreparedTextLayout {
1931 self.prepare_with_options_for_node(None, text, style, options, max_width)
1932 }
1933
1934 fn prepare_with_options_for_node(
1935 &self,
1936 node_id: Option<NodeId>,
1937 text: &cranpose_ui::text::AnnotatedString,
1938 style: &cranpose_ui::text::TextStyle,
1939 options: cranpose_ui::text::TextLayoutOptions,
1940 max_width: Option<f32>,
1941 ) -> cranpose_ui::text::PreparedTextLayout {
1942 let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
1943 let telemetry_sequence = telemetry
1944 .map(|t| t.prepare_with_options_calls.fetch_add(1, Ordering::Relaxed) + 1)
1945 .unwrap_or(0);
1946 let normalized_options = options.normalized();
1947 let normalized_max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
1948 let text_str = text.text.as_str();
1949 let font_size = resolve_font_size(style);
1950 let style_hash = text_buffer_style_hash(style, text);
1951 let size_int = (font_size * 100.0) as i32;
1952
1953 let mut hasher = FxHasher::default();
1954 text_str.hash(&mut hasher);
1955 let text_hash = hasher.finish();
1956 let cache_key = PreparedTextLayoutCacheKey {
1957 text_hash,
1958 size_int,
1959 style_hash,
1960 options: normalized_options,
1961 max_width_bits: normalized_max_width.map(f32::to_bits),
1962 };
1963
1964 {
1965 let mut cache = self.prepared_layout_cache.borrow_mut();
1966 if let Some((cached_text, prepared)) = cache.get(&cache_key) {
1967 if cached_text == text_str {
1968 if let Some(t) = telemetry {
1969 t.prepared_layout_cache_hits.fetch_add(1, Ordering::Relaxed);
1970 maybe_report_text_measure_telemetry(telemetry_sequence);
1971 }
1972 return prepared.clone();
1973 }
1974 }
1975 }
1976 if let Some(t) = telemetry {
1977 t.prepared_layout_cache_misses
1978 .fetch_add(1, Ordering::Relaxed);
1979 }
1980
1981 let prepared = if let Some(prepared) = self.try_prepare_with_options_fast_path(
1982 node_id,
1983 text,
1984 style,
1985 normalized_options,
1986 normalized_max_width,
1987 ) {
1988 if let Some(t) = telemetry {
1989 t.prepare_fast_path_hits.fetch_add(1, Ordering::Relaxed);
1990 }
1991 prepared
1992 } else {
1993 if let Some(t) = telemetry {
1994 t.prepare_fast_path_misses.fetch_add(1, Ordering::Relaxed);
1995 }
1996 cranpose_ui::text::measure::prepare_text_layout_with_measurer_for_node(
1997 self,
1998 node_id,
1999 text,
2000 style,
2001 normalized_options,
2002 normalized_max_width,
2003 )
2004 };
2005
2006 let mut cache = self.prepared_layout_cache.borrow_mut();
2007 cache.put(cache_key, (text_str.to_string(), prepared.clone()));
2008 if telemetry.is_some() {
2009 maybe_report_text_measure_telemetry(telemetry_sequence);
2010 }
2011
2012 prepared
2013 }
2014
2015 fn get_offset_for_position(
2016 &self,
2017 text: &cranpose_ui::text::AnnotatedString,
2018 style: &cranpose_ui::text::TextStyle,
2019 x: f32,
2020 y: f32,
2021 ) -> usize {
2022 let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
2023 let telemetry_sequence = telemetry
2024 .map(|t| t.offset_calls.fetch_add(1, Ordering::Relaxed) + 1)
2025 .unwrap_or(0);
2026 let text_str = text.text.as_str();
2027 let font_size = resolve_font_size(style);
2028 let line_height = resolve_effective_line_height(style, text, font_size);
2029 let style_hash = text_buffer_style_hash(style, text);
2030 if text_str.is_empty() {
2031 return 0;
2032 }
2033
2034 let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
2035
2036 let mut text_state = self.text_state.lock().unwrap();
2037 let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
2038
2039 let (text_cache_hit, evicted, cache_len, buffer) =
2040 shared_text_buffer_mut(text_cache, cache_key, font_system, font_size, line_height);
2041 if let Some(t) = telemetry {
2042 if text_cache_hit {
2043 t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
2044 } else {
2045 t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
2046 }
2047 if evicted {
2048 t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
2049 }
2050 t.text_cache_occupancy
2051 .store(cache_len as u64, Ordering::Relaxed);
2052 }
2053
2054 let reshaped = buffer.ensure(
2055 font_system,
2056 font_family_resolver,
2057 EnsureTextBufferParams {
2058 annotated_text: text,
2059 font_size_px: font_size,
2060 line_height_px: line_height,
2061 style_hash,
2062 style,
2063 scale: 1.0,
2064 },
2065 );
2066 if let Some(t) = telemetry {
2067 if reshaped {
2068 t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
2069 } else {
2070 t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
2071 }
2072 maybe_report_text_measure_telemetry(telemetry_sequence);
2073 }
2074
2075 let line_offsets: Vec<(usize, usize)> = text_str
2076 .split('\n')
2077 .scan(0usize, |line_start, line| {
2078 let start = *line_start;
2079 let end = start + line.len();
2080 *line_start = end.saturating_add(1);
2081 Some((start, end))
2082 })
2083 .collect();
2084
2085 let mut target_line = None;
2086 let mut best_vertical_distance = f32::INFINITY;
2087
2088 for run in buffer.buffer.layout_runs() {
2089 let mut run_height = run.line_height;
2090 for glyph in run.glyphs.iter() {
2091 run_height = run_height.max(glyph.font_size * 1.4);
2092 }
2093
2094 let top = run.line_top;
2095 let bottom = top + run_height.max(1.0);
2096 let vertical_distance = if y < top {
2097 top - y
2098 } else if y > bottom {
2099 y - bottom
2100 } else {
2101 0.0
2102 };
2103
2104 if vertical_distance < best_vertical_distance {
2105 best_vertical_distance = vertical_distance;
2106 target_line = Some(run.line_i);
2107 if vertical_distance == 0.0 {
2108 break;
2109 }
2110 }
2111 }
2112
2113 let fallback_line = (y / line_height).floor().max(0.0) as usize;
2114 let target_line = target_line
2115 .unwrap_or(fallback_line)
2116 .min(line_offsets.len().saturating_sub(1));
2117 let (line_start, line_end) = line_offsets
2118 .get(target_line)
2119 .copied()
2120 .unwrap_or((0, text_str.len()));
2121 let line_len = line_end.saturating_sub(line_start);
2122
2123 let mut best_offset = line_offsets
2124 .get(target_line)
2125 .map(|(_, end)| *end)
2126 .unwrap_or(text_str.len());
2127 let mut best_distance = f32::INFINITY;
2128 let mut found_glyph = false;
2129
2130 for run in buffer.buffer.layout_runs() {
2131 if run.line_i != target_line {
2132 continue;
2133 }
2134 for glyph in run.glyphs.iter() {
2135 found_glyph = true;
2136 let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
2137 let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
2138 let left_dist = (x - glyph.x).abs();
2139 if left_dist < best_distance {
2140 best_distance = left_dist;
2141 best_offset = glyph_start;
2142 }
2143
2144 let right_x = glyph.x + glyph.w;
2145 let right_dist = (x - right_x).abs();
2146 if right_dist < best_distance {
2147 best_distance = right_dist;
2148 best_offset = glyph_end;
2149 }
2150 }
2151 }
2152
2153 if !found_glyph {
2154 if let Some((start, end)) = line_offsets.get(target_line) {
2155 best_offset = if x <= 0.0 { *start } else { *end };
2156 }
2157 }
2158
2159 best_offset.min(text_str.len())
2160 }
2161
2162 fn get_cursor_x_for_offset(
2163 &self,
2164 text: &cranpose_ui::text::AnnotatedString,
2165 style: &cranpose_ui::text::TextStyle,
2166 offset: usize,
2167 ) -> f32 {
2168 let text = text.text.as_str();
2169 let clamped_offset = offset.min(text.len());
2170 if clamped_offset == 0 {
2171 return 0.0;
2172 }
2173
2174 let prefix = &text[..clamped_offset];
2176 self.measure(&cranpose_ui::text::AnnotatedString::from(prefix), style)
2177 .width
2178 }
2179
2180 fn choose_auto_hyphen_break(
2181 &self,
2182 line: &str,
2183 style: &cranpose_ui::text::TextStyle,
2184 segment_start_char: usize,
2185 measured_break_char: usize,
2186 ) -> Option<usize> {
2187 choose_shared_auto_hyphen_break(line, style, segment_start_char, measured_break_char)
2188 }
2189
2190 fn layout(
2191 &self,
2192 text: &cranpose_ui::text::AnnotatedString,
2193 style: &cranpose_ui::text::TextStyle,
2194 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
2195 let telemetry = text_measure_telemetry_enabled().then_some(text_measure_telemetry());
2196 let telemetry_sequence = telemetry
2197 .map(|t| t.layout_calls.fetch_add(1, Ordering::Relaxed) + 1)
2198 .unwrap_or(0);
2199 let text_str = text.text.as_str();
2200 use cranpose_ui::text_layout_result::{
2201 GlyphLayout, LineLayout, TextLayoutData, TextLayoutResult,
2202 };
2203
2204 let font_size = resolve_font_size(style);
2205 let line_height = resolve_effective_line_height(style, text, font_size);
2206 let style_hash = text_buffer_style_hash(style, text);
2207
2208 let cache_key = TextCacheKey::new(text_str, font_size, style_hash);
2209 let mut text_state = self.text_state.lock().unwrap();
2210 let (font_system, font_family_resolver, text_cache) = text_state.parts_mut();
2211
2212 let (text_cache_hit, evicted, cache_len, buffer) = shared_text_buffer_mut(
2213 text_cache,
2214 cache_key.clone(),
2215 font_system,
2216 font_size,
2217 line_height,
2218 );
2219 if let Some(t) = telemetry {
2220 if text_cache_hit {
2221 t.text_cache_hits.fetch_add(1, Ordering::Relaxed);
2222 } else {
2223 t.text_cache_misses.fetch_add(1, Ordering::Relaxed);
2224 }
2225 if evicted {
2226 t.text_cache_evictions.fetch_add(1, Ordering::Relaxed);
2227 }
2228 t.text_cache_occupancy
2229 .store(cache_len as u64, Ordering::Relaxed);
2230 }
2231 let reshaped = buffer.ensure(
2232 font_system,
2233 font_family_resolver,
2234 EnsureTextBufferParams {
2235 annotated_text: text,
2236 font_size_px: font_size,
2237 line_height_px: line_height,
2238 style_hash,
2239 style,
2240 scale: 1.0,
2241 },
2242 );
2243 if let Some(t) = telemetry {
2244 if reshaped {
2245 t.ensure_reshapes.fetch_add(1, Ordering::Relaxed);
2246 } else {
2247 t.ensure_reuses.fetch_add(1, Ordering::Relaxed);
2248 }
2249 maybe_report_text_measure_telemetry(telemetry_sequence);
2250 }
2251 let measured_size = buffer.size();
2252
2253 let mut glyph_x_positions = Vec::new();
2255 let mut char_to_byte = Vec::new();
2256 let mut glyph_layouts = Vec::new();
2257 let mut lines = Vec::new();
2258 let text_lines: Vec<&str> = text_str.split('\n').collect();
2259 let line_offsets: Vec<(usize, usize)> = text_lines
2260 .iter()
2261 .scan(0usize, |line_start, line| {
2262 let start = *line_start;
2263 let end = start + line.len();
2264 *line_start = end.saturating_add(1);
2265 Some((start, end))
2266 })
2267 .collect();
2268
2269 for run in buffer.buffer.layout_runs() {
2270 let line_idx = run.line_i;
2271 let run_height = run
2272 .glyphs
2273 .iter()
2274 .fold(run.line_height, |acc, glyph| acc.max(glyph.font_size * 1.4))
2275 .max(1.0);
2276
2277 for glyph in run.glyphs.iter() {
2278 let (line_start, line_end) = line_offsets
2279 .get(line_idx)
2280 .copied()
2281 .unwrap_or((0, text_str.len()));
2282 let line_len = line_end.saturating_sub(line_start);
2283 let glyph_start = line_start.saturating_add(glyph.start.min(line_len));
2284 let glyph_end = line_start.saturating_add(glyph.end.min(line_len));
2285
2286 glyph_x_positions.push(glyph.x);
2287 char_to_byte.push(glyph_start);
2288 if glyph_end > glyph_start {
2289 glyph_layouts.push(GlyphLayout {
2290 line_index: line_idx,
2291 start_offset: glyph_start,
2292 end_offset: glyph_end,
2293 x: glyph.x,
2294 y: run.line_top,
2295 width: glyph.w.max(0.0),
2296 height: run_height,
2297 });
2298 }
2299 }
2300 }
2301
2302 glyph_x_positions.push(measured_size.width);
2304 char_to_byte.push(text_str.len());
2305
2306 let mut y = 0.0f32;
2308 let mut line_start = 0usize;
2309 for (i, line_text) in text_lines.iter().enumerate() {
2310 let line_end = if i == text_lines.len() - 1 {
2311 text_str.len()
2312 } else {
2313 line_start + line_text.len()
2314 };
2315
2316 lines.push(LineLayout {
2317 start_offset: line_start,
2318 end_offset: line_end,
2319 y,
2320 height: line_height,
2321 });
2322
2323 line_start = line_end + 1;
2324 y += line_height;
2325 }
2326
2327 if lines.is_empty() {
2328 lines.push(LineLayout {
2329 start_offset: 0,
2330 end_offset: 0,
2331 y: 0.0,
2332 height: line_height,
2333 });
2334 }
2335
2336 let metrics = cranpose_ui::TextMetrics {
2337 width: measured_size.width,
2338 height: measured_size.height,
2339 line_height,
2340 line_count: text_lines.len().max(1),
2341 };
2342 TextLayoutResult::new(
2343 text_str,
2344 TextLayoutData {
2345 width: metrics.width,
2346 height: metrics.height,
2347 line_height,
2348 glyph_x_positions,
2349 char_to_byte,
2350 lines,
2351 glyph_layouts,
2352 },
2353 )
2354 }
2355}
2356
2357#[cfg(test)]
2358mod tests {
2359 use super::*;
2360 use std::sync::mpsc;
2361 use std::time::Duration;
2362
2363 const WORKER_TEST_TIMEOUT_SECS: u64 = 15;
2364
2365 fn seeded_font_system_and_resolver() -> (FontSystem, WgpuFontFamilyResolver) {
2366 let mut db = glyphon::fontdb::Database::new();
2367 db.load_font_data(TEST_FONT.to_vec());
2368 let mut font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
2369 let mut resolver = WgpuFontFamilyResolver::default();
2370 resolver.prime(&mut font_system);
2371 (font_system, resolver)
2372 }
2373
2374 fn seeded_text_state() -> SharedTextSystemState {
2375 let (font_system, resolver) = seeded_font_system_and_resolver();
2376 Arc::new(Mutex::new(TextSystemState::from_parts(
2377 font_system,
2378 resolver,
2379 )))
2380 }
2381
2382 #[test]
2383 fn attrs_resolution_falls_back_for_missing_named_family() {
2384 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2385 let style = cranpose_ui::text::TextStyle {
2386 span_style: cranpose_ui::text::SpanStyle {
2387 font_family: Some(cranpose_ui::text::FontFamily::named("Missing Family Name")),
2388 ..Default::default()
2389 },
2390 ..Default::default()
2391 };
2392
2393 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2394 assert_eq!(attrs.family_owned, FamilyOwned::SansSerif);
2395 }
2396
2397 #[test]
2398 fn attrs_resolution_seeds_generic_families_from_loaded_fonts() {
2399 let (font_system, resolver) = seeded_font_system_and_resolver();
2400 assert!(
2401 resolver.generic_fallback_seeded,
2402 "expected generic fallback seeding after resolver prime"
2403 );
2404 let query = glyphon::fontdb::Query {
2405 families: &[glyphon::fontdb::Family::Monospace],
2406 weight: glyphon::fontdb::Weight::NORMAL,
2407 stretch: glyphon::fontdb::Stretch::Normal,
2408 style: glyphon::fontdb::Style::Normal,
2409 };
2410 assert!(
2411 font_system.db().query(&query).is_some(),
2412 "generic monospace query should resolve after fallback seeding"
2413 );
2414 }
2415
2416 #[test]
2417 fn attrs_resolution_named_family_lookup_is_case_insensitive() {
2418 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2419 let style = cranpose_ui::text::TextStyle {
2420 span_style: cranpose_ui::text::SpanStyle {
2421 font_family: Some(cranpose_ui::text::FontFamily::named("noto sans")),
2422 ..Default::default()
2423 },
2424 ..Default::default()
2425 };
2426
2427 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2428 assert!(
2429 matches!(attrs.family_owned, FamilyOwned::Name(_)),
2430 "case-insensitive family lookup should resolve to a concrete family name"
2431 );
2432 }
2433
2434 #[test]
2435 fn attrs_resolution_synthesizes_italic_when_no_italic_face_available() {
2436 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2437 let style = cranpose_ui::text::TextStyle {
2438 span_style: cranpose_ui::text::SpanStyle {
2439 font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
2440 font_style: Some(cranpose_ui::text::FontStyle::Italic),
2441 ..Default::default()
2442 },
2443 ..Default::default()
2444 };
2445
2446 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2447 assert_eq!(
2450 attrs.style,
2451 GlyphonStyle::Normal,
2452 "style must stay Normal for font matching when no italic face exists"
2453 );
2454 assert!(
2455 attrs
2456 .cache_key_flags
2457 .contains(glyphon::cosmic_text::CacheKeyFlags::FAKE_ITALIC),
2458 "FAKE_ITALIC must be set when the font family lacks a native italic face"
2459 );
2460 }
2461
2462 #[test]
2463 fn attrs_resolution_preserves_requested_bold_for_synthesis() {
2464 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2465 let style = cranpose_ui::text::TextStyle {
2466 span_style: cranpose_ui::text::SpanStyle {
2467 font_family: Some(cranpose_ui::text::FontFamily::named("Noto Sans")),
2468 font_weight: Some(cranpose_ui::text::FontWeight::BOLD),
2469 ..Default::default()
2470 },
2471 ..Default::default()
2472 };
2473
2474 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2475 assert_eq!(
2476 attrs.weight,
2477 GlyphonWeight(cranpose_ui::text::FontWeight::BOLD.0),
2478 "requested bold must be preserved in attrs so glyphon can synthesize it"
2479 );
2480 }
2481
2482 #[test]
2483 fn span_level_italic_propagates_through_rich_text_ensure() {
2484 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2485 let mut text = cranpose_ui::text::AnnotatedString::from("normal italic");
2486 text.span_styles.push(cranpose_ui::text::RangeStyle {
2487 item: cranpose_ui::text::SpanStyle {
2488 font_style: Some(cranpose_ui::text::FontStyle::Italic),
2489 ..Default::default()
2490 },
2491 range: 7..13, });
2493 let style = cranpose_ui::text::TextStyle::default();
2494 let style_hash = text_buffer_style_hash(&style, &text);
2495 let mut buffer = new_shared_text_buffer(&mut font_system, 14.0, 14.0 * 1.4);
2496 buffer.ensure(
2497 &mut font_system,
2498 &mut resolver,
2499 EnsureTextBufferParams {
2500 annotated_text: &text,
2501 font_size_px: 14.0,
2502 line_height_px: 14.0 * 1.4,
2503 style_hash,
2504 style: &style,
2505 scale: 1.0,
2506 },
2507 );
2508 let has_fake_italic = buffer.buffer.layout_runs().any(|run| {
2510 run.glyphs.iter().any(|glyph| {
2511 glyph.start >= 7
2512 && glyph
2513 .cache_key_flags
2514 .contains(glyphon::cosmic_text::CacheKeyFlags::FAKE_ITALIC)
2515 })
2516 });
2517 assert!(
2518 has_fake_italic,
2519 "span-level italic must produce FAKE_ITALIC glyphs when the font lacks native italic"
2520 );
2521 }
2522
2523 #[test]
2524 fn bold_text_uses_bold_font_face_when_available() {
2525 let mut db = glyphon::fontdb::Database::new();
2526 db.load_font_data(TEST_FONT.to_vec());
2527 db.load_font_data(TEST_BOLD_FONT.to_vec());
2528 let mut font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
2529 let mut resolver = WgpuFontFamilyResolver::default();
2530 resolver.prime(&mut font_system);
2531
2532 let style = cranpose_ui::text::TextStyle {
2533 span_style: cranpose_ui::text::SpanStyle {
2534 font_weight: Some(cranpose_ui::text::FontWeight::BOLD),
2535 ..Default::default()
2536 },
2537 ..Default::default()
2538 };
2539 let text = cranpose_ui::text::AnnotatedString::from("bold text");
2540 let style_hash = text_buffer_style_hash(&style, &text);
2541 let mut buffer = new_shared_text_buffer(&mut font_system, 14.0, 14.0 * 1.4);
2542 buffer.ensure(
2543 &mut font_system,
2544 &mut resolver,
2545 EnsureTextBufferParams {
2546 annotated_text: &text,
2547 font_size_px: 14.0,
2548 line_height_px: 14.0 * 1.4,
2549 style_hash,
2550 style: &style,
2551 scale: 1.0,
2552 },
2553 );
2554 let bold_face_used = buffer.buffer.layout_runs().any(|run| {
2555 run.glyphs.iter().any(|glyph| {
2556 font_system
2557 .db()
2558 .face(glyph.font_id)
2559 .is_some_and(|face| face.weight.0 == 700)
2560 })
2561 });
2562 assert!(
2563 bold_face_used,
2564 "bold text must use the bold font face (weight 700) when available"
2565 );
2566 }
2567
2568 #[test]
2569 fn attrs_from_text_style_applies_alpha_to_foreground_color() {
2570 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2571 let style = cranpose_ui::text::TextStyle::from_span_style(cranpose_ui::text::SpanStyle {
2572 color: Some(cranpose_ui::Color(0.2, 0.4, 0.6, 1.0)),
2573 alpha: Some(0.25),
2574 ..Default::default()
2575 });
2576
2577 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2578
2579 assert_eq!(
2580 attrs.color_opt,
2581 Some(glyphon::Color::rgba(51, 102, 153, 63)),
2582 "glyph attrs must track alpha-adjusted foreground color"
2583 );
2584 }
2585
2586 #[test]
2587 fn attrs_from_text_style_disables_native_hinting() {
2588 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2589 let attrs = attrs_from_text_style(
2590 &cranpose_ui::text::TextStyle::default(),
2591 14.0,
2592 1.0,
2593 &mut font_system,
2594 &mut resolver,
2595 );
2596
2597 assert!(
2598 attrs
2599 .cache_key_flags
2600 .contains(glyphon::cosmic_text::CacheKeyFlags::DISABLE_HINTING),
2601 "renderer text attrs should disable native hinting to keep glyph rasterization stable across scroll phases"
2602 );
2603 }
2604
2605 #[test]
2606 fn text_buffer_style_hash_changes_when_top_level_color_changes() {
2607 let text = cranpose_ui::text::AnnotatedString::from("theme");
2608 let dark = cranpose_ui::text::TextStyle::from_span_style(cranpose_ui::text::SpanStyle {
2609 color: Some(cranpose_ui::Color::BLACK),
2610 ..Default::default()
2611 });
2612 let light = cranpose_ui::text::TextStyle::from_span_style(cranpose_ui::text::SpanStyle {
2613 color: Some(cranpose_ui::Color::WHITE),
2614 ..Default::default()
2615 });
2616
2617 assert_ne!(
2618 text_buffer_style_hash(&dark, &text),
2619 text_buffer_style_hash(&light, &text),
2620 "color-only theme flips must invalidate glyph buffer caches"
2621 );
2622 }
2623
2624 #[test]
2625 fn text_buffer_style_hash_changes_when_span_alpha_changes() {
2626 let mut opaque = cranpose_ui::text::AnnotatedString::from("theme");
2627 opaque.span_styles.push(cranpose_ui::text::RangeStyle {
2628 item: cranpose_ui::text::SpanStyle {
2629 color: Some(cranpose_ui::Color::BLACK),
2630 alpha: Some(1.0),
2631 ..Default::default()
2632 },
2633 range: 0..5,
2634 });
2635
2636 let mut translucent = cranpose_ui::text::AnnotatedString::from("theme");
2637 translucent.span_styles.push(cranpose_ui::text::RangeStyle {
2638 item: cranpose_ui::text::SpanStyle {
2639 color: Some(cranpose_ui::Color::BLACK),
2640 alpha: Some(0.2),
2641 ..Default::default()
2642 },
2643 range: 0..5,
2644 });
2645
2646 assert_ne!(
2647 text_buffer_style_hash(&cranpose_ui::text::TextStyle::default(), &opaque),
2648 text_buffer_style_hash(&cranpose_ui::text::TextStyle::default(), &translucent),
2649 "span alpha changes must invalidate glyph buffer caches"
2650 );
2651 }
2652
2653 #[test]
2654 fn select_text_shaping_uses_basic_for_simple_text_when_requested() {
2655 let style =
2656 cranpose_ui::text::TextStyle::from_paragraph_style(cranpose_ui::text::ParagraphStyle {
2657 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
2658 include_font_padding: None,
2659 shaping: Some(cranpose_ui::text::TextShaping::Basic),
2660 }),
2661 ..Default::default()
2662 });
2663 let text = cranpose_ui::text::AnnotatedString::from("• Item 0042: basic markdown text");
2664
2665 assert_eq!(select_text_shaping(&text, &style), Shaping::Basic);
2666 }
2667
2668 #[test]
2669 fn select_text_shaping_falls_back_to_advanced_for_complex_text() {
2670 let style =
2671 cranpose_ui::text::TextStyle::from_paragraph_style(cranpose_ui::text::ParagraphStyle {
2672 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
2673 include_font_padding: None,
2674 shaping: Some(cranpose_ui::text::TextShaping::Basic),
2675 }),
2676 ..Default::default()
2677 });
2678 let text = cranpose_ui::text::AnnotatedString::from("emoji 😀 requires fallback");
2679
2680 assert_eq!(select_text_shaping(&text, &style), Shaping::Advanced);
2681 }
2682
2683 #[test]
2684 fn layout_matches_measure_without_reentrant_mutex_lock() {
2685 use std::sync::mpsc;
2686 use std::time::Duration;
2687
2688 let (tx, rx) = mpsc::channel();
2689 std::thread::spawn(move || {
2690 let measurer = WgpuTextMeasurer::new(seeded_text_state());
2691 let text = cranpose_ui::text::AnnotatedString::from("hello\nworld");
2692 let style = cranpose_ui::text::TextStyle::default();
2693
2694 let layout = measurer.layout(&text, &style);
2695 let metrics = measurer.measure(&text, &style);
2696 tx.send((
2697 layout.width,
2698 layout.height,
2699 layout.lines.len(),
2700 metrics.width,
2701 metrics.height,
2702 metrics.line_count,
2703 ))
2704 .expect("send layout metrics");
2705 });
2706
2707 let (
2708 layout_width,
2709 layout_height,
2710 layout_lines,
2711 measured_width,
2712 measured_height,
2713 measured_lines,
2714 ) = rx
2715 .recv_timeout(Duration::from_secs(WORKER_TEST_TIMEOUT_SECS))
2716 .expect("layout timed out; possible recursive mutex acquisition");
2717
2718 assert!((layout_width - measured_width).abs() < 0.5);
2719 assert!((layout_height - measured_height).abs() < 0.5);
2720 assert_eq!(layout_lines, measured_lines.max(1));
2721 }
2722
2723 #[test]
2724 fn measure_with_options_fast_path_wraps_to_width() {
2725 use std::sync::mpsc;
2726 use std::time::Duration;
2727
2728 let (tx, rx) = mpsc::channel();
2729 std::thread::spawn(move || {
2730 let measurer = WgpuTextMeasurer::new(seeded_text_state());
2731 let text = cranpose_ui::text::AnnotatedString::from("wrap me ".repeat(120));
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 let metrics =
2740 TextMeasurer::measure_with_options(&measurer, &text, &style, options, Some(120.0));
2741 tx.send((metrics.width, metrics.line_count))
2742 .expect("send wrapped metrics");
2743 });
2744
2745 let (width, line_count) = rx
2746 .recv_timeout(Duration::from_secs(WORKER_TEST_TIMEOUT_SECS))
2747 .expect("measure_with_options timed out");
2748 assert!(width <= 120.5, "wrapped width should honor max width");
2749 assert!(line_count > 1, "wrapped text should produce multiple lines");
2750 }
2751
2752 #[test]
2753 fn prepare_with_options_reuses_cached_layout() {
2754 use std::sync::mpsc;
2755 use std::time::Duration;
2756
2757 let (tx, rx) = mpsc::channel();
2758 std::thread::spawn(move || {
2759 let measurer = WgpuTextMeasurer::new(seeded_text_state());
2760 let text = cranpose_ui::text::AnnotatedString::from(
2761 "This paragraph demonstrates wrapping with a cached prepared layout.",
2762 );
2763 let style = cranpose_ui::text::TextStyle::default();
2764 let options = cranpose_ui::text::TextLayoutOptions {
2765 overflow: cranpose_ui::text::TextOverflow::Clip,
2766 soft_wrap: true,
2767 max_lines: usize::MAX,
2768 min_lines: 1,
2769 };
2770
2771 let first =
2772 TextMeasurer::prepare_with_options(&measurer, &text, &style, options, Some(96.0));
2773 let first_cache_len = measurer.prepared_layout_cache.borrow().len();
2774
2775 let second =
2776 TextMeasurer::prepare_with_options(&measurer, &text, &style, options, Some(96.0));
2777 let second_cache_len = measurer.prepared_layout_cache.borrow().len();
2778
2779 tx.send((
2780 first == second,
2781 first.text.text.contains('\n'),
2782 first_cache_len,
2783 second_cache_len,
2784 ))
2785 .expect("send prepared layout cache result");
2786 });
2787
2788 let (same_layout, wrapped_text, first_cache_len, second_cache_len) = rx
2789 .recv_timeout(Duration::from_secs(WORKER_TEST_TIMEOUT_SECS))
2790 .expect("prepare_with_options timed out");
2791 assert!(same_layout, "cached prepared layout should be identical");
2792 assert!(
2793 wrapped_text,
2794 "prepared layout should preserve wrapped text output"
2795 );
2796 assert_eq!(first_cache_len, 1);
2797 assert_eq!(second_cache_len, 1);
2798 }
2799
2800 #[test]
2801 fn measure_for_node_uses_node_cache_identity() {
2802 use std::sync::mpsc;
2803 use std::time::Duration;
2804
2805 let (tx, rx) = mpsc::channel();
2806 std::thread::spawn(move || {
2807 let measurer = WgpuTextMeasurer::new(seeded_text_state());
2808 let text = cranpose_ui::text::AnnotatedString::from("shared node identity");
2809 let style = cranpose_ui::text::TextStyle::default();
2810 let node_id = 4242;
2811
2812 let _ = TextMeasurer::measure_for_node(&measurer, Some(node_id), &text, &style);
2813
2814 let font_size = resolve_font_size(&style);
2815 let style_hash = text_buffer_style_hash(&style, &text);
2816 let expected_key = TextCacheKey::for_node(node_id, font_size, style_hash);
2817 let text_state = measurer.text_state.lock().expect("text state lock");
2818 let cache = &text_state.text_cache;
2819
2820 tx.send((
2821 cache.len(),
2822 cache.contains(&expected_key),
2823 cache
2824 .iter()
2825 .any(|(key, _)| matches!(key.key, TextKey::Content(_))),
2826 ))
2827 .expect("send node cache result");
2828 });
2829
2830 let (cache_len, has_node_key, has_content_key) = rx
2831 .recv_timeout(Duration::from_secs(WORKER_TEST_TIMEOUT_SECS))
2832 .expect("measure_for_node timed out");
2833 assert_eq!(cache_len, 1);
2834 assert!(
2835 has_node_key,
2836 "node-aware measurement should populate node cache key"
2837 );
2838 assert!(
2839 !has_content_key,
2840 "node-aware measurement should not populate content cache keys"
2841 );
2842 }
2843
2844 #[test]
2845 fn renderer_measurement_keeps_render_text_cache_empty() {
2846 let (tx, rx) = mpsc::channel();
2847 std::thread::spawn(move || {
2848 let renderer = WgpuRenderer::new(&[TEST_FONT]);
2849 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
2850 let style = cranpose_ui::text::TextStyle::default();
2851
2852 let _ = cranpose_ui::text::measure_text(&text, &style);
2853
2854 tx.send(renderer.render_text_state.lock().unwrap().text_cache.len())
2855 .expect("send render text cache size");
2856 });
2857
2858 let render_text_cache_len = rx
2859 .recv_timeout(Duration::from_secs(WORKER_TEST_TIMEOUT_SECS))
2860 .expect("renderer measurement isolation timed out");
2861 assert_eq!(
2862 render_text_cache_len, 0,
2863 "measurement should not populate render-owned text cache"
2864 );
2865 }
2866
2867 #[test]
2868 fn shared_text_cache_uses_bounded_lru_eviction() {
2869 let mut font_system = FontSystem::new();
2870 let mut cache = LruCache::new(NonZeroUsize::new(SHARED_TEXT_CACHE_CAPACITY).unwrap());
2871
2872 for index in 0..=SHARED_TEXT_CACHE_CAPACITY {
2873 let text = format!("cache-entry-{index}");
2874 let key = TextCacheKey::new(text.as_str(), 14.0, 7);
2875 let _ = shared_text_buffer_mut(&mut cache, key, &mut font_system, 14.0, 16.0);
2876 }
2877
2878 let oldest = TextCacheKey::new("cache-entry-0", 14.0, 7);
2879 let newest = TextCacheKey::new(
2880 format!("cache-entry-{}", SHARED_TEXT_CACHE_CAPACITY).as_str(),
2881 14.0,
2882 7,
2883 );
2884
2885 assert_eq!(cache.len(), SHARED_TEXT_CACHE_CAPACITY);
2886 assert!(!cache.contains(&oldest));
2887 assert!(cache.contains(&newest));
2888 }
2889
2890 static TEST_FONT: &[u8] =
2892 include_bytes!("../../../../apps/desktop-demo/assets/NotoSansMerged.ttf");
2893 static TEST_BOLD_FONT: &[u8] =
2894 include_bytes!("../../../../apps/desktop-demo/assets/NotoSansBold.ttf");
2895 static TEST_EMOJI_FONT: &[u8] =
2896 include_bytes!("../../../../apps/desktop-demo/assets/TwemojiMozilla.ttf");
2897
2898 fn empty_font_system() -> FontSystem {
2899 let db = glyphon::fontdb::Database::new();
2900 FontSystem::new_with_locale_and_db("en-US".to_string(), db)
2901 }
2902
2903 #[test]
2904 fn load_fonts_populates_face_db() {
2905 let mut fs = empty_font_system();
2906 load_fonts(&mut fs, &[TEST_FONT]);
2907 assert!(
2908 fs.db().faces().count() > 0,
2909 "load_fonts must load at least one face"
2910 );
2911 }
2912
2913 #[test]
2914 fn load_fonts_empty_slice_leaves_db_empty() {
2915 let mut fs = empty_font_system();
2916 load_fonts(&mut fs, &[]);
2917 assert_eq!(
2918 fs.db().faces().count(),
2919 0,
2920 "empty slice must not load any faces"
2921 );
2922 }
2923
2924 fn queried_family_name(font_system: &FontSystem, family: glyphon::fontdb::Family) -> String {
2925 let query = glyphon::fontdb::Query {
2926 families: &[family],
2927 weight: glyphon::fontdb::Weight::NORMAL,
2928 stretch: glyphon::fontdb::Stretch::Normal,
2929 style: glyphon::fontdb::Style::Normal,
2930 };
2931 let face_id = font_system
2932 .db()
2933 .query(&query)
2934 .expect("generic family should resolve to a face");
2935 let face = font_system
2936 .db()
2937 .face(face_id)
2938 .expect("queried face id should exist");
2939 face.families
2940 .first()
2941 .map(|(name, _)| name.clone())
2942 .expect("queried face should carry a family name")
2943 }
2944
2945 #[test]
2946 fn generic_fallbacks_prefer_loaded_font_family_over_existing_faces() {
2947 let mut font_system = empty_font_system();
2948 load_fonts(&mut font_system, &[TEST_EMOJI_FONT, TEST_FONT]);
2949 let mut resolver = WgpuFontFamilyResolver::default();
2950 resolver.set_preferred_generic_family(primary_family_name_from_bytes(TEST_FONT));
2951 resolver.prime(&mut font_system);
2952
2953 let generic_serif = queried_family_name(&font_system, glyphon::fontdb::Family::Serif);
2954 let expected = primary_family_name_from_bytes(TEST_FONT)
2955 .expect("test font should resolve to a family name");
2956 assert_eq!(generic_serif, expected);
2957 }
2958
2959 #[test]
2960 fn resolver_logs_warning_if_font_db_is_empty() {
2961 let mut font_system = empty_font_system();
2963 let mut resolver = WgpuFontFamilyResolver::default();
2964 let span_style = cranpose_ui::text::SpanStyle::default();
2965 let _ = resolver.resolve_family_owned(&mut font_system, &span_style);
2967 }
2968
2969 #[test]
2970 #[cfg(not(target_arch = "wasm32"))]
2971 fn attrs_resolution_loads_file_backed_family_from_path() {
2972 let (mut font_system, mut resolver) = seeded_font_system_and_resolver();
2973 let nonce = std::time::SystemTime::now()
2974 .duration_since(std::time::UNIX_EPOCH)
2975 .map(|duration| duration.as_nanos())
2976 .unwrap_or_default();
2977 let unique_path = format!(
2978 "{}/cranpose-font-resolver-{}-{}.ttf",
2979 std::env::temp_dir().display(),
2980 std::process::id(),
2981 nonce
2982 );
2983 std::fs::write(&unique_path, TEST_FONT).expect("write font fixture");
2984
2985 let style = cranpose_ui::text::TextStyle {
2986 span_style: cranpose_ui::text::SpanStyle {
2987 font_family: Some(cranpose_ui::text::FontFamily::file_backed(vec![
2988 cranpose_ui::text::FontFile::new(unique_path.clone()),
2989 ])),
2990 ..Default::default()
2991 },
2992 ..Default::default()
2993 };
2994
2995 let attrs = attrs_from_text_style(&style, 14.0, 1.0, &mut font_system, &mut resolver);
2996 assert!(
2997 matches!(attrs.family_owned, FamilyOwned::Name(_)),
2998 "file-backed font family should resolve to an installed family name"
2999 );
3000
3001 let _ = std::fs::remove_file(&unique_path);
3002 }
3003}