Skip to main content

cranpose_render_wgpu/
lib.rs

1//! WGPU renderer backend for GPU-accelerated 2D rendering.
2//!
3//! This renderer uses WGPU for cross-platform GPU support across
4//! desktop (Windows/Mac/Linux), web (WebGPU), and mobile Android.
5
6#![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
49/// Convert an axis-aligned rectangle to four corner positions (TL, TR, BL, BR).
50pub(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/// CPU-readable RGBA frame captured from the renderer output.
66#[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
165/// Create an accurate WGPU text measurer for headless tests without launching a window.
166pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
167    headless_text_measurer_with_fonts(&[])
168}
169
170/// Create an accurate WGPU text measurer for headless tests with explicit fonts.
171pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
172    Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
173}
174
175/// WGPU-based renderer for GPU-accelerated 2D rendering.
176///
177/// This renderer supports:
178/// - GPU-accelerated shape rendering (rectangles, rounded rectangles)
179/// - Gradients (solid, linear, radial)
180/// - GPU text rendering via retained raster image batches
181/// - Cross-platform support (Desktop, Web, Android)
182pub 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 factor for text rendering (use for density scaling)
189    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    /// Create a new WGPU renderer.
203    ///
204    /// * `fonts` – font bytes to load, ordered by priority (first = highest priority).
205    ///   Pass `&[]` to load no fonts; text will not render until fonts are provided.
206    ///
207    /// Call [`init_gpu`][Self::init_gpu] before rendering.
208    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    /// Initialize GPU resources with a WGPU device and queue.
226    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    /// Set root scale factor for text rendering (e.g., density scaling on Android)
243    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    /// Render the scene to a texture view.
252    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    /// Render the current scene into an RGBA pixel buffer for robot tests.
299    ///
300    /// Uses the renderer's configured root scale.
301    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    /// Render the current scene into an RGBA pixel buffer with an explicit scale.
310    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    /// Return the WGPU device when GPU resources are initialized.
390    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        // Build scene in logical dp - scaling happens in GPU vertex upload
430        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        // Build scene in logical dp - scaling happens in GPU vertex upload
444        // Traverse layout nodes via applier instead of rebuilding LayoutTree
445        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}