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