1#![deny(unsafe_code)]
7
8mod effect_renderer;
9mod frame_graph;
10pub(crate) mod gpu_stats;
11mod layer_events;
12mod layer_surface_cache;
13mod normalized_scene;
14mod offscreen;
15mod pipeline;
16mod render;
17mod scene;
18mod shader_cache;
19mod shaders;
20mod surface_executor;
21mod surface_plan;
22mod surface_requirements;
23#[cfg(test)]
24mod test_support;
25
26pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
27pub use scene::{ClickAction, HitRegion, Scene};
28
29use cranpose_core::{MemoryApplier, NodeId};
30use cranpose_render_common::{
31 graph::{
32 CachePolicy, DrawPrimitiveNode, IsolationReasons, LayerNode, PrimitiveEntry, PrimitiveNode,
33 PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
34 },
35 raster_cache::LayerRasterCacheHashes,
36 software_text_raster::{
37 software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
38 },
39 RenderScene, Renderer,
40};
41use cranpose_ui::{LayoutTree, TextMeasurer};
42use cranpose_ui_graphics::{
43 Brush, Color, CornerRadii, DrawPrimitive, GraphicsLayer, Point, Rect, Size,
44};
45use render::GpuRenderer;
46use std::rc::{Rc, Weak};
47use std::sync::Arc;
48
49pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
51 [
52 [rect.x, rect.y],
53 [rect.x + rect.width, rect.y],
54 [rect.x, rect.y + rect.height],
55 [rect.x + rect.width, rect.y + rect.height],
56 ]
57}
58
59#[derive(Debug)]
60pub enum WgpuRendererError {
61 Layout(String),
62 Wgpu(String),
63}
64
65#[derive(Debug, Clone)]
67pub struct CapturedFrame {
68 pub width: u32,
69 pub height: u32,
70 pub pixels: Vec<u8>,
71}
72
73#[doc(hidden)]
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub struct DebugCpuAllocationStats {
76 pub scene_graph_node_count: usize,
77 pub scene_graph_heap_bytes: usize,
78 pub scene_hits_len: usize,
79 pub scene_hits_cap: usize,
80 pub scene_node_index_len: usize,
81 pub scene_node_index_cap: usize,
82 pub text_renderer_pool_len: usize,
83 pub text_renderer_pool_cap: usize,
84 pub swash_image_cache_len: usize,
85 pub swash_image_cache_cap: usize,
86 pub swash_outline_cache_len: usize,
87 pub swash_outline_cache_cap: usize,
88 pub image_texture_cache_len: usize,
89 pub image_texture_cache_cap: usize,
90 pub scratch_shape_data_cap: usize,
91 pub scratch_gradients_cap: usize,
92 pub scratch_vertices_cap: usize,
93 pub scratch_indices_cap: usize,
94 pub scratch_image_vertices_cap: usize,
95 pub scratch_image_indices_cap: usize,
96 pub scratch_image_cmds_cap: usize,
97 pub scratch_segment_items_cap: usize,
98 pub scratch_effect_ranges_cap: usize,
99 pub scratch_layer_events_cap: usize,
100 pub staged_upload_bytes_cap: usize,
101 pub staged_upload_copies_cap: usize,
102 pub layer_surface_cache_len: usize,
103 pub layer_surface_cache_cap: usize,
104 pub layer_surface_cache_identity_len: usize,
105 pub layer_surface_cache_identity_cap: usize,
106 pub layer_surface_rect_cache_len: usize,
107 pub layer_surface_rect_cache_cap: usize,
108 pub layer_surface_requirements_cache_len: usize,
109 pub layer_surface_requirements_cache_cap: usize,
110 pub layer_cache_seen_this_frame_len: usize,
111 pub layer_cache_seen_this_frame_cap: usize,
112}
113
114pub(crate) struct TextSystemState {
115 measurer: SoftwareTextMeasurer,
116}
117
118impl TextSystemState {
119 fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
120 Self {
121 measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
122 }
123 }
124
125 pub(crate) fn text_cache_len(&self) -> usize {
126 0
127 }
128}
129
130impl pipeline::TextLayoutResolver for TextSystemState {
131 fn layout_text(
132 &mut self,
133 text: &cranpose_ui::text::AnnotatedString,
134 style: &cranpose_ui::text::TextStyle,
135 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
136 if cranpose_ui::has_current_app_context() {
137 cranpose_ui::text::layout_text(text, style)
138 } else {
139 self.measurer.layout(text, style)
140 }
141 }
142}
143
144#[derive(Clone)]
145pub struct WgpuTextSystem {
146 software_fonts: SoftwareTextFontSet,
147}
148
149impl WgpuTextSystem {
150 pub fn from_fonts(fonts: &[&[u8]]) -> Self {
151 Self {
152 software_fonts: software_text_font_set_from_fonts_or_default(fonts),
153 }
154 }
155
156 fn render_state(&self) -> TextSystemState {
157 TextSystemState::from_font_set(self.software_fonts.clone())
158 }
159
160 fn software_fonts(&self) -> SoftwareTextFontSet {
161 self.software_fonts.clone()
162 }
163}
164
165pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
167 headless_text_measurer_with_fonts(&[])
168}
169
170pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
172 Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
173}
174
175pub struct WgpuRenderer {
183 scene: Scene,
184 gpu_renderer: Option<GpuRenderer>,
185 text_state: TextSystemState,
186 text_fonts: SoftwareTextFontSet,
187 app_context: Option<Weak<cranpose_ui::AppContext>>,
188 root_scale: f32,
190 dev_overlay_cache: Option<DevOverlayCache>,
191 dev_overlay_graph: Option<RenderGraph>,
192}
193
194#[derive(Clone, Debug)]
195struct DevOverlayCache {
196 text: String,
197 viewport_width_bits: u32,
198 viewport_height_bits: u32,
199}
200
201impl WgpuRenderer {
202 pub fn new(fonts: &[&[u8]]) -> Self {
209 Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
210 }
211
212 pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
213 Self {
214 scene: Scene::new(),
215 gpu_renderer: None,
216 text_state: text_system.render_state(),
217 text_fonts: text_system.software_fonts(),
218 app_context: None,
219 root_scale: 1.0,
220 dev_overlay_cache: None,
221 dev_overlay_graph: None,
222 }
223 }
224
225 pub fn init_gpu(
227 &mut self,
228 device: Arc<wgpu::Device>,
229 queue: Arc<wgpu::Queue>,
230 surface_format: wgpu::TextureFormat,
231 adapter_backend: wgpu::Backend,
232 ) {
233 self.gpu_renderer = Some(GpuRenderer::new(
234 device,
235 queue,
236 surface_format,
237 adapter_backend,
238 self.text_fonts.clone(),
239 ));
240 }
241
242 pub fn set_root_scale(&mut self, scale: f32) {
244 self.root_scale = scale;
245 }
246
247 pub fn root_scale(&self) -> f32 {
248 self.root_scale
249 }
250
251 pub fn render(
253 &mut self,
254 view: &wgpu::TextureView,
255 width: u32,
256 height: u32,
257 ) -> Result<(), WgpuRendererError> {
258 if let Some(gpu_renderer) = &mut self.gpu_renderer {
259 let graph = self
260 .scene
261 .graph
262 .as_ref()
263 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
264 let text_state = &mut self.text_state;
265 let root_scale = self.root_scale;
266 let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
267 let result = if let Some(app_context) = app_context {
268 app_context.enter(|| {
269 gpu_renderer.render(
270 text_state,
271 view,
272 graph,
273 self.dev_overlay_graph.as_ref(),
274 width,
275 height,
276 root_scale,
277 )
278 })
279 } else {
280 gpu_renderer.render(
281 text_state,
282 view,
283 graph,
284 self.dev_overlay_graph.as_ref(),
285 width,
286 height,
287 root_scale,
288 )
289 };
290 result.map_err(WgpuRendererError::Wgpu)
291 } else {
292 Err(WgpuRendererError::Wgpu(
293 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
294 ))
295 }
296 }
297
298 pub fn capture_frame(
302 &mut self,
303 width: u32,
304 height: u32,
305 ) -> Result<CapturedFrame, WgpuRendererError> {
306 self.capture_frame_with_scale(width, height, self.root_scale)
307 }
308
309 pub fn capture_frame_with_scale(
311 &mut self,
312 width: u32,
313 height: u32,
314 root_scale: f32,
315 ) -> Result<CapturedFrame, WgpuRendererError> {
316 if let Some(gpu_renderer) = &mut self.gpu_renderer {
317 let graph = self
318 .scene
319 .graph
320 .as_ref()
321 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
322 let text_state = &mut self.text_state;
323 let app_context = self.app_context.as_ref().and_then(Weak::upgrade);
324 let pixels = if let Some(app_context) = app_context {
325 app_context.enter(|| {
326 gpu_renderer.render_to_rgba_pixels(
327 text_state,
328 graph,
329 self.dev_overlay_graph.as_ref(),
330 width,
331 height,
332 root_scale,
333 )
334 })
335 } else {
336 gpu_renderer.render_to_rgba_pixels(
337 text_state,
338 graph,
339 self.dev_overlay_graph.as_ref(),
340 width,
341 height,
342 root_scale,
343 )
344 }
345 .map_err(WgpuRendererError::Wgpu)?;
346 Ok(CapturedFrame {
347 width,
348 height,
349 pixels,
350 })
351 } else {
352 Err(WgpuRendererError::Wgpu(
353 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
354 ))
355 }
356 }
357
358 pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
359 self.gpu_renderer
360 .as_ref()
361 .and_then(GpuRenderer::last_frame_stats)
362 }
363
364 pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
365 let mut stats = self
366 .gpu_renderer
367 .as_ref()
368 .map(GpuRenderer::debug_cpu_allocation_stats)
369 .unwrap_or_default();
370 stats.scene_graph_node_count = self
371 .scene
372 .graph
373 .as_ref()
374 .map(RenderGraph::node_count)
375 .unwrap_or(0);
376 stats.scene_graph_heap_bytes = self
377 .scene
378 .graph
379 .as_ref()
380 .map(RenderGraph::heap_bytes)
381 .unwrap_or(0);
382 stats.scene_hits_len = self.scene.hits.len();
383 stats.scene_hits_cap = self.scene.hits.capacity();
384 stats.scene_node_index_len = self.scene.node_index.len();
385 stats.scene_node_index_cap = self.scene.node_index.capacity();
386 stats
387 }
388
389 pub fn try_device(&self) -> Option<&wgpu::Device> {
391 self.gpu_renderer.as_ref().map(|r| &*r.device)
392 }
393}
394
395impl Default for WgpuRenderer {
396 fn default() -> Self {
397 Self::new(&[])
398 }
399}
400
401impl Renderer for WgpuRenderer {
402 type Scene = Scene;
403 type Error = WgpuRendererError;
404
405 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
406 app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
407 self.text_fonts.clone(),
408 8192,
409 ));
410 self.app_context = Some(app_context.downgrade());
411 }
412
413 fn scene(&self) -> &Self::Scene {
414 &self.scene
415 }
416
417 fn scene_mut(&mut self) -> &mut Self::Scene {
418 &mut self.scene
419 }
420
421 fn rebuild_scene(
422 &mut self,
423 layout_tree: &LayoutTree,
424 _viewport: Size,
425 ) -> Result<(), Self::Error> {
426 self.scene.clear();
427 self.dev_overlay_graph = None;
428 self.dev_overlay_cache = None;
429 pipeline::render_layout_tree(layout_tree.root(), &mut self.scene);
431 Ok(())
432 }
433
434 fn rebuild_scene_from_applier(
435 &mut self,
436 applier: &mut MemoryApplier,
437 root: NodeId,
438 _viewport: Size,
439 ) -> Result<(), Self::Error> {
440 self.scene.clear();
441 self.dev_overlay_graph = None;
442 self.dev_overlay_cache = None;
443 pipeline::render_from_applier(applier, root, &mut self.scene, 1.0);
446 Ok(())
447 }
448
449 fn update_scene_from_applier(
450 &mut self,
451 applier: &mut MemoryApplier,
452 root: NodeId,
453 viewport: Size,
454 dirty_nodes: &[NodeId],
455 ) -> Result<(), Self::Error> {
456 if dirty_nodes.is_empty() {
457 return self.rebuild_scene_from_applier(applier, root, viewport);
458 }
459 pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, true);
460 Ok(())
461 }
462
463 fn update_visual_scene_from_applier(
464 &mut self,
465 applier: &mut MemoryApplier,
466 root: NodeId,
467 viewport: Size,
468 dirty_nodes: &[NodeId],
469 ) -> Result<(), Self::Error> {
470 if dirty_nodes.is_empty() {
471 return self.rebuild_scene_from_applier(applier, root, viewport);
472 }
473 pipeline::update_from_applier(applier, root, &mut self.scene, 1.0, dirty_nodes, false);
474 Ok(())
475 }
476
477 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
478 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
479 let padding = 8.0;
480 let font_size = 14.0;
481 let char_width = 7.0;
482 let viewport_width_bits = viewport.width.to_bits();
483 let viewport_height_bits = viewport.height.to_bits();
484 if self.dev_overlay_graph.is_some()
485 && self.dev_overlay_cache.as_ref().is_some_and(|cache| {
486 cache.text == text
487 && cache.viewport_width_bits == viewport_width_bits
488 && cache.viewport_height_bits == viewport_height_bits
489 })
490 {
491 return;
492 }
493
494 let text_width = text.len() as f32 * char_width;
495 let text_height = font_size * 1.4;
496 let x = (viewport.width - text_width - padding * 2.0).max(padding);
497 let y = padding;
498
499 let mut overlay_layer = LayerNode {
500 node_id: Some(DEV_OVERLAY_NODE_ID),
501 local_bounds: Rect {
502 x: 0.0,
503 y: 0.0,
504 width: text_width + padding,
505 height: text_height + padding / 2.0,
506 },
507 transform_to_parent: ProjectiveTransform::translation(x, y),
508 content_offset: Point::default(),
509 motion_context_animated: false,
510 translated_content_context: false,
511 translated_content_offset: Point::default(),
512 graphics_layer: GraphicsLayer::default(),
513 clip_to_bounds: false,
514 shadow_clip: None,
515 hit_test: None,
516 has_hit_targets: false,
517 isolation: IsolationReasons::default(),
518 cache_policy: CachePolicy::None,
519 cache_hashes: LayerRasterCacheHashes::default(),
520 cache_hashes_valid: false,
521 children: vec![
522 RenderNode::Primitive(PrimitiveEntry {
523 phase: PrimitivePhase::BeforeChildren,
524 node: PrimitiveNode::Draw(DrawPrimitiveNode {
525 primitive: DrawPrimitive::RoundRect {
526 rect: Rect {
527 x: 0.0,
528 y: 0.0,
529 width: text_width + padding,
530 height: text_height + padding / 2.0,
531 },
532 brush: Brush::Solid(Color(0.0, 0.0, 0.0, 0.7)),
533 radii: CornerRadii::uniform(4.0),
534 },
535 clip: None,
536 }),
537 }),
538 RenderNode::Primitive(PrimitiveEntry {
539 phase: PrimitivePhase::AfterChildren,
540 node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
541 node_id: DEV_OVERLAY_NODE_ID,
542 rect: Rect {
543 x: padding / 2.0,
544 y: padding / 4.0,
545 width: text_width,
546 height: text_height,
547 },
548 text: cranpose_ui::text::AnnotatedString::from(text),
549 text_style: cranpose_ui::TextStyle::default(),
550 font_size,
551 layout_options: cranpose_ui::TextLayoutOptions::default(),
552 clip: None,
553 })),
554 }),
555 ],
556 };
557 overlay_layer.recompute_raster_cache_hashes();
558
559 let mut graph = RenderGraph::new(LayerNode {
560 node_id: None,
561 local_bounds: Rect::from_size(viewport),
562 transform_to_parent: ProjectiveTransform::identity(),
563 content_offset: Point::default(),
564 motion_context_animated: false,
565 translated_content_context: false,
566 translated_content_offset: Point::default(),
567 graphics_layer: GraphicsLayer::default(),
568 clip_to_bounds: false,
569 shadow_clip: None,
570 hit_test: None,
571 has_hit_targets: false,
572 isolation: IsolationReasons::default(),
573 cache_policy: CachePolicy::None,
574 cache_hashes: LayerRasterCacheHashes::default(),
575 cache_hashes_valid: false,
576 children: vec![RenderNode::Layer(Box::new(overlay_layer))],
577 });
578 graph.root.recompute_raster_cache_hashes();
579 self.dev_overlay_graph = Some(graph);
580 self.dev_overlay_cache = Some(DevOverlayCache {
581 text: text.to_string(),
582 viewport_width_bits,
583 viewport_height_bits,
584 });
585 }
586
587 fn needs_frame_warmup(&self) -> bool {
588 self.gpu_renderer
589 .as_ref()
590 .is_some_and(GpuRenderer::needs_frame_warmup)
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::pipeline::TextLayoutResolver;
598 use std::cell::Cell;
599
600 static TEST_FONT: &[u8] =
601 cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
602
603 #[test]
604 fn dev_overlay_is_recorded_outside_app_graph() {
605 let mut renderer = WgpuRenderer::new(&[]);
606 renderer.draw_dev_overlay(
607 "240 FPS | avg 4.0ms | p95 4.5ms",
608 Size {
609 width: 800.0,
610 height: 600.0,
611 },
612 );
613
614 assert!(
615 renderer
616 .scene
617 .graph
618 .as_ref()
619 .is_none_or(|graph| graph.root.children.iter().all(|child| {
620 !matches!(
621 child,
622 RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
623 )
624 })),
625 "dev overlay must not be mixed into the app scene graph"
626 );
627
628 let graph = renderer.dev_overlay_graph.as_ref().expect("overlay graph");
629 let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
630 panic!("dev overlay should be the final top-level layer");
631 };
632
633 assert_eq!(overlay.node_id, Some(NodeId::MAX));
634 assert_eq!(
635 overlay.graphics_layer.compositing_strategy,
636 GraphicsLayer::default().compositing_strategy,
637 "dev overlay should not allocate an offscreen surface"
638 );
639 }
640
641 struct CountingTextMeasurer {
642 inner: SoftwareTextMeasurer,
643 layout_calls: Rc<Cell<usize>>,
644 }
645
646 impl CountingTextMeasurer {
647 fn new(layout_calls: Rc<Cell<usize>>) -> Self {
648 Self {
649 inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
650 layout_calls,
651 }
652 }
653 }
654
655 impl TextMeasurer for CountingTextMeasurer {
656 fn measure(
657 &self,
658 text: &cranpose_ui::text::AnnotatedString,
659 style: &cranpose_ui::text::TextStyle,
660 ) -> cranpose_ui::TextMetrics {
661 self.inner.measure(text, style)
662 }
663
664 fn get_offset_for_position(
665 &self,
666 text: &cranpose_ui::text::AnnotatedString,
667 style: &cranpose_ui::text::TextStyle,
668 x: f32,
669 y: f32,
670 ) -> usize {
671 self.inner.get_offset_for_position(text, style, x, y)
672 }
673
674 fn get_cursor_x_for_offset(
675 &self,
676 text: &cranpose_ui::text::AnnotatedString,
677 style: &cranpose_ui::text::TextStyle,
678 offset: usize,
679 ) -> f32 {
680 self.inner.get_cursor_x_for_offset(text, style, offset)
681 }
682
683 fn layout(
684 &self,
685 text: &cranpose_ui::text::AnnotatedString,
686 style: &cranpose_ui::text::TextStyle,
687 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
688 self.layout_calls.set(self.layout_calls.get() + 1);
689 self.inner.layout(text, style)
690 }
691 }
692
693 #[test]
694 fn headless_text_measurer_uses_software_text_font() {
695 let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
696 let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
697 let style = cranpose_ui::text::TextStyle::default();
698
699 let metrics = measurer.measure(&text, &style);
700 let layout = measurer.layout(&text, &style);
701
702 assert!(metrics.width > 0.0);
703 assert!(metrics.height > 0.0);
704 assert_eq!(layout.lines.len(), metrics.line_count);
705 }
706
707 #[test]
708 fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
709 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
710 let app_context = cranpose_ui::AppContext::new();
711 renderer.attach_app_context_services(&app_context);
712
713 let metrics = app_context.enter(|| {
714 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
715 let style = cranpose_ui::text::TextStyle {
716 span_style: cranpose_ui::text::SpanStyle {
717 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
718 ..Default::default()
719 },
720 paragraph_style: cranpose_ui::text::ParagraphStyle {
721 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
722 include_font_padding: None,
723 shaping: Some(cranpose_ui::text::TextShaping::Basic),
724 }),
725 ..Default::default()
726 },
727 };
728 cranpose_ui::text::measure_text(&text, &style)
729 });
730
731 assert!(
732 metrics.width > 0.0,
733 "software text service should measure text"
734 );
735 assert_eq!(
736 renderer.text_state.text_cache_len(),
737 0,
738 "WGPU must not keep a renderer-side shaping cache for measurement"
739 );
740 }
741
742 #[test]
743 fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
744 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
745 let app_context = cranpose_ui::AppContext::new();
746 renderer.attach_app_context_services(&app_context);
747
748 let prepared = app_context.enter(|| {
749 let text = cranpose_ui::text::AnnotatedString::from(
750 (0..48)
751 .map(|line| format!("// markdown code line {line:02}"))
752 .collect::<Vec<_>>()
753 .join("\n"),
754 );
755 let style = cranpose_ui::text::TextStyle::default();
756 cranpose_ui::text::prepare_text_layout(
757 &text,
758 &style,
759 cranpose_ui::text::TextLayoutOptions::default(),
760 Some(952.0),
761 )
762 });
763
764 assert_eq!(prepared.metrics.line_count, 48);
765 assert!(
766 prepared.metrics.line_height > 18.0,
767 "renderer-attached text service must not use fallback monospaced line height: {:?}",
768 prepared.metrics
769 );
770 assert!(
771 prepared.metrics.height > 900.0,
772 "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
773 prepared.metrics
774 );
775 }
776
777 #[test]
778 fn render_text_layout_routes_through_attached_app_context_service() {
779 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
780 let app_context = cranpose_ui::AppContext::new();
781 renderer.attach_app_context_services(&app_context);
782 let layout_calls = Rc::new(Cell::new(0));
783 app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
784
785 app_context.enter(|| {
786 let text = cranpose_ui::text::AnnotatedString::from("render text");
787 let style = cranpose_ui::text::TextStyle::default();
788 let layout = renderer.text_state.layout_text(&text, &style);
789 assert!(layout.width > 0.0);
790 });
791
792 assert_eq!(layout_calls.get(), 1);
793 }
794}