1#![deny(unsafe_code)]
7
8#[cfg(not(target_arch = "wasm32"))]
9mod cost_tuner;
10mod display_clip;
11mod effect_renderer;
12mod frame_graph;
13mod frame_packet;
14mod frontend;
15pub(crate) mod gpu_stats;
16mod layer_events;
17mod layer_surface_cache;
18mod lazy_resource;
19mod normalized_scene;
20mod offscreen;
21mod output_conversion;
22mod pipeline;
23#[cfg(not(target_arch = "wasm32"))]
24mod pipeline_disk_cache;
25#[cfg(not(target_arch = "wasm32"))]
26mod present_runtime;
27mod render;
28mod run_entry;
29mod scene;
30#[cfg(not(target_arch = "wasm32"))]
31mod segment_surface;
32mod shader_cache;
33mod shaders;
34#[cfg(not(target_arch = "wasm32"))]
35mod shape_replay;
36#[cfg(not(target_arch = "wasm32"))]
37mod stage_executor;
38mod surface_executor;
39mod surface_plan;
40mod surface_requirements;
41#[cfg(test)]
42mod test_support;
43
44#[doc(hidden)]
45#[cfg(not(target_arch = "wasm32"))]
46pub use display_clip::pixel_is_visible as display_clip_pixel_is_visible;
47pub use display_clip::DisplayVisibleRegion;
48pub use frame_packet::PresentTimings;
49#[doc(hidden)]
50pub use frame_packet::{CancelReason, PresentOutcome};
51pub use gpu_stats::FrameStatsSnapshot as RenderStatsSnapshot;
52#[doc(hidden)]
53#[cfg(not(target_arch = "wasm32"))]
54pub use pipeline::retained_feed_generation;
55pub use render::frames_presented;
56pub use scene::{ClickAction, HitRegion, Scene};
57#[doc(hidden)]
58#[cfg(not(target_arch = "wasm32"))]
59pub use shape_replay::feed_live_stats as command_feed_live_stats;
60#[doc(hidden)]
61#[cfg(not(target_arch = "wasm32"))]
62pub use shape_replay::{inject_feed_capture_for_tests, pending_feed_capture_count_for_tests};
63#[doc(hidden)]
64#[cfg(not(target_arch = "wasm32"))]
65pub use shape_replay::{planner_replay_queue_stats_for_tests, recycled_ops_capacities_for_tests};
66
67use cranpose_core::{MemoryApplier, NodeId};
68use cranpose_render_common::{
69 graph::RenderGraph,
70 software_text_raster::{
71 software_text_font_set_from_fonts_or_default, SoftwareTextFontSet, SoftwareTextMeasurer,
72 },
73 RenderScene, Renderer,
74};
75use cranpose_ui::{LayoutTree, TextMeasurer};
76use cranpose_ui_graphics::{Rect, Size};
77use frame_packet::RenderReturns;
78#[cfg(not(target_arch = "wasm32"))]
79use frame_packet::ReplayConfirmation;
80use frontend::{DevOverlayCache, RendererFrontend};
81#[cfg(not(target_arch = "wasm32"))]
82use present_runtime::{
83 PresentControl, PresentHandle, PresentMsg, PresentRuntimeInit, PresentState,
84};
85use render::GpuRenderer;
86use std::rc::Rc;
87use std::sync::Arc;
88
89pub(crate) fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
91 [
92 [rect.x, rect.y],
93 [rect.x + rect.width, rect.y],
94 [rect.x, rect.y + rect.height],
95 [rect.x + rect.width, rect.y + rect.height],
96 ]
97}
98
99#[derive(Debug)]
100pub enum WgpuRendererError {
101 Layout(String),
102 Wgpu(String),
103}
104
105#[derive(Debug, Clone)]
107pub struct CapturedFrame {
108 pub width: u32,
109 pub height: u32,
110 pub pixels: Vec<u8>,
111}
112
113#[doc(hidden)]
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct DebugCpuAllocationStats {
116 pub scene_graph_node_count: usize,
117 pub scene_graph_heap_bytes: usize,
118 pub scene_hits_len: usize,
119 pub scene_hits_cap: usize,
120 pub scene_node_index_len: usize,
121 pub scene_node_index_cap: usize,
122 pub text_renderer_pool_len: usize,
123 pub text_renderer_pool_cap: usize,
124 pub swash_image_cache_len: usize,
125 pub swash_image_cache_cap: usize,
126 pub swash_outline_cache_len: usize,
127 pub swash_outline_cache_cap: usize,
128 pub image_texture_cache_len: usize,
129 pub image_texture_cache_cap: usize,
130 pub scratch_shape_data_cap: usize,
131 pub scratch_gradients_cap: usize,
132 pub scratch_image_vertices_cap: usize,
133 pub scratch_image_indices_cap: usize,
134 pub scratch_image_cmds_cap: usize,
135 pub scratch_segment_items_cap: usize,
136 pub scratch_effect_ranges_cap: usize,
137 pub scratch_layer_events_cap: usize,
138 pub staged_upload_bytes_cap: usize,
139 pub staged_upload_copies_cap: usize,
140 pub layer_surface_cache_len: usize,
141 pub layer_surface_cache_cap: usize,
142 pub layer_surface_cache_identity_len: usize,
143 pub layer_surface_cache_identity_cap: usize,
144 pub layer_surface_rect_cache_len: usize,
145 pub layer_surface_rect_cache_cap: usize,
146 pub layer_surface_requirements_cache_len: usize,
147 pub layer_surface_requirements_cache_cap: usize,
148 pub layer_cache_seen_this_frame_len: usize,
149 pub layer_cache_seen_this_frame_cap: usize,
150}
151
152pub(crate) struct TextSystemState {
153 measurer: SoftwareTextMeasurer,
154}
155
156impl TextSystemState {
157 fn from_font_set(fonts: SoftwareTextFontSet) -> Self {
158 Self {
159 measurer: SoftwareTextMeasurer::from_font_set(fonts, 8192),
160 }
161 }
162
163 pub(crate) fn text_cache_len(&self) -> usize {
164 0
165 }
166}
167
168impl pipeline::TextLayoutResolver for TextSystemState {
169 fn layout_text(
170 &mut self,
171 text: &cranpose_ui::text::AnnotatedString,
172 style: &cranpose_ui::text::TextStyle,
173 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
174 if cranpose_ui::has_current_app_context() {
175 cranpose_ui::text::layout_text(text, style)
176 } else {
177 self.measurer.layout(text, style)
178 }
179 }
180}
181
182#[derive(Clone)]
183pub struct WgpuTextSystem {
184 software_fonts: SoftwareTextFontSet,
185}
186
187impl WgpuTextSystem {
188 pub fn from_fonts(fonts: &[&[u8]]) -> Self {
189 Self {
190 software_fonts: software_text_font_set_from_fonts_or_default(fonts),
191 }
192 }
193
194 pub fn from_font_set(software_fonts: SoftwareTextFontSet) -> Self {
198 Self { software_fonts }
199 }
200
201 pub(crate) fn render_state(&self) -> TextSystemState {
202 TextSystemState::from_font_set(self.software_fonts.clone())
203 }
204
205 pub(crate) fn software_fonts(&self) -> SoftwareTextFontSet {
206 self.software_fonts.clone()
207 }
208}
209
210pub fn headless_text_measurer() -> Rc<dyn TextMeasurer> {
212 headless_text_measurer_with_fonts(&[])
213}
214
215pub fn headless_text_measurer_with_fonts(fonts: &[&[u8]]) -> Rc<dyn TextMeasurer> {
217 Rc::new(SoftwareTextMeasurer::from_fonts_or_default(fonts, 8192))
218}
219
220enum PresentBackend {
228 None,
230 Sync(Box<GpuRenderer>),
232 #[cfg(not(target_arch = "wasm32"))]
234 Threaded(PresentHandle),
235}
236
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum PublishOutcome {
240 NoGraph,
242 NoCredit,
245 Published,
247}
248
249pub struct WgpuRenderer {
257 frontend: RendererFrontend,
260 backend: PresentBackend,
262 #[cfg(not(target_arch = "wasm32"))]
266 pending_recycled_confirmations: Option<Vec<ReplayConfirmation>>,
267 renderer_epoch: u64,
272 surface_epoch: u64,
277 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
284 display_visible_region: DisplayVisibleRegion,
285}
286
287impl WgpuRenderer {
288 fn update_scene_and_plan_memo(
289 &mut self,
290 applier: &mut MemoryApplier,
291 root: NodeId,
292 dirty_nodes: &[NodeId],
293 refresh_hits: bool,
294 ) {
295 let mut changed_nodes = std::mem::take(&mut self.frontend.changed_nodes);
296 let outcome = pipeline::update_from_applier(
297 applier,
298 root,
299 &mut self.frontend.scene,
300 1.0,
301 dirty_nodes,
302 refresh_hits,
303 &mut changed_nodes,
304 );
305 match outcome {
306 pipeline::SceneUpdateOutcome::Patched => {
307 for node_id in &changed_nodes {
308 self.frontend
309 .layer_surface_requirements_cache
310 .remove(node_id);
311 }
312 }
313 pipeline::SceneUpdateOutcome::Rebuilt => {
314 self.frontend.layer_surface_requirements_cache.clear();
315 }
316 }
317 changed_nodes.clear();
318 self.frontend.changed_nodes = changed_nodes;
319 }
320
321 pub fn new(fonts: &[&[u8]]) -> Self {
328 Self::with_text_system(WgpuTextSystem::from_fonts(fonts))
329 }
330
331 pub fn with_font_set(fonts: SoftwareTextFontSet) -> Self {
336 Self::with_text_system(WgpuTextSystem::from_font_set(fonts))
337 }
338
339 pub fn with_text_system(text_system: WgpuTextSystem) -> Self {
340 Self {
341 frontend: RendererFrontend::new(
342 text_system.render_state(),
343 text_system.software_fonts(),
344 ),
345 backend: PresentBackend::None,
346 #[cfg(not(target_arch = "wasm32"))]
347 pending_recycled_confirmations: None,
348 renderer_epoch: 0,
349 surface_epoch: 0,
350 display_visible_region: DisplayVisibleRegion::Full,
351 }
352 }
353
354 fn sync_gpu_renderer(&self) -> Option<&GpuRenderer> {
356 match &self.backend {
357 PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_ref()),
358 _ => None,
359 }
360 }
361
362 #[cfg(not(target_arch = "wasm32"))]
363 fn sync_gpu_renderer_mut(&mut self) -> Option<&mut GpuRenderer> {
364 match &mut self.backend {
365 PresentBackend::Sync(gpu_renderer) => Some(gpu_renderer.as_mut()),
366 _ => None,
367 }
368 }
369
370 #[cfg(not(target_arch = "wasm32"))]
372 fn present_handle_mut(&mut self) -> Option<&mut PresentHandle> {
373 match &mut self.backend {
374 PresentBackend::Threaded(handle) => Some(handle),
375 _ => None,
376 }
377 }
378
379 fn retire_live_backend(&mut self) {
386 #[allow(unused_mut)]
387 let mut backend = std::mem::replace(&mut self.backend, PresentBackend::None);
388 if matches!(backend, PresentBackend::None) {
389 return;
390 }
391 #[cfg(not(target_arch = "wasm32"))]
392 {
393 if let PresentBackend::Threaded(handle) = &mut backend {
394 while let Some((ack, recycled)) = handle.try_drain_ack() {
399 let confirmations = crate::shape_replay::SHAPE_REPLAY
400 .with(|state| state.borrow_mut().apply_ack(ack, recycled));
401 self.stash_recycled_confirmations(confirmations);
402 }
403 while let Some(returns) = handle.try_drain() {
404 if let Some(confirmations) = self.frontend.apply_returns(returns) {
405 self.stash_recycled_confirmations(confirmations);
406 }
407 }
408 handle.shutdown();
409 }
410 crate::shape_replay::SHAPE_REPLAY.with(|state| state.borrow_mut().renderer_replaced());
411 log::warn!(
412 "[command-feed] renderer replaced: retained slots retired, \
413 confirmations revoked, feed generation bumped"
414 );
415 }
416 drop(backend);
417 }
418
419 #[cfg(not(target_arch = "wasm32"))]
422 fn stash_recycled_confirmations(&mut self, confirmations: Vec<ReplayConfirmation>) {
423 match &self.pending_recycled_confirmations {
424 Some(parked) if parked.capacity() >= confirmations.capacity() => {}
425 _ => self.pending_recycled_confirmations = Some(confirmations),
426 }
427 }
428
429 #[cfg(not(target_arch = "wasm32"))]
443 #[doc(hidden)]
444 pub fn drain_replay_acks(&mut self) -> usize {
445 let mut drained = 0;
446 loop {
447 let (ack, recycled) = {
448 let PresentBackend::Threaded(handle) = &mut self.backend else {
449 break;
450 };
451 match handle.try_drain_ack() {
452 Some(batch) => batch,
453 None => break,
454 }
455 };
456 drained += 1;
457 let confirmations = crate::shape_replay::SHAPE_REPLAY
458 .with(|state| state.borrow_mut().apply_ack(ack, recycled));
459 self.stash_recycled_confirmations(confirmations);
460 }
461 drained
462 }
463
464 pub fn init_gpu(
474 &mut self,
475 device: Arc<wgpu::Device>,
476 queue: Arc<wgpu::Queue>,
477 surface_format: wgpu::TextureFormat,
478 adapter_backend: wgpu::Backend,
479 adapter_downlevel: wgpu::DownlevelFlags,
480 ) {
481 self.retire_live_backend();
482 self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
483 #[cfg(not(target_arch = "wasm32"))]
487 let store_feed_generation = pipeline::retained_feed_generation();
488 #[cfg(target_arch = "wasm32")]
489 let store_feed_generation = 0;
490 self.backend = PresentBackend::Sync(Box::new(GpuRenderer::new(
491 device,
492 queue,
493 surface_format,
494 adapter_backend,
495 adapter_downlevel,
496 self.frontend.text_fonts.clone(),
497 self.renderer_epoch,
498 store_feed_generation,
499 )));
500 #[cfg(not(target_arch = "wasm32"))]
501 {
502 let region = self.display_visible_region;
503 if let Some(gpu_renderer) = self.sync_gpu_renderer_mut() {
504 gpu_renderer.set_display_visible_region(region);
505 }
506 }
507 }
508
509 pub fn set_display_visible_region(&mut self, region: DisplayVisibleRegion) {
526 self.display_visible_region = region;
527 #[cfg(not(target_arch = "wasm32"))]
528 match &mut self.backend {
529 PresentBackend::Sync(gpu_renderer) => {
530 gpu_renderer.set_display_visible_region(region);
531 }
532 PresentBackend::Threaded(handle) => {
533 handle.send_display_visible_region(region);
534 }
535 PresentBackend::None => {}
536 }
537 }
538
539 #[cfg(not(target_arch = "wasm32"))]
554 #[allow(clippy::too_many_arguments)]
555 pub fn init_gpu_threaded(
556 &mut self,
557 device: Arc<wgpu::Device>,
558 queue: Arc<wgpu::Queue>,
559 surface_format: wgpu::TextureFormat,
560 adapter_backend: wgpu::Backend,
561 adapter_downlevel: wgpu::DownlevelFlags,
562 waker: Arc<dyn Fn() + Send + Sync>,
563 clock: Option<Arc<dyn Fn() -> i64 + Send + Sync>>,
564 ) -> Result<(), WgpuRendererError> {
565 self.retire_live_backend();
566 self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
567 let store_feed_generation = pipeline::retained_feed_generation();
569 let init = PresentRuntimeInit {
570 device,
571 queue,
572 surface_format,
573 adapter_backend,
574 adapter_downlevel,
575 text_fonts: self.frontend.text_fonts.clone(),
576 renderer_epoch: self.renderer_epoch,
577 store_feed_generation,
578 clock,
579 display_visible_region: self.display_visible_region,
580 };
581 let handle = PresentHandle::spawn(init, waker).map_err(WgpuRendererError::Wgpu)?;
582 self.backend = PresentBackend::Threaded(handle);
583 Ok(())
584 }
585
586 #[cfg(not(target_arch = "wasm32"))]
591 #[doc(hidden)]
592 pub fn init_gpu_inline_for_tests(
593 &mut self,
594 device: Arc<wgpu::Device>,
595 queue: Arc<wgpu::Queue>,
596 surface_format: wgpu::TextureFormat,
597 adapter_backend: wgpu::Backend,
598 adapter_downlevel: wgpu::DownlevelFlags,
599 ) -> InlinePresentRuntime {
600 self.retire_live_backend();
601 self.renderer_epoch = self.renderer_epoch.wrapping_add(1);
602 let store_feed_generation = pipeline::retained_feed_generation();
603 let init = PresentRuntimeInit {
604 device,
605 queue,
606 surface_format,
607 adapter_backend,
608 adapter_downlevel,
609 text_fonts: self.frontend.text_fonts.clone(),
610 renderer_epoch: self.renderer_epoch,
611 store_feed_generation,
612 clock: None,
613 display_visible_region: self.display_visible_region,
614 };
615 let (handle, state, msg_rx) = PresentHandle::new_inline(init, Arc::new(|| {}));
616 self.backend = PresentBackend::Threaded(handle);
617 InlinePresentRuntime {
618 state,
619 msg_rx,
620 shutdown_seen: false,
621 }
622 }
623
624 pub fn note_surface_reconfigured(&mut self) {
629 self.surface_epoch = self.surface_epoch.wrapping_add(1);
630 }
631
632 pub fn set_root_scale(&mut self, scale: f32) {
634 self.frontend.root_scale = scale;
635 }
636
637 pub fn root_scale(&self) -> f32 {
638 self.frontend.root_scale
639 }
640
641 pub fn render(
649 &mut self,
650 view: &wgpu::TextureView,
651 width: u32,
652 height: u32,
653 ) -> Result<(), WgpuRendererError> {
654 self.render_frame(view, width, height)
655 }
656
657 pub fn render_surface_texture(
658 &mut self,
659 view: &wgpu::TextureView,
660 width: u32,
661 height: u32,
662 ) -> Result<(), WgpuRendererError> {
663 self.render_frame(view, width, height)
664 }
665
666 fn render_frame(
667 &mut self,
668 view: &wgpu::TextureView,
669 width: u32,
670 height: u32,
671 ) -> Result<(), WgpuRendererError> {
672 let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
673 return Err(WgpuRendererError::Wgpu(
674 "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
675 .to_string(),
676 ));
677 };
678 let packet = self
679 .frontend
680 .build_frame_packet(
681 width,
682 height,
683 gpu_renderer.replay_supported(),
684 self.renderer_epoch,
685 self.surface_epoch,
686 )
687 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
688 let frontend = &mut self.frontend;
689 let mut returns = RenderReturns::default();
690 let result = gpu_renderer.render(
694 view,
695 width,
696 height,
697 packet,
698 self.surface_epoch,
699 &mut returns,
700 );
701 if let Some(confirmations) = frontend.apply_returns(returns) {
702 gpu_renderer.restore_replay_ack_confirmations(confirmations);
703 }
704 result.map_err(WgpuRendererError::Wgpu)
705 }
706
707 pub fn capture_frame(
711 &mut self,
712 width: u32,
713 height: u32,
714 ) -> Result<CapturedFrame, WgpuRendererError> {
715 self.capture_frame_with_scale(width, height, self.frontend.root_scale)
716 }
717
718 pub fn capture_frame_with_scale(
720 &mut self,
721 width: u32,
722 height: u32,
723 root_scale: f32,
724 ) -> Result<CapturedFrame, WgpuRendererError> {
725 let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
726 return Err(WgpuRendererError::Wgpu(
727 "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
728 .to_string(),
729 ));
730 };
731 let packet = self
732 .frontend
733 .build_frame_packet_with_scale(
734 width,
735 height,
736 root_scale,
737 gpu_renderer.replay_supported(),
738 self.renderer_epoch,
739 self.surface_epoch,
740 )
741 .ok_or_else(|| WgpuRendererError::Wgpu("scene graph is missing".to_string()))?;
742 let frontend = &mut self.frontend;
743 let mut returns = RenderReturns::default();
744 let result = gpu_renderer.render_to_rgba_pixels(
747 width,
748 height,
749 packet,
750 self.surface_epoch,
751 &mut returns,
752 );
753 if let Some(confirmations) = frontend.apply_returns(returns) {
754 gpu_renderer.restore_replay_ack_confirmations(confirmations);
755 }
756 let pixels = result.map_err(WgpuRendererError::Wgpu)?;
757 Ok(CapturedFrame {
758 width,
759 height,
760 pixels,
761 })
762 }
763
764 #[cfg(not(target_arch = "wasm32"))]
769 pub fn has_frame_credit(&self) -> bool {
770 match &self.backend {
771 PresentBackend::Threaded(handle) => handle.has_credit(),
772 PresentBackend::Sync(_) | PresentBackend::None => true,
773 }
774 }
775
776 #[cfg(not(target_arch = "wasm32"))]
782 pub fn publish_frame(&mut self, width: u32, height: u32) -> PublishOutcome {
783 self.drain_replay_acks();
789 let PresentBackend::Threaded(handle) = &mut self.backend else {
790 log::error!("publish_frame called without a threaded present runtime");
791 return PublishOutcome::NoCredit;
792 };
793 if !handle.has_credit() {
794 return PublishOutcome::NoCredit;
795 }
796 let replay_supported = handle
797 .status()
798 .replay_supported
799 .load(std::sync::atomic::Ordering::Relaxed);
800 let Some(mut packet) = self.frontend.build_frame_packet(
801 width,
802 height,
803 replay_supported,
804 self.renderer_epoch,
805 self.surface_epoch,
806 ) else {
807 return PublishOutcome::NoGraph;
808 };
809 packet.recycled_confirmations = self.pending_recycled_confirmations.take();
810 match handle.publish(packet) {
811 Ok(()) => PublishOutcome::Published,
812 Err(mut packet) => {
813 if let Some(confirmations) = packet.recycled_confirmations.take() {
816 self.pending_recycled_confirmations = Some(confirmations);
817 }
818 let mut returns = RenderReturns::default();
819 let _ = GpuRenderer::cancel_packet(
820 *packet,
821 CancelReason::SurfaceUnavailable,
822 &mut returns,
823 );
824 if let Some(confirmations) = self.frontend.apply_returns(returns) {
825 self.stash_recycled_confirmations(confirmations);
826 }
827 log::error!("present runtime unavailable; frame recovered, not published");
828 PublishOutcome::NoCredit
829 }
830 }
831 }
832
833 #[cfg(not(target_arch = "wasm32"))]
838 pub fn drain_present_returns(&mut self) -> usize {
839 self.drain_present_returns_with(&mut |_, _, _| {})
840 }
841
842 #[cfg(not(target_arch = "wasm32"))]
846 pub fn drain_present_returns_with(
847 &mut self,
848 on_return: &mut dyn FnMut(u64, PresentOutcome, PresentTimings),
849 ) -> usize {
850 self.drain_replay_acks();
854 let mut drained = 0;
855 loop {
856 let returns = {
859 let PresentBackend::Threaded(handle) = &mut self.backend else {
860 break;
861 };
862 match handle.try_drain() {
863 Some(returns) => returns,
864 None => break,
865 }
866 };
867 drained += 1;
868 let frame_id = returns.frame_id;
869 let outcome = returns.outcome;
870 let timings = returns.timings;
871 if let Some(confirmations) = self.frontend.apply_returns(returns) {
872 self.stash_recycled_confirmations(confirmations);
873 }
874 on_return(frame_id, outcome, timings);
875 }
876 drained
877 }
878
879 #[cfg(not(target_arch = "wasm32"))]
885 pub fn present_replace_surface(
886 &mut self,
887 surface: wgpu::Surface<'static>,
888 config: wgpu::SurfaceConfiguration,
889 ) -> bool {
890 let surface_epoch = self.surface_epoch;
891 let Some(handle) = self.present_handle_mut() else {
892 log::error!("present_replace_surface called without a threaded present runtime");
893 return false;
894 };
895 handle.send_control_and_wait(
896 move |ack| PresentControl::ReplaceSurface {
897 surface,
898 config,
899 surface_epoch,
900 ack,
901 },
902 "replace surface",
903 )
904 }
905
906 #[cfg(not(target_arch = "wasm32"))]
910 pub fn present_reconfigure(&mut self, config: wgpu::SurfaceConfiguration) -> bool {
911 let surface_epoch = self.surface_epoch;
912 let Some(handle) = self.present_handle_mut() else {
913 log::error!("present_reconfigure called without a threaded present runtime");
914 return false;
915 };
916 handle.send_control_and_wait(
917 move |ack| PresentControl::Reconfigure {
918 config,
919 surface_epoch,
920 ack,
921 },
922 "reconfigure surface",
923 )
924 }
925
926 #[cfg(not(target_arch = "wasm32"))]
930 pub fn present_drop_surface(&mut self) -> bool {
931 let Some(handle) = self.present_handle_mut() else {
932 log::error!("present_drop_surface called without a threaded present runtime");
933 return false;
934 };
935 handle.send_control_and_wait(|ack| PresentControl::DropSurface { ack }, "drop surface")
936 }
937
938 #[cfg(not(target_arch = "wasm32"))]
941 pub fn shutdown_present_runtime(&mut self) {
942 if matches!(self.backend, PresentBackend::Threaded(_)) {
943 self.retire_live_backend();
944 }
945 }
946
947 #[cfg(not(target_arch = "wasm32"))]
950 #[doc(hidden)]
951 pub fn present_attach_offscreen_for_tests(&mut self, width: u32, height: u32) -> bool {
952 let Some(handle) = self.present_handle_mut() else {
953 return false;
954 };
955 handle.send_control_and_wait(
956 move |ack| PresentControl::AttachOffscreenTargetForTests { width, height, ack },
957 "attach offscreen target",
958 )
959 }
960
961 #[cfg(not(target_arch = "wasm32"))]
964 #[doc(hidden)]
965 pub fn send_attach_offscreen_unacked_for_tests(
966 &mut self,
967 width: u32,
968 height: u32,
969 ) -> Option<std::sync::mpsc::Receiver<()>> {
970 let handle = self.present_handle_mut()?;
971 handle.send_control_unacked(move |ack| PresentControl::AttachOffscreenTargetForTests {
972 width,
973 height,
974 ack,
975 })
976 }
977
978 #[cfg(not(target_arch = "wasm32"))]
982 #[doc(hidden)]
983 pub fn send_reconfigure_unacked_for_tests(
984 &mut self,
985 config: wgpu::SurfaceConfiguration,
986 ) -> Option<std::sync::mpsc::Receiver<()>> {
987 let surface_epoch = self.surface_epoch;
988 let handle = self.present_handle_mut()?;
989 handle.send_control_unacked(move |ack| PresentControl::Reconfigure {
990 config,
991 surface_epoch,
992 ack,
993 })
994 }
995
996 #[cfg(not(target_arch = "wasm32"))]
998 #[doc(hidden)]
999 pub fn send_drop_surface_unacked_for_tests(&mut self) -> Option<std::sync::mpsc::Receiver<()>> {
1000 let handle = self.present_handle_mut()?;
1001 handle.send_control_unacked(|ack| PresentControl::DropSurface { ack })
1002 }
1003
1004 pub fn last_published_frame_id(&self) -> u64 {
1009 self.frontend.frame_sequence
1010 }
1011
1012 #[cfg(not(target_arch = "wasm32"))]
1017 #[doc(hidden)]
1018 pub fn seed_recycled_confirmations_for_tests(&mut self, capacity: usize) {
1019 self.pending_recycled_confirmations = Some(Vec::with_capacity(capacity));
1020 }
1021
1022 #[cfg(not(target_arch = "wasm32"))]
1026 #[doc(hidden)]
1027 pub fn pending_recycled_confirmations_capacity_for_tests(&self) -> Option<usize> {
1028 self.pending_recycled_confirmations
1029 .as_ref()
1030 .map(Vec::capacity)
1031 }
1032
1033 #[cfg(not(target_arch = "wasm32"))]
1036 #[doc(hidden)]
1037 pub fn present_status_snapshot_for_tests(&self) -> Option<(bool, bool, u64)> {
1038 match &self.backend {
1039 PresentBackend::Threaded(handle) => {
1040 let status = handle.status();
1041 Some((
1042 status
1043 .needs_frame_warmup
1044 .load(std::sync::atomic::Ordering::Relaxed),
1045 status
1046 .replay_supported
1047 .load(std::sync::atomic::Ordering::Relaxed),
1048 status
1049 .presented_frames
1050 .load(std::sync::atomic::Ordering::Relaxed),
1051 ))
1052 }
1053 _ => None,
1054 }
1055 }
1056
1057 pub fn last_frame_stats(&self) -> Option<RenderStatsSnapshot> {
1058 self.sync_gpu_renderer()
1059 .and_then(GpuRenderer::last_frame_stats)
1060 }
1061
1062 pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
1063 let mut stats = self
1064 .sync_gpu_renderer()
1065 .map(GpuRenderer::debug_cpu_allocation_stats)
1066 .unwrap_or_default();
1067 stats.scene_graph_node_count = self
1068 .frontend
1069 .scene
1070 .graph
1071 .as_ref()
1072 .map(RenderGraph::node_count)
1073 .unwrap_or(0);
1074 stats.scene_graph_heap_bytes = self
1075 .frontend
1076 .scene
1077 .graph
1078 .as_ref()
1079 .map(RenderGraph::heap_bytes)
1080 .unwrap_or(0);
1081 stats.scene_hits_len = self.frontend.scene.hits.len();
1082 stats.scene_hits_cap = self.frontend.scene.hits.capacity();
1083 stats.scene_node_index_len = self.frontend.scene.node_index.len();
1084 stats.scene_node_index_cap = self.frontend.scene.node_index.capacity();
1085 stats.layer_surface_rect_cache_len += self.frontend.layer_surface_rect_cache.len();
1089 stats.layer_surface_rect_cache_cap += self.frontend.layer_surface_rect_cache.capacity();
1090 stats.layer_surface_requirements_cache_len +=
1091 self.frontend.layer_surface_requirements_cache.len();
1092 stats.layer_surface_requirements_cache_cap +=
1093 self.frontend.layer_surface_requirements_cache.capacity();
1094 stats
1095 }
1096
1097 pub fn try_device(&self) -> Option<&wgpu::Device> {
1101 self.sync_gpu_renderer().map(|r| &*r.device)
1102 }
1103
1104 #[doc(hidden)]
1108 pub fn device_error_count_for_tests(&self) -> u64 {
1109 self.sync_gpu_renderer()
1110 .map(GpuRenderer::device_error_count)
1111 .unwrap_or(0)
1112 }
1113
1114 #[cfg(not(target_arch = "wasm32"))]
1119 #[doc(hidden)]
1120 pub fn instanced_quads_active(&self) -> bool {
1121 self.sync_gpu_renderer()
1122 .is_some_and(GpuRenderer::instanced_quads_active)
1123 }
1124
1125 #[cfg(not(target_arch = "wasm32"))]
1128 #[doc(hidden)]
1129 pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
1130 self.sync_gpu_renderer()
1131 .map(GpuRenderer::replay_slot_mesh_stats)
1132 .unwrap_or((0, 0))
1133 }
1134
1135 #[cfg(not(target_arch = "wasm32"))]
1139 #[doc(hidden)]
1140 pub fn replay_slot_mesh_engagement(&self) -> (usize, usize, usize) {
1141 self.sync_gpu_renderer()
1142 .map(GpuRenderer::replay_slot_mesh_engagement)
1143 .unwrap_or((0, 0, 0))
1144 }
1145
1146 #[cfg(not(target_arch = "wasm32"))]
1149 #[doc(hidden)]
1150 pub fn retained_bundle_stats(&self) -> (u64, u64) {
1151 self.sync_gpu_renderer()
1152 .map(GpuRenderer::retained_bundle_stats)
1153 .unwrap_or((0, 0))
1154 }
1155
1156 #[cfg(not(target_arch = "wasm32"))]
1160 #[doc(hidden)]
1161 pub fn rim_meshes_emitted(&self) -> u64 {
1162 self.sync_gpu_renderer()
1163 .map(GpuRenderer::rim_meshes_emitted)
1164 .unwrap_or(0)
1165 }
1166
1167 #[cfg(not(target_arch = "wasm32"))]
1172 #[doc(hidden)]
1173 pub fn static_span_stats(&self) -> (u64, u64) {
1174 self.sync_gpu_renderer()
1175 .map(GpuRenderer::static_span_stats)
1176 .unwrap_or((0, 0))
1177 }
1178
1179 #[cfg(not(target_arch = "wasm32"))]
1183 #[doc(hidden)]
1184 pub fn segment_surface_stats(&self) -> (u64, u64, u64, u64, u64) {
1185 self.sync_gpu_renderer()
1186 .map(GpuRenderer::segment_surface_stats)
1187 .unwrap_or((0, 0, 0, 0, 0))
1188 }
1189
1190 #[cfg(not(target_arch = "wasm32"))]
1195 #[doc(hidden)]
1196 pub fn replay_generation_drops_for_tests(&self) -> u64 {
1197 self.sync_gpu_renderer()
1198 .map(GpuRenderer::replay_generation_drops)
1199 .unwrap_or(0)
1200 }
1201
1202 #[cfg(not(target_arch = "wasm32"))]
1210 #[doc(hidden)]
1211 pub fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
1212 self.sync_gpu_renderer_mut()
1213 .expect("GPU renderer not initialized")
1214 .replay_ops_roundtrip_for_tests(generation_skew)
1215 }
1216
1217 #[doc(hidden)]
1223 pub fn build_frame_packet_for_tests(
1224 &mut self,
1225 width: u32,
1226 height: u32,
1227 ) -> Option<HeldFramePacket> {
1228 let replay_supported = match &self.backend {
1229 PresentBackend::Sync(gpu_renderer) => gpu_renderer.replay_supported(),
1230 #[cfg(not(target_arch = "wasm32"))]
1231 PresentBackend::Threaded(handle) => handle
1232 .status()
1233 .replay_supported
1234 .load(std::sync::atomic::Ordering::Relaxed),
1235 PresentBackend::None => false,
1236 };
1237 self.frontend
1238 .build_frame_packet(
1239 width,
1240 height,
1241 replay_supported,
1242 self.renderer_epoch,
1243 self.surface_epoch,
1244 )
1245 .map(HeldFramePacket)
1246 }
1247
1248 #[doc(hidden)]
1252 pub fn render_held_packet_for_tests(
1253 &mut self,
1254 view: &wgpu::TextureView,
1255 width: u32,
1256 height: u32,
1257 packet: HeldFramePacket,
1258 ) -> Result<PresentOutcome, WgpuRendererError> {
1259 let PresentBackend::Sync(gpu_renderer) = &mut self.backend else {
1260 return Err(WgpuRendererError::Wgpu(
1261 "GPU renderer not initialized for synchronous rendering. Call init_gpu() first."
1262 .to_string(),
1263 ));
1264 };
1265 let frontend = &mut self.frontend;
1266 let mut returns = RenderReturns::default();
1267 let result = gpu_renderer.render(
1268 view,
1269 width,
1270 height,
1271 packet.0,
1272 self.surface_epoch,
1273 &mut returns,
1274 );
1275 let outcome = returns.outcome;
1276 if let Some(confirmations) = frontend.apply_returns(returns) {
1277 gpu_renderer.restore_replay_ack_confirmations(confirmations);
1278 }
1279 result.map_err(WgpuRendererError::Wgpu)?;
1280 Ok(outcome)
1281 }
1282
1283 #[doc(hidden)]
1286 pub fn has_retained_direct_scene_for_tests(&self) -> bool {
1287 !self.frontend.retained_direct_scenes.is_empty()
1288 }
1289}
1290
1291#[doc(hidden)]
1294pub struct HeldFramePacket(frame_packet::FramePacket);
1295
1296#[cfg(not(target_arch = "wasm32"))]
1300#[doc(hidden)]
1301pub struct InlinePresentRuntime {
1302 state: PresentState,
1303 msg_rx: std::sync::mpsc::Receiver<PresentMsg>,
1304 shutdown_seen: bool,
1305}
1306
1307#[cfg(not(target_arch = "wasm32"))]
1308impl InlinePresentRuntime {
1309 pub fn pump(&mut self) -> bool {
1314 if self.shutdown_seen {
1315 return false;
1316 }
1317 while let Ok(msg) = self.msg_rx.try_recv() {
1318 if !self.state.run_once(msg) {
1319 self.shutdown_seen = true;
1320 return false;
1321 }
1322 }
1323 self.state.consume_waiting();
1324 true
1325 }
1326
1327 pub fn has_waiting_packet(&self) -> bool {
1330 self.state.has_waiting_packet()
1331 }
1332
1333 pub fn step_one_message(&mut self) -> bool {
1337 if self.shutdown_seen {
1338 return false;
1339 }
1340 match self.msg_rx.try_recv() {
1341 Ok(msg) => {
1342 if !self.state.run_once(msg) {
1343 self.shutdown_seen = true;
1344 }
1345 true
1346 }
1347 Err(_) => false,
1348 }
1349 }
1350
1351 pub fn consume_waiting(&mut self) {
1354 self.state.consume_waiting();
1355 }
1356
1357 pub fn store_ack_confirmations_capacity(&self) -> usize {
1360 self.state.store_ack_confirmations_capacity()
1361 }
1362}
1363
1364impl Default for WgpuRenderer {
1365 fn default() -> Self {
1366 Self::new(&[])
1367 }
1368}
1369
1370impl Renderer for WgpuRenderer {
1371 type Scene = Scene;
1372 type Error = WgpuRendererError;
1373
1374 fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
1375 app_context.set_text_measurer(SoftwareTextMeasurer::from_font_set(
1376 self.frontend.text_fonts.clone(),
1377 8192,
1378 ));
1379 self.frontend.app_context = Some(app_context.downgrade());
1380 }
1381
1382 fn scene(&self) -> &Self::Scene {
1383 &self.frontend.scene
1384 }
1385
1386 fn scene_mut(&mut self) -> &mut Self::Scene {
1387 &mut self.frontend.scene
1388 }
1389
1390 fn rebuild_scene(
1391 &mut self,
1392 layout_tree: &LayoutTree,
1393 _viewport: Size,
1394 ) -> Result<(), Self::Error> {
1395 self.frontend.scene.clear();
1396 self.frontend.layer_surface_requirements_cache.clear();
1397 self.frontend.dev_overlay_graph = None;
1398 self.frontend.dev_overlay_cache = None;
1399 pipeline::render_layout_tree(layout_tree.root(), &mut self.frontend.scene);
1401 Ok(())
1402 }
1403
1404 fn rebuild_scene_from_applier(
1405 &mut self,
1406 applier: &mut MemoryApplier,
1407 root: NodeId,
1408 _viewport: Size,
1409 ) -> Result<(), Self::Error> {
1410 self.frontend.scene.clear();
1411 self.frontend.layer_surface_requirements_cache.clear();
1412 self.frontend.dev_overlay_graph = None;
1413 self.frontend.dev_overlay_cache = None;
1414 pipeline::render_from_applier(applier, root, &mut self.frontend.scene, 1.0);
1417 Ok(())
1418 }
1419
1420 fn update_scene_from_applier(
1421 &mut self,
1422 applier: &mut MemoryApplier,
1423 root: NodeId,
1424 viewport: Size,
1425 dirty_nodes: &[NodeId],
1426 ) -> Result<(), Self::Error> {
1427 if dirty_nodes.is_empty() {
1428 return self.rebuild_scene_from_applier(applier, root, viewport);
1429 }
1430 self.update_scene_and_plan_memo(applier, root, dirty_nodes, true);
1431 Ok(())
1432 }
1433
1434 fn update_visual_scene_from_applier(
1435 &mut self,
1436 applier: &mut MemoryApplier,
1437 root: NodeId,
1438 viewport: Size,
1439 dirty_nodes: &[NodeId],
1440 ) -> Result<(), Self::Error> {
1441 if dirty_nodes.is_empty() {
1442 return self.rebuild_scene_from_applier(applier, root, viewport);
1443 }
1444 self.update_scene_and_plan_memo(applier, root, dirty_nodes, false);
1445 Ok(())
1446 }
1447
1448 fn draw_dev_overlay(&mut self, text: &str, viewport: Size) {
1449 const DEV_OVERLAY_NODE_ID: NodeId = NodeId::MAX;
1450 let key = cranpose_render_common::dev_overlay::DevOverlayKey::new(text, viewport);
1451 if self.frontend.dev_overlay_graph.is_some()
1452 && self
1453 .frontend
1454 .dev_overlay_cache
1455 .as_ref()
1456 .is_some_and(|cache| {
1457 cache.text == key.text
1458 && cache.viewport_width_bits == key.viewport_width_bits
1459 && cache.viewport_height_bits == key.viewport_height_bits
1460 })
1461 {
1462 return;
1463 }
1464 self.frontend.dev_overlay_graph = Some(
1465 cranpose_render_common::dev_overlay::build_dev_overlay_graph(
1466 text,
1467 viewport,
1468 DEV_OVERLAY_NODE_ID,
1469 ),
1470 );
1471 self.frontend.dev_overlay_cache = Some(DevOverlayCache {
1472 text: key.text,
1473 viewport_width_bits: key.viewport_width_bits,
1474 viewport_height_bits: key.viewport_height_bits,
1475 });
1476 }
1477
1478 fn needs_frame_warmup(&self) -> bool {
1479 match &self.backend {
1480 PresentBackend::Sync(gpu_renderer) => gpu_renderer.needs_frame_warmup(),
1481 #[cfg(not(target_arch = "wasm32"))]
1485 PresentBackend::Threaded(handle) => handle
1486 .status()
1487 .needs_frame_warmup
1488 .load(std::sync::atomic::Ordering::Relaxed),
1489 PresentBackend::None => false,
1490 }
1491 }
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use super::*;
1497 use crate::pipeline::TextLayoutResolver;
1498 use cranpose_render_common::graph::RenderNode;
1499 use cranpose_ui_graphics::GraphicsLayer;
1500 use std::cell::Cell;
1501
1502 static TEST_FONT: &[u8] =
1503 cranpose_render_common::software_text_raster::DEFAULT_SOFTWARE_TEXT_FONT_BYTES;
1504
1505 #[test]
1506 fn dev_overlay_is_recorded_outside_app_graph() {
1507 let mut renderer = WgpuRenderer::new(&[]);
1508 renderer.draw_dev_overlay(
1509 "240 FPS | avg 4.0ms | p95 4.5ms",
1510 Size {
1511 width: 800.0,
1512 height: 600.0,
1513 },
1514 );
1515
1516 assert!(
1517 renderer
1518 .frontend
1519 .scene
1520 .graph
1521 .as_ref()
1522 .is_none_or(|graph| graph.root.children.iter().all(|child| {
1523 !matches!(
1524 child,
1525 RenderNode::Layer(layer) if layer.node_id == Some(NodeId::MAX)
1526 )
1527 })),
1528 "dev overlay must not be mixed into the app scene graph"
1529 );
1530
1531 let graph = renderer
1532 .frontend
1533 .dev_overlay_graph
1534 .as_ref()
1535 .expect("overlay graph");
1536 let Some(RenderNode::Layer(overlay)) = graph.root.children.last() else {
1537 panic!("dev overlay should be the final top-level layer");
1538 };
1539
1540 assert_eq!(overlay.node_id, Some(NodeId::MAX));
1541 assert_eq!(
1542 overlay.graphics_layer.compositing_strategy,
1543 GraphicsLayer::default().compositing_strategy,
1544 "dev overlay should not allocate an offscreen surface"
1545 );
1546 }
1547
1548 struct CountingTextMeasurer {
1549 inner: SoftwareTextMeasurer,
1550 layout_calls: Rc<Cell<usize>>,
1551 }
1552
1553 impl CountingTextMeasurer {
1554 fn new(layout_calls: Rc<Cell<usize>>) -> Self {
1555 Self {
1556 inner: SoftwareTextMeasurer::from_fonts_or_default(&[TEST_FONT], 16),
1557 layout_calls,
1558 }
1559 }
1560 }
1561
1562 impl TextMeasurer for CountingTextMeasurer {
1563 fn measure(
1564 &self,
1565 text: &cranpose_ui::text::AnnotatedString,
1566 style: &cranpose_ui::text::TextStyle,
1567 ) -> cranpose_ui::TextMetrics {
1568 self.inner.measure(text, style)
1569 }
1570
1571 fn get_offset_for_position(
1572 &self,
1573 text: &cranpose_ui::text::AnnotatedString,
1574 style: &cranpose_ui::text::TextStyle,
1575 x: f32,
1576 y: f32,
1577 ) -> usize {
1578 self.inner.get_offset_for_position(text, style, x, y)
1579 }
1580
1581 fn get_cursor_x_for_offset(
1582 &self,
1583 text: &cranpose_ui::text::AnnotatedString,
1584 style: &cranpose_ui::text::TextStyle,
1585 offset: usize,
1586 ) -> f32 {
1587 self.inner.get_cursor_x_for_offset(text, style, offset)
1588 }
1589
1590 fn layout(
1591 &self,
1592 text: &cranpose_ui::text::AnnotatedString,
1593 style: &cranpose_ui::text::TextStyle,
1594 ) -> cranpose_ui::text_layout_result::TextLayoutResult {
1595 self.layout_calls.set(self.layout_calls.get() + 1);
1596 self.inner.layout(text, style)
1597 }
1598 }
1599
1600 #[test]
1601 fn headless_text_measurer_uses_software_text_font() {
1602 let measurer = headless_text_measurer_with_fonts(&[TEST_FONT]);
1603 let text = cranpose_ui::text::AnnotatedString::from("software text measurement");
1604 let style = cranpose_ui::text::TextStyle::default();
1605
1606 let metrics = measurer.measure(&text, &style);
1607 let layout = measurer.layout(&text, &style);
1608
1609 assert!(metrics.width > 0.0);
1610 assert!(metrics.height > 0.0);
1611 assert_eq!(layout.lines.len(), metrics.line_count);
1612 }
1613
1614 #[test]
1615 fn renderer_measurement_uses_software_text_service_without_render_cache_side_effect() {
1616 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1617 let app_context = cranpose_ui::AppContext::new();
1618 renderer.attach_app_context_services(&app_context);
1619
1620 let metrics = app_context.enter(|| {
1621 let text = cranpose_ui::text::AnnotatedString::from("phase local text cache");
1622 let style = cranpose_ui::text::TextStyle {
1623 span_style: cranpose_ui::text::SpanStyle {
1624 font_size: cranpose_ui::text::TextUnit::Sp(14.0),
1625 ..Default::default()
1626 },
1627 paragraph_style: cranpose_ui::text::ParagraphStyle {
1628 platform_style: Some(cranpose_ui::text::PlatformParagraphStyle {
1629 include_font_padding: None,
1630 shaping: Some(cranpose_ui::text::TextShaping::Basic),
1631 }),
1632 ..Default::default()
1633 },
1634 };
1635 cranpose_ui::text::measure_text(&text, &style)
1636 });
1637
1638 assert!(
1639 metrics.width > 0.0,
1640 "software text service should measure text"
1641 );
1642 assert_eq!(
1643 renderer.frontend.text_state.text_cache_len(),
1644 0,
1645 "WGPU must not keep a renderer-side shaping cache for measurement"
1646 );
1647 }
1648
1649 #[test]
1650 fn renderer_attached_text_service_measures_long_multiline_text_with_software_line_height() {
1651 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1652 let app_context = cranpose_ui::AppContext::new();
1653 renderer.attach_app_context_services(&app_context);
1654
1655 let prepared = app_context.enter(|| {
1656 let text = cranpose_ui::text::AnnotatedString::from(
1657 (0..48)
1658 .map(|line| format!("// markdown code line {line:02}"))
1659 .collect::<Vec<_>>()
1660 .join("\n"),
1661 );
1662 let style = cranpose_ui::text::TextStyle::default();
1663 cranpose_ui::text::prepare_text_layout(
1664 &text,
1665 &style,
1666 cranpose_ui::text::TextLayoutOptions::default(),
1667 Some(952.0),
1668 )
1669 });
1670
1671 assert_eq!(prepared.metrics.line_count, 48);
1672 assert!(
1673 prepared.metrics.line_height > 18.0,
1674 "renderer-attached text service must not use fallback monospaced line height: {:?}",
1675 prepared.metrics
1676 );
1677 assert!(
1678 prepared.metrics.height > 900.0,
1679 "48 software-measured lines should not collapse to a viewport-sized block: {:?}",
1680 prepared.metrics
1681 );
1682 }
1683
1684 #[test]
1685 fn render_text_layout_routes_through_attached_app_context_service() {
1686 let mut renderer = WgpuRenderer::new(&[TEST_FONT]);
1687 let app_context = cranpose_ui::AppContext::new();
1688 renderer.attach_app_context_services(&app_context);
1689 let layout_calls = Rc::new(Cell::new(0));
1690 app_context.set_text_measurer(CountingTextMeasurer::new(Rc::clone(&layout_calls)));
1691
1692 app_context.enter(|| {
1693 let text = cranpose_ui::text::AnnotatedString::from("render text");
1694 let style = cranpose_ui::text::TextStyle::default();
1695 let layout = renderer.frontend.text_state.layout_text(&text, &style);
1696 assert!(layout.width > 0.0);
1697 });
1698
1699 assert_eq!(layout_calls.get(), 1);
1700 }
1701}