1#![deny(unsafe_code)]
7
8#[cfg(not(target_arch = "wasm32"))]
9mod cost_tuner;
10mod effect_renderer;
11mod frame_graph;
12mod frame_packet;
13mod frontend;
14pub(crate) mod gpu_stats;
15mod layer_events;
16mod layer_surface_cache;
17mod normalized_scene;
18mod offscreen;
19mod pipeline;
20mod render;
21mod run_entry;
22mod scene;
23mod shader_cache;
24mod shaders;
25#[cfg(not(target_arch = "wasm32"))]
26mod shape_replay;
27#[cfg(not(target_arch = "wasm32"))]
28mod stage_executor;
29mod surface_executor;
30mod surface_plan;
31mod surface_requirements;
32#[cfg(test)]
33mod test_support;
34
35#[doc(hidden)]
36pub use frame_packet::{CancelReason, PresentOutcome};
37pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
38#[doc(hidden)]
39#[cfg(not(target_arch = "wasm32"))]
40pub use pipeline::retained_feed_generation;
41pub use render::frames_presented;
42pub use scene::{ClickAction, HitRegion, Scene};
43#[doc(hidden)]
44#[cfg(not(target_arch = "wasm32"))]
45pub use shape_replay::feed_live_stats as command_feed_live_stats;
46#[doc(hidden)]
47#[cfg(not(target_arch = "wasm32"))]
48pub use shape_replay::{inject_feed_capture_for_tests, pending_feed_capture_count_for_tests};
49#[doc(hidden)]
50#[cfg(not(target_arch = "wasm32"))]
51pub use shape_replay::{planner_replay_queue_stats_for_tests, recycled_ops_capacities_for_tests};
52
53use cranpose_core::{MemoryApplier, NodeId};
54use cranpose_render_common::{
55 graph::RenderGraph,
56 software_text_raster::{
57 software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
58 },
59 RenderScene, Renderer,
60};
61use cranpose_ui::{LayoutTree, TextMeasurer};
62use cranpose_ui_graphics::{Rect, Size};
63use frame_packet::RenderReturns;
64use frontend::{DevOverlayCache, RendererFrontend};
65use render::GpuRenderer;
66use std::rc::Rc;
67use std::sync::Arc;
68
69pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
71 [
72 [rect.x, rect.y],
73 [rect.x + rect.width, rect.y],
74 [rect.x, rect.y + rect.height],
75 [rect.x + rect.width, rect.y + rect.height],
76 ]
77}
78
79#[derive(Debug)]
80pub enum WgpuRendererError {
81 Layout(String),
82 Wgpu(String),
83}
84
85#[derive(Debug, Clone)]
87pub struct CapturedFrame {
88 pub width: u32,
89 pub height: u32,
90 pub pixels: Vec<u8>,
91}
92
93#[doc(hidden)]
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct DebugCpuAllocationStats {
96 pub scene_graph_node_count: usize,
97 pub scene_graph_heap_bytes: usize,
98 pub scene_hits_len: usize,
99 pub scene_hits_cap: usize,
100 pub scene_node_index_len: usize,
101 pub scene_node_index_cap: usize,
102 pub text_renderer_pool_len: usize,
103 pub text_renderer_pool_cap: usize,
104 pub swash_image_cache_len: usize,
105 pub swash_image_cache_cap: usize,
106 pub swash_outline_cache_len: usize,
107 pub swash_outline_cache_cap: usize,
108 pub image_texture_cache_len: usize,
109 pub image_texture_cache_cap: usize,
110 pub scratch_shape_data_cap: usize,
111 pub scratch_gradients_cap: usize,
112 pub scratch_image_vertices_cap: usize,
113 pub scratch_image_indices_cap: usize,
114 pub scratch_image_cmds_cap: usize,
115 pub scratch_segment_items_cap: usize,
116 pub scratch_effect_ranges_cap: usize,
117 pub scratch_layer_events_cap: usize,
118 pub staged_upload_bytes_cap: usize,
119 pub staged_upload_copies_cap: usize,
120 pub layer_surface_cache_len: usize,
121 pub layer_surface_cache_cap: usize,
122 pub layer_surface_cache_identity_len: usize,
123 pub layer_surface_cache_identity_cap: usize,
124 pub layer_surface_rect_cache_len: usize,
125 pub layer_surface_rect_cache_cap: usize,
126 pub layer_surface_requirements_cache_len: usize,
127 pub layer_surface_requirements_cache_cap: usize,
128 pub layer_cache_seen_this_frame_len: usize,
129 pub layer_cache_seen_this_frame_cap: usize,
130}
131
132pub(crate) struct TextSystemState {
133 measurer: SoftwareTextMeasurer,
134}
135
136impl TextSystemState {
137 fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
138 Self {
139 measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
140 }
141 }
142
143 pub(crate) fn text_cache_len(&self) -> usize {
144 0
145 }
146}
147
148impl pipeline::TextLayoutResolver for TextSystemState {
149 fn layout_text(
150 &mut self,
151 text: &cranpose_ui::text::AnnotatedString,
152 style: &cranpose_ui::text::TextStyle,
153 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
154 if cranpose_ui::has_current_app_context() {
155 cranpose_ui::text::layout_text(text, style)
156 } else {
157 self.measurer.layout(text, style)
158 }
159 }
160}
161
162#[derive(Clone)]
163pub struct WgpuTextSystem {
164 software_fonts: SoftwareTextFontSet,
165}
166
167impl WgpuTextSystem {
168 pub fn from_fonts(fonts: &[&[u8]]) -> Self {
169 Self {
170 software_fonts: software_text_font_set_from_fonts_or_default(fonts),
171 }
172 }
173
174 pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
178 Self { software_fonts }
179 }
180
181 pub(crate) fn render_state(&self) -> TextSystemState {
182 TextSystemState::from_font_set(self.software_fonts.clone())
183 }
184
185 pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
186 self.software_fonts.clone()
187 }
188}
189
190pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
192 headless_text_measurer_with_fonts(&[])
193}
194
195pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
197 Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
198}
199
200pub struct WgpuRenderer {
208 frontend: RendererFrontend,
211 gpu_renderer: Option<GpuRenderer>,
213 renderer_epoch: u64,
218 surface_epoch: u64,
223}
224
225impl WgpuRenderer {
226 pub fn new(fonts: &[&[u8]]) -> Self {
233 Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
234 }
235
236 pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
241 Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
242 }
243
244 pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
245 Self {
246 frontend: RendererFrontend::new(
247 text_system.render_state(),
248 text_system.software_fonts(),
249 ),
250 gpu_renderer: None,
251 renderer_epoch: 0,
252 surface_epoch: 0,
253 }
254 }
255
256 pub fn init_gpu(
266 &mut self,
267 device: Arc<wgpu::Device>,
268 queue: Arc<wgpu::Queue>,
269 surface_format: wgpu::TextureFormat,
270 adapter_backend: wgpu::Backend,
271 ) {
272 #[cfg(not(target_arch = "wasm32"))]
273 if self.gpu_renderer.is_some() {
274 crate::shape_replay::SHAPE_REPLAY.with(|state| state.borrow_mut().renderer_replaced());
275 log::warn!(
276 "[command-feed] renderer replaced: retained slots retired, \
277 confirmations revoked, feed generation bumped"
278 );
279 }
280 self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
281 #[cfg(not(target_arch = "wasm32"))]
285 let store_feed_generation = pipeline::retained_feed_generation();
286 #[cfg(target_arch = "wasm32")]
287 let store_feed_generation = 0;
288 self.gpu_renderer = Some(GpuRenderer::new(
289 device,
290 queue,
291 surface_format,
292 adapter_backend,
293 self.frontend.text_fonts.clone(),
294 self.renderer_epoch,
295 store_feed_generation,
296 ));
297 }
298
299 pub fn note_surface_reconfigured(&mut self) {
304 self.surface_epoch = self.surface_epoch.wrapping_add(1);
305 }
306
307 pub fn set_root_scale(&mut self, scale: f32) {
309 self.frontend.root_scale = scale;
310 }
311
312 pub fn root_scale(&self) -> f32 {
313 self.frontend.root_scale
314 }
315
316 pub fn render(
324 &mut self,
325 view: &wgpu::TextureView,
326 width: u32,
327 height: u32,
328 ) -> Result<(), WgpuRendererError> {
329 let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
330 return Err(WgpuRendererError::Wgpu(
331 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
332 ));
333 };
334 let packet = self
335 .frontend
336 .build_frame_packet(
337 width,
338 height,
339 gpu_renderer.replay_supported(),
340 self.renderer_epoch,
341 self.surface_epoch,
342 )
343 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
344 let frontend = &mut self.frontend;
345 let mut returns = RenderReturns::default();
346 let result = gpu_renderer.render(
350 view,
351 width,
352 height,
353 packet,
354 self.surface_epoch,
355 &mut returns,
356 );
357 if let Some(confirmations) = frontend.apply_returns(returns) {
358 gpu_renderer.restore_replay_ack_confirmations(confirmations);
359 }
360 result.map_err(WgpuRendererError::Wgpu)
361 }
362
363 pub fn capture_frame(
367 &mut self,
368 width: u32,
369 height: u32,
370 ) -> Result<CapturedFrame, WgpuRendererError> {
371 self.capture_frame_with_scale(width, height, self.frontend.root_scale)
372 }
373
374 pub fn capture_frame_with_scale(
376 &mut self,
377 width: u32,
378 height: u32,
379 root_scale: f32,
380 ) -> Result<CapturedFrame, WgpuRendererError> {
381 let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
382 return Err(WgpuRendererError::Wgpu(
383 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
384 ));
385 };
386 let packet = self
387 .frontend
388 .build_frame_packet_with_scale(
389 width,
390 height,
391 root_scale,
392 gpu_renderer.replay_supported(),
393 self.renderer_epoch,
394 self.surface_epoch,
395 )
396 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
397 let frontend = &mut self.frontend;
398 let mut returns = RenderReturns::default();
399 let result = gpu_renderer.render_to_rgba_pixels(
402 width,
403 height,
404 packet,
405 self.surface_epoch,
406 &mut returns,
407 );
408 if let Some(confirmations) = frontend.apply_returns(returns) {
409 gpu_renderer.restore_replay_ack_confirmations(confirmations);
410 }
411 let pixels = result.map_err(WgpuRendererError::Wgpu)?;
412 Ok(CapturedFrame {
413 width,
414 height,
415 pixels,
416 })
417 }
418
419 pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
420 self.gpu_renderer
421 .as_ref()
422 .and_then(GpuRenderer::last_frame_stats)
423 }
424
425 pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
426 let mut stats = self
427 .gpu_renderer
428 .as_ref()
429 .map(GpuRenderer::debug_cpu_allocation_stats)
430 .unwrap_or_default();
431 stats.scene_graph_node_count = self
432 .frontend
433 .scene
434 .graph
435 .as_ref()
436 .map(RenderGraph::node_count)
437 .unwrap_or(0);
438 stats.scene_graph_heap_bytes = self
439 .frontend
440 .scene
441 .graph
442 .as_ref()
443 .map(RenderGraph::heap_bytes)
444 .unwrap_or(0);
445 stats.scene_hits_len = self.frontend.scene.hits.len();
446 stats.scene_hits_cap = self.frontend.scene.hits.capacity();
447 stats.scene_node_index_len = self.frontend.scene.node_index.len();
448 stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
449 stats.layer_surface_rect_cache_len += self.frontend.layer_surface_rect_cache.len();
453 stats.layer_surface_rect_cache_cap += self.frontend.layer_surface_rect_cache.capacity();
454 stats.layer_surface_requirements_cache_len +=
455 self.frontend.layer_surface_requirements_cache.len();
456 stats.layer_surface_requirements_cache_cap +=
457 self.frontend.layer_surface_requirements_cache.capacity();
458 stats
459 }
460
461 pub fn try_device(&self) -> Option<&wgpu::Device> {
463 self.gpu_renderer.as_ref().map(|r| &*r.device)
464 }
465
466 #[cfg(not(target_arch = "wasm32"))]
471 #[doc(hidden)]
472 pub fn instanced_quads_active(&self) -> bool {
473 self.gpu_renderer
474 .as_ref()
475 .is_some_and(GpuRenderer::instanced_quads_active)
476 }
477
478 #[cfg(not(target_arch = "wasm32"))]
481 #[doc(hidden)]
482 pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
483 self.gpu_renderer
484 .as_ref()
485 .map(GpuRenderer::replay_slot_mesh_stats)
486 .unwrap_or((0, 0))
487 }
488
489 #[cfg(not(target_arch = "wasm32"))]
492 #[doc(hidden)]
493 pub fn retained_bundle_stats(&self) -> (u64, u64) {
494 self.gpu_renderer
495 .as_ref()
496 .map(GpuRenderer::retained_bundle_stats)
497 .unwrap_or((0, 0))
498 }
499
500 #[cfg(not(target_arch = "wasm32"))]
505 #[doc(hidden)]
506 pub fn replay_generation_drops_for_tests(&self) -> u64 {
507 self.gpu_renderer
508 .as_ref()
509 .map(GpuRenderer::replay_generation_drops)
510 .unwrap_or(0)
511 }
512
513 #[cfg(not(target_arch = "wasm32"))]
521 #[doc(hidden)]
522 pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
523 self.gpu_renderer
524 .as_mut()
525 .expect("GPU renderer not initialized")
526 .replay_ops_roundtrip_for_tests(generation_skew)
527 }
528
529 #[doc(hidden)]
535 pub fn build_frame_packet_for_tests(
536 &mut self,
537 width: u32,
538 height: u32,
539 ) -> Option<HeldFramePacket> {
540 let replay_supported = self
541 .gpu_renderer
542 .as_ref()
543 .is_some_and(GpuRenderer::replay_supported);
544 self.frontend
545 .build_frame_packet(
546 width,
547 height,
548 replay_supported,
549 self.renderer_epoch,
550 self.surface_epoch,
551 )
552 .map(HeldFramePacket)
553 }
554
555 #[doc(hidden)]
559 pub fn render_held_packet_for_tests(
560 &mut self,
561 view: &wgpu::TextureView,
562 width: u32,
563 height: u32,
564 packet: HeldFramePacket,
565 ) -> Result<PresentOutcome, WgpuRendererError> {
566 let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
567 return Err(WgpuRendererError::Wgpu(
568 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
569 ));
570 };
571 let frontend = &mut self.frontend;
572 let mut returns = RenderReturns::default();
573 let result = gpu_renderer.render(
574 view,
575 width,
576 height,
577 packet.0,
578 self.surface_epoch,
579 &mut returns,
580 );
581 let outcome = returns.outcome;
582 if let Some(confirmations) = frontend.apply_returns(returns) {
583 gpu_renderer.restore_replay_ack_confirmations(confirmations);
584 }
585 result.map_err(WgpuRendererError::Wgpu)?;
586 Ok(outcome)
587 }
588
589 #[doc(hidden)]
592 pub fn has_retained_direct_scene_for_tests(&self) -> bool {
593 self.frontend.retained_direct_scene.is_some()
594 }
595}
596
597#[doc(hidden)]
600pub struct HeldFramePacket(frame_packet::FramePacket);
601
602impl Default for WgpuRenderer {
603 fn default() -> Self {
604 Self::new(&[])
605 }
606}
607
608impl Renderer for WgpuRenderer {
609 type Scene = Scene;
610 type Error = WgpuRendererError;
611
612 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
613 app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
614 self.frontend.text_fonts.clone(),
615 8192,
616 ));
617 self.frontend.app_context = Some(app_context.downgrade());
618 }
619
620 fn scene(&self) -> &Self::Scene {
621 &self.frontend.scene
622 }
623
624 fn scene_mut(&mut self) -> &mut Self::Scene {
625 &mut self.frontend.scene
626 }
627
628 fn rebuild_scene(
629 &mut self,
630 layout_tree: &LayoutTree,
631 _viewport: Size,
632 ) -> Result<(), Self::Error> {
633 self.frontend.scene.clear();
634 self.frontend.dev_overlay_graph = None;
635 self.frontend.dev_overlay_cache = None;
636 pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
638 Ok(())
639 }
640
641 fn rebuild_scene_from_applier(
642 &mut self,
643 applier: &mut MemoryApplier,
644 root: NodeId,
645 _viewport: Size,
646 ) -> Result<(), Self::Error> {
647 self.frontend.scene.clear();
648 self.frontend.dev_overlay_graph = None;
649 self.frontend.dev_overlay_cache = None;
650 pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
653 Ok(())
654 }
655
656 fn update_scene_from_applier(
657 &mut self,
658 applier: &mut MemoryApplier,
659 root: NodeId,
660 viewport: Size,
661 dirty_nodes: &[NodeId],
662 ) -> Result<(), Self::Error> {
663 if dirty_nodes.is_empty() {
664 return self.rebuild_scene_from_applier(applier, root, viewport);
665 }
666 pipeline::update_from_applier(
667 applier,
668 root,
669 &mut self.frontend.scene,
670 1.0,
671 dirty_nodes,
672 true,
673 );
674 Ok(())
675 }
676
677 fn update_visual_scene_from_applier(
678 &mut self,
679 applier: &mut MemoryApplier,
680 root: NodeId,
681 viewport: Size,
682 dirty_nodes: &[NodeId],
683 ) -> Result<(), Self::Error> {
684 if dirty_nodes.is_empty() {
685 return self.rebuild_scene_from_applier(applier, root, viewport);
686 }
687 pipeline::update_from_applier(
688 applier,
689 root,
690 &mut self.frontend.scene,
691 1.0,
692 dirty_nodes,
693 false,
694 );
695 Ok(())
696 }
697
698 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
699 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
700 let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
701 if self.frontend.dev_overlay_graph.is_some()
702 && self
703 .frontend
704 .dev_overlay_cache
705 .as_ref()
706 .is_some_and(|cache| {
707 cache.text == key.text
708 && cache.viewport_width_bits == key.viewport_width_bits
709 && cache.viewport_height_bits == key.viewport_height_bits
710 })
711 {
712 return;
713 }
714 self.frontend.dev_overlay_graph = Some(
715 cranpose_render_common::dev_overlay::build_dev_overlay_graph(
716 text,
717 viewport,
718 DEV_OVERLAY_NODE_ID,
719 ),
720 );
721 self.frontend.dev_overlay_cache = Some(DevOverlayCache {
722 text: key.text,
723 viewport_width_bits: key.viewport_width_bits,
724 viewport_height_bits: key.viewport_height_bits,
725 });
726 }
727
728 fn needs_frame_warmup(&self) -> bool {
729 self.gpu_renderer
730 .as_ref()
731 .is_some_and(GpuRenderer::needs_frame_warmup)
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::pipeline::TextLayoutResolver;
739 use cranpose_render_common::graph::RenderNode;
740 use cranpose_ui_graphics::GraphicsLayer;
741 use std::cell::Cell;
742
743 static TEST_FONT: &[u8] =
744 cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
745
746 #[test]
747 fn dev_overlay_is_recorded_outside_app_graph() {
748 let mut renderer = WgpuRenderer::new(&[]);
749 renderer.draw_dev_overlay(
750 "240 FPS | avg 4.0ms | p95 4.5ms",
751 Size {
752 width: 800.0,
753 height: 600.0,
754 },
755 );
756
757 assert!(
758 renderer
759 .frontend
760 .scene
761 .graph
762 .as_ref()
763 .is_none_or(|graph| graph.root.children.iter().all(|child| {
764 !matches!(
765 child,
766 RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
767 )
768 })),
769 "dev overlay must not be mixed into the app scene graph"
770 );
771
772 let graph = renderer
773 .frontend
774 .dev_overlay_graph
775 .as_ref()
776 .expect("overlay graph");
777 let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
778 panic!("dev overlay should be the final top-level layer");
779 };
780
781 assert_eq!(overlay.node_id, Some(NodeId::MAX));
782 assert_eq!(
783 overlay.graphics_layer.compositing_strategy,
784 GraphicsLayer::default().compositing_strategy,
785 "dev overlay should not allocate an offscreen surface"
786 );
787 }
788
789 struct CountingTextMeasurer {
790 inner: SoftwareTextMeasurer,
791 layout_calls: Rc<Cell<usize>>,
792 }
793
794 impl CountingTextMeasurer {
795 fn new(layout_calls: Rc<Cell<usize>>) -> Self {
796 Self {
797 inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
798 layout_calls,
799 }
800 }
801 }
802
803 impl TextMeasurer for CountingTextMeasurer {
804 fn measure(
805 &self,
806 text: &cranpose_ui::text::AnnotatedString,
807 style: &cranpose_ui::text::TextStyle,
808 ) -> cranpose_ui::TextMetrics {
809 self.inner.measure(text, style)
810 }
811
812 fn get_offset_for_position(
813 &self,
814 text: &cranpose_ui::text::AnnotatedString,
815 style: &cranpose_ui::text::TextStyle,
816 x: f32,
817 y: f32,
818 ) -> usize {
819 self.inner.get_offset_for_position(text, style, x, y)
820 }
821
822 fn get_cursor_x_for_offset(
823 &self,
824 text: &cranpose_ui::text::AnnotatedString,
825 style: &cranpose_ui::text::TextStyle,
826 offset: usize,
827 ) -> f32 {
828 self.inner.get_cursor_x_for_offset(text, style, offset)
829 }
830
831 fn layout(
832 &self,
833 text: &cranpose_ui::text::AnnotatedString,
834 style: &cranpose_ui::text::TextStyle,
835 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
836 self.layout_calls.set(self.layout_calls.get() + 1);
837 self.inner.layout(text, style)
838 }
839 }
840
841 #[test]
842 fn headless_text_measurer_uses_software_text_font() {
843 let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
844 let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
845 let style = cranpose_ui::text::TextStyle::default();
846
847 let metrics = measurer.measure(&text, &style);
848 let layout = measurer.layout(&text, &style);
849
850 assert!(metrics.width > 0.0);
851 assert!(metrics.height > 0.0);
852 assert_eq!(layout.lines.len(), metrics.line_count);
853 }
854
855 #[test]
856 fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
857 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
858 let app_context = cranpose_ui::AppContext::new();
859 renderer.attach_app_context_services(&app_context);
860
861 let metrics = app_context.enter(|| {
862 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
863 let style = cranpose_ui::text::TextStyle {
864 span_style: cranpose_ui::text::SpanStyle {
865 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
866 ..Default::default()
867 },
868 paragraph_style: cranpose_ui::text::ParagraphStyle {
869 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
870 include_font_padding: None,
871 shaping: Some(cranpose_ui::text::TextShaping::Basic),
872 }),
873 ..Default::default()
874 },
875 };
876 cranpose_ui::text::measure_text(&text, &style)
877 });
878
879 assert!(
880 metrics.width > 0.0,
881 "software text service should measure text"
882 );
883 assert_eq!(
884 renderer.frontend.text_state.text_cache_len(),
885 0,
886 "WGPU must not keep a renderer-side shaping cache for measurement"
887 );
888 }
889
890 #[test]
891 fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
892 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
893 let app_context = cranpose_ui::AppContext::new();
894 renderer.attach_app_context_services(&app_context);
895
896 let prepared = app_context.enter(|| {
897 let text = cranpose_ui::text::AnnotatedString::from(
898 (0..48)
899 .map(|line| format!("// markdown code line {line:02}"))
900 .collect::<Vec<_>>()
901 .join("\n"),
902 );
903 let style = cranpose_ui::text::TextStyle::default();
904 cranpose_ui::text::prepare_text_layout(
905 &text,
906 &style,
907 cranpose_ui::text::TextLayoutOptions::default(),
908 Some(952.0),
909 )
910 });
911
912 assert_eq!(prepared.metrics.line_count, 48);
913 assert!(
914 prepared.metrics.line_height > 18.0,
915 "renderer-attached text service must not use fallback monospaced line height: {:?}",
916 prepared.metrics
917 );
918 assert!(
919 prepared.metrics.height > 900.0,
920 "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
921 prepared.metrics
922 );
923 }
924
925 #[test]
926 fn render_text_layout_routes_through_attached_app_context_service() {
927 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
928 let app_context = cranpose_ui::AppContext::new();
929 renderer.attach_app_context_services(&app_context);
930 let layout_calls = Rc::new(Cell::new(0));
931 app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
932
933 app_context.enter(|| {
934 let text = cranpose_ui::text::AnnotatedString::from("render text");
935 let style = cranpose_ui::text::TextStyle::default();
936 let layout = renderer.frontend.text_state.layout_text(&text, &style);
937 assert!(layout.width > 0.0);
938 });
939
940 assert_eq!(layout_calls.get(), 1);
941 }
942}