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