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"))]
505 #[doc(hidden)]
506 pub fn rim_meshes_emitted(&self) -> u64 {
507 self.gpu_renderer
508 .as_ref()
509 .map(GpuRenderer::rim_meshes_emitted)
510 .unwrap_or(0)
511 }
512
513 #[cfg(not(target_arch = "wasm32"))]
518 #[doc(hidden)]
519 pub fn static_span_stats(&self) -> (u64, u64) {
520 self.gpu_renderer
521 .as_ref()
522 .map(GpuRenderer::static_span_stats)
523 .unwrap_or((0, 0))
524 }
525
526 #[cfg(not(target_arch = "wasm32"))]
531 #[doc(hidden)]
532 pub fn replay_generation_drops_for_tests(&self) -> u64 {
533 self.gpu_renderer
534 .as_ref()
535 .map(GpuRenderer::replay_generation_drops)
536 .unwrap_or(0)
537 }
538
539 #[cfg(not(target_arch = "wasm32"))]
547 #[doc(hidden)]
548 pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
549 self.gpu_renderer
550 .as_mut()
551 .expect("GPU renderer not initialized")
552 .replay_ops_roundtrip_for_tests(generation_skew)
553 }
554
555 #[doc(hidden)]
561 pub fn build_frame_packet_for_tests(
562 &mut self,
563 width: u32,
564 height: u32,
565 ) -> Option<HeldFramePacket> {
566 let replay_supported = self
567 .gpu_renderer
568 .as_ref()
569 .is_some_and(GpuRenderer::replay_supported);
570 self.frontend
571 .build_frame_packet(
572 width,
573 height,
574 replay_supported,
575 self.renderer_epoch,
576 self.surface_epoch,
577 )
578 .map(HeldFramePacket)
579 }
580
581 #[doc(hidden)]
585 pub fn render_held_packet_for_tests(
586 &mut self,
587 view: &wgpu::TextureView,
588 width: u32,
589 height: u32,
590 packet: HeldFramePacket,
591 ) -> Result<PresentOutcome, WgpuRendererError> {
592 let Some(gpu_renderer) = self.gpu_renderer.as_mut() else {
593 return Err(WgpuRendererError::Wgpu(
594 "GPU renderer not initialized. Call init_gpu() first.".to_string(),
595 ));
596 };
597 let frontend = &mut self.frontend;
598 let mut returns = RenderReturns::default();
599 let result = gpu_renderer.render(
600 view,
601 width,
602 height,
603 packet.0,
604 self.surface_epoch,
605 &mut returns,
606 );
607 let outcome = returns.outcome;
608 if let Some(confirmations) = frontend.apply_returns(returns) {
609 gpu_renderer.restore_replay_ack_confirmations(confirmations);
610 }
611 result.map_err(WgpuRendererError::Wgpu)?;
612 Ok(outcome)
613 }
614
615 #[doc(hidden)]
618 pub fn has_retained_direct_scene_for_tests(&self) -> bool {
619 self.frontend.retained_direct_scene.is_some()
620 }
621}
622
623#[doc(hidden)]
626pub struct HeldFramePacket(frame_packet::FramePacket);
627
628impl Default for WgpuRenderer {
629 fn default() -> Self {
630 Self::new(&[])
631 }
632}
633
634impl Renderer for WgpuRenderer {
635 type Scene = Scene;
636 type Error = WgpuRendererError;
637
638 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
639 app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
640 self.frontend.text_fonts.clone(),
641 8192,
642 ));
643 self.frontend.app_context = Some(app_context.downgrade());
644 }
645
646 fn scene(&self) -> &Self::Scene {
647 &self.frontend.scene
648 }
649
650 fn scene_mut(&mut self) -> &mut Self::Scene {
651 &mut self.frontend.scene
652 }
653
654 fn rebuild_scene(
655 &mut self,
656 layout_tree: &LayoutTree,
657 _viewport: Size,
658 ) -> Result<(), Self::Error> {
659 self.frontend.scene.clear();
660 self.frontend.dev_overlay_graph = None;
661 self.frontend.dev_overlay_cache = None;
662 pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
664 Ok(())
665 }
666
667 fn rebuild_scene_from_applier(
668 &mut self,
669 applier: &mut MemoryApplier,
670 root: NodeId,
671 _viewport: Size,
672 ) -> Result<(), Self::Error> {
673 self.frontend.scene.clear();
674 self.frontend.dev_overlay_graph = None;
675 self.frontend.dev_overlay_cache = None;
676 pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
679 Ok(())
680 }
681
682 fn update_scene_from_applier(
683 &mut self,
684 applier: &mut MemoryApplier,
685 root: NodeId,
686 viewport: Size,
687 dirty_nodes: &[NodeId],
688 ) -> Result<(), Self::Error> {
689 if dirty_nodes.is_empty() {
690 return self.rebuild_scene_from_applier(applier, root, viewport);
691 }
692 pipeline::update_from_applier(
693 applier,
694 root,
695 &mut self.frontend.scene,
696 1.0,
697 dirty_nodes,
698 true,
699 );
700 Ok(())
701 }
702
703 fn update_visual_scene_from_applier(
704 &mut self,
705 applier: &mut MemoryApplier,
706 root: NodeId,
707 viewport: Size,
708 dirty_nodes: &[NodeId],
709 ) -> Result<(), Self::Error> {
710 if dirty_nodes.is_empty() {
711 return self.rebuild_scene_from_applier(applier, root, viewport);
712 }
713 pipeline::update_from_applier(
714 applier,
715 root,
716 &mut self.frontend.scene,
717 1.0,
718 dirty_nodes,
719 false,
720 );
721 Ok(())
722 }
723
724 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
725 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
726 let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
727 if self.frontend.dev_overlay_graph.is_some()
728 && self
729 .frontend
730 .dev_overlay_cache
731 .as_ref()
732 .is_some_and(|cache| {
733 cache.text == key.text
734 && cache.viewport_width_bits == key.viewport_width_bits
735 && cache.viewport_height_bits == key.viewport_height_bits
736 })
737 {
738 return;
739 }
740 self.frontend.dev_overlay_graph = Some(
741 cranpose_render_common::dev_overlay::build_dev_overlay_graph(
742 text,
743 viewport,
744 DEV_OVERLAY_NODE_ID,
745 ),
746 );
747 self.frontend.dev_overlay_cache = Some(DevOverlayCache {
748 text: key.text,
749 viewport_width_bits: key.viewport_width_bits,
750 viewport_height_bits: key.viewport_height_bits,
751 });
752 }
753
754 fn needs_frame_warmup(&self) -> bool {
755 self.gpu_renderer
756 .as_ref()
757 .is_some_and(GpuRenderer::needs_frame_warmup)
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::pipeline::TextLayoutResolver;
765 use cranpose_render_common::graph::RenderNode;
766 use cranpose_ui_graphics::GraphicsLayer;
767 use std::cell::Cell;
768
769 static TEST_FONT: &[u8] =
770 cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
771
772 #[test]
773 fn dev_overlay_is_recorded_outside_app_graph() {
774 let mut renderer = WgpuRenderer::new(&[]);
775 renderer.draw_dev_overlay(
776 "240 FPS | avg 4.0ms | p95 4.5ms",
777 Size {
778 width: 800.0,
779 height: 600.0,
780 },
781 );
782
783 assert!(
784 renderer
785 .frontend
786 .scene
787 .graph
788 .as_ref()
789 .is_none_or(|graph| graph.root.children.iter().all(|child| {
790 !matches!(
791 child,
792 RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
793 )
794 })),
795 "dev overlay must not be mixed into the app scene graph"
796 );
797
798 let graph = renderer
799 .frontend
800 .dev_overlay_graph
801 .as_ref()
802 .expect("overlay graph");
803 let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
804 panic!("dev overlay should be the final top-level layer");
805 };
806
807 assert_eq!(overlay.node_id, Some(NodeId::MAX));
808 assert_eq!(
809 overlay.graphics_layer.compositing_strategy,
810 GraphicsLayer::default().compositing_strategy,
811 "dev overlay should not allocate an offscreen surface"
812 );
813 }
814
815 struct CountingTextMeasurer {
816 inner: SoftwareTextMeasurer,
817 layout_calls: Rc<Cell<usize>>,
818 }
819
820 impl CountingTextMeasurer {
821 fn new(layout_calls: Rc<Cell<usize>>) -> Self {
822 Self {
823 inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
824 layout_calls,
825 }
826 }
827 }
828
829 impl TextMeasurer for CountingTextMeasurer {
830 fn measure(
831 &self,
832 text: &cranpose_ui::text::AnnotatedString,
833 style: &cranpose_ui::text::TextStyle,
834 ) -> cranpose_ui::TextMetrics {
835 self.inner.measure(text, style)
836 }
837
838 fn get_offset_for_position(
839 &self,
840 text: &cranpose_ui::text::AnnotatedString,
841 style: &cranpose_ui::text::TextStyle,
842 x: f32,
843 y: f32,
844 ) -> usize {
845 self.inner.get_offset_for_position(text, style, x, y)
846 }
847
848 fn get_cursor_x_for_offset(
849 &self,
850 text: &cranpose_ui::text::AnnotatedString,
851 style: &cranpose_ui::text::TextStyle,
852 offset: usize,
853 ) -> f32 {
854 self.inner.get_cursor_x_for_offset(text, style, offset)
855 }
856
857 fn layout(
858 &self,
859 text: &cranpose_ui::text::AnnotatedString,
860 style: &cranpose_ui::text::TextStyle,
861 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
862 self.layout_calls.set(self.layout_calls.get() + 1);
863 self.inner.layout(text, style)
864 }
865 }
866
867 #[test]
868 fn headless_text_measurer_uses_software_text_font() {
869 let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
870 let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
871 let style = cranpose_ui::text::TextStyle::default();
872
873 let metrics = measurer.measure(&text, &style);
874 let layout = measurer.layout(&text, &style);
875
876 assert!(metrics.width > 0.0);
877 assert!(metrics.height > 0.0);
878 assert_eq!(layout.lines.len(), metrics.line_count);
879 }
880
881 #[test]
882 fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
883 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
884 let app_context = cranpose_ui::AppContext::new();
885 renderer.attach_app_context_services(&app_context);
886
887 let metrics = app_context.enter(|| {
888 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
889 let style = cranpose_ui::text::TextStyle {
890 span_style: cranpose_ui::text::SpanStyle {
891 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
892 ..Default::default()
893 },
894 paragraph_style: cranpose_ui::text::ParagraphStyle {
895 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
896 include_font_padding: None,
897 shaping: Some(cranpose_ui::text::TextShaping::Basic),
898 }),
899 ..Default::default()
900 },
901 };
902 cranpose_ui::text::measure_text(&text, &style)
903 });
904
905 assert!(
906 metrics.width > 0.0,
907 "software text service should measure text"
908 );
909 assert_eq!(
910 renderer.frontend.text_state.text_cache_len(),
911 0,
912 "WGPU must not keep a renderer-side shaping cache for measurement"
913 );
914 }
915
916 #[test]
917 fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
918 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
919 let app_context = cranpose_ui::AppContext::new();
920 renderer.attach_app_context_services(&app_context);
921
922 let prepared = app_context.enter(|| {
923 let text = cranpose_ui::text::AnnotatedString::from(
924 (0..48)
925 .map(|line| format!("// markdown code line {line:02}"))
926 .collect::<Vec<_>>()
927 .join("\n"),
928 );
929 let style = cranpose_ui::text::TextStyle::default();
930 cranpose_ui::text::prepare_text_layout(
931 &text,
932 &style,
933 cranpose_ui::text::TextLayoutOptions::default(),
934 Some(952.0),
935 )
936 });
937
938 assert_eq!(prepared.metrics.line_count, 48);
939 assert!(
940 prepared.metrics.line_height > 18.0,
941 "renderer-attached text service must not use fallback monospaced line height: {:?}",
942 prepared.metrics
943 );
944 assert!(
945 prepared.metrics.height > 900.0,
946 "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
947 prepared.metrics
948 );
949 }
950
951 #[test]
952 fn render_text_layout_routes_through_attached_app_context_service() {
953 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
954 let app_context = cranpose_ui::AppContext::new();
955 renderer.attach_app_context_services(&app_context);
956 let layout_calls = Rc::new(Cell::new(0));
957 app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
958
959 app_context.enter(|| {
960 let text = cranpose_ui::text::AnnotatedString::from("render text");
961 let style = cranpose_ui::text::TextStyle::default();
962 let layout = renderer.frontend.text_state.layout_text(&text, &style);
963 assert!(layout.width > 0.0);
964 });
965
966 assert_eq!(layout_calls.get(), 1);
967 }
968}