1#![deny(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4mod focus_reveal;
5mod fps_monitor;
6mod frame_rate_boost;
7mod hit_path_tracker;
8pub mod inspector;
9mod modal_focus;
10#[cfg(debug_assertions)]
11mod semantics_check;
12mod shell_debug;
13mod shell_frame;
14mod shell_input;
15mod surface;
16mod touch_router;
17mod wheel;
18use std::{
19 cell::{Cell, RefCell},
20 fmt::{Debug, Write},
21 rc::Rc,
22};
23
24use cranpose_core::{
25 Applier, Composition, Key, MemoryApplier, NodeError, NodeId, NodeMarks,
26 SceneNodeAttachmentScratch, collections::map::HashSet, enter_event_handler_scope, location_key,
27 run_in_mutable_snapshot,
28};
29pub use cranpose_foundation::{
30 DEFAULT_ROTARY_SCROLL_FACTOR_DP, Modifiers, PointerSource, RotaryScrollEvent,
31 rotary_scroll_pixels_from_detents,
32};
33use cranpose_foundation::{PointerButton, PointerButtons, PointerEvent, PointerEventKind};
34use cranpose_render_common::{HitTestTarget, RenderScene, Renderer, SceneUpdates};
35use cranpose_runtime_std::StdRuntime;
36use cranpose_ui::{
37 HeadlessRenderer, LayoutBox, LayoutNode, LayoutTree, MeasureLayoutOptions, SemanticsTree,
38 SubcomposeLayoutNode, WindowRootEntry, format_layout_tree, format_render_scene,
39 format_screen_summary, process_focus_invalidations, process_pointer_repasses,
40 process_semantics_invalidations, request_render_invalidation,
41};
42pub use cranpose_ui::{KeyCode, KeyEvent, KeyEventType};
43use cranpose_ui_graphics::{Point, PointerIcon, Rect, Size};
44pub use fps_monitor::FpsStats;
45pub use frame_rate_boost::FrameRateBoost;
46use hit_path_tracker::PointerId;
47#[cfg(test)]
48use shell_frame::build_draw_refresh_scope;
49pub use surface::{RootId, RootSurface, SurfaceMut};
50use surface::{
51 SurfaceDirtyLane, TextInputRouter, TextInputRoutes, route_geometry_scene_nodes,
52 route_nodes_by_surface,
53};
54pub use touch_router::TouchRouter;
55use web_time::Instant;
56pub use wheel::WheelScroll;
57
58#[cfg(all(
59 feature = "clipboard-native",
60 not(target_arch = "wasm32"),
61 not(target_os = "android"),
62 not(target_os = "ios")
63))]
64#[derive(Default)]
65struct ShellClipboard {
66 inner: std::cell::OnceCell<std::cell::RefCell<Option<arboard::Clipboard>>>,
70}
71
72#[cfg(all(
73 feature = "clipboard-native",
74 not(target_arch = "wasm32"),
75 not(target_os = "android"),
76 not(target_os = "ios")
77))]
78impl ShellClipboard {
79 fn with<T>(
82 &self,
83 use_clipboard: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,
84 ) -> Option<T> {
85 self.inner
86 .get_or_init(|| std::cell::RefCell::new(arboard::Clipboard::new().ok()))
87 .borrow_mut()
88 .as_mut()
89 .and_then(use_clipboard)
90 }
91}
92
93#[cfg(all(
94 feature = "clipboard-native",
95 not(target_arch = "wasm32"),
96 not(target_os = "android"),
97 not(target_os = "ios")
98))]
99impl cranpose_ui::clipboard_session::PlatformClipboard for ShellClipboard {
100 fn write_text(&self, text: &str) {
101 self.with(|clipboard| clipboard.set_text(text).ok());
102 }
103
104 fn read_text(&self) -> Option<String> {
105 self.with(|clipboard| clipboard.get_text().ok())
106 }
107}
108#[cfg(any(test, feature = "test-support"))]
109use cranpose_core::{
110 CompositionPassDebugStats, SlotId,
111 runtime::{RuntimeDebugStats, StateArenaDebugStats},
112 snapshot_pinning::{SnapshotPinningDebugStats, debug_snapshot_pinning_stats},
113 snapshot_v2::{SnapshotV2DebugStats, debug_snapshot_v2_stats},
114};
115#[cfg(any(test, feature = "test-support"))]
116use cranpose_core::{
117 MemoryApplierDebugStats, RecomposeScopeRegistryDebugStats, SlotTableDebugStats,
118 debug_recompose_scope_registry_stats,
119};
120pub use cranpose_ui::{ImeEditorState, PlatformTextInputHandler};
121#[cfg(any(test, feature = "test-support"))]
122pub mod accessibility_audit;
123#[cfg(any(test, feature = "test-support"))]
124pub mod placed_semantics;
125
126#[derive(Debug, Clone, Copy, PartialEq, Default)]
136pub enum FrameRatePreference {
137 #[default]
142 Auto,
143 NoPreference,
145 Exact(f32),
148}
149
150impl FrameRatePreference {
151 pub const AUTO_QUIET_RATE_HZ: f32 = 60.0;
158
159 pub fn desired_rate_hz(
173 self,
174 producing_frames: bool,
175 boosted: bool,
176 panel_max_hz: Option<f32>,
177 ) -> f32 {
178 match self {
179 FrameRatePreference::Auto => {
180 if boosted {
181 panel_max_hz
182 .filter(|rate| *rate > 0.0)
183 .unwrap_or(Self::AUTO_QUIET_RATE_HZ)
184 } else if producing_frames {
185 Self::AUTO_QUIET_RATE_HZ
186 } else {
187 0.0
188 }
189 }
190 FrameRatePreference::NoPreference => 0.0,
191 FrameRatePreference::Exact(rate) if rate > 0.0 => rate,
192 FrameRatePreference::Exact(_) => 0.0,
193 }
194 }
195}
196
197pub(crate) struct ShellApp {
198 pub(crate) app_context: Rc<cranpose_ui::AppContext>,
199 pub(crate) runtime: StdRuntime,
200 pub(crate) composition: Composition<MemoryApplier>,
201 pub(crate) content: Box<dyn FnMut()>,
202 pub(crate) start_time: Instant,
203 pub(crate) last_frame_time_nanos: u64,
204 pub(crate) semantics_enabled: bool,
205 pub(crate) semantics_snapshot_revision: u64,
206 pub(crate) revealed_focus: Option<NodeId>,
207 pub(crate) layout_requested: bool,
208 pub(crate) content_moved: bool,
209 pub(crate) force_layout_pass: bool,
210 pub(crate) modifiers: Option<Modifiers>,
211 pub(crate) rotary_scroll_factor: f32,
212 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
213 pub(crate) clipboard: Option<arboard::Clipboard>,
214 pub(crate) dev_options: DevOptions,
215 pub(crate) inspector_projector: Option<inspector::InspectorProjector>,
216 pub(crate) fps_monitor: fps_monitor::FpsMonitor,
217 pub(crate) text_input_routes: Rc<RefCell<TextInputRoutes>>,
218 pub(crate) text_input_router_installed: bool,
219 pub(crate) window_roots_seen: Option<u64>,
220 pub(crate) rebuilt_frames_since_observation_prune: u32,
222 #[cfg(feature = "installed-fonts")]
224 pub(crate) installed_fonts: usize,
225}
226
227pub struct AppShell<R>
235where
236 R: Renderer,
237{
238 pub(crate) app: ShellApp,
239 pub(crate) surfaces: Vec<RootSurface<R>>,
240 pub(crate) routing_scratch: SurfaceRoutingScratch,
241 pub(crate) pending_dirty_nodes: Vec<NodeId>,
242 pub(crate) pending_layer_property_nodes: Vec<NodeId>,
243 pub(crate) geometry_scene_nodes: cranpose_ui::GeometrySceneNodes,
244}
245
246#[derive(Default)]
247pub(crate) struct SurfaceRoutingScratch {
248 pub(crate) attached: Vec<Option<NodeId>>,
249 pub(crate) owners: Vec<Option<NodeId>>,
250 pub(crate) seen: HashSet<(usize, NodeId)>,
251 pub(crate) attachment: SceneNodeAttachmentScratch,
252 pub(crate) window_roots: cranpose_ui::WindowRootRoutingScratch,
253 pub(crate) laid_out: NodeMarks,
256 pub(crate) laid_out_starts: Vec<usize>,
258 pub(crate) moved: Vec<NodeId>,
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub struct PointerEventTime {
265 pub platform_time_ms: Option<i64>,
267 pub animation_time_nanos: u64,
269}
270
271fn update_stage_telemetry_threshold_ms() -> Option<f64> {
272 static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
273 *THRESHOLD_MS.get_or_init(|| {
274 std::env::var("CRANPOSE_UPDATE_STAGE_TELEMETRY_MS")
275 .ok()
276 .and_then(|value| value.parse::<f64>().ok())
277 .filter(|value| value.is_finite() && *value >= 0.0)
278 })
279}
280
281type UpdateStages = cranpose_core::StageTimes<5>;
284
285struct UpdateOutcome {
286 should_render: bool,
287 reconcile_attempted: bool,
288 reconcile_changed: bool,
289}
290
291fn log_update_stage_telemetry(stages: &UpdateStages, outcome: UpdateOutcome) {
292 let Some(threshold_ms) = update_stage_telemetry_threshold_ms() else {
293 return;
294 };
295 let Some(
296 [
297 started_at,
298 after_frame_callbacks,
299 after_ui_drain,
300 after_reconcile,
301 after_process_frame,
302 ],
303 ) = stages.times()
304 else {
305 return;
306 };
307 let ms = cranpose_core::stage_ms;
308 let total_ms = ms(started_at, after_process_frame);
309 if total_ms < threshold_ms {
310 return;
311 }
312 eprintln!(
313 "[update-stage-telemetry] total_ms={total_ms:.2} frame_callbacks_ms={:.2} ui_drain_ms={:.2} reconcile_ms={:.2} process_frame_ms={:.2} should_render={} reconcile_attempted={} reconcile_changed={}",
314 ms(started_at, after_frame_callbacks),
315 ms(after_frame_callbacks, after_ui_drain),
316 ms(after_ui_drain, after_reconcile),
317 ms(after_reconcile, after_process_frame),
318 outcome.should_render,
319 outcome.reconcile_attempted,
320 outcome.reconcile_changed
321 );
322}
323
324#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
326pub enum FramePacingMode {
327 #[default]
330 Vsync,
331 Hard60,
333 Hard120,
335 NoVsync,
338}
339
340impl FramePacingMode {
341 pub const ALL: [Self; 4] = [Self::Vsync, Self::Hard60, Self::Hard120, Self::NoVsync];
343
344 pub fn label(self) -> &'static str {
346 match self {
347 Self::Vsync => "VSync",
348 Self::Hard60 => "60fps",
349 Self::Hard120 => "120fps",
350 Self::NoVsync => "NoVSync",
351 }
352 }
353
354 pub fn target_fps(self) -> Option<u32> {
357 match self {
358 Self::Hard60 => Some(60),
359 Self::Hard120 => Some(120),
360 Self::Vsync | Self::NoVsync => None,
361 }
362 }
363}
364
365#[derive(Clone, Copy, Debug, Default, PartialEq)]
368pub struct FrameSchedule {
369 pub needs_update: bool,
372 pub needs_frame: bool,
374 pub next_deadline: Option<web_time::Instant>,
377}
378
379#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
381pub struct FrameUpdateResult {
382 pub visual_changed: bool,
384 pub structure_changed: bool,
387 pub content_moved: bool,
392 pub content_redrawn: bool,
395}
396
397pub trait PlatformFrameDriver {
400 fn request_frame(&self);
402 fn request_wake_at(&self, deadline: web_time::Instant);
405 fn clear_wake(&self);
407}
408
409#[derive(Debug, Default)]
413pub struct FrameScheduler {
414 latest: Cell<FrameSchedule>,
415}
416
417impl FrameScheduler {
418 pub fn record(&self, schedule: FrameSchedule) {
420 self.latest.set(FrameSchedule {
421 next_deadline: if schedule.needs_update {
422 None
423 } else {
424 schedule.next_deadline
425 },
426 ..schedule
427 });
428 }
429
430 pub fn schedule<D>(&self, schedule: FrameSchedule, driver: &D)
432 where
433 D: PlatformFrameDriver + ?Sized,
434 {
435 self.record(schedule);
436 schedule.apply_to(driver);
437 }
438
439 pub fn snapshot(&self) -> FrameSchedule {
441 self.latest.get()
442 }
443}
444
445impl FrameSchedule {
446 pub fn is_idle(self) -> bool {
448 !self.needs_frame && !self.needs_update && self.next_deadline.is_none()
449 }
450
451 pub fn apply_to<D>(self, driver: &D)
454 where
455 D: PlatformFrameDriver + ?Sized,
456 {
457 if self.needs_frame {
458 driver.clear_wake();
459 driver.request_frame();
460 } else if self.needs_update {
461 driver.request_wake_at(web_time::Instant::now());
462 } else if let Some(deadline) = self.next_deadline {
463 driver.request_wake_at(deadline);
464 } else {
465 driver.clear_wake();
466 }
467 }
468}
469
470#[derive(Clone, Copy, Debug)]
471pub(crate) struct DevOverlayControl {
472 bounds: Rect,
473 mode: FramePacingMode,
474}
475
476#[derive(Clone, Debug, Default)]
481pub struct DevOptions {
482 pub fps_counter: bool,
484 pub recomposition_counter: bool,
486 pub layout_timing: bool,
488 pub frame_pacing_controls: bool,
490 pub frame_pacing_mode: FramePacingMode,
492}
493
494#[cfg(any(test, feature = "test-support"))]
495#[doc(hidden)]
496#[derive(Clone, Copy, Debug)]
497pub struct RuntimeLeakDebugStats {
498 pub applier_stats: MemoryApplierDebugStats,
499 pub live_node_heap_bytes: usize,
500 pub recycled_node_heap_bytes: usize,
501 pub slot_table_heap_bytes: usize,
502 pub pass_stats: CompositionPassDebugStats,
503 pub slot_stats: SlotTableDebugStats,
504 pub runtime_stats: RuntimeDebugStats,
505 pub state_arena_stats: StateArenaDebugStats,
506 pub recompose_scope_stats: RecomposeScopeRegistryDebugStats,
507 pub snapshot_v2_stats: SnapshotV2DebugStats,
508 pub snapshot_pinning_stats: SnapshotPinningDebugStats,
509}
510
511impl ShellApp {
512 pub(crate) fn flush_semantics_invalidations(&mut self) {
513 if self.app_context.has_pending_semantics_invalidations() {
514 let mut applier = self.composition.applier_mut();
515 process_semantics_invalidations(|node_id| {
516 cranpose_core::bubble_semantics_dirty(&mut *applier, node_id);
517 });
518 }
519 }
520
521 pub(crate) fn request_layout_pass(&mut self) {
522 self.layout_requested = true;
523 }
524
525 pub(crate) fn request_forced_layout_pass(&mut self) {
526 self.layout_requested = true;
527 self.force_layout_pass = true;
528 }
529
530 pub(crate) fn composition_tree_needs_layout(&mut self) -> bool {
531 let Some(root) = self.composition.root() else {
532 return true;
533 };
534 let mut applier = self.composition.applier_mut();
535 cranpose_ui::tree_needs_layout(&mut *applier, root).unwrap_or_else(|err| {
536 log::warn!("Cannot check layout dirty status for root #{root}: {err}");
537 true
538 })
539 }
540
541 pub(crate) fn has_stale_work_in_context(&self) -> bool {
542 self.layout_requested || self.app_context.has_stale_work()
543 }
544
545 pub(crate) fn wants_frame_in_context(&self) -> bool {
546 self.layout_requested
547 || self.app_context.has_pending_frame_work()
548 || self.composition.should_render()
549 }
550
551 pub(crate) fn needs_ui_update_in_context(&self, surfaces_dirty: bool) -> bool {
552 surfaces_dirty
553 || self.has_stale_work_in_context()
554 || self.composition.runtime().has_pending_ui()
555 || self.app_context.has_pending_semantics_invalidations()
556 || self.composition.should_render()
557 }
558
559 pub(crate) fn next_event_time(&self) -> Option<web_time::Instant> {
560 self.app_context.enter(cranpose_ui::next_cursor_blink_time)
561 }
562
563 pub(crate) fn frame_time_nanos_at(&self, now: Instant) -> u64 {
564 now.checked_duration_since(self.start_time)
565 .unwrap_or_default()
566 .as_nanos()
567 .min(u128::from(u64::MAX)) as u64
568 }
569
570 pub(crate) fn realtime_pointer_event_time(
571 &self,
572 platform_time_ms: Option<i64>,
573 ) -> PointerEventTime {
574 PointerEventTime {
575 platform_time_ms,
576 animation_time_nanos: self
577 .frame_time_nanos_at(Instant::now())
578 .max(self.last_frame_time_nanos),
579 }
580 }
581
582 pub(crate) fn install_text_input_router(&mut self) {
583 if self.text_input_router_installed {
584 return;
585 }
586 let router = Rc::new(TextInputRouter {
587 routes: Rc::clone(&self.text_input_routes),
588 });
589 self.app_context
590 .enter(|| cranpose_ui::text_input_session::set_platform_text_input_handler(router));
591 self.text_input_router_installed = true;
592 }
593}
594
595impl<R> AppShell<R>
596where
597 R: Renderer,
598 R::Error: Debug,
599{
600 pub fn new(renderer: R, root_key: Key, content: impl FnMut() + 'static) -> Self {
603 Self::new_with_size(renderer, root_key, content, (800, 600), (800.0, 600.0))
604 }
605
606 pub fn new_with_size(
609 renderer: R,
610 root_key: Key,
611 content: impl FnMut() + 'static,
612 buffer_size: (u32, u32),
613 viewport: (f32, f32),
614 ) -> Self {
615 Self::new_with_size_and_density(renderer, root_key, content, buffer_size, viewport, 1.0)
616 }
617
618 pub fn new_with_size_and_density(
621 mut renderer: R,
622 root_key: Key,
623 content: impl FnMut() + 'static,
624 buffer_size: (u32, u32),
625 viewport: (f32, f32),
626 density: f32,
627 ) -> Self {
628 renderer.set_root_scale(density);
629 let app_context = cranpose_ui::AppContext::new_with_density(density);
630 let runtime = StdRuntime::new();
631 let mut composition = Composition::with_runtime(MemoryApplier::new(), runtime.runtime());
632 let app_content = Rc::new(std::cell::RefCell::new(content));
633 let mut build: Box<dyn FnMut()> = Box::new(move || {
634 let app_content = Rc::clone(&app_content);
635 cranpose_ui::density::ProvideDensity(cranpose_ui::Density::from_host(), move || {
636 cranpose_ui::widgets::PopupHost(move || {
637 (app_content.borrow_mut())();
638 });
639 });
640 });
641 renderer.attach_app_context_services(&app_context);
642 app_context.enter(|| {
643 #[cfg(all(
644 feature = "clipboard-native",
645 not(target_arch = "wasm32"),
646 not(target_os = "android"),
647 not(target_os = "ios")
648 ))]
649 {
650 cranpose_ui::clipboard_session::set_platform_clipboard(std::rc::Rc::new(
651 ShellClipboard::default(),
652 ));
653 }
654 if let Err(err) = composition.render_stable(root_key, &mut *build) {
655 log::error!("initial render failed: {err}");
656 }
657 });
658 renderer.scene_mut().clear();
659 let app = ShellApp {
660 app_context,
661 runtime,
662 composition,
663 content: build,
664 start_time: Instant::now(),
665 last_frame_time_nanos: 0,
666 semantics_enabled: false,
667 semantics_snapshot_revision: 0,
668 revealed_focus: None,
669 layout_requested: true,
670 content_moved: false,
671 force_layout_pass: true,
672 modifiers: None,
673 rotary_scroll_factor: DEFAULT_ROTARY_SCROLL_FACTOR_DP,
674 #[cfg(all(feature = "clipboard-native", target_os = "linux"))]
675 clipboard: arboard::Clipboard::new().ok(),
676 dev_options: DevOptions::default(),
677 inspector_projector: None,
678 fps_monitor: fps_monitor::FpsMonitor::new(),
679 text_input_routes: Rc::new(RefCell::new(TextInputRoutes::default())),
680 text_input_router_installed: false,
681 window_roots_seen: None,
682 rebuilt_frames_since_observation_prune: 0,
683 #[cfg(feature = "installed-fonts")]
684 installed_fonts: 0,
685 };
686 let mut shell = Self {
687 app,
688 surfaces: vec![RootSurface::new(
689 RootId::Primary,
690 renderer,
691 buffer_size,
692 viewport,
693 )],
694 routing_scratch: SurfaceRoutingScratch::default(),
695 pending_dirty_nodes: Vec::new(),
696 pending_layer_property_nodes: Vec::new(),
697 geometry_scene_nodes: cranpose_ui::GeometrySceneNodes::default(),
698 };
699 shell.process_frame();
700 shell
701 }
702
703 pub fn app_context(&self) -> &Rc<cranpose_ui::AppContext> {
708 &self.app.app_context
709 }
710
711 pub fn surface(&mut self, root: RootId) -> Option<SurfaceMut<'_, R>> {
714 let index = self.surface_index(root)?;
715 Some(SurfaceMut::new(self, index))
716 }
717
718 pub fn primary(&mut self) -> SurfaceMut<'_, R> {
720 SurfaceMut::new(self, 0)
721 }
722
723 pub fn root_holding_the_press(&mut self) -> Option<RootId> {
732 let pressed = self
733 .surfaces
734 .iter()
735 .find_map(|surface| surface.hit_path_tracker.dispatch_order(PointerId::PRIMARY))?;
736 let primary_root = self.app.composition.root()?;
737 let mut applier = self.app.composition.applier_mut();
738 let holder = pressed.into_iter().find_map(|node| {
739 let node = applier.scene_node_attached_to(node, primary_root)?;
740 Some(cranpose_ui::nearest_window_root(&mut applier, node))
741 })?;
742 drop(applier);
743 self.surfaces
744 .iter()
745 .find(|surface| surface.owns_nodes_under(holder))
746 .map(|surface| surface.id)
747 }
748
749 fn surface_index(&self, root: RootId) -> Option<usize> {
750 self.surfaces.iter().position(|surface| surface.id == root)
751 }
752
753 pub fn add_window_surface(
763 &mut self,
764 window: u64,
765 renderer: R,
766 buffer_size: (u32, u32),
767 viewport: (f32, f32),
768 ) -> Option<R> {
769 let previous = self.remove_window_surface(window);
770 self.surfaces.push(RootSurface::new(
771 RootId::Window(window),
772 renderer,
773 buffer_size,
774 viewport,
775 ));
776 self.app.window_roots_seen = None;
777 self.sync_window_roots();
778 previous
779 }
780
781 pub fn remove_window_surface(&mut self, window: u64) -> Option<R> {
783 let root = RootId::Window(window);
784 let index = self.surface_index(root)?;
785 self.app.text_input_routes.borrow_mut().remove(root);
786 Some(self.surfaces.remove(index).renderer)
787 }
788
789 pub fn surface_ids(&self) -> Vec<RootId> {
791 self.surfaces.iter().map(|surface| surface.id).collect()
792 }
793
794 pub fn window_roots(&self) -> Vec<WindowRootEntry> {
798 self.app.app_context.enter(cranpose_ui::window_roots)
799 }
800
801 pub fn window_roots_revision(&self) -> u64 {
805 self.app
806 .app_context
807 .enter(cranpose_ui::window_roots_revision)
808 }
809
810 pub fn primary_has_content(&mut self) -> bool {
815 self.read_primary_boxes(cranpose_ui::has_placed_content)
816 .unwrap_or(false)
817 }
818
819 pub fn primary_content_size(&mut self) -> Option<Size> {
825 self.read_primary_boxes(cranpose_ui::placed_content_extent)
826 .flatten()
827 }
828
829 fn read_primary_boxes<T>(
833 &mut self,
834 read: impl FnOnce(&mut MemoryApplier, NodeId) -> Result<T, NodeError>,
835 ) -> Option<T> {
836 let _app_context = self.app.app_context.enter_scope();
837 let root = self.surfaces[0].root_node(&self.app)?;
838 let mut applier = self.app.composition.applier_mut();
839 read(&mut applier, root)
840 .inspect_err(|err| log::debug!("failed to read the primary root's boxes: {err}"))
841 .ok()
842 }
843
844 pub(crate) fn drag_and_drop_target_at(
850 &self,
851 point: cranpose_ui::DragAndDropPoint,
852 source: usize,
853 ) -> Option<(NodeId, cranpose_ui::Point)> {
854 let drag_and_drop = self.app.app_context.drag_and_drop();
855 let target_in = |index: usize, local: cranpose_ui::Point| {
856 self.surfaces[index]
857 .renderer
858 .scene()
859 .hit_test_nodes(local.x, local.y)
860 .into_iter()
861 .find(|node| drag_and_drop.is_target(*node))
862 .map(|node| (node, local))
863 };
864 match point.screen {
865 Some(screen) => (0..self.surfaces.len()).rev().find_map(|index| {
866 let local = self.surfaces[index].screen_point_inside(screen)?;
867 target_in(index, local)
868 }),
869 None => target_in(source, point.local),
870 }
871 }
872
873 pub fn set_active_root(&mut self, root: RootId) {
877 self.app.text_input_routes.borrow_mut().set_active(root);
878 }
879
880 pub fn active_root(&self) -> RootId {
882 self.app.text_input_routes.borrow().active()
883 }
884
885 fn sync_window_roots(&mut self) {
886 let _app_context = self.app.app_context.enter_scope();
887 self.sync_window_roots_in_context();
888 }
889
890 pub(crate) fn sync_window_roots_in_context(&mut self) {
891 let revision = self.app.app_context.window_roots().revision();
892 if self.app.window_roots_seen == Some(revision) {
893 return;
894 }
895 self.app.window_roots_seen = Some(revision);
896 let entries = cranpose_ui::window_roots();
897 for surface in &mut self.surfaces {
898 let RootId::Window(id) = surface.id else {
899 continue;
900 };
901 let root = entries
902 .iter()
903 .find(|entry| entry.node as u64 == id)
904 .map(|entry| entry.node);
905 surface.set_root(root);
906 }
907 }
908
909 pub(crate) fn any_surface_dirty(&self) -> bool {
910 self.surfaces
911 .iter()
912 .any(|surface| surface.is_dirty || surface.scene_dirty)
913 }
914
915 pub(crate) fn mark_all_dirty(&mut self) {
916 for surface in &mut self.surfaces {
917 surface.is_dirty = true;
918 }
919 }
920
921 fn clear_surface_dirt(&mut self) {
922 for surface in &mut self.surfaces {
923 surface.is_dirty = false;
924 }
925 }
926
927 fn invalidate_dev_overlay_text(&mut self) {
928 for surface in &mut self.surfaces {
929 surface.invalidate_dev_overlay_text();
930 }
931 }
932
933 pub fn set_dev_options(&mut self, options: DevOptions) {
938 self.app.dev_options = options;
939 self.invalidate_dev_overlay_text();
940 self.app.app_context.enter(request_render_invalidation);
941 self.mark_all_dirty();
942 }
943
944 pub fn dev_options(&self) -> &DevOptions {
946 &self.app.dev_options
947 }
948
949 pub fn frame_pacing_mode(&self) -> FramePacingMode {
951 self.app.dev_options.frame_pacing_mode
952 }
953
954 pub fn current_fps(&self) -> f32 {
956 self.app.fps_monitor.current_fps()
957 }
958
959 pub fn fps_stats(&self) -> FpsStats {
961 self.app.fps_monitor.stats()
962 }
963
964 pub fn reset_fps_stats(&mut self) {
966 self.app.fps_monitor.reset_stats();
967 self.invalidate_dev_overlay_text();
968 }
969
970 pub fn record_presented_frame(
973 &mut self,
974 frame_started_at: Instant,
975 frame_finished_at: Instant,
976 ) {
977 self.app
978 .fps_monitor
979 .record_frame_work(frame_started_at, frame_finished_at);
980 }
981
982 #[cfg(any(test, feature = "test-support"))]
983 #[doc(hidden)]
984 pub fn record_presented_frame_for_test(
985 &mut self,
986 frame_started_nanos: u64,
987 frame_finished_nanos: u64,
988 ) {
989 let started = self.app.start_time + std::time::Duration::from_nanos(frame_started_nanos);
990 let finished = self.app.start_time + std::time::Duration::from_nanos(frame_finished_nanos);
991 self.record_presented_frame(started, finished);
992 }
993
994 pub fn set_frame_pacing_mode(&mut self, mode: FramePacingMode) {
997 if self.app.dev_options.frame_pacing_mode == mode {
998 return;
999 }
1000 self.app.dev_options.frame_pacing_mode = mode;
1001 self.invalidate_dev_overlay_text();
1002 self.app.app_context.enter(request_render_invalidation);
1003 self.mark_all_dirty();
1004 }
1005
1006 pub fn dev_overlay_control_center(&self, mode: FramePacingMode) -> Option<(f32, f32)> {
1013 self.surfaces[0].dev_overlay_control_center(mode)
1014 }
1015
1016 pub fn set_viewport(&mut self, width: f32, height: f32) {
1019 if self.surfaces[0].viewport == (width, height) {
1020 return;
1021 }
1022 self.primary().set_viewport(width, height);
1023 self.process_frame();
1024 }
1025
1026 pub fn viewport_size(&self) -> (f32, f32) {
1028 self.surfaces[0].viewport
1029 }
1030
1031 pub fn set_screen_origin(&mut self, origin: Option<cranpose_ui_graphics::Point>) {
1034 self.surfaces[0].screen_origin = origin;
1035 }
1036
1037 pub fn set_buffer_size(&mut self, width: u32, height: u32) {
1039 self.surfaces[0].buffer_size = (width, height);
1040 }
1041
1042 pub fn buffer_size(&self) -> (u32, u32) {
1044 self.surfaces[0].buffer_size
1045 }
1046
1047 pub fn scene(&self) -> &R::Scene {
1049 self.surfaces[0].renderer.scene()
1050 }
1051
1052 pub fn renderer(&mut self) -> &mut R {
1054 &mut self.surfaces[0].renderer
1055 }
1056
1057 #[cfg(not(target_arch = "wasm32"))]
1060 pub fn set_frame_waker(&mut self, waker: impl Fn() + Send + Sync + 'static) {
1061 self.app.runtime.set_frame_waker(waker);
1062 }
1063
1064 #[cfg(target_arch = "wasm32")]
1067 pub fn set_frame_waker(&mut self, waker: impl Fn() + 'static) {
1068 self.app.runtime.set_frame_waker(waker);
1069 }
1070
1071 pub fn clear_frame_waker(&mut self) {
1073 self.app.runtime.clear_frame_waker();
1074 }
1075
1076 pub fn should_render(&self) -> bool {
1079 self.app.app_context.enter(|| {
1080 self.app.wants_frame_in_context()
1081 || self.surfaces.iter().any(|surface| surface.scene_dirty)
1082 })
1083 }
1084
1085 pub fn needs_update(&self) -> bool {
1088 self.app.app_context.enter(|| {
1089 self.app
1090 .needs_ui_update_in_context(self.any_surface_dirty())
1091 })
1092 }
1093
1094 pub fn needs_update_without_frame(&self) -> bool {
1097 self.app.app_context.enter(|| {
1098 self.any_surface_dirty()
1099 || self.app.has_stale_work_in_context()
1100 || self.app.composition.should_recompose()
1101 || self.app.composition.runtime().has_pending_ui()
1102 || self.app.app_context.has_pending_semantics_invalidations()
1103 || cranpose_ui::next_cursor_blink_time().is_some_and(|at| at <= Instant::now())
1104 || self
1105 .surfaces
1106 .iter()
1107 .any(|surface| surface.renderer_warmup_due(&self.app))
1108 })
1109 }
1110
1111 pub fn has_pending_ui(&self) -> bool {
1119 self.app.composition.runtime().has_pending_ui()
1120 }
1121
1122 pub fn run_pending_tasks(&mut self) {
1129 if !self.app.composition.runtime().has_pending_ui() {
1132 return;
1133 }
1134 let runtime_handle = self.app.composition.runtime_handle();
1135 self.app.app_context.enter(|| {
1136 runtime_handle.with_deferred_state_releases(|| runtime_handle.drain_ui());
1137 });
1138 }
1139
1140 pub fn needs_redraw(&self) -> bool {
1153 self.app
1154 .app_context
1155 .enter(|| self.surfaces[0].needs_redraw_in_context(&self.app))
1156 }
1157
1158 pub fn mark_dirty(&mut self) {
1160 self.surfaces[0].is_dirty = true;
1161 }
1162
1163 pub fn request_root_render(&mut self) {
1166 self.app.composition.request_root_render();
1167 self.app.request_forced_layout_pass();
1168 self.app.app_context.enter(request_render_invalidation);
1169 self.mark_all_dirty();
1170 }
1171
1172 pub fn set_density(&mut self, density: f32) {
1175 self.surfaces[0].renderer.set_root_scale(density);
1176 let changed = self.app.app_context.enter(|| {
1177 let previous = cranpose_ui::current_density().to_bits();
1178 cranpose_ui::set_density(density);
1179 previous != cranpose_ui::current_density().to_bits()
1180 });
1181 if changed {
1182 self.request_root_render();
1183 }
1184 }
1185
1186 pub fn set_font_scale(&mut self, font_scale: f32) {
1197 self.set_font_scale_curve(cranpose_ui::FontScaleCurve::linear(font_scale));
1198 }
1199
1200 pub fn set_font_scale_curve(&mut self, curve: cranpose_ui::FontScaleCurve) {
1207 let changed = self.app.app_context.enter(|| {
1208 let previous = cranpose_ui::current_font_scale_curve();
1209 cranpose_ui::set_font_scale_curve(curve);
1210 previous != cranpose_ui::current_font_scale_curve()
1211 });
1212 if changed {
1213 self.request_root_render();
1214 }
1215 }
1216
1217 #[cfg(any(test, feature = "test-support"))]
1218 #[doc(hidden)]
1219 pub fn debug_current_density(&self) -> f32 {
1220 self.app.app_context.enter(cranpose_ui::current_density)
1221 }
1222
1223 #[cfg(any(test, feature = "test-support"))]
1224 #[doc(hidden)]
1225 pub fn debug_current_font_scale(&self) -> f32 {
1226 self.app.app_context.enter(cranpose_ui::current_font_scale)
1227 }
1228
1229 #[cfg(any(test, feature = "test-support"))]
1230 #[doc(hidden)]
1231 pub fn debug_current_font_scale_curve(&self) -> cranpose_ui::FontScaleCurve {
1232 self.app
1233 .app_context
1234 .enter(cranpose_ui::current_font_scale_curve)
1235 }
1236
1237 #[cfg(any(test, feature = "test-support"))]
1238 #[doc(hidden)]
1239 pub fn debug_enter_app_context<T>(&self, block: impl FnOnce() -> T) -> T {
1240 self.app.app_context.enter(block)
1241 }
1242
1243 pub fn has_active_animations(&self) -> bool {
1245 self.app.composition.should_render()
1246 }
1247
1248 pub fn has_transient_frame_callbacks(&self) -> bool {
1251 self.app
1252 .composition
1253 .runtime()
1254 .has_transient_frame_callbacks()
1255 }
1256
1257 pub fn has_active_pointer_gesture(&self) -> bool {
1259 self.surfaces[0].has_active_pointer_gesture()
1260 }
1261
1262 pub fn has_active_primary_gesture(&self) -> bool {
1264 self.surfaces[0].has_active_primary_gesture()
1265 }
1266
1267 pub fn frame_owed(&self) -> bool {
1269 self.surfaces[0].frame_owed
1270 }
1271
1272 pub fn take_frame_owed(&mut self) -> bool {
1274 std::mem::take(&mut self.surfaces[0].frame_owed)
1275 }
1276
1277 pub fn next_event_time(&self) -> Option<web_time::Instant> {
1280 self.app.next_event_time()
1281 }
1282
1283 fn compute_frame_schedule(&self) -> FrameSchedule {
1284 self.surfaces[0].compute_frame_schedule(&self.app, self.any_surface_dirty())
1285 }
1286
1287 pub fn frame_schedule(&self) -> FrameSchedule {
1290 let schedule = self.compute_frame_schedule();
1291 self.surfaces[0].frame_scheduler.record(schedule);
1292 schedule
1293 }
1294
1295 pub fn schedule_platform_frame<D>(&self, driver: &D) -> FrameSchedule
1298 where
1299 D: PlatformFrameDriver + ?Sized,
1300 {
1301 let schedule = self.compute_frame_schedule();
1302 self.surfaces[0].frame_scheduler.schedule(schedule, driver);
1303 schedule
1304 }
1305
1306 pub fn frame_scheduler_snapshot(&self) -> FrameSchedule {
1309 self.surfaces[0].frame_scheduler.snapshot()
1310 }
1311
1312 pub fn realtime_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1314 self.app.realtime_pointer_event_time(platform_time_ms)
1315 }
1316
1317 pub fn exact_pointer_event_time(&self, platform_time_ms: Option<i64>) -> PointerEventTime {
1319 PointerEventTime {
1320 platform_time_ms,
1321 animation_time_nanos: self.app.last_frame_time_nanos,
1322 }
1323 }
1324
1325 pub fn update_after_frame_interval(
1328 &mut self,
1329 frame_interval: std::time::Duration,
1330 ) -> FrameUpdateResult {
1331 let wall_frame_time = self.app.frame_time_nanos_at(Instant::now());
1332 let base_frame_time = self.app.last_frame_time_nanos.max(wall_frame_time);
1333 let frame_time = base_frame_time
1334 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1335 self.update_at_frame_time_nanos(frame_time)
1336 }
1337
1338 pub fn update_after_exact_interval(
1344 &mut self,
1345 frame_interval: std::time::Duration,
1346 ) -> FrameUpdateResult {
1347 let frame_time = self
1348 .app
1349 .last_frame_time_nanos
1350 .saturating_add(frame_interval.as_nanos().min(u128::from(u64::MAX)) as u64);
1351 self.update_at_frame_time_nanos(frame_time)
1352 }
1353
1354 pub fn dispatch_frame_at(&mut self, frame_time: Instant) {
1360 let frame_time = self.app.frame_time_nanos_at(frame_time);
1361 let _app_context = self.app.app_context.enter_scope();
1362 let runtime_handle = self.app.runtime.runtime_handle();
1363 runtime_handle.with_deferred_state_releases(|| {
1364 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1365 self.app
1366 .runtime
1367 .drain_frame_callbacks(self.app.last_frame_time_nanos);
1368 self.apply_pointer_claim();
1369 runtime_handle.drain_ui();
1370 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1371 });
1372 }
1373
1374 pub fn update_without_frame(&mut self) -> FrameUpdateResult {
1377 self.update_with_frame_time(None)
1378 }
1379
1380 pub fn update_at_frame_time_nanos(&mut self, frame_time: u64) -> FrameUpdateResult {
1384 self.update_with_frame_time(Some(frame_time))
1385 }
1386
1387 fn update_with_frame_time(&mut self, frame_time: Option<u64>) -> FrameUpdateResult {
1388 let _app_context = self.app.app_context.enter_scope();
1389 let mut stages = UpdateStages::start(update_stage_telemetry_threshold_ms().is_some());
1390 let runtime_handle = self.app.runtime.runtime_handle();
1391 runtime_handle.with_deferred_state_releases(|| {
1392 if let Some(frame_time) = frame_time {
1393 self.app.last_frame_time_nanos = frame_time.max(self.app.last_frame_time_nanos);
1394 self.app.runtime.drain_frame_callbacks(self.app.last_frame_time_nanos);
1395 self.apply_pointer_claim();
1396 }
1397 stages.reached();
1398 runtime_handle.drain_ui();
1399 self.dispatch_requested_mouse_moves(self.app.last_frame_time_nanos);
1400 stages.reached();
1401 let should_render = self.app.composition.should_recompose();
1402 let mut reconcile_attempted = false;
1403 let mut reconcile_changed = false;
1404 if should_render {
1405 log::trace!(
1406 target: "cranpose::input",
1407 "update begin: should_render=true layout_requested={} scene_dirty={} is_dirty={}",
1408 self.app.layout_requested,
1409 self.surfaces[0].scene_dirty,
1410 self.surfaces[0].is_dirty
1411 );
1412 (reconcile_attempted, reconcile_changed) = self.reconcile_in_context();
1413 }
1414 stages.reached();
1415 let result = self.process_frame_in_context(reconcile_changed);
1416 stages.reached();
1417 log_update_stage_telemetry(
1418 &stages,
1419 UpdateOutcome {
1420 should_render,
1421 reconcile_attempted,
1422 reconcile_changed,
1423 },
1424 );
1425 self.clear_surface_dirt();
1426 result
1427 })
1428 }
1429
1430 fn apply_pointer_claim(&mut self) {
1433 let Some(node) = self.app.app_context.take_pointer_claim() else {
1434 return;
1435 };
1436 for index in 0..self.surfaces.len() {
1437 SurfaceMut::new(self, index).give_press_to(node);
1438 }
1439 }
1440
1441 fn dispatch_requested_mouse_moves(&mut self, frame_time: u64) {
1442 use cranpose_ui::mouse_input::MouseInputTarget;
1443 let pending = self.app.app_context.pending_mouse_moves();
1444 for _ in 0..pending {
1445 let Some(request) = self.app.app_context.take_mouse_move() else {
1446 break;
1447 };
1448 let root = match request.target {
1449 MouseInputTarget::Primary => RootId::Primary,
1450 MouseInputTarget::Window(id) => RootId::Window(id),
1451 };
1452 if let Some(mut surface) = self.surface(root) {
1453 surface.set_pointer_source(PointerSource::Mouse);
1454 surface.set_cursor_at_event_time(request.position.x, request.position.y, PointerEventTime {
1455 platform_time_ms: Some((frame_time / 1_000_000).min(i64::MAX as u64) as i64),
1456 animation_time_nanos: frame_time,
1457 });
1458 }
1459 }
1460 }
1461
1462 fn reconcile_in_context(&mut self) -> (bool, bool) {
1463 let Some(root_key) = self.app.composition.root_key() else {
1464 return (false, false);
1465 };
1466 match self
1467 .app
1468 .composition
1469 .reconcile(root_key, &mut *self.app.content)
1470 {
1471 Ok(changed) => {
1472 log::trace!(
1473 target: "cranpose::input",
1474 "reconcile changed={changed}"
1475 );
1476 if changed {
1477 self.app.fps_monitor.record_recomposition();
1478 if self.app.composition_tree_needs_layout() {
1479 self.app.request_layout_pass();
1480 }
1481 request_render_invalidation();
1482 }
1483 (true, changed)
1484 }
1485 Err(NodeError::Missing { id }) => {
1486 log::debug!("Recomposition skipped: node {id} no longer exists");
1487 self.app.request_layout_pass();
1488 request_render_invalidation();
1489 (true, false)
1490 }
1491 Err(err) => {
1492 log::error!("recomposition failed: {err}");
1493 self.app.request_layout_pass();
1494 request_render_invalidation();
1495 (true, false)
1496 }
1497 }
1498 }
1499
1500 pub fn update(&mut self) -> FrameUpdateResult {
1503 let frame_time = self.app.frame_time_nanos_at(Instant::now());
1504 self.update_at_frame_time_nanos(frame_time)
1505 }
1506}
1507
1508impl<R> Drop for AppShell<R>
1509where
1510 R: Renderer,
1511{
1512 fn drop(&mut self) {
1513 self.app.runtime.clear_frame_waker();
1514 }
1515}
1516
1517pub fn default_root_key() -> Key {
1519 location_key(file!(), line!(), column!())
1520}
1521
1522#[cfg(test)]
1523#[path = "tests/app_shell_frame_pacing_tests.rs"]
1524mod frame_pacing_tests;
1525
1526#[cfg(test)]
1527#[path = "tests/app_shell_tests.rs"]
1528mod tests;
1529
1530#[cfg(test)]
1531#[path = "tests/surface_tests.rs"]
1532mod surface_tests;